05/07 Phase 3: initial codes

This commit is contained in:
2026-05-07 12:17:19 -04:00
parent 6e5518c103
commit ce462c57b2
107 changed files with 6365 additions and 208 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ MAIL_USE_TLS=true
MAIL_USERNAME=noreply@mydomain.com MAIL_USERNAME=noreply@mydomain.com
MAIL_PASSWORD= MAIL_PASSWORD=
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24 ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
ADMIN_DOMAIN=admin.mydomain.com ADMIN_DOMAIN=posadmin.ngodanguyen.tech
TENANT_DOMAIN=mydomain.com TENANT_DOMAIN=pos.ngodanguyen.tech
DEMO_TENANT_SLUG=demo DEMO_TENANT_SLUG=demo
BACKUP_DIR=/var/backups/salon_pos BACKUP_DIR=/var/backups/salon_pos
BACKUP_RETAIN_DAYS=30 BACKUP_RETAIN_DAYS=30
+45 -3
View File
@@ -682,8 +682,37 @@ WantedBy=multi-user.target
- [x] `tests/test_admin_phase2.py` — full test suite for all 7 modules - [x] `tests/test_admin_phase2.py` — full test suite for all 7 modules
- [x] Demo account creation deferred — available via `POST /tenants/new` with `is_demo=1` - [x] Demo account creation deferred — available via `POST /tenants/new` with `is_demo=1`
### Phase 3 — Multi-Location & Tenant Core Modules ### Phase 3 — Multi-Location & Tenant Core Modules ✅ COMPLETE
- [ ] Location management (CRUD, primary flag, per-location settings) - [x] Location management (CRUD, primary flag, switch, plan limit check)
- [x] Location switcher UI + tenant_staff location restriction enforcement
- [x] Staff management (profiles, job type, location assignment, passcode set/reset)
- [x] Staff passcode management (validate_passcode helper in security.py)
- [x] Dashboard (Phase 3 KPIs: revenue, appointments, staff on shift, queue count, upcoming)
- [x] Customers (search, CRUD, visit history, no-show count, soft-delete)
- [x] Services & products (tenant-wide catalogue, soft-delete)
- [x] Promotions management (create, activate/deactivate, applies_to, target_ids_json)
- [x] Promotion engine: get_active_promotion() + apply_promotion_to_price() in app/tenant/utils.py
- [x] Appointments (calendar by day, create, edit, status workflow, no-show counter, cancellation)
- [x] Customer check-in kiosk (/checkin/<slug>; auto-profile creation; queue entry; rate-limited; CSRF-exempt)
- [x] Queue polling API: GET /api/v1/checkin/queue + POST /api/v1/checkin/queue/<id>/acknowledge
- [x] Online booking (/book/<slug>; no auth; availability check; confirmation email; tenant_feature_required)
- [x] Waitlist (add, notify via email, set booked/expired; tenant_feature_required)
- [x] Staff Portal (clock in/out, today's appointments, schedule, commission, profile)
- [x] POS/Checkout (services + products, auto-apply promotions, tip, gift card redemption, payment method)
- [x] Rebook at checkout (creates pending appointment + 24h reminder; linked to transaction)
- [x] Gift cards (unique code gen, issuance, balance lookup, POS redemption, deactivate)
- [x] Transaction void (reason required; gift card balance reversed)
- [x] End-of-day reconciliation (close day, cash count, variance, history)
- [x] Reviews dashboard (avg rating, distribution chart, list)
- [x] Settings (managed key/value pairs for booking, reviews, receipt)
- [x] Phase 4 stubs (inventory, marketing, reports) registered with feature_unavailable page
- [x] app/tenant/utils.py — log_tenant_action, plan_limit_check, get_active_promotion, apply_promotion_to_price
- [x] All 17 Phase 3 blueprints registered in create_tenant_app()
- [x] All nav links in tenant/layouts/base.html wired to Phase 3 routes
- [x] Both template trees (templates/ and app/templates/) in sync: 67 files each
- [x] All 46 render_template references verified against disk
### Phase 3 Location management (CRUD, primary flag, per-location settings)
- [ ] Location switcher UI + `tenant_staff` location restriction enforcement - [ ] Location switcher UI + `tenant_staff` location restriction enforcement
- [ ] Staff ↔ location assignment (many-to-many) - [ ] Staff ↔ location assignment (many-to-many)
- [ ] Staff passcode management (set at creation, reset by admin/manager, never stored in plaintext) - [ ] Staff passcode management (set at creation, reset by admin/manager, never stored in plaintext)
@@ -953,6 +982,16 @@ Both portal apps set `template_folder` to the project-root `templates/` director
- Admin templates: `"admin/auth/login.html"`, `{% extends "admin/layouts/base.html" %}` - Admin templates: `"admin/auth/login.html"`, `{% extends "admin/layouts/base.html" %}`
- Tenant templates: `"tenant/auth/login.html"`, `{% extends "tenant/layouts/base.html" %}` - Tenant templates: `"tenant/auth/login.html"`, `{% extends "tenant/layouts/base.html" %}`
### Template Authoring Checklist
Every new template must satisfy all of the following before delivery:
1. File lives in `templates/` (project root) — not `app/templates/`
2. First line is `{% extends "admin/layouts/base.html" %}` (admin) or `{% extends "tenant/layouts/base.html" %}` (tenant)
3. Every `url_for('blueprint.endpoint')` call references a route that is actually registered (not a stub). Stubs use `'#'`
4. No bare `{{ expression }}` outside an HTML tag
5. No `{{ csrf_token() }}` rendered as standalone text — only inside `value="..."` of a hidden input
### Phase 2 Blueprint Stubs ### Phase 2 Blueprint Stubs
All Phase 2+ blueprints are registered as stubs (blueprint object only, no routes) so the app boots cleanly. The admin portal base template references to `url_for('tenants.index')`, `url_for('system_users.index')` etc. are replaced with `#` until Phase 2 routes are implemented. All Phase 2+ blueprints are registered as stubs (blueprint object only, no routes) so the app boots cleanly. The admin portal base template references to `url_for('tenants.index')`, `url_for('system_users.index')` etc. are replaced with `#` until Phase 2 routes are implemented.
@@ -988,4 +1027,7 @@ All Phase 2+ blueprints are registered as stubs (blueprint object only, no route
| 24 | Admin login URL | Admin auth blueprint uses `url_prefix=""`. Login page is at `posadmin.ngodanguyen.tech/login`. Root `/` redirects to `/login` (unauthenticated) or `/dashboard` (authenticated). | | 24 | Admin login URL | Admin auth blueprint uses `url_prefix=""`. Login page is at `posadmin.ngodanguyen.tech/login`. Root `/` redirects to `/login` (unauthenticated) or `/dashboard` (authenticated). |
| 25 | JWT blocklist table location | `jwt_blocklist` defined in `platform.py` (not `salon.py`) — it is a platform-level concern shared across all tenants. DB-persisted (not in-memory) to survive Gunicorn worker restarts. | | 25 | JWT blocklist table location | `jwt_blocklist` defined in `platform.py` (not `salon.py`) — it is a platform-level concern shared across all tenants. DB-persisted (not in-memory) to survive Gunicorn worker restarts. |
| 26 | Template path convention | `template_folder` points to project-root `templates/`. All `render_template()` calls and `{% extends %}` use full paths: `"admin/auth/login.html"`, `"admin/layouts/base.html"`, `"tenant/auth/login.html"`, etc. | | 26 | Template path convention | `template_folder` points to project-root `templates/`. All `render_template()` calls and `{% extends %}` use full paths: `"admin/auth/login.html"`, `"admin/layouts/base.html"`, `"tenant/auth/login.html"`, etc. |
| 27 | Production domains | Admin portal: `posadmin.ngodanguyen.tech`. Tenant portal: `pos.ngodanguyen.tech`. Updated in `.env`, `nginx.conf`, and all documentation. | | 27 | Production domains | Admin portal: `posadmin.ngodanguyen.tech`. Tenant portal: `pos.ngodanguyen.tech`. Updated in `.env`, `nginx.conf`, and all documentation. |
| 28 | Authoritative template tree | `templates/` (project root) is the **only** authoritative template tree. The app factories use `os.path.abspath(__file__)` to resolve this path absolutely. `app/templates/` exists as a mirror for legacy compatibility but `templates/` is the source of truth. All new templates go in `templates/` only. |
| 29 | Template extends convention | All admin templates extend `"admin/layouts/base.html"`. All tenant templates extend `"tenant/layouts/base.html"`. These paths are relative to the `templates/` root. Any other extends path (`"layouts/base.html"`, `"tenant/base.html"`, `"admin/base.html"`) is wrong and will produce a 500. |
| 30 | Phase 3+ nav links in base template | All Phase 3+ `url_for()` calls in `templates/tenant/layouts/base.html` are replaced with `'#'` until those blueprints are implemented. Leaving live `url_for()` calls pointing at stub-only blueprints causes `BuildError` 500s on every authenticated page load. |
+1 -1
View File
@@ -315,4 +315,4 @@ salon_pos/
├── .env.example ├── .env.example
├── deploy/ # systemd units, Nginx config, backup scripts ├── deploy/ # systemd units, Nginx config, backup scripts
└── tests/ # pytest test suites └── tests/ # pytest test suites
``` ```
+1 -1
View File
@@ -111,4 +111,4 @@ def index():
stats=stats, stats=stats,
expiring_soon=expiring_soon, expiring_soon=expiring_soon,
now=now, now=now,
) )
+1 -1
View File
@@ -68,4 +68,4 @@ def model_to_dict(obj, fields):
result[f] = float(val) result[f] = float(val)
else: else:
result[f] = val result[f] = val
return result return result
+9
View File
@@ -104,3 +104,12 @@ def validate_setting_key(key: str) -> bool:
"""Return True if key is a valid setting key (alphanumeric, underscores, dots).""" """Return True if key is a valid setting key (alphanumeric, underscores, dots)."""
import re import re
return bool(key and re.match(r'^[a-zA-Z0-9_.]+$', key) and len(key) <= 100) return bool(key and re.match(r'^[a-zA-Z0-9_.]+$', key) and len(key) <= 100)
def validate_passcode(passcode: str, min_len: int = 4, max_len: int = 6) -> bool:
"""Return True if passcode is digits only and within the allowed length range."""
return (
bool(passcode)
and passcode.isdigit()
and min_len <= len(passcode) <= max_len
)
@@ -0,0 +1,75 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Appointment{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Appointment</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 mb-3">
<div class="col-md-6">
<label class="form-label">Start Date & Time <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="start_time" required
value="{{ appointment.start_time.strftime('%Y-%m-%dT%H:%M') if appointment else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Service</label>
<select class="form-select" name="service_id">
<option value="">— None —</option>
{% for s in services %}
<option value="{{ s.id }}"
{{ 'selected' if appointment and appointment.service_id == s.id }}>
{{ s.name }} ({{ s.duration_min }} min)
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Customer</label>
<select class="form-select" name="customer_id">
<option value="">— Walk-in —</option>
{% for c in customers %}
<option value="{{ c.id }}"
{{ 'selected' if appointment and appointment.customer_id == c.id }}>
{{ c.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Any —</option>
{% for s in staff_list %}
<option value="{{ s.id }}"
{{ 'selected' if appointment and appointment.staff_id == s.id }}>
{{ s.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_walk_in" value="1"
id="is_walk_in" {{ 'checked' if appointment and appointment.is_walk_in }}>
<label class="form-check-label" for="is_walk_in">Walk-in (no appointment)</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="2">{{ appointment.notes if appointment else '' }}</textarea>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('appointments.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,56 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Appointments{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold mb-0"><i class="bi bi-calendar3 me-2"></i>Appointments</h4>
<div class="d-flex align-items-center gap-2">
<a href="{{ url_for('appointments.index', date=(view_date - timedelta(days=1)).isoformat()) }}"
class="btn btn-outline-secondary btn-sm"></a>
<input type="date" class="form-control form-control-sm" value="{{ view_date.isoformat() }}"
onchange="window.location='{{ url_for('appointments.index') }}?date='+this.value"
style="width:150px;">
<a href="{{ url_for('appointments.index', date=(view_date + timedelta(days=1)).isoformat()) }}"
class="btn btn-outline-secondary btn-sm"></a>
<a href="{{ url_for('appointments.create') }}" class="btn btn-primary btn-sm ms-2">+ New</a>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Time</th><th>Customer</th><th>Service</th><th>Staff</th><th>Status</th><th>Type</th><th></th></tr>
</thead>
<tbody>
{% for appt in appointments %}
<tr>
<td class="fw-semibold text-nowrap">{{ appt.start_time.strftime('%I:%M %p') }}</td>
<td>{{ appt.customer.name if appt.customer else '<em class="text-muted">Walk-in</em>' | safe }}</td>
<td class="small">{{ appt.service.name if appt.service else '—' }}</td>
<td class="small">{{ appt.staff.name if appt.staff else '—' }}</td>
<td>
<span class="badge bg-{{ {'pending':'warning','confirmed':'primary','in_progress':'info','completed':'success','cancelled':'danger','no_show':'dark'}.get(appt.status,'secondary') }}">
{{ appt.status }}
</span>
</td>
<td class="small">{{ 'Walk-in' if appt.is_walk_in else 'Booked' }}</td>
<td>
<a href="{{ url_for('appointments.view', appt_id=appt.id) }}" class="btn btn-outline-secondary btn-sm">View</a>
{% if appt.status in ['pending','confirmed','in_progress'] %}
<a href="{{ url_for('pos.checkout') }}?appointment_id={{ appt.id }}" class="btn btn-success btn-sm">Checkout</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No appointments for this day.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Import timedelta not available in Jinja — use JS for date nav
</script>
{% endblock %}
@@ -0,0 +1,64 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Appointment #{{ appointment.id }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0">Appointment #{{ appointment.id }}</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('appointments.edit', appt_id=appointment.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
{% if appointment.status in ['pending','confirmed','in_progress'] %}
<a href="{{ url_for('pos.checkout') }}?appointment_id={{ appointment.id }}" class="btn btn-success btn-sm">
<i class="bi bi-cash-register me-1"></i>Checkout
</a>
{% endif %}
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Customer</dt><dd class="col-sm-8">{{ appointment.customer.name if appointment.customer else 'Walk-in' }}</dd>
<dt class="col-sm-4">Service</dt><dd class="col-sm-8">{{ appointment.service.name if appointment.service else '—' }}</dd>
<dt class="col-sm-4">Staff</dt><dd class="col-sm-8">{{ appointment.staff.name if appointment.staff else '—' }}</dd>
<dt class="col-sm-4">Start</dt><dd class="col-sm-8">{{ appointment.start_time.strftime('%Y-%m-%d %I:%M %p') }}</dd>
<dt class="col-sm-4">End</dt><dd class="col-sm-8">{{ appointment.end_time.strftime('%I:%M %p') if appointment.end_time else '—' }}</dd>
<dt class="col-sm-4">Type</dt><dd class="col-sm-8">{{ 'Walk-in' if appointment.is_walk_in else 'Booked' }}</dd>
<dt class="col-sm-4">Source</dt><dd class="col-sm-8">{{ appointment.rebook_source or 'manual' }}</dd>
{% if appointment.notes %}
<dt class="col-sm-4">Notes</dt><dd class="col-sm-8">{{ appointment.notes }}</dd>
{% endif %}
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Update Status</div>
<div class="card-body">
<form method="POST" action="{{ url_for('appointments.set_status', appt_id=appointment.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<select class="form-select" name="status">
{% for s in ['pending','confirmed','in_progress','completed','cancelled','no_show'] %}
<option value="{{ s }}" {{ 'selected' if appointment.status == s }}>{{ s.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3" id="cancel-reason-field">
<label class="form-label small">Cancellation reason</label>
<input type="text" class="form-control form-control-sm" name="reason"
value="{{ appointment.cancellation_reason or '' }}">
</div>
<button type="submit" class="btn btn-primary btn-sm">Update Status</button>
</form>
</div>
</div>
</div>
</div>
<a href="{{ url_for('appointments.index', date=appointment.start_time.date().isoformat()) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Calendar
</a>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Book an Appointment — {{ tenant.name }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light">
<div class="container py-5" style="max-width:540px;">
{% if confirmed %}
<div class="card shadow text-center p-5">
<div class="display-1 mb-3"></div>
<h3 class="fw-bold">Booking Requested!</h3>
<p class="text-muted">We'll confirm your appointment shortly. Check your email for details.</p>
<a href="/book/{{ tenant.slug }}" class="btn btn-outline-primary mt-3">Book Another</a>
</div>
{% else %}
<div class="card shadow p-4">
<h3 class="fw-bold mb-1">{{ tenant.name }}</h3>
<p class="text-muted mb-4">Book an appointment online</p>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Your Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" required autofocus>
</div>
<div class="col">
<label class="form-label">Phone <span class="text-danger">*</span></label>
<input type="tel" class="form-control" name="phone" required inputmode="numeric">
</div>
</div>
<div class="mb-3">
<label class="form-label">Email <small class="text-muted">(for confirmation)</small></label>
<input type="email" class="form-control" name="email">
</div>
<div class="mb-3">
<label class="form-label">Service <span class="text-danger">*</span></label>
<select class="form-select" name="service_id" required>
<option value="">— Select a service —</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }} — ${{ "%.2f"|format(s.price) }} ({{ s.duration_min }} min)</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Preferred Staff <small class="text-muted">(optional)</small></label>
<select class="form-select" name="staff_id">
<option value="">— No preference —</option>
{% for s in staff_list %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Date <span class="text-danger">*</span></label>
<input type="date" class="form-control" name="preferred_date" required>
</div>
<div class="col">
<label class="form-label">Time <span class="text-danger">*</span></label>
<input type="time" class="form-control" name="preferred_time" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="2"></textarea>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">Request Appointment</button>
</div>
</form>
</div>
{% endif %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Not Found</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="text-center">
<h2 class="fw-bold">Booking Page Not Found</h2>
<p class="text-muted">This salon's booking page doesn't exist or is no longer available.</p>
</div>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Booking Unavailable</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="text-center">
<h2 class="fw-bold">Online Booking Unavailable</h2>
<p class="text-muted">Online booking is not currently available for this salon. Please call to book.</p>
</div>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Check-In Not Found</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="text-center">
<h2 class="fw-bold">Check-In Unavailable</h2>
<p class="text-muted">This check-in kiosk is not available. Please see a staff member.</p>
</div>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Customer{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Customer</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ customer.name if customer else '' }}" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" name="phone"
value="{{ customer.phone if customer else '' }}">
</div>
<div class="col">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email"
value="{{ customer.email if customer else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Date of Birth</label>
<input type="date" class="form-control" name="date_of_birth"
value="{{ customer.date_of_birth.isoformat() if customer and customer.date_of_birth else '' }}">
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="3">{{ customer.notes if customer else '' }}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Customers{% 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>Customers</h4>
<a href="{{ url_for('customers.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Customer
</a>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<form method="GET" class="d-flex gap-2">
<input type="search" class="form-control form-control-sm" name="q"
value="{{ q }}" placeholder="Search by name, phone, or email…" autofocus>
<button class="btn btn-outline-secondary btn-sm">Search</button>
{% if q %}<a href="{{ url_for('customers.index') }}" class="btn btn-outline-danger btn-sm">Clear</a>{% endif %}
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>Phone</th><th>Email</th><th>Loyalty</th><th>No-Shows</th><th></th></tr></thead>
<tbody>
{% for c in customers %}
<tr>
<td><a href="{{ url_for('customers.view', customer_id=c.id) }}" class="text-decoration-none fw-semibold">{{ c.name }}</a></td>
<td class="text-muted small">{{ c.phone or '—' }}</td>
<td class="text-muted small">{{ c.email or '—' }}</td>
<td><span class="badge bg-warning text-dark">{{ c.loyalty_points }} pts</span></td>
<td>{% if c.no_show_count > 0 %}<span class="badge bg-danger">{{ c.no_show_count }}</span>{% else %}0{% endif %}</td>
<td><a href="{{ url_for('customers.edit', customer_id=c.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No customers found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ customer.name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0">{{ customer.name }}</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<a href="{{ url_for('appointments.create') }}?customer_id={{ customer.id }}" class="btn btn-primary btn-sm">New Appointment</a>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<p class="mb-1"><i class="bi bi-telephone me-2 text-muted"></i>{{ customer.phone or '—' }}</p>
<p class="mb-1"><i class="bi bi-envelope me-2 text-muted"></i>{{ customer.email or '—' }}</p>
{% if customer.date_of_birth %}
<p class="mb-1"><i class="bi bi-cake me-2 text-muted"></i>{{ customer.date_of_birth.strftime('%B %d') }}</p>
{% endif %}
<p class="mb-1"><i class="bi bi-star me-2 text-warning"></i>{{ customer.loyalty_points }} loyalty points</p>
{% if customer.no_show_count > 0 %}
<p class="mb-0"><i class="bi bi-x-circle me-2 text-danger"></i>{{ customer.no_show_count }} no-show(s)</p>
{% endif %}
{% if customer.notes %}
<hr><p class="text-muted small mb-0">{{ customer.notes }}</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Recent Appointments</div>
{% if appointments %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Date</th><th>Service</th><th>Staff</th><th>Status</th></tr></thead>
<tbody>
{% for a in appointments %}
<tr>
<td class="small">{{ a.start_time.strftime('%Y-%m-%d %I:%M %p') }}</td>
<td class="small">{{ a.service.name if a.service else '—' }}</td>
<td class="small">{{ a.staff.name if a.staff else '—' }}</td>
<td><span class="badge bg-{{ {'completed':'success','cancelled':'danger','no_show':'warning'}.get(a.status,'secondary') }} small">{{ a.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-muted small">No appointments yet.</div>
{% endif %}
</div>
</div>
</div>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
{% endblock %}
+62 -6
View File
@@ -1,10 +1,66 @@
{% extends "tenant/layouts/base.html" %} {% extends "tenant/layouts/base.html" %}
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block content %} {% block content %}
<h4 class="mb-4">Dashboard <div class="d-flex justify-content-between align-items-center mb-4">
{% if location %}<small class="text-muted fs-6">— {{ location.name }}</small>{% endif %} <h4 class="fw-bold mb-0">
</h4> <i class="bi bi-speedometer2 me-2"></i>Dashboard
<div class="alert alert-info"> {% if location %}<small class="text-muted fs-6 ms-2">— {{ location.name }}</small>{% endif %}
Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts). </h4>
<span class="text-muted small">{{ today.strftime('%A, %B %d, %Y') }}</span>
</div> </div>
{% endblock %}
<!-- KPI cards -->
<div class="row g-3 mb-4">
{% set kpi_items = [
("Today's Revenue", "$" ~ "%.2f"|format(kpis.today_revenue), "graph-up-arrow", "success"),
("Appointments", kpis.total_appointments, "calendar3", "primary"),
("Completed", kpis.completed_appointments, "check-circle", "success"),
("Pending", kpis.pending_appointments, "hourglass-split", "warning"),
("Staff on Shift", kpis.staff_on_shift, "person-badge", "info"),
("Check-in Queue", kpis.queue_count, "door-open", "danger" if kpis.queue_count > 0 else "secondary"),
] %}
{% for label, value, icon, color in kpi_items %}
<div class="col-sm-6 col-lg-4 col-xl-2">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3 py-3">
<div class="rounded-circle bg-{{ color }} bg-opacity-10 p-2">
<i class="bi bi-{{ icon }} fs-5 text-{{ color }}"></i>
</div>
<div>
<div class="fw-bold fs-5">{{ value }}</div>
<div class="text-muted" style="font-size:.75rem;">{{ label }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Upcoming appointments -->
<div class="card shadow-sm">
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
<span><i class="bi bi-calendar-check me-2"></i>Upcoming Today</span>
<a href="{{ url_for('appointments.index') }}" class="btn btn-outline-primary btn-sm">Full Calendar</a>
</div>
{% if upcoming_appointments %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Time</th><th>Customer</th><th>Service</th><th>Staff</th><th>Status</th></tr></thead>
<tbody>
{% for appt in upcoming_appointments %}
<tr>
<td class="fw-semibold">{{ appt.start_time.strftime('%I:%M %p') }}</td>
<td>{{ appt.customer.name if appt.customer else '—' }}</td>
<td>{{ appt.service.name if appt.service else '—' }}</td>
<td>{{ appt.staff.name if appt.staff else '—' }}</td>
<td><span class="badge bg-{{ {'pending':'warning','confirmed':'primary','in_progress':'info','completed':'success'}.get(appt.status,'secondary') }}">{{ appt.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-muted small">No upcoming appointments for today.</div>
{% endif %}
</div>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Issue Gift Card{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Issue New Gift Card</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">Value ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" min="1" class="form-control"
name="value" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Customer <small class="text-muted">(optional)</small></label>
<select class="form-select" name="customer_id">
<option value="">— None —</option>
{% for c in customers %}
<option value="{{ c.id }}">{{ c.name }}{% if c.phone %} — {{ c.phone }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Expiry Date <small class="text-muted">(optional)</small></label>
<input type="datetime-local" class="form-control" name="expires_at">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Issue Card</button>
<a href="{{ url_for('gift_cards.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,49 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Gift Cards{% 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-gift me-2"></i>Gift Cards</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('gift_cards.lookup') }}" class="btn btn-outline-secondary btn-sm">Lookup Code</a>
<a href="{{ url_for('gift_cards.issue') }}" class="btn btn-primary btn-sm">+ Issue Gift Card</a>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Code</th><th>Original</th><th>Balance</th><th>Customer</th><th>Expires</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for card in cards %}
<tr class="{{ '' if card.is_active else 'text-muted' }}">
<td class="font-monospace fw-semibold">{{ card.code }}</td>
<td>${{ "%.2f"|format(card.original_value) }}</td>
<td class="fw-semibold {% if card.remaining_balance <= 0 %}text-danger{% endif %}">
${{ "%.2f"|format(card.remaining_balance) }}
</td>
<td class="small">{{ card.customer.name if card.issued_to_customer_id and card.customer else '—' }}</td>
<td class="small text-muted">{{ card.expires_at.strftime('%Y-%m-%d') if card.expires_at else 'No expiry' }}</td>
<td>
<span class="badge {{ 'bg-success' if card.is_active else 'bg-secondary' }}">
{{ 'Active' if card.is_active else 'Inactive' }}
</span>
</td>
<td>
{% if card.is_active %}
<form method="POST" action="{{ url_for('gift_cards.deactivate', card_id=card.id) }}"
class="d-inline" onsubmit="return confirm('Deactivate this gift card?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Deactivate</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No gift cards issued yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Gift Card Lookup{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Gift Card Lookup</div>
<div class="card-body">
<form method="GET" class="mb-3">
<div class="input-group">
<input type="text" class="form-control font-monospace" name="code"
value="{{ code }}" placeholder="XXXX-XXXX-XXXX" autofocus>
<button class="btn btn-primary">Look Up</button>
</div>
</form>
{% if code and card %}
<div class="alert {{ 'alert-success' if card.is_active else 'alert-warning' }}">
<dl class="row mb-0">
<dt class="col-sm-5">Code</dt><dd class="col-sm-7 font-monospace">{{ card.code }}</dd>
<dt class="col-sm-5">Original Value</dt><dd class="col-sm-7">${{ "%.2f"|format(card.original_value) }}</dd>
<dt class="col-sm-5">Remaining Balance</dt>
<dd class="col-sm-7 fw-bold {% if card.remaining_balance <= 0 %}text-danger{% endif %}">
${{ "%.2f"|format(card.remaining_balance) }}
</dd>
<dt class="col-sm-5">Status</dt>
<dd class="col-sm-7">{{ 'Active' if card.is_active else 'Inactive / Depleted' }}</dd>
{% if card.expires_at %}
<dt class="col-sm-5">Expires</dt><dd class="col-sm-7">{{ card.expires_at.strftime('%Y-%m-%d') }}</dd>
{% endif %}
</dl>
</div>
{% elif code %}
<div class="alert alert-danger">No gift card found with code <strong>{{ code }}</strong>.</div>
{% endif %}
<a href="{{ url_for('gift_cards.index') }}" class="btn btn-outline-secondary btn-sm">Back</a>
</div>
</div>
</div>
</div>
{% endblock %}
+10 -10
View File
@@ -37,7 +37,7 @@
{% for loc in locations %} {% for loc in locations %}
<li> <li>
<a class="dropdown-item {% if g.location and g.location.id == loc.id %}active{% endif %}" <a class="dropdown-item {% if g.location and g.location.id == loc.id %}active{% endif %}"
href="{{ '#' }}"> href="{{ url_for('locations.switch', location_id=loc.id) }}">
{{ loc.name }} {{ loc.name }}
{% if loc.is_primary %}<span class="badge bg-secondary ms-1">Primary</span>{% endif %} {% if loc.is_primary %}<span class="badge bg-secondary ms-1">Primary</span>{% endif %}
</a> </a>
@@ -86,31 +86,31 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'appointments' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'appointments' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('appointments.index') }}">
<i class="bi bi-calendar3 me-2"></i>Appointments <i class="bi bi-calendar3 me-2"></i>Appointments
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'pos' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'pos' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('pos.checkout') }}">
<i class="bi bi-cash-register me-2"></i>POS / Checkout <i class="bi bi-cash-register me-2"></i>POS / Checkout
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'customers' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'customers' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('customers.index') }}">
<i class="bi bi-people me-2"></i>Customers <i class="bi bi-people me-2"></i>Customers
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'staff' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'staff' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('staff.index') }}">
<i class="bi bi-person-badge me-2"></i>Staff <i class="bi bi-person-badge me-2"></i>Staff
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'services' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'services' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('services.index') }}">
<i class="bi bi-card-list me-2"></i>Services & Products <i class="bi bi-card-list me-2"></i>Services & Products
</a> </a>
</li> </li>
@@ -122,13 +122,13 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'gift_cards' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'gift_cards' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('gift_cards.index') }}">
<i class="bi bi-gift me-2"></i>Gift Cards <i class="bi bi-gift me-2"></i>Gift Cards
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'reconciliation' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'reconciliation' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('reconciliation.index') }}">
<i class="bi bi-calculator me-2"></i>Reconciliation <i class="bi bi-calculator me-2"></i>Reconciliation
</a> </a>
</li> </li>
@@ -147,13 +147,13 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'locations' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'locations' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('locations.index') }}">
<i class="bi bi-geo-alt me-2"></i>Locations <i class="bi bi-geo-alt me-2"></i>Locations
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'settings' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'settings' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('settings.index') }}">
<i class="bi bi-gear me-2"></i>Settings <i class="bi bi-gear me-2"></i>Settings
</a> </a>
</li> </li>
+62
View File
@@ -0,0 +1,62 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Location{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Location</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ location.name if location else '' }}" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Address</label>
<input type="text" class="form-control" name="address"
value="{{ location.address if location else '' }}">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" name="phone"
value="{{ location.phone if location else '' }}">
</div>
<div class="col">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email"
value="{{ location.email if location else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Timezone</label>
<input type="text" class="form-control" name="timezone"
value="{{ location.timezone if location else 'America/New_York' }}">
</div>
{% if mode == 'edit' %}
<div class="mb-3 d-flex gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if location and location.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_primary" value="1"
id="is_primary" {{ 'checked' if location and location.is_primary }}>
<label class="form-check-label" for="is_primary">Set as Primary</label>
</div>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('locations.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Locations{% 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-geo-alt me-2"></i>Locations</h4>
<a href="{{ url_for('locations.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Location
</a>
</div>
<div class="row g-3">
{% for loc in locations %}
<div class="col-md-6 col-lg-4">
<div class="card shadow-sm h-100 {{ '' if loc.is_active else 'opacity-50' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>{{ loc.name }}</strong>
<div>
{% if loc.is_primary %}<span class="badge bg-primary me-1">Primary</span>{% endif %}
<span class="badge {{ 'bg-success' if loc.is_active else 'bg-secondary' }}">{{ 'Active' if loc.is_active else 'Inactive' }}</span>
</div>
</div>
<div class="card-body small text-muted">
{% if loc.address %}<p class="mb-1"><i class="bi bi-map me-1"></i>{{ loc.address }}</p>{% endif %}
{% if loc.phone %}<p class="mb-1"><i class="bi bi-telephone me-1"></i>{{ loc.phone }}</p>{% endif %}
{% if loc.email %}<p class="mb-1"><i class="bi bi-envelope me-1"></i>{{ loc.email }}</p>{% endif %}
<p class="mb-0"><i class="bi bi-clock me-1"></i>{{ loc.timezone }}</p>
</div>
<div class="card-footer d-flex gap-2">
<a href="{{ url_for('locations.edit', location_id=loc.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
{% if not loc.is_primary %}
<form method="POST" action="{{ url_for('locations.set_primary', location_id=loc.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-primary btn-sm">Set Primary</button>
</form>
{% endif %}
<a href="{{ url_for('locations.switch', location_id=loc.id) }}" class="btn btn-outline-success btn-sm ms-auto">Switch</a>
</div>
</div>
</div>
{% else %}
<div class="col"><p class="text-muted">No locations found.</p></div>
{% endfor %}
</div>
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}POS Checkout{% endblock %}
{% block content %}
<h4 class="fw-bold mb-4"><i class="bi bi-cash-register me-2"></i>Checkout</h4>
<form method="POST" action="{{ url_for('pos.submit') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if appointment %}
<input type="hidden" name="appointment_id" value="{{ appointment.id }}">
{% endif %}
<div class="row g-3">
<!-- Left: items -->
<div class="col-lg-7">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Services</div>
<div class="card-body">
{% for s in services %}
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" name="service_ids"
value="{{ s.id }}" id="svc_{{ s.id }}"
{{ 'checked' if appointment and appointment.service_id == s.id }}>
<label class="form-check-label" for="svc_{{ s.id }}">
{{ s.name }} <span class="text-muted small">({{ s.duration_min }}min)</span>
<span class="fw-semibold">${{ "%.2f"|format(s.price) }}</span>
</label>
</div>
{% endfor %}
</div>
</div>
{% if services %}
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Products</div>
<div class="card-body">
{% for p in products %}
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" name="product_ids"
value="{{ p.id }}" id="prod_{{ p.id }}">
<label class="form-check-label" for="prod_{{ p.id }}">
{{ p.name }} <span class="fw-semibold">${{ "%.2f"|format(p.sale_price) }}</span>
</label>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Right: payment details -->
<div class="col-lg-5">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Payment Details</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Customer</label>
<select class="form-select" name="customer_id">
<option value="">— Walk-in / No Customer —</option>
{% for c in customers %}
<option value="{{ c.id }}"
{{ 'selected' if appointment and appointment.customer_id == c.id }}>
{{ c.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Not assigned —</option>
{% for s in staff_list %}
<option value="{{ s.id }}"
{{ 'selected' if appointment and appointment.staff_id == s.id }}>
{{ s.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Payment Method</label>
<select class="form-select" name="payment_method">
{% for m in payment_methods %}
<option value="{{ m }}">{{ m.title() }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Payment Reference <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" name="payment_reference"
placeholder="Transaction ID, note…">
</div>
<div class="mb-3">
<label class="form-label">Tip ($)</label>
<input type="number" step="0.01" class="form-control" name="tip_amount"
value="0" min="0">
</div>
<div class="mb-3">
<label class="form-label">Gift Card Code</label>
<input type="text" class="form-control font-monospace" name="gift_card_code"
placeholder="XXXX-XXXX-XXXX">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="rebook" value="1" id="rebook">
<label class="form-check-label" for="rebook">Schedule next visit after checkout</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success btn-lg">
<i class="bi bi-check-circle me-2"></i>Complete Checkout
</button>
</div>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Schedule Next Visit{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-calendar-plus me-2"></i>Schedule Next Visit</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">Date & Time <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="start_time" required>
</div>
<div class="mb-3">
<label class="form-label">Service</label>
<select class="form-select" name="service_id">
<option value="">— Same as today —</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Any —</option>
{% for s in staff_list %}
<option value="{{ s.id }}" {{ 'selected' if transaction.staff_id == s.id }}>{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Schedule</button>
<a href="{{ url_for('pos.receipt', transaction_id=transaction.id) }}" class="btn btn-outline-secondary">Skip</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Receipt #{{ transaction.id }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<h5 class="fw-bold">{{ tenant.name }}</h5>
{% if location %}<p class="text-muted small mb-0">{{ location.name }}</p>{% endif %}
<p class="text-muted small">{{ transaction.created_at.strftime('%B %d, %Y %I:%M %p') }}</p>
<p class="text-muted small">Receipt #{{ transaction.id }}</p>
{% if transaction.voided_at %}
<div class="alert alert-danger py-1 mt-2">VOIDED — {{ transaction.void_reason }}</div>
{% endif %}
</div>
<table class="table table-sm mb-3">
<tbody>
{% for item in items %}
<tr>
<td>
{% if item.service %}{{ item.service.name }}
{% elif item.product %}{{ item.product.name }}
{% endif %}
{% if item.discount_percent > 0 %}
<span class="badge bg-success ms-1">{{ item.discount_percent }}% off</span>
{% endif %}
</td>
<td class="text-end">
{% if item.discount_percent > 0 %}
<del class="text-muted small">${{ "%.2f"|format(item.original_price) }}</del>
{% endif %}
${{ "%.2f"|format(item.unit_price) }}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="fw-semibold">
{% if transaction.discount > 0 %}
<tr class="text-success"><td>Savings</td><td class="text-end">-${{ "%.2f"|format(transaction.discount) }}</td></tr>
{% endif %}
{% if transaction.tip_amount > 0 %}
<tr><td>Tip</td><td class="text-end">${{ "%.2f"|format(transaction.tip_amount) }}</td></tr>
{% endif %}
{% if transaction.gift_card_amount > 0 %}
<tr class="text-info"><td>Gift Card</td><td class="text-end">-${{ "%.2f"|format(transaction.gift_card_amount) }}</td></tr>
{% endif %}
<tr class="table-dark fs-5"><td>Total</td><td class="text-end">${{ "%.2f"|format(transaction.total) }}</td></tr>
<tr><td>Payment</td><td class="text-end">{{ transaction.payment_method.title() }}</td></tr>
</tfoot>
</table>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="{{ url_for('pos.checkout') }}" class="btn btn-primary">New Transaction</a>
<a href="{{ url_for('pos.rebook', transaction_id=transaction.id) }}" class="btn btn-outline-primary">Rebook</a>
{% if not transaction.voided_at %}
<a href="{{ url_for('pos.void', transaction_id=transaction.id) }}" class="btn btn-outline-danger">Void</a>
{% endif %}
<a href="{{ url_for('pos.transactions') }}" class="btn btn-outline-secondary">Transactions</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Transactions{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold mb-0">Transactions</h4>
<input type="date" class="form-control form-control-sm" value="{{ view_date.isoformat() }}"
onchange="window.location='{{ url_for('pos.transactions') }}?date='+this.value"
style="width:150px;">
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Time</th><th>Customer</th><th>Staff</th><th>Total</th><th>Method</th><th>Tip</th><th></th></tr></thead>
<tbody>
{% for t in transactions %}
<tr>
<td class="small">{{ t.created_at.strftime('%I:%M %p') }}</td>
<td class="small">{{ t.customer.name if t.customer else '—' }}</td>
<td class="small">{{ t.staff.name if t.staff else '—' }}</td>
<td class="fw-semibold">${{ "%.2f"|format(t.total) }}</td>
<td class="small">{{ t.payment_method.title() }}</td>
<td class="small">{% if t.tip_amount > 0 %}${{ "%.2f"|format(t.tip_amount) }}{% else %}—{% endif %}</td>
<td><a href="{{ url_for('pos.receipt', transaction_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 transactions for this day.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Void Transaction{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm border-danger">
<div class="card-header bg-danger text-white fw-semibold">Void Transaction #{{ transaction.id }}</div>
<div class="card-body">
<p class="text-muted">Total: <strong>${{ "%.2f"|format(transaction.total) }}</strong></p>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate onsubmit="return confirm('Are you sure you want to void this transaction?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Reason <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="reason" required autofocus>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">Confirm Void</button>
<a href="{{ url_for('pos.receipt', transaction_id=transaction.id) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,42 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Close Day{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-calculator me-2"></i>Close Day — {{ today.strftime('%B %d, %Y') }}
</div>
<div class="card-body">
<!-- Summary from transactions -->
<table class="table table-sm mb-4">
<tbody>
<tr><td>Cash Sales</td><td class="text-end fw-semibold">${{ "%.2f"|format(total_cash) }}</td></tr>
<tr><td>App Payments (Zelle / Venmo / etc.)</td><td class="text-end">${{ "%.2f"|format(total_app) }}</td></tr>
<tr><td>Tips Collected</td><td class="text-end">${{ "%.2f"|format(total_tips) }}</td></tr>
<tr><td>Gift Card Redemptions</td><td class="text-end">${{ "%.2f"|format(total_gc) }}</td></tr>
<tr class="table-light fw-bold"><td>Expected Cash in Drawer</td><td class="text-end">${{ "%.2f"|format(expected_cash) }}</td></tr>
</tbody>
</table>
{% 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 fw-semibold">Actual Cash Counted ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" min="0" class="form-control form-control-lg"
name="actual_cash" required autofocus placeholder="0.00">
</div>
<div class="mb-3">
<label class="form-label">Notes <small class="text-muted">(optional)</small></label>
<textarea class="form-control" name="notes" rows="2"></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Close Day</button>
<a href="{{ url_for('reconciliation.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Reconciliation{% 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-calculator me-2"></i>End-of-Day Reconciliation</h4>
<a href="{{ url_for('reconciliation.close_day') }}" class="btn btn-primary btn-sm">Close Today</a>
</div>
{% if records %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Date</th><th>Cash</th><th>App Payments</th><th>Tips</th><th>Gift Cards</th><th>Expected</th><th>Actual</th><th>Variance</th><th>Closed By</th></tr>
</thead>
<tbody>
{% for r in records %}
<tr>
<td class="fw-semibold">{{ r.date.strftime('%Y-%m-%d') }}</td>
<td>${{ "%.2f"|format(r.total_cash) }}</td>
<td>${{ "%.2f"|format(r.total_app_payments) }}</td>
<td>${{ "%.2f"|format(r.total_tips) }}</td>
<td>${{ "%.2f"|format(r.total_gift_card_redemptions) }}</td>
<td>${{ "%.2f"|format(r.expected_cash_in_drawer) }}</td>
<td>${{ "%.2f"|format(r.actual_cash_counted) }}</td>
<td class="fw-semibold {{ 'text-danger' if r.variance < 0 else 'text-success' if r.variance > 0 else '' }}">
{{ '+' if r.variance > 0 else '' }}${{ "%.2f"|format(r.variance) }}
</td>
<td class="small text-muted">{{ r.closed_at.strftime('%I:%M %p') if r.closed_at else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No reconciliation records yet.</div>
{% endif %}
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Reviews{% 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-star me-2"></i>Customer Reviews</h4>
<div class="text-end">
<div class="fs-3 fw-bold text-warning">{{ avg_rating }} ★</div>
<div class="text-muted small">Average rating</div>
</div>
</div>
<!-- Rating distribution -->
<div class="card shadow-sm mb-4" style="max-width:400px;">
<div class="card-body py-2">
{% for star in [5,4,3,2,1] %}
{% set count = rating_dist.get(star, 0) %}
{% set total = reviews|length %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="text-muted small" style="width:20px;">{{ star }}★</span>
<div class="progress flex-grow-1" style="height:8px;">
<div class="progress-bar bg-warning"
style="width:{{ ((count / total * 100) | int) if total else 0 }}%"></div>
</div>
<span class="text-muted small" style="width:30px;">{{ count }}</span>
</div>
{% endfor %}
</div>
</div>
{% if reviews %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Date</th><th>Rating</th><th>Customer</th><th>Staff</th><th>Comment</th></tr></thead>
<tbody>
{% for r in reviews %}
<tr>
<td class="small text-muted">{{ r.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<span class="text-warning fw-bold">{{ r.rating }}★</span>
</td>
<td class="small">{{ r.customer.name if r.customer else '—' }}</td>
<td class="small">{{ r.staff.name if r.staff else '—' }}</td>
<td class="small">{{ r.comment or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No reviews yet.</div>
{% endif %}
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Services & Products{% 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>Services & Products</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('services.create_service') }}" class="btn btn-primary btn-sm">+ Service</a>
<a href="{{ url_for('services.create_product') }}" class="btn btn-outline-primary btn-sm">+ Product</a>
<a href="{{ url_for('services.create_promotion') }}" class="btn btn-outline-warning btn-sm">+ Promotion</a>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Services</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>Category</th><th>Duration</th><th>Price</th><th></th></tr></thead>
<tbody>
{% for s in services %}
<tr class="{{ '' if s.is_active else 'text-muted' }}">
<td class="fw-semibold">{{ s.name }}</td>
<td class="small">{{ s.category or '—' }}</td>
<td class="small">{{ s.duration_min }} min</td>
<td>${{ "%.2f"|format(s.price) }}</td>
<td>
<a href="{{ url_for('services.edit_service', service_id=s.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<form method="POST" action="{{ url_for('services.delete_service', service_id=s.id) }}" class="d-inline"
onsubmit="return confirm('Delete this service?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Del</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No services yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Products</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>SKU</th><th>Price</th><th></th></tr></thead>
<tbody>
{% for p in products %}
<tr class="{{ '' if p.is_active else 'text-muted' }}">
<td class="fw-semibold">{{ p.name }}</td>
<td class="small text-muted">{{ p.sku or '—' }}</td>
<td>${{ "%.2f"|format(p.sale_price) }}</td>
<td>
<a href="{{ url_for('services.edit_product', product_id=p.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<form method="POST" action="{{ url_for('services.delete_product', product_id=p.id) }}" class="d-inline"
onsubmit="return confirm('Delete this product?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Del</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-3">No products yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if promotions %}
<div class="card shadow-sm mt-3 border-warning">
<div class="card-header fw-semibold bg-warning-subtle">Active Promotions</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Name</th><th>Discount</th><th>Applies To</th><th>Ends</th><th></th></tr></thead>
<tbody>
{% for promo in promotions %}
<tr>
<td class="fw-semibold">{{ promo.name }}</td>
<td>{{ promo.discount_percent }}%</td>
<td class="small">{{ promo.applies_to }}</td>
<td class="small text-muted">{{ promo.ends_at.strftime('%Y-%m-%d') }}</td>
<td>
<form method="POST" action="{{ url_for('services.toggle_promotion', promo_id=promo.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-{{ 'danger' if promo.is_active else 'success' }} btn-sm">
{{ 'Deactivate' if promo.is_active else 'Activate' }}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Product{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Product</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ product.name if product else '' }}" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">SKU</label>
<input type="text" class="form-control" name="sku"
value="{{ product.sku if product else '' }}">
</div>
<div class="col">
<label class="form-label">Category</label>
<input type="text" class="form-control" name="category"
value="{{ product.category if product else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Sale Price ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" class="form-control" name="sale_price" min="0"
value="{{ "%.2f"|format(product.sale_price) if product else '' }}" required>
</div>
{% if mode == 'edit' %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if product and product.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,84 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}New Promotion{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">New Promotion</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">Promotion Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Discount % <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="discount_percent" min="1" max="100" required>
</div>
<div class="col">
<label class="form-label">Applies To <span class="text-danger">*</span></label>
<select class="form-select" name="applies_to" id="applies_to"
onchange="toggleTargets(this.value)">
<option value="all_services">All Services</option>
<option value="all_products">All Products</option>
<option value="all">All Services & Products</option>
<option value="service">Specific Services</option>
<option value="product">Specific Products</option>
</select>
</div>
</div>
<div id="target_services" class="mb-3 d-none">
<label class="form-label">Select Services</label>
{% for svc in services %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="target_ids"
value="{{ svc.id }}" id="svc_{{ svc.id }}">
<label class="form-check-label" for="svc_{{ svc.id }}">
{{ svc.name }} — ${{ "%.2f"|format(svc.price) }}
</label>
</div>
{% endfor %}
</div>
<div id="target_products" class="mb-3 d-none">
<label class="form-label">Select Products</label>
{% for p in products %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="target_ids"
value="{{ p.id }}" id="prod_{{ p.id }}">
<label class="form-check-label" for="prod_{{ p.id }}">
{{ p.name }} — ${{ "%.2f"|format(p.sale_price) }}
</label>
</div>
{% endfor %}
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Starts At <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="starts_at" required>
</div>
<div class="col">
<label class="form-label">Ends At <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="ends_at" required>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning">Create Promotion</button>
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleTargets(val) {
document.getElementById("target_services").classList.toggle("d-none", val !== "service");
document.getElementById("target_products").classList.toggle("d-none", val !== "product");
}
</script>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Service{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Service</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ service.name if service else '' }}" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<input type="text" class="form-control" name="category"
value="{{ service.category if service else '' }}" placeholder="e.g. Nails, Spa, Waxing">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Duration (min) <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="duration_min" min="5" max="480"
value="{{ service.duration_min if service else 30 }}" required>
</div>
<div class="col">
<label class="form-label">Price ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" class="form-control" name="price" min="0"
value="{{ "%.2f"|format(service.price) if service else '' }}" required>
</div>
</div>
{% if mode == 'edit' %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if service and service.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-7">
<h4 class="fw-bold mb-4"><i class="bi bi-gear me-2"></i>Settings</h4>
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="{{ url_for('settings.save') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h6 class="fw-semibold text-muted mb-3">Booking</h6>
<div class="mb-3">
<label class="form-label">Advance Booking Days</label>
<input type="number" class="form-control" name="booking_advance_days"
value="{{ settings.get('booking_advance_days', '30') }}" min="1" max="365">
<div class="form-text">How many days ahead customers can book online.</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="auto_confirm_bookings"
value="true" id="auto_confirm"
{{ 'checked' if settings.get('auto_confirm_bookings') == 'true' }}>
<label class="form-check-label" for="auto_confirm">Auto-confirm online bookings</label>
</div>
</div>
<h6 class="fw-semibold text-muted mb-3 mt-4">Reviews</h6>
<div class="mb-3">
<label class="form-label">Review Request Delay (minutes after checkout)</label>
<input type="number" class="form-control" name="review_request_delay_minutes"
value="{{ settings.get('review_request_delay_minutes', '60') }}" min="0">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Google Review URL</label>
<input type="url" class="form-control" name="google_review_url"
value="{{ settings.get('google_review_url', '') }}" placeholder="https://g.page/…">
</div>
<div class="col">
<label class="form-label">Yelp Review URL</label>
<input type="url" class="form-control" name="yelp_review_url"
value="{{ settings.get('yelp_review_url', '') }}" placeholder="https://yelp.com/…">
</div>
</div>
<h6 class="fw-semibold text-muted mb-3 mt-4">Receipt</h6>
<div class="mb-3">
<label class="form-label">Receipt Footer Note</label>
<textarea class="form-control" name="receipt_footer_note" rows="2">{{ settings.get('receipt_footer_note', '') }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ staff.name if staff else '' }}" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Phone <span class="text-danger">*</span></label>
<input type="tel" class="form-control" name="phone"
value="{{ staff.phone if staff else '' }}" required>
</div>
{% if mode == 'create' %}
<div class="mb-3">
<label class="form-label">Passcode (46 digits) <span class="text-danger">*</span></label>
<input type="password" class="form-control" name="passcode"
inputmode="numeric" maxlength="6" required>
<div class="form-text">Staff will use this PIN to log in. Show it to them once, then discard it.</div>
</div>
{% endif %}
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Job Type</label>
<select class="form-select" name="staff_type">
{% for t in ['full_time','part_time','seasonal','receptionist','salon_manager'] %}
<option value="{{ t }}" {{ 'selected' if staff and staff.staff_type == t }}>
{{ t.replace('_',' ').title() }}
</option>
{% endfor %}
</select>
</div>
<div class="col">
<label class="form-label">Pay Type</label>
<select class="form-select" name="pay_type">
{% for t in ['hourly','salary','guarantee'] %}
<option value="{{ t }}" {{ 'selected' if staff and staff.pay_type == t }}>{{ t.title() }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Location Assignments</label>
{% for loc in locations %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="location_ids"
value="{{ loc.id }}" id="loc_{{ loc.id }}"
{{ 'checked' if loc.id in (assigned_ids or set()) }}>
<label class="form-check-label" for="loc_{{ loc.id }}">{{ loc.name }}</label>
</div>
{% endfor %}
</div>
{% if mode == 'edit' %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if staff and staff.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('staff.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Staff{% 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-person-badge me-2"></i>Staff</h4>
<a href="{{ url_for('staff.create') }}" class="btn btn-primary btn-sm">+ New Staff Member</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>Phone</th><th>Type</th><th>Pay Type</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{% for s in staff_list %}
<tr class="{{ '' if s.is_active else 'text-muted' }}">
<td class="fw-semibold">{{ s.name }}</td>
<td class="small">{{ s.phone }}</td>
<td class="small">{{ s.staff_type.replace('_',' ').title() }}</td>
<td class="small">{{ s.pay_type.title() }}</td>
<td><span class="badge {{ 'bg-success' if s.is_active else 'bg-secondary' }}">{{ 'Active' if s.is_active else 'Inactive' }}</span></td>
<td>
<a href="{{ url_for('staff.edit', staff_id=s.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<a href="{{ url_for('staff.reset_passcode', staff_id=s.id) }}" class="btn btn-outline-warning btn-sm">Reset PIN</a>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No staff members yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Reset Passcode — {{ staff.name }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm border-warning">
<div class="card-header bg-warning-subtle fw-semibold">Reset Passcode — {{ staff.name }}</div>
<div class="card-body">
<p class="text-muted small">Enter a new 46 digit PIN for this staff member. Show it to them once, then discard it.</p>
{% 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">New Passcode</label>
<input type="text" class="form-control form-control-lg text-center font-monospace"
name="passcode" inputmode="numeric" maxlength="6"
placeholder="••••" autofocus required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning">Set New Passcode</button>
<a href="{{ url_for('staff.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Commission & Pay{% endblock %}
{% block content %}
<h4 class="fw-bold mb-4">Commission & Pay Summary</h4>
{% if pay_periods %}
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Pay Periods</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Period</th><th>Type</th><th>Base</th><th>Commission</th><th>Total</th><th>Status</th></tr></thead>
<tbody>
{% for pp in pay_periods %}
<tr>
<td class="small">{{ pp.period_start.strftime('%b %d') }}{{ pp.period_end.strftime('%b %d, %Y') }}</td>
<td class="small">{{ pp.pay_type.title() }}</td>
<td>${{ "%.2f"|format(pp.base_amount) }}</td>
<td>${{ "%.2f"|format(pp.commission_amount) }}</td>
<td class="fw-semibold">${{ "%.2f"|format(pp.total_amount) }}</td>
<td><span class="badge bg-{{ {'paid':'success','approved':'primary','draft':'secondary'}.get(pp.status,'secondary') }}">{{ pp.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if commission_logs %}
<div class="card shadow-sm">
<div class="card-header fw-semibold">Recent Commissions</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Period</th><th>Amount</th></tr></thead>
<tbody>
{% for log in commission_logs %}
<tr><td class="small">{{ log.period or '—' }}</td><td>${{ "%.2f"|format(log.amount) }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<a href="{{ url_for('staff_portal.index') }}" class="btn btn-outline-secondary btn-sm mt-3">Back</a>
{% endblock %}
@@ -0,0 +1,69 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Staff Portal{% 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-person-badge me-2"></i>Welcome, {{ staff.name }}
</h4>
<span class="text-muted small">{{ today.strftime('%A, %B %d') }}</span>
</div>
<!-- Clock in/out -->
<div class="card shadow-sm mb-3 {{ 'border-success' if current_clocking else '' }}">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
{% if current_clocking %}
<span class="badge bg-success fs-6 me-2">On Shift</span>
<span class="text-muted small">Since {{ current_clocking.clocked_in_at.strftime('%I:%M %p') }}</span>
{% else %}
<span class="badge bg-secondary fs-6">Off Shift</span>
{% endif %}
</div>
<div>
{% if current_clocking %}
<form method="POST" action="{{ url_for('staff_portal.clock_out') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger">Clock Out</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('staff_portal.clock_in') }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-success">Clock In</button>
</form>
{% endif %}
</div>
</div>
</div>
<!-- Today's appointments -->
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">
Today's Appointments
<a href="{{ url_for('staff_portal.schedule') }}" class="btn btn-outline-secondary btn-sm float-end">Full Schedule</a>
</div>
{% if todays_appointments %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Time</th><th>Customer</th><th>Service</th><th>Status</th></tr></thead>
<tbody>
{% for a in todays_appointments %}
<tr>
<td class="fw-semibold">{{ a.start_time.strftime('%I:%M %p') }}</td>
<td>{{ a.customer.name if a.customer else 'Walk-in' }}</td>
<td class="small">{{ a.service.name if a.service else '—' }}</td>
<td><span class="badge bg-{{ {'confirmed':'primary','in_progress':'info','pending':'warning'}.get(a.status,'secondary') }}">{{ a.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-muted small">No appointments scheduled for today.</div>
{% endif %}
</div>
<div class="row g-2">
<div class="col-6"><a href="{{ url_for('staff_portal.commission') }}" class="btn btn-outline-primary w-100">Commission & Pay</a></div>
<div class="col-6"><a href="{{ url_for('staff_portal.profile') }}" class="btn btn-outline-secondary w-100">My Profile</a></div>
</div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}My Profile{% endblock %}
{% block content %}
<h4 class="fw-bold mb-4">My Profile</h4>
<div class="card shadow-sm" style="max-width:400px;">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Name</dt><dd class="col-sm-8">{{ staff.name }}</dd>
<dt class="col-sm-4">Phone</dt><dd class="col-sm-8">{{ staff.phone }}</dd>
<dt class="col-sm-4">Type</dt><dd class="col-sm-8">{{ staff.staff_type.replace('_',' ').title() }}</dd>
<dt class="col-sm-4">Pay Type</dt><dd class="col-sm-8">{{ staff.pay_type.title() }}</dd>
</dl>
</div>
</div>
<a href="{{ url_for('staff_portal.index') }}" class="btn btn-outline-secondary btn-sm mt-3">Back</a>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}My Schedule{% endblock %}
{% block content %}
<h4 class="fw-bold mb-4">My Schedule — Next 7 Days</h4>
{% if appointments %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Date</th><th>Time</th><th>Customer</th><th>Service</th><th>Status</th></tr></thead>
<tbody>
{% for a in appointments %}
<tr>
<td class="small">{{ a.start_time.strftime('%b %d') }}</td>
<td class="fw-semibold">{{ a.start_time.strftime('%I:%M %p') }}</td>
<td>{{ a.customer.name if a.customer else 'Walk-in' }}</td>
<td class="small">{{ a.service.name if a.service else '—' }}</td>
<td><span class="badge bg-{{ {'confirmed':'primary','in_progress':'info','pending':'warning'}.get(a.status,'secondary') }}">{{ a.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No upcoming appointments in the next 7 days.</div>
{% endif %}
<a href="{{ url_for('staff_portal.index') }}" class="btn btn-outline-secondary btn-sm mt-3">Back</a>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Add to Waitlist{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Add to Waitlist</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">Customer Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="customer_name" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" name="customer_phone">
</div>
<div class="col">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="customer_email">
</div>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Preferred Service</label>
<select class="form-select" name="service_id">
<option value="">— Any —</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col">
<label class="form-label">Preferred Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Any —</option>
{% for s in staff_list %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Requested Date</label>
<input type="date" class="form-control" name="requested_date">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Add to Waitlist</button>
<a href="{{ url_for('waitlist.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Waitlist{% 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-clock-history me-2"></i>Waitlist</h4>
<a href="{{ url_for('waitlist.add') }}" class="btn btn-primary btn-sm">+ Add to Waitlist</a>
</div>
{% if entries %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Name</th><th>Phone</th><th>Service</th><th>Staff</th><th>Requested</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td class="fw-semibold">{{ e.customer_name }}</td>
<td class="small">{{ e.customer_phone or '—' }}</td>
<td class="small">{{ e.service.name if e.service else '—' }}</td>
<td class="small">{{ e.staff.name if e.staff else '—' }}</td>
<td class="small text-muted">{{ e.requested_date.strftime('%b %d') if e.requested_date else '—' }}</td>
<td>
<span class="badge {{ 'bg-warning text-dark' if e.status == 'waiting' else 'bg-info' }}">
{{ e.status }}
</span>
</td>
<td class="d-flex gap-1">
{% if e.status == 'waiting' %}
<form method="POST" action="{{ url_for('waitlist.notify', entry_id=e.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-primary btn-sm">Notify</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('waitlist.set_status', entry_id=e.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" value="booked">
<button class="btn btn-outline-success btn-sm">Booked</button>
</form>
<form method="POST" action="{{ url_for('waitlist.set_status', entry_id=e.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" value="expired">
<button class="btn btn-outline-secondary btn-sm">Expire</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No active waitlist entries.</div>
{% endif %}
{% endblock %}
+28 -8
View File
@@ -92,16 +92,36 @@ def create_tenant_app(config_override=None):
from app.tenant.staff_auth.routes import staff_auth_bp from app.tenant.staff_auth.routes import staff_auth_bp
from app.tenant.checkin.routes import checkin_bp from app.tenant.checkin.routes import checkin_bp
from app.tenant.dashboard.routes import dashboard_bp from app.tenant.dashboard.routes import dashboard_bp
from app.tenant.locations.routes import locations_bp
from app.tenant.customers.routes import customers_bp
from app.tenant.services.routes import services_bp
from app.tenant.appointments.routes import appointments_bp
from app.tenant.pos.routes import pos_bp
from app.tenant.gift_cards.routes import gift_cards_bp
from app.tenant.staff.routes import staff_bp
from app.tenant.staff_portal.routes import staff_portal_bp
from app.tenant.booking.routes import booking_bp
from app.tenant.waitlist.routes import waitlist_bp
from app.tenant.reviews.routes import reviews_bp
from app.tenant.reconciliation.routes import reconciliation_bp
from app.tenant.settings.routes import settings_bp
flask_app.register_blueprint(tenant_auth_bp) for bp in [
flask_app.register_blueprint(staff_auth_bp) tenant_auth_bp, staff_auth_bp, checkin_bp, dashboard_bp,
flask_app.register_blueprint(checkin_bp) locations_bp, customers_bp, services_bp, appointments_bp,
flask_app.register_blueprint(dashboard_bp) pos_bp, gift_cards_bp, staff_bp, staff_portal_bp,
booking_bp, waitlist_bp, reviews_bp, reconciliation_bp,
settings_bp,
]:
flask_app.register_blueprint(bp)
# Remaining blueprints registered in Phases 36: # Phase 4+ stubs (inventory, marketing, reports)
# locations, customers, appointments, services, pos, staff, from app.tenant.inventory.routes import inventory_bp
# staff_portal, booking, waitlist, gift_cards, reviews, from app.tenant.marketing.routes import marketing_bp
# reconciliation, inventory, marketing, reports, settings from app.tenant.reports.routes import reports_bp
flask_app.register_blueprint(inventory_bp)
flask_app.register_blueprint(marketing_bp)
flask_app.register_blueprint(reports_bp)
# ── Import all models for Migrate ───────────────────────── # ── Import all models for Migrate ─────────────────────────
import app.models # noqa: F401 import app.models # noqa: F401
+222 -2
View File
@@ -1,7 +1,227 @@
""" """
app/tenant/appointments/routes.py app/tenant/appointments/routes.py
Phase 3+ implementation. Appointment management: calendar view, create, edit, status workflow,
cancellation reason, no-show capture.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone, timedelta, date
from flask import Blueprint, render_template, redirect, url_for, flash, request, g, jsonify
from flask_login import login_required
from app.extensions import db
from app.models.salon import Appointment, Customer, Staff, Service, Location
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments") appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments")
VALID_STATUSES = {"pending", "confirmed", "in_progress", "completed", "cancelled", "no_show"}
@appointments_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
# Default to today
date_str = request.args.get("date", date.today().isoformat())
try:
view_date = date.fromisoformat(date_str)
except ValueError:
view_date = date.today()
day_start = datetime.combine(view_date, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
appts = Appointment.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Appointment.start_time >= day_start,
Appointment.start_time < day_end,
).order_by(Appointment.start_time).all()
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
return render_template("tenant/appointments/index.html",
appointments=appts, view_date=view_date,
staff_list=staff_list, services=services)
@appointments_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create():
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
customers = Customer.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
if request.method == "POST":
start_raw = request.form.get("start_time", "")
if not start_raw:
return render_template("tenant/appointments/form.html",
mode="create", staff_list=staff_list,
services=services, customers=customers,
error="Start time is required.")
try:
start_time = datetime.fromisoformat(start_raw)
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
except ValueError:
return render_template("tenant/appointments/form.html",
mode="create", staff_list=staff_list,
services=services, customers=customers,
error="Invalid date/time format.")
service_id = request.form.get("service_id", type=int)
svc = Service.query.get(service_id) if service_id else None
duration = svc.duration_min if svc else 30
end_time = start_time + timedelta(minutes=duration)
customer_id = request.form.get("customer_id", type=int) or None
staff_id = request.form.get("staff_id", type=int) or None
is_walk_in = request.form.get("is_walk_in") == "1"
appt = Appointment(
tenant_id=g.tenant.id,
location_id=g.location.id,
customer_id=customer_id,
staff_id=staff_id,
service_id=service_id,
start_time=start_time,
end_time=end_time,
is_walk_in=is_walk_in,
status="confirmed" if not is_walk_in else "in_progress",
notes=request.form.get("notes", "").strip() or None,
rebook_source="manual",
created_by=_user_db_id(),
)
db.session.add(appt)
db.session.flush()
log_tenant_action("appointment.create", "appointment", appt.id,
{"customer": customer_id, "staff": staff_id,
"start": start_raw})
db.session.commit()
flash("Appointment created.", "success")
return redirect(url_for("appointments.index",
date=start_time.date().isoformat()))
return render_template("tenant/appointments/form.html",
mode="create", staff_list=staff_list,
services=services, customers=customers)
@appointments_bp.route("/<int:appt_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def view(appt_id):
appt = Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id).first_or_404()
return render_template("tenant/appointments/view.html", appointment=appt)
@appointments_bp.route("/<int:appt_id>/status", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def set_status(appt_id):
appt = Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id).first_or_404()
new_status = request.form.get("status", "").strip()
if new_status not in VALID_STATUSES:
flash("Invalid status.", "danger")
return redirect(url_for("appointments.view", appt_id=appt_id))
old_status = appt.status
appt.status = new_status
if new_status == "cancelled":
appt.cancellation_reason = request.form.get("reason", "").strip() or None
appt.cancelled_at = datetime.now(timezone.utc)
# Cancel pending reminders
from app.models.salon import AppointmentReminder
AppointmentReminder.query.filter_by(
appointment_id=appt_id, status="pending"
).update({"status": "cancelled"})
elif new_status == "no_show" and appt.customer_id:
# Increment no-show counter on customer record
Customer.query.filter_by(id=appt.customer_id).update(
{"no_show_count": Customer.no_show_count + 1}
)
log_tenant_action("appointment.set_status", "appointment", appt.id,
{"old": old_status, "new": new_status})
db.session.commit()
flash(f"Appointment status updated to '{new_status}'.", "success")
return redirect(url_for("appointments.view", appt_id=appt_id))
@appointments_bp.route("/<int:appt_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit(appt_id):
appt = Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id).first_or_404()
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
customers = Customer.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
if request.method == "POST":
start_raw = request.form.get("start_time", "")
try:
start_time = datetime.fromisoformat(start_raw)
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
except ValueError:
return render_template("tenant/appointments/form.html",
mode="edit", appointment=appt,
staff_list=staff_list, services=services,
customers=customers,
error="Invalid date/time.")
service_id = request.form.get("service_id", type=int)
svc = Service.query.get(service_id) if service_id else None
duration = svc.duration_min if svc else 30
appt.customer_id = request.form.get("customer_id", type=int) or None
appt.staff_id = request.form.get("staff_id", type=int) or None
appt.service_id = service_id
appt.start_time = start_time
appt.end_time = start_time + timedelta(minutes=duration)
appt.notes = request.form.get("notes", "").strip() or None
log_tenant_action("appointment.edit", "appointment", appt.id,
{"start": start_raw})
db.session.commit()
flash("Appointment updated.", "success")
return redirect(url_for("appointments.view", appt_id=appt_id))
return render_template("tenant/appointments/form.html",
mode="edit", appointment=appt,
staff_list=staff_list, services=services,
customers=customers)
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+124 -3
View File
@@ -1,7 +1,128 @@
""" """
app/tenant/booking/routes.py app/tenant/booking/routes.py
Phase 3+ implementation. Public online customer booking /book/<tenant_slug>
No authentication required.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone, timedelta, date
from flask import Blueprint, render_template, request, g
from app.extensions import db, limiter, csrf, mail
from app.models.platform import Tenant
from app.models.salon import Appointment, Customer, Staff, Service, Location
from app.decorators import tenant_feature_required
booking_bp = Blueprint("booking", __name__, url_prefix="") logger = logging.getLogger(__name__)
booking_bp = Blueprint("booking", __name__)
@booking_bp.route("/book/<tenant_slug>", methods=["GET", "POST"])
@limiter.limit("15 per minute")
@csrf.exempt
def public_booking(tenant_slug):
tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
Tenant.status.in_(["active", "trial"])
).first()
if not tenant:
return render_template("tenant/booking/not_found.html"), 404
if not tenant.has_feature("online_booking"):
return render_template("tenant/booking/unavailable.html", tenant=tenant), 403
location = Location.query.filter_by(
tenant_id=tenant.id, is_primary=True, is_active=True
).first() or Location.query.filter_by(
tenant_id=tenant.id, is_active=True).first()
if not location:
return render_template("tenant/booking/unavailable.html", tenant=tenant), 403
services = Service.query.filter_by(
tenant_id=tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
staff_list = Staff.query.filter_by(
tenant_id=tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
confirmed = False
error = None
if request.method == "POST":
name = request.form.get("name", "").strip()[:255]
phone = request.form.get("phone", "").strip()[:30]
email = request.form.get("email", "").strip()[:255] or None
service_id = request.form.get("service_id", type=int)
staff_id = request.form.get("staff_id", type=int) or None
date_raw = request.form.get("preferred_date", "")
time_raw = request.form.get("preferred_time", "")
notes = request.form.get("notes", "").strip()[:500] or None
if not name or not phone or not service_id or not date_raw or not time_raw:
error = "Please complete all required fields."
else:
try:
start_time = datetime.fromisoformat(f"{date_raw}T{time_raw}")
start_time = start_time.replace(tzinfo=timezone.utc)
except ValueError:
error = "Invalid date or time."
start_time = None
if start_time and start_time < datetime.now(timezone.utc):
error = "Please choose a future date and time."
if not error:
svc = Service.query.filter_by(
id=service_id, tenant_id=tenant.id).first()
duration = svc.duration_min if svc else 30
end_time = start_time + timedelta(minutes=duration)
# Look up or create customer
customer = Customer.query.filter_by(
tenant_id=tenant.id, phone=phone).filter(
Customer.deleted_at.is_(None)).first()
if not customer:
customer = Customer(
tenant_id=tenant.id, name=name, phone=phone,
email=email, is_active=True, loyalty_points=0,
no_show_count=0,
)
db.session.add(customer)
db.session.flush()
appt = Appointment(
tenant_id=tenant.id, location_id=location.id,
customer_id=customer.id,
staff_id=staff_id, service_id=service_id,
start_time=start_time, end_time=end_time,
is_walk_in=False, status="pending",
notes=notes, rebook_source="online",
)
db.session.add(appt)
db.session.commit()
logger.info("Online booking: tenant=%s appt=%s customer=%s",
tenant.id, appt.id, customer.id)
# Send confirmation email
if email:
try:
from flask_mail import Message
msg = Message(
subject=f"Booking Confirmed — {tenant.name}",
recipients=[email],
body=(
f"Hi {name},\n\nYour appointment has been requested.\n"
f"Service: {svc.name if svc else 'N/A'}\n"
f"Date: {start_time.strftime('%B %d, %Y at %I:%M %p')}\n\n"
f"We'll confirm your booking shortly. Thank you!"
),
)
mail.send(msg)
except Exception as exc:
logger.error("Booking confirmation email failed: %s", exc)
confirmed = True
return render_template(
"tenant/booking/form.html",
tenant=tenant, services=services, staff_list=staff_list,
confirmed=confirmed, error=error,
)
+69 -102
View File
@@ -1,135 +1,102 @@
""" """
app/tenant/checkin/routes.py Customer self check-in kiosk. app/tenant/checkin/routes.py
Route: GET/POST /checkin/<tenant_slug> Customer self check-in kiosk /checkin/<tenant_slug>
No authentication required. CSRF-exempt. Rate-limited. No authentication required. CSRF-exempt. Rate-limited.
Alert delivery: 5-second polling via GET /api/v1/checkin/queue?status=waiting. Receptionist alert delivered via 5-second polling: GET /api/v1/checkin/queue?status=waiting
""" """
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import ( from flask import Blueprint, render_template, request, redirect, url_for, g
Blueprint, render_template, request, jsonify,
current_app, abort,
)
from app.extensions import db, limiter, csrf from app.extensions import db, limiter, csrf
from app.models.platform import Tenant from app.models.platform import Tenant
from app.models.salon import Customer, CheckinQueue, Location from app.models.salon import CheckinQueue, Customer, Service, Location
from app.security import sanitise_string, validate_slug
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
checkin_bp = Blueprint("checkin", __name__) checkin_bp = Blueprint("checkin", __name__)
@checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"]) @checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"])
@csrf.exempt
@limiter.limit("20 per minute") @limiter.limit("20 per minute")
def kiosk(tenant_slug: str): @csrf.exempt
""" def kiosk(tenant_slug):
Public kiosk page. No auth required. # Validate slug against known tenants — prevents enumeration
Tenant slug validated against known slugs (not guessable). tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
Accepts: name, phone, service_requested all other fields ignored. Tenant.status.in_(["active", "trial"])
Phone is sanitised and validated before profile lookup. ).first()
Page auto-resets after 10 seconds (configurable per tenant) via JS. if not tenant:
""" return render_template("tenant/checkin/not_found.html"), 404
# Validate slug format before hitting the DB
if not validate_slug(tenant_slug):
logger.warning("Kiosk: invalid slug format: %s", tenant_slug)
abort(404)
tenant = Tenant.query.filter_by(slug=tenant_slug, is_demo=False).first() # Resolve location: session-stored or primary
if not tenant or not tenant.is_active_status():
logger.warning("Kiosk: tenant not found or inactive: %s", tenant_slug)
abort(404)
# Resolve the primary location for this tenant
location = Location.query.filter_by( location = Location.query.filter_by(
tenant_id=tenant.id, tenant_id=tenant.id, is_primary=True, is_active=True
is_primary=True, ).first()
is_active=True, if not location:
).filter(Location.deleted_at.is_(None)).first() location = Location.query.filter_by(
tenant_id=tenant.id, is_active=True
).first()
if not location:
return render_template("tenant/checkin/not_found.html"), 404
if location is None: services = Service.query.filter_by(
abort(404) tenant_id=tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
confirmed = False confirmed = False
error = None
if request.method == "POST": if request.method == "POST":
raw_name = request.form.get("customer_name", "") # Accept ONLY these three fields — all others ignored
raw_phone = request.form.get("customer_phone", "") customer_name = request.form.get("customer_name", "").strip()[:255]
raw_service = request.form.get("service_requested", "") customer_phone = request.form.get("customer_phone", "").strip()[:30]
service_requested = request.form.get("service_requested", "").strip()[:255] or None
name = sanitise_string(raw_name, max_length=150) if not customer_name or not customer_phone:
phone = sanitise_string(raw_phone, max_length=30) error = "Please enter your name and phone number."
service = sanitise_string(raw_service, max_length=150) else:
# Look up or create customer profile
customer = Customer.query.filter_by(
tenant_id=tenant.id, phone=customer_phone
).filter(Customer.deleted_at.is_(None)).first()
customer_id = None
if customer:
customer_id = customer.id
else:
# Auto-create profile
new_cust = Customer(
tenant_id=tenant.id,
name=customer_name,
phone=customer_phone,
is_active=True,
loyalty_points=0,
no_show_count=0,
)
db.session.add(new_cust)
db.session.flush()
customer_id = new_cust.id
logger.info("Kiosk: new customer profile created tenant=%s phone=%s",
tenant.id, customer_phone)
# Validate required fields entry = CheckinQueue(
if not name or not phone:
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
error="Name and phone number are required.",
confirmed=False,
)
# Strip non-digit characters from phone for lookup consistency
phone_digits = "".join(filter(str.isdigit, phone))
if len(phone_digits) < 7:
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
error="Please enter a valid phone number.",
confirmed=False,
)
# Look up or create customer profile
customer = Customer.query.filter_by(
tenant_id=tenant.id,
phone=phone_digits,
).filter(Customer.deleted_at.is_(None)).first()
if customer is None:
customer = Customer(
tenant_id=tenant.id, tenant_id=tenant.id,
name=name, location_id=location.id,
phone=phone_digits, customer_id=customer_id,
) customer_name=customer_name,
db.session.add(customer) customer_phone=customer_phone,
db.session.flush() # get customer.id before committing service_requested=service_requested,
logger.info( checked_in_at=datetime.now(timezone.utc),
"Kiosk: new customer profile created for tenant %s", tenant.slug status="waiting",
) )
db.session.add(entry)
db.session.commit()
# Queue the walk-in entry logger.info("Kiosk check-in: tenant=%s location=%s customer=%s service=%s",
entry = CheckinQueue( tenant.id, location.id, customer_id, service_requested)
tenant_id=tenant.id, confirmed = True
location_id=location.id,
customer_id=customer.id,
customer_name=name,
customer_phone=phone_digits,
service_requested=service or None,
status="waiting",
)
db.session.add(entry)
db.session.commit()
logger.info(
"Kiosk: check-in queued for tenant=%s location=%s customer=%s",
tenant.slug, location.id, customer.id,
)
confirmed = True
# Fetch services for the kiosk selector (if configured)
from app.models.salon import Service
services = Service.query.filter_by(
tenant_id=tenant.id,
is_active=True,
).filter(Service.deleted_at.is_(None)).order_by(Service.name).all()
return render_template( return render_template(
"tenant/checkin/kiosk.html", "tenant/checkin/kiosk.html",
tenant=tenant, tenant=tenant,
services=services, services=services,
confirmed=confirmed, confirmed=confirmed,
error=None, error=error,
) )
+130 -2
View File
@@ -1,7 +1,135 @@
""" """
app/tenant/customers/routes.py app/tenant/customers/routes.py
Phase 3+ implementation. Customer management: list (search), create, view, edit, soft-delete, restore.
""" """
from flask import Blueprint import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import Customer, Appointment, Transaction
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
customers_bp = Blueprint("customers", __name__, url_prefix="/customers") customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
@customers_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
q = request.args.get("q", "").strip()
query = Customer.query.filter_by(tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None))
if q:
query = query.filter(
db.or_(
Customer.name.ilike(f"%{q}%"),
Customer.phone.ilike(f"%{q}%"),
Customer.email.ilike(f"%{q}%"),
)
)
customers = query.order_by(Customer.name).limit(200).all()
return render_template("tenant/customers/index.html", customers=customers, q=q)
@customers_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create():
if request.method == "POST":
name = request.form.get("name", "").strip()
phone = request.form.get("phone", "").strip() or None
if not name:
return render_template("tenant/customers/form.html",
mode="create", error="Name is required.")
customer = Customer(
tenant_id=g.tenant.id,
name=name, phone=phone,
email=request.form.get("email", "").strip() or None,
date_of_birth=_parse_date(request.form.get("date_of_birth", "")),
notes=request.form.get("notes", "").strip() or None,
is_active=True, loyalty_points=0, no_show_count=0,
)
db.session.add(customer)
db.session.flush()
log_tenant_action("customer.create", "customer", customer.id, {"name": name})
db.session.commit()
flash(f"Customer \'{name}\' created.", "success")
return redirect(url_for("customers.view", customer_id=customer.id))
return render_template("tenant/customers/form.html", mode="create")
@customers_bp.route("/<int:customer_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def view(customer_id):
customer = Customer.query.filter_by(
id=customer_id, tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None)).first_or_404()
appointments = Appointment.query.filter_by(
tenant_id=g.tenant.id, customer_id=customer_id
).order_by(Appointment.start_time.desc()).limit(20).all()
transactions = Transaction.query.filter_by(
tenant_id=g.tenant.id, customer_id=customer_id
).filter(Transaction.voided_at.is_(None)).order_by(
Transaction.created_at.desc()).limit(20).all()
return render_template("tenant/customers/view.html",
customer=customer,
appointments=appointments,
transactions=transactions)
@customers_bp.route("/<int:customer_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit(customer_id):
customer = Customer.query.filter_by(
id=customer_id, tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/customers/form.html",
mode="edit", customer=customer,
error="Name is required.")
customer.name = name
customer.phone = request.form.get("phone", "").strip() or None
customer.email = request.form.get("email", "").strip() or None
customer.date_of_birth = _parse_date(request.form.get("date_of_birth", ""))
customer.notes = request.form.get("notes", "").strip() or None
log_tenant_action("customer.edit", "customer", customer.id, {"name": name})
db.session.commit()
flash(f"Customer \'{name}\' updated.", "success")
return redirect(url_for("customers.view", customer_id=customer.id))
return render_template("tenant/customers/form.html",
mode="edit", customer=customer)
@customers_bp.route("/<int:customer_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def soft_delete(customer_id):
customer = Customer.query.filter_by(
id=customer_id, tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None)).first_or_404()
from datetime import datetime, timezone
customer.deleted_at = datetime.now(timezone.utc)
log_tenant_action("customer.delete", "customer", customer.id,
{"name": customer.name})
db.session.commit()
flash(f"Customer \'{customer.name}\' deleted.", "success")
return redirect(url_for("customers.index"))
def _parse_date(value):
if not value:
return None
try:
from datetime import date
return date.fromisoformat(value)
except ValueError:
return None
+73 -3
View File
@@ -1,14 +1,20 @@
""" """
app/tenant/dashboard/routes.py Tenant dashboard (placeholder for Phase 3 KPIs). app/tenant/dashboard/routes.py
Dashboard with Phase 3 KPI widgets scoped to active location.
""" """
import logging import logging
from datetime import datetime, timezone, timedelta, date
from decimal import Decimal
from flask import Blueprint, render_template, g from flask import Blueprint, render_template, g
from flask_login import login_required from flask_login import login_required
from app.extensions import db
from app.models.salon import (
Appointment, Transaction, Staff, CheckinQueue, StaffClocking,
)
from app.decorators import require_role from app.decorators import require_role
from sqlalchemy import func
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
dashboard_bp = Blueprint("dashboard", __name__) dashboard_bp = Blueprint("dashboard", __name__)
@@ -16,8 +22,72 @@ dashboard_bp = Blueprint("dashboard", __name__)
@login_required @login_required
@require_role("tenant_admin", "tenant_manager") @require_role("tenant_admin", "tenant_manager")
def index(): def index():
today = date.today()
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
location_id = g.location.id if g.location else None
# ── Today's revenue ──────────────────────────────────────
revenue_row = db.session.query(
func.sum(Transaction.total)
).filter_by(
tenant_id=g.tenant.id, location_id=location_id
).filter(
Transaction.created_at >= day_start,
Transaction.created_at < day_end,
Transaction.voided_at.is_(None),
).scalar()
today_revenue = float(revenue_row or 0)
# ── Today's appointments ─────────────────────────────────
appt_counts = dict(
db.session.query(Appointment.status, func.count(Appointment.id))
.filter_by(tenant_id=g.tenant.id, location_id=location_id)
.filter(
Appointment.start_time >= day_start,
Appointment.start_time < day_end,
)
.group_by(Appointment.status)
.all()
)
total_appts = sum(appt_counts.values())
completed_appts = appt_counts.get("completed", 0)
pending_appts = appt_counts.get("pending", 0) + appt_counts.get("confirmed", 0)
# ── Staff on shift ────────────────────────────────────────
staff_on_shift = StaffClocking.query.filter_by(
tenant_id=g.tenant.id, location_id=location_id
).filter(StaffClocking.clocked_out_at.is_(None)).count()
# ── Check-in queue ─────────────────────────────────────────
queue_count = CheckinQueue.query.filter_by(
tenant_id=g.tenant.id, location_id=location_id, status="waiting"
).count()
# ── Upcoming appointments today (next 3) ──────────────────
now = datetime.now(timezone.utc)
upcoming = Appointment.query.filter_by(
tenant_id=g.tenant.id, location_id=location_id
).filter(
Appointment.start_time >= now,
Appointment.start_time < day_end,
Appointment.status.in_(["pending", "confirmed"]),
).order_by(Appointment.start_time).limit(5).all()
kpis = {
"today_revenue": today_revenue,
"total_appointments": total_appts,
"completed_appointments": completed_appts,
"pending_appointments": pending_appts,
"staff_on_shift": staff_on_shift,
"queue_count": queue_count,
}
return render_template( return render_template(
"tenant/dashboard/index.html", "tenant/dashboard/index.html",
tenant=g.tenant, tenant=g.tenant,
location=g.location, location=g.location,
kpis=kpis,
upcoming_appointments=upcoming,
today=today,
) )
+114 -2
View File
@@ -1,7 +1,119 @@
""" """
app/tenant/gift_cards/routes.py app/tenant/gift_cards/routes.py
Phase 3+ implementation. Gift card issuance, management, and balance enquiry.
""" """
from flask import Blueprint import logging
import secrets
import string
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import GiftCard, Customer
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards") gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards")
def _generate_code(tenant_id: int) -> str:
"""Generate a unique gift card code for this tenant."""
alphabet = string.ascii_uppercase + string.digits
for _ in range(20):
code = "".join(secrets.choice(alphabet) for _ in range(12))
code = f"{code[:4]}-{code[4:8]}-{code[8:12]}"
if not GiftCard.query.filter_by(tenant_id=tenant_id, code=code).first():
return code
raise RuntimeError("Failed to generate unique gift card code")
@gift_cards_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
cards = GiftCard.query.filter_by(
tenant_id=g.tenant.id
).order_by(GiftCard.created_at.desc()).limit(200).all()
return render_template("tenant/gift_cards/index.html", cards=cards)
@gift_cards_bp.route("/issue", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def issue():
customers = Customer.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
if request.method == "POST":
try:
value = float(request.form.get("value", 0))
assert value > 0
except (ValueError, AssertionError):
return render_template("tenant/gift_cards/form.html",
customers=customers, error="Value must be greater than 0.")
customer_id = request.form.get("customer_id", type=int) or None
expires_raw = request.form.get("expires_at", "").strip()
expires_at = None
if expires_raw:
try:
expires_at = datetime.fromisoformat(expires_raw)
except ValueError:
pass
code = _generate_code(g.tenant.id)
card = GiftCard(
tenant_id=g.tenant.id, code=code,
original_value=value, remaining_balance=value,
issued_by=_user_db_id(),
issued_to_customer_id=customer_id,
expires_at=expires_at, is_active=True,
)
db.session.add(card)
db.session.flush()
log_tenant_action("gift_card.issue", "gift_card", card.id,
{"code": code, "value": value})
db.session.commit()
flash(f"Gift card issued: {code} (${value:.2f})", "success")
return redirect(url_for("gift_cards.index"))
return render_template("tenant/gift_cards/form.html", customers=customers)
@gift_cards_bp.route("/lookup")
@login_required
@require_role("tenant_admin", "tenant_manager")
def lookup():
code = request.args.get("code", "").strip().upper()
card = None
if code:
card = GiftCard.query.filter_by(
tenant_id=g.tenant.id, code=code).first()
return render_template("tenant/gift_cards/lookup.html",
card=card, code=code)
@gift_cards_bp.route("/<int:card_id>/deactivate", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def deactivate(card_id):
card = GiftCard.query.filter_by(
id=card_id, tenant_id=g.tenant.id).first_or_404()
card.is_active = False
log_tenant_action("gift_card.deactivate", "gift_card", card.id,
{"code": card.code})
db.session.commit()
flash(f"Gift card {card.code} deactivated.", "success")
return redirect(url_for("gift_cards.index"))
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+12 -2
View File
@@ -1,7 +1,17 @@
""" """
app/tenant/inventory/routes.py app/tenant/inventory/routes.py
Phase 3+ implementation. Phase 4 stub implemented in Phase 4.
""" """
from flask import Blueprint from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory") inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
@inventory_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template("tenant/feature_unavailable.html",
feature="Inventory")
+112 -2
View File
@@ -1,7 +1,117 @@
""" """
app/tenant/locations/routes.py app/tenant/locations/routes.py
Phase 3+ implementation. Location management: list, create, edit, set primary, switch.
""" """
from flask import Blueprint import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, g
from flask_login import login_required, current_user
from app.extensions import db
from app.models.salon import Location
from app.decorators import require_role, tenant_feature_required, demo_readonly
from app.tenant.utils import log_tenant_action, plan_limit_check
logger = logging.getLogger(__name__)
locations_bp = Blueprint("locations", __name__, url_prefix="/locations") locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
@locations_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
locs = Location.query.filter_by(tenant_id=g.tenant.id).order_by(
Location.is_primary.desc(), Location.name).all()
return render_template("tenant/locations/index.html", locations=locs)
@locations_bp.route("/switch/<int:location_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def switch(location_id):
loc = Location.query.filter_by(
id=location_id, tenant_id=g.tenant.id, is_active=True).first_or_404()
session["active_location_id"] = loc.id
logger.info("Location switched: location=%s tenant=%s user=%s",
loc.id, g.tenant.id, current_user.get_id())
flash(f"Switched to {loc.name}.", "info")
return redirect(request.referrer or url_for("dashboard.index"))
@locations_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def create():
allowed, err = plan_limit_check("location")
if not allowed:
flash(err, "warning")
return redirect(url_for("locations.index"))
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/locations/form.html",
mode="create", error="Name is required.")
allowed, err = plan_limit_check("location")
if not allowed:
return render_template("tenant/locations/form.html",
mode="create", error=err)
loc = Location(
tenant_id=g.tenant.id, name=name,
address=request.form.get("address", "").strip() or None,
phone=request.form.get("phone", "").strip() or None,
email=request.form.get("email", "").strip() or None,
timezone=request.form.get("timezone", "America/New_York"),
is_active=True, is_primary=False,
)
db.session.add(loc)
db.session.flush()
log_tenant_action("location.create", "location", loc.id, {"name": name})
db.session.commit()
flash(f"Location \'{name}\' created.", "success")
return redirect(url_for("locations.index"))
return render_template("tenant/locations/form.html", mode="create")
@locations_bp.route("/<int:location_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def edit(location_id):
loc = Location.query.filter_by(
id=location_id, tenant_id=g.tenant.id).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/locations/form.html",
mode="edit", location=loc, error="Name is required.")
old_name = loc.name
loc.name = name
loc.address = request.form.get("address", "").strip() or None
loc.phone = request.form.get("phone", "").strip() or None
loc.email = request.form.get("email", "").strip() or None
loc.timezone = request.form.get("timezone", loc.timezone)
loc.is_active = request.form.get("is_active") == "1"
if request.form.get("is_primary") == "1" and not loc.is_primary:
Location.query.filter_by(tenant_id=g.tenant.id, is_primary=True).update({"is_primary": False})
loc.is_primary = True
log_tenant_action("location.edit", "location", loc.id,
{"old_name": old_name, "new_name": name})
db.session.commit()
flash(f"Location \'{name}\' updated.", "success")
return redirect(url_for("locations.index"))
return render_template("tenant/locations/form.html", mode="edit", location=loc)
@locations_bp.route("/<int:location_id>/set-primary", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def set_primary(location_id):
loc = Location.query.filter_by(
id=location_id, tenant_id=g.tenant.id, is_active=True).first_or_404()
Location.query.filter_by(tenant_id=g.tenant.id, is_primary=True).update({"is_primary": False})
loc.is_primary = True
log_tenant_action("location.set_primary", "location", loc.id)
db.session.commit()
flash(f"\'{loc.name}\' set as primary.", "success")
return redirect(url_for("locations.index"))
+12 -2
View File
@@ -1,7 +1,17 @@
""" """
app/tenant/marketing/routes.py app/tenant/marketing/routes.py
Phase 3+ implementation. Phase 4 stub implemented in Phase 4.
""" """
from flask import Blueprint from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing") marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
@marketing_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template("tenant/feature_unavailable.html",
feature="Marketing")
+370 -2
View File
@@ -1,7 +1,375 @@
""" """
app/tenant/pos/routes.py app/tenant/pos/routes.py
Phase 3+ implementation. POS / Checkout: new transaction, line item entry, promotion auto-apply,
tip, gift card redemption, void, receipt.
Rebook-at-checkout creates a new pending appointment.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone, timedelta
from decimal import Decimal, ROUND_HALF_UP
from flask import Blueprint, render_template, redirect, url_for, flash, request, g, jsonify
from flask_login import login_required
from app.extensions import db
from app.models.salon import (
Transaction, TransactionItem, Appointment, Customer, Staff,
Service, Product, GiftCard, AppointmentReminder,
)
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action, get_active_promotion, apply_promotion_to_price
logger = logging.getLogger(__name__)
pos_bp = Blueprint("pos", __name__, url_prefix="/pos") pos_bp = Blueprint("pos", __name__, url_prefix="/pos")
PAYMENT_METHODS = ["cash", "zelle", "venmo", "cashapp", "gift_card", "other"]
@pos_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def checkout():
"""New POS transaction entry form."""
customers = Customer.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.category, Service.name).all()
products = Product.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Product.deleted_at.is_(None)).order_by(Product.name).all()
# Pre-fill from appointment if provided
appt_id = request.args.get("appointment_id", type=int)
appointment = None
if appt_id:
appointment = Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id).first()
return render_template("tenant/pos/checkout.html",
customers=customers, staff_list=staff_list,
services=services, products=products,
appointment=appointment,
payment_methods=PAYMENT_METHODS)
@pos_bp.route("/submit", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def submit():
"""Process a completed checkout."""
customer_id = request.form.get("customer_id", type=int) or None
staff_id = request.form.get("staff_id", type=int) or None
appt_id = request.form.get("appointment_id", type=int) or None
payment_method = request.form.get("payment_method", "cash")
payment_reference = request.form.get("payment_reference", "").strip() or None
tip_raw = request.form.get("tip_amount", "0")
gc_code = request.form.get("gift_card_code", "").strip().upper() or None
if payment_method not in PAYMENT_METHODS:
flash("Invalid payment method.", "danger")
return redirect(url_for("pos.checkout"))
try:
tip_amount = Decimal(tip_raw or "0").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
except Exception:
tip_amount = Decimal("0")
# Parse line items from form: service_ids[] and product_ids[]
service_ids = request.form.getlist("service_ids")
product_ids = request.form.getlist("product_ids")
if not service_ids and not product_ids:
flash("Please add at least one service or product.", "danger")
return redirect(url_for("pos.checkout"))
# Resolve gift card
gift_card = None
gc_applied = Decimal("0")
if gc_code:
gift_card = GiftCard.query.filter_by(
tenant_id=g.tenant.id, code=gc_code, is_active=True).first()
if not gift_card or gift_card.remaining_balance <= 0:
flash(f"Gift card {gc_code} is invalid or has no balance.", "danger")
return redirect(url_for("pos.checkout"))
# Build transaction
txn = Transaction(
tenant_id=g.tenant.id,
location_id=g.location.id,
appointment_id=appt_id,
customer_id=customer_id,
staff_id=staff_id,
payment_method=payment_method,
payment_reference=payment_reference,
tip_amount=float(tip_amount),
gift_card_id=gift_card.id if gift_card else None,
subtotal=0, discount=0, gift_card_amount=0, total=0,
)
db.session.add(txn)
db.session.flush()
subtotal = Decimal("0")
total_discount = Decimal("0")
# Add service line items
for sid_str in service_ids:
try:
sid = int(sid_str)
except ValueError:
continue
svc = Service.query.filter_by(
id=sid, tenant_id=g.tenant.id).first()
if not svc:
continue
original_price = Decimal(str(svc.price))
promo = get_active_promotion(g.tenant.id, sid, "service")
unit_price_f, disc_pct = apply_promotion_to_price(float(original_price), promo)
unit_price = Decimal(str(unit_price_f))
discount_amt = original_price - unit_price
item = TransactionItem(
transaction_id=txn.id,
service_id=sid, product_id=None,
qty=1, unit_price=float(unit_price),
original_price=float(original_price),
discount_percent=disc_pct,
promotion_id=promo.id if promo else None,
)
db.session.add(item)
subtotal += unit_price
total_discount += discount_amt
# Add product line items
for pid_str in product_ids:
try:
pid = int(pid_str)
except ValueError:
continue
prod = Product.query.filter_by(
id=pid, tenant_id=g.tenant.id).first()
if not prod:
continue
original_price = Decimal(str(prod.sale_price))
promo = get_active_promotion(g.tenant.id, pid, "product")
unit_price_f, disc_pct = apply_promotion_to_price(float(original_price), promo)
unit_price = Decimal(str(unit_price_f))
discount_amt = original_price - unit_price
item = TransactionItem(
transaction_id=txn.id,
service_id=None, product_id=pid,
qty=1, unit_price=float(unit_price),
original_price=float(original_price),
discount_percent=disc_pct,
promotion_id=promo.id if promo else None,
)
db.session.add(item)
subtotal += unit_price
total_discount += discount_amt
# Apply gift card
if gift_card:
gc_available = Decimal(str(gift_card.remaining_balance))
gc_applied = min(gc_available, subtotal + tip_amount)
gift_card.remaining_balance = float(gc_available - gc_applied)
if gift_card.remaining_balance <= 0:
gift_card.is_active = False
total = subtotal + tip_amount - gc_applied
if total < 0:
total = Decimal("0")
txn.subtotal = float(subtotal)
txn.discount = float(total_discount)
txn.tip_amount = float(tip_amount)
txn.gift_card_amount = float(gc_applied)
txn.total = float(total)
# Mark appointment completed if linked
if appt_id:
Appointment.query.filter_by(
id=appt_id, tenant_id=g.tenant.id
).update({"status": "completed"})
# Commission log if staff has commission enabled
if staff_id:
staff = Staff.query.get(staff_id)
if staff and staff.commission_enabled and staff.commission_rate:
from app.models.salon import CommissionLog
commission_amount = float(
(Decimal(str(subtotal)) * Decimal(str(staff.commission_rate)) /
Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
from datetime import date
period = date.today().strftime("%Y-W%U")
comm_log = CommissionLog(
tenant_id=g.tenant.id,
location_id=g.location.id,
staff_id=staff_id,
transaction_id=txn.id,
amount=commission_amount,
period=period,
)
db.session.add(comm_log)
log_tenant_action("transaction.create", "transaction", txn.id,
{"total": float(total), "payment": payment_method})
db.session.commit()
flash(f"Checkout complete — Total: ${float(total):.2f}", "success")
# Rebook at checkout
rebook = request.form.get("rebook") == "1"
if rebook:
return redirect(url_for("pos.rebook", transaction_id=txn.id))
return redirect(url_for("pos.receipt", transaction_id=txn.id))
@pos_bp.route("/receipt/<int:transaction_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def receipt(transaction_id):
txn = Transaction.query.filter_by(
id=transaction_id, tenant_id=g.tenant.id).first_or_404()
items = list(txn.items)
return render_template("tenant/pos/receipt.html",
transaction=txn, items=items, tenant=g.tenant,
location=g.location)
@pos_bp.route("/rebook/<int:transaction_id>", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def rebook(transaction_id):
"""Next-visit scheduling at checkout."""
txn = Transaction.query.filter_by(
id=transaction_id, tenant_id=g.tenant.id).first_or_404()
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
if request.method == "POST":
start_raw = request.form.get("start_time", "")
service_id = request.form.get("service_id", type=int)
staff_id = request.form.get("staff_id", type=int) or None
try:
start_time = datetime.fromisoformat(start_raw)
if start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
except ValueError:
return render_template("tenant/pos/rebook.html",
transaction=txn, staff_list=staff_list,
services=services,
error="Invalid date/time.")
svc = Service.query.get(service_id) if service_id else None
duration = svc.duration_min if svc else 30
end_time = start_time + timedelta(minutes=duration)
appt = Appointment(
tenant_id=g.tenant.id, location_id=g.location.id,
customer_id=txn.customer_id,
staff_id=staff_id, service_id=service_id,
start_time=start_time, end_time=end_time,
is_walk_in=False, status="pending",
rebook_source="checkout",
rebooked_from_transaction_id=txn.id,
created_by=_user_db_id(),
)
db.session.add(appt)
db.session.flush()
# Schedule 24h reminder
reminder_time = start_time - timedelta(hours=24)
if reminder_time > datetime.now(timezone.utc):
reminder = AppointmentReminder(
tenant_id=g.tenant.id, location_id=g.location.id,
appointment_id=appt.id, reminder_type="24h",
scheduled_for=reminder_time, channel="email", status="pending",
)
db.session.add(reminder)
log_tenant_action("appointment.rebook", "appointment", appt.id,
{"from_transaction": txn.id, "start": start_raw})
db.session.commit()
flash("Next visit scheduled.", "success")
return redirect(url_for("pos.receipt", transaction_id=txn.id))
return render_template("tenant/pos/rebook.html",
transaction=txn, staff_list=staff_list,
services=services)
@pos_bp.route("/void/<int:transaction_id>", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def void(transaction_id):
txn = Transaction.query.filter_by(
id=transaction_id, tenant_id=g.tenant.id).first_or_404()
if txn.voided_at:
flash("This transaction is already voided.", "warning")
return redirect(url_for("pos.receipt", transaction_id=transaction_id))
if request.method == "POST":
reason = request.form.get("reason", "").strip()
if not reason:
return render_template("tenant/pos/void.html",
transaction=txn, error="Reason is required.")
txn.voided_at = datetime.now(timezone.utc)
txn.voided_by = _user_db_id()
txn.void_reason = reason
# Reverse gift card balance if applicable
if txn.gift_card_id and txn.gift_card_amount > 0:
gc = GiftCard.query.get(txn.gift_card_id)
if gc:
gc.remaining_balance = float(
Decimal(str(gc.remaining_balance)) +
Decimal(str(txn.gift_card_amount))
)
gc.is_active = True
log_tenant_action("transaction.void", "transaction", txn.id,
{"reason": reason, "total": txn.total})
db.session.commit()
flash("Transaction voided.", "success")
return redirect(url_for("pos.receipt", transaction_id=transaction_id))
return render_template("tenant/pos/void.html", transaction=txn)
@pos_bp.route("/transactions")
@login_required
@require_role("tenant_admin", "tenant_manager")
def transactions():
from datetime import date
date_str = request.args.get("date", date.today().isoformat())
try:
view_date = date.fromisoformat(date_str)
except ValueError:
view_date = date.today()
day_start = datetime.combine(view_date, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
txns = Transaction.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Transaction.created_at >= day_start,
Transaction.created_at < day_end,
Transaction.voided_at.is_(None),
).order_by(Transaction.created_at.desc()).all()
return render_template("tenant/pos/transactions.html",
transactions=txns, view_date=view_date)
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+122 -2
View File
@@ -1,7 +1,127 @@
""" """
app/tenant/reconciliation/routes.py app/tenant/reconciliation/routes.py
Phase 3+ implementation. End-of-day reconciliation: close day, cash count, variance calculation.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone, timedelta, date
from decimal import Decimal
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import DailyReconciliation, Transaction
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation") reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
@reconciliation_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
records = DailyReconciliation.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).order_by(DailyReconciliation.date.desc()).limit(30).all()
return render_template("tenant/reconciliation/index.html", records=records)
@reconciliation_bp.route("/close", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def close_day():
today = date.today()
# Check if already closed for today
existing = DailyReconciliation.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id, date=today
).first()
if existing and existing.closed_at:
flash("Today has already been reconciled.", "info")
return redirect(url_for("reconciliation.index"))
# Compute expected totals from today's transactions
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
txns = Transaction.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Transaction.created_at >= day_start,
Transaction.created_at < day_end,
Transaction.voided_at.is_(None),
).all()
total_cash = sum(
Decimal(str(t.total)) for t in txns if t.payment_method == "cash"
)
total_app = sum(
Decimal(str(t.total)) for t in txns
if t.payment_method in ("zelle", "venmo", "cashapp", "other")
)
total_tips = sum(Decimal(str(t.tip_amount)) for t in txns)
total_gc = sum(Decimal(str(t.gift_card_amount)) for t in txns)
expected_cash = total_cash # Starting float not tracked in MVP
if request.method == "POST":
try:
actual_cash = Decimal(request.form.get("actual_cash", "0"))
except Exception:
return render_template("tenant/reconciliation/close.html",
today=today, total_cash=float(total_cash),
total_app=float(total_app),
total_tips=float(total_tips),
total_gc=float(total_gc),
expected_cash=float(expected_cash),
error="Invalid cash amount.")
variance = actual_cash - expected_cash
notes = request.form.get("notes", "").strip() or None
if existing:
existing.actual_cash_counted = float(actual_cash)
existing.variance = float(variance)
existing.closed_by = _user_db_id()
existing.closed_at = datetime.now(timezone.utc)
existing.notes = notes
rec = existing
else:
rec = DailyReconciliation(
tenant_id=g.tenant.id,
location_id=g.location.id,
date=today,
total_cash=float(total_cash),
total_app_payments=float(total_app),
total_tips=float(total_tips),
total_gift_card_redemptions=float(total_gc),
expected_cash_in_drawer=float(expected_cash),
actual_cash_counted=float(actual_cash),
variance=float(variance),
closed_by=_user_db_id(),
closed_at=datetime.now(timezone.utc),
notes=notes,
)
db.session.add(rec)
log_tenant_action("reconciliation.close", "reconciliation", None,
{"date": str(today), "variance": float(variance)})
db.session.commit()
flash(f"Day closed. Variance: ${float(variance):.2f}", "success")
return redirect(url_for("reconciliation.index"))
return render_template("tenant/reconciliation/close.html",
today=today,
total_cash=float(total_cash),
total_app=float(total_app),
total_tips=float(total_tips),
total_gc=float(total_gc),
expected_cash=float(expected_cash))
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+12 -2
View File
@@ -1,7 +1,17 @@
""" """
app/tenant/reports/routes.py app/tenant/reports/routes.py
Phase 3+ implementation. Phase 4 stub implemented in Phase 4.
""" """
from flask import Blueprint from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
reports_bp = Blueprint("reports", __name__, url_prefix="/reports") reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
@reports_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template("tenant/feature_unavailable.html",
feature="Reports")
+33 -2
View File
@@ -1,7 +1,38 @@
""" """
app/tenant/reviews/routes.py app/tenant/reviews/routes.py
Phase 3+ implementation. Owner view of customer reviews submitted at checkout.
""" """
from flask import Blueprint import logging
from flask import Blueprint, render_template, g
from flask_login import login_required
from app.models.salon import CheckoutReview
from app.decorators import require_role
from sqlalchemy import func
from app.extensions import db
logger = logging.getLogger(__name__)
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews") reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
@reviews_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
reviews = CheckoutReview.query.filter_by(
tenant_id=g.tenant.id
).order_by(CheckoutReview.created_at.desc()).limit(100).all()
avg_row = db.session.query(
func.avg(CheckoutReview.rating)
).filter_by(tenant_id=g.tenant.id).scalar()
avg_rating = round(float(avg_row or 0), 1)
dist = dict(
db.session.query(CheckoutReview.rating, func.count(CheckoutReview.id))
.filter_by(tenant_id=g.tenant.id)
.group_by(CheckoutReview.rating).all()
)
return render_template("tenant/reviews/index.html",
reviews=reviews, avg_rating=avg_rating,
rating_dist=dist)
+277 -2
View File
@@ -1,7 +1,282 @@
""" """
app/tenant/services/routes.py app/tenant/services/routes.py
Phase 3+ implementation. Services, products, and promotions catalogue.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import Service, Product, Promotion
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
services_bp = Blueprint("services", __name__, url_prefix="/services") services_bp = Blueprint("services", __name__, url_prefix="/services")
# ── Services ──────────────────────────────────────────────────────────────────
@services_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
services = Service.query.filter_by(tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).order_by(
Service.category, Service.name).all()
products = Product.query.filter_by(tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).order_by(
Product.category, Product.name).all()
promotions = Promotion.query.filter_by(
tenant_id=g.tenant.id, is_active=True).order_by(
Promotion.ends_at).all()
return render_template("tenant/services/index.html",
services=services, products=products,
promotions=promotions)
@services_bp.route("/services/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_service():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/service_form.html",
mode="create", error="Name is required.")
try:
price = float(request.form.get("price", 0))
duration = int(request.form.get("duration_min", 30))
except ValueError:
return render_template("tenant/services/service_form.html",
mode="create", error="Invalid price or duration.")
svc = Service(
tenant_id=g.tenant.id, name=name,
category=request.form.get("category", "").strip() or None,
price=price, duration_min=duration, is_active=True,
)
db.session.add(svc)
db.session.flush()
log_tenant_action("service.create", "service", svc.id, {"name": name})
db.session.commit()
flash(f"Service \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/service_form.html", mode="create")
@services_bp.route("/services/<int:service_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit_service(service_id):
svc = Service.query.filter_by(
id=service_id, tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/service_form.html",
mode="edit", service=svc, error="Name is required.")
try:
price = float(request.form.get("price", 0))
duration = int(request.form.get("duration_min", 30))
except ValueError:
return render_template("tenant/services/service_form.html",
mode="edit", service=svc, error="Invalid price or duration.")
svc.name = name
svc.category = request.form.get("category", "").strip() or None
svc.price = price
svc.duration_min = duration
svc.is_active = request.form.get("is_active") == "1"
log_tenant_action("service.edit", "service", svc.id, {"name": name})
db.session.commit()
flash(f"Service \'{name}\' updated.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/service_form.html",
mode="edit", service=svc)
@services_bp.route("/services/<int:service_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def delete_service(service_id):
svc = Service.query.filter_by(
id=service_id, tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).first_or_404()
svc.deleted_at = datetime.now(timezone.utc)
log_tenant_action("service.delete", "service", svc.id, {"name": svc.name})
db.session.commit()
flash(f"Service \'{svc.name}\' deleted.", "success")
return redirect(url_for("services.index"))
# ── Products ──────────────────────────────────────────────────────────────────
@services_bp.route("/products/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_product():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/product_form.html",
mode="create", error="Name is required.")
try:
price = float(request.form.get("sale_price", 0))
except ValueError:
return render_template("tenant/services/product_form.html",
mode="create", error="Invalid price.")
prod = Product(
tenant_id=g.tenant.id, name=name,
sku=request.form.get("sku", "").strip() or None,
category=request.form.get("category", "").strip() or None,
sale_price=price, is_active=True,
)
db.session.add(prod)
db.session.flush()
log_tenant_action("product.create", "product", prod.id, {"name": name})
db.session.commit()
flash(f"Product \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/product_form.html", mode="create")
@services_bp.route("/products/<int:product_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit_product(product_id):
prod = Product.query.filter_by(
id=product_id, tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/product_form.html",
mode="edit", product=prod, error="Name is required.")
try:
price = float(request.form.get("sale_price", 0))
except ValueError:
return render_template("tenant/services/product_form.html",
mode="edit", product=prod, error="Invalid price.")
prod.name = name
prod.sku = request.form.get("sku", "").strip() or None
prod.category = request.form.get("category", "").strip() or None
prod.sale_price = price
prod.is_active = request.form.get("is_active") == "1"
log_tenant_action("product.edit", "product", prod.id, {"name": name})
db.session.commit()
flash(f"Product \'{name}\' updated.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/product_form.html",
mode="edit", product=prod)
@services_bp.route("/products/<int:product_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def delete_product(product_id):
prod = Product.query.filter_by(
id=product_id, tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).first_or_404()
prod.deleted_at = datetime.now(timezone.utc)
log_tenant_action("product.delete", "product", prod.id, {"name": prod.name})
db.session.commit()
flash(f"Product \'{prod.name}\' deleted.", "success")
return redirect(url_for("services.index"))
# ── Promotions ─────────────────────────────────────────────────────────────────
@services_bp.route("/promotions/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_promotion():
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
products = Product.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Product.deleted_at.is_(None)).order_by(Product.name).all()
if request.method == "POST":
name = request.form.get("name", "").strip()
applies_to = request.form.get("applies_to", "all_services")
try:
pct = int(request.form.get("discount_percent", 0))
assert 1 <= pct <= 100
except (ValueError, AssertionError):
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Discount must be 1100%.")
starts_raw = request.form.get("starts_at", "")
ends_raw = request.form.get("ends_at", "")
if not starts_raw or not ends_raw:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Start and end dates are required.")
try:
starts_at = datetime.fromisoformat(starts_raw)
ends_at = datetime.fromisoformat(ends_raw)
except ValueError:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Invalid date format.")
# Build target_ids_json for specific targets
target_ids = None
if applies_to in ("service", "product"):
raw_ids = request.form.getlist("target_ids")
target_ids = [int(i) for i in raw_ids if i.isdigit()]
if not target_ids:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Please select at least one target.")
from flask_login import current_user
promo = Promotion(
tenant_id=g.tenant.id, name=name,
discount_percent=pct, applies_to=applies_to,
target_ids_json=target_ids,
starts_at=starts_at, ends_at=ends_at,
is_active=True,
created_by=_user_id(),
)
db.session.add(promo)
db.session.flush()
log_tenant_action("promotion.create", "promotion", promo.id,
{"name": name, "discount": pct})
db.session.commit()
flash(f"Promotion \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products)
@services_bp.route("/promotions/<int:promo_id>/toggle", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def toggle_promotion(promo_id):
promo = Promotion.query.filter_by(
id=promo_id, tenant_id=g.tenant.id).first_or_404()
promo.is_active = not promo.is_active
action = "promotion.activate" if promo.is_active else "promotion.deactivate"
log_tenant_action(action, "promotion", promo.id, {"name": promo.name})
db.session.commit()
status = "activated" if promo.is_active else "deactivated"
flash(f"Promotion \'{promo.name}\' {status}.", "success")
return redirect(url_for("services.index"))
def _user_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+54 -2
View File
@@ -1,7 +1,59 @@
""" """
app/tenant/settings/routes.py app/tenant/settings/routes.py
Phase 3+ implementation. Tenant settings management.
""" """
from flask import Blueprint import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import TenantSetting
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings") settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
# Keys managed by the UI — any key not in this list is hidden
MANAGED_KEYS = [
"business_hours",
"booking_advance_days",
"auto_confirm_bookings",
"review_request_delay_minutes",
"receipt_footer_note",
"google_review_url",
"facebook_review_url",
"yelp_review_url",
]
@settings_bp.route("/")
@login_required
@require_role("tenant_admin")
def index():
settings = {s.setting_key: s.setting_value for s in TenantSetting.query.filter_by(
tenant_id=g.tenant.id).all()}
return render_template("tenant/settings/index.html",
settings=settings, managed_keys=MANAGED_KEYS)
@settings_bp.route("/save", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def save():
for key in MANAGED_KEYS:
value = request.form.get(key, "").strip()
existing = TenantSetting.query.filter_by(
tenant_id=g.tenant.id, setting_key=key).first()
if existing:
existing.setting_value = value or None
else:
setting = TenantSetting(
tenant_id=g.tenant.id, setting_key=key,
setting_value=value or None,
)
db.session.add(setting)
log_tenant_action("settings.save", "tenant", g.tenant.id)
db.session.commit()
flash("Settings saved.", "success")
return redirect(url_for("settings.index"))
+190 -2
View File
@@ -1,7 +1,195 @@
""" """
app/tenant/staff/routes.py app/tenant/staff/routes.py
Phase 3+ implementation. Staff profiles, passcode management, location assignment.
Phase 4 adds pay structure, schedules, and commission config.
""" """
from flask import Blueprint import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db, bcrypt
from app.models.salon import Staff, StaffLocation, Location
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action, plan_limit_check
from app.security import validate_passcode
logger = logging.getLogger(__name__)
staff_bp = Blueprint("staff", __name__, url_prefix="/staff") staff_bp = Blueprint("staff", __name__, url_prefix="/staff")
@staff_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
return render_template("tenant/staff/index.html", staff_list=staff_list)
@staff_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def create():
allowed, err = plan_limit_check("staff")
if not allowed:
flash(err, "warning")
return redirect(url_for("staff.index"))
locations = Location.query.filter_by(
tenant_id=g.tenant.id, is_active=True).order_by(Location.name).all()
if request.method == "POST":
name = request.form.get("name", "").strip()
phone = request.form.get("phone", "").strip()
passcode = request.form.get("passcode", "").strip()
min_len = 4
max_len = 6
if not name or not phone:
return render_template("tenant/staff/form.html", mode="create",
locations=locations, error="Name and phone are required.")
if not validate_passcode(passcode, min_len, max_len):
return render_template("tenant/staff/form.html", mode="create",
locations=locations,
error=f"Passcode must be {min_len}{max_len} digits.")
if Staff.query.filter_by(tenant_id=g.tenant.id, phone=phone).filter(
Staff.deleted_at.is_(None)).first():
return render_template("tenant/staff/form.html", mode="create",
locations=locations,
error="A staff member with that phone number already exists.")
allowed, err = plan_limit_check("staff")
if not allowed:
return render_template("tenant/staff/form.html", mode="create",
locations=locations, error=err)
member = Staff(
tenant_id=g.tenant.id, name=name, phone=phone,
passcode_hash=bcrypt.generate_password_hash(passcode).decode("utf-8"),
staff_type=request.form.get("staff_type", "full_time"),
pay_type=request.form.get("pay_type", "hourly"),
is_active=True,
)
db.session.add(member)
db.session.flush()
# Location assignments
loc_ids = request.form.getlist("location_ids")
for lid_str in loc_ids:
try:
lid = int(lid_str)
loc = Location.query.filter_by(
id=lid, tenant_id=g.tenant.id).first()
if loc:
assignment = StaffLocation(
tenant_id=g.tenant.id,
staff_id=member.id,
location_id=lid,
)
db.session.add(assignment)
except ValueError:
pass
log_tenant_action("staff.create", "staff", member.id, {"name": name})
db.session.commit()
flash(f"Staff member \'{name}\' created.", "success")
return redirect(url_for("staff.index"))
return render_template("tenant/staff/form.html", mode="create", locations=locations)
@staff_bp.route("/<int:staff_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def edit(staff_id):
member = Staff.query.filter_by(
id=staff_id, tenant_id=g.tenant.id).filter(
Staff.deleted_at.is_(None)).first_or_404()
locations = Location.query.filter_by(
tenant_id=g.tenant.id, is_active=True).order_by(Location.name).all()
assigned_ids = {a.location_id for a in StaffLocation.query.filter_by(
staff_id=staff_id, tenant_id=g.tenant.id).all()}
if request.method == "POST":
name = request.form.get("name", "").strip()
phone = request.form.get("phone", "").strip()
if not name or not phone:
return render_template("tenant/staff/form.html", mode="edit",
staff=member, locations=locations,
assigned_ids=assigned_ids,
error="Name and phone are required.")
# Check phone uniqueness excluding self
conflict = Staff.query.filter_by(
tenant_id=g.tenant.id, phone=phone).filter(
Staff.id != staff_id,
Staff.deleted_at.is_(None)).first()
if conflict:
return render_template("tenant/staff/form.html", mode="edit",
staff=member, locations=locations,
assigned_ids=assigned_ids,
error="Another staff member has that phone number.")
member.name = name
member.phone = phone
member.staff_type = request.form.get("staff_type", member.staff_type)
member.is_active = request.form.get("is_active") == "1"
# Update location assignments
StaffLocation.query.filter_by(
staff_id=staff_id, tenant_id=g.tenant.id).delete()
loc_ids = request.form.getlist("location_ids")
for lid_str in loc_ids:
try:
lid = int(lid_str)
loc = Location.query.filter_by(
id=lid, tenant_id=g.tenant.id).first()
if loc:
db.session.add(StaffLocation(
tenant_id=g.tenant.id,
staff_id=staff_id,
location_id=lid,
))
except ValueError:
pass
log_tenant_action("staff.edit", "staff", member.id, {"name": name})
db.session.commit()
flash(f"\'{name}\' updated.", "success")
return redirect(url_for("staff.index"))
return render_template("tenant/staff/form.html", mode="edit",
staff=member, locations=locations,
assigned_ids=assigned_ids)
@staff_bp.route("/<int:staff_id>/reset-passcode", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def reset_passcode(staff_id):
member = Staff.query.filter_by(
id=staff_id, tenant_id=g.tenant.id).filter(
Staff.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
new_passcode = request.form.get("passcode", "").strip()
min_len = 4
max_len = 6
if not validate_passcode(new_passcode, min_len, max_len):
return render_template("tenant/staff/reset_passcode.html",
staff=member,
error=f"Passcode must be {min_len}{max_len} digits.")
member.passcode_hash = bcrypt.generate_password_hash(new_passcode).decode("utf-8")
member.passcode_failed_attempts = 0
member.passcode_locked_until = None
log_tenant_action("staff.reset_passcode", "staff", member.id,
{"name": member.name})
db.session.commit()
flash(f"Passcode for \'{member.name}\' reset. Show it to them once, then discard it.",
"success")
return redirect(url_for("staff.index"))
return render_template("tenant/staff/reset_passcode.html", staff=member)
+183 -2
View File
@@ -1,7 +1,188 @@
""" """
app/tenant/staff_portal/routes.py app/tenant/staff_portal/routes.py
Phase 3+ implementation. Staff Portal accessible by tenant_staff role (phone + passcode login).
Routes: personal schedule, upcoming appointments, clock in/out,
commission summary, payment history, read-only profile.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone, timedelta, date
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required, current_user
from app.extensions import db
from app.models.salon import (
Staff, Appointment, StaffClocking, CommissionLog,
StaffPayPeriod, Transaction,
)
from app.decorators import require_role
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal") staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
def _get_current_staff():
"""Resolve the Staff record from the current user session."""
uid_str = current_user.get_id()
if not uid_str or not uid_str.startswith("staff:"):
return None
try:
sid = int(uid_str.split(":")[1])
return Staff.query.filter_by(id=sid, is_active=True).filter(
Staff.deleted_at.is_(None)).first()
except (ValueError, IndexError):
return None
@staff_portal_bp.route("/")
@login_required
@require_role("tenant_staff")
def index():
staff = _get_current_staff()
if not staff:
flash("Staff profile not found.", "danger")
return redirect(url_for("staff_auth.staff_login"))
today = date.today()
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
# Today's appointments
todays_appts = Appointment.query.filter_by(
tenant_id=staff.tenant_id,
staff_id=staff.id,
).filter(
Appointment.start_time >= day_start,
Appointment.start_time < day_end,
Appointment.status.notin_(["cancelled", "no_show"]),
).order_by(Appointment.start_time).all()
# Current clocking state
current_clocking = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
return render_template("tenant/staff_portal/index.html",
staff=staff,
todays_appointments=todays_appts,
current_clocking=current_clocking,
today=today)
@staff_portal_bp.route("/clock-in", methods=["POST"])
@login_required
@require_role("tenant_staff")
def clock_in():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Check not already clocked in
existing = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
if existing:
flash("You are already clocked in.", "warning")
return redirect(url_for("staff_portal.index"))
location_id = g.location.id if g.location else None
clocking = StaffClocking(
tenant_id=staff.tenant_id,
location_id=location_id,
staff_id=staff.id,
clocked_in_at=datetime.now(timezone.utc),
)
db.session.add(clocking)
log_tenant_action("staff.clock_in", "staff", staff.id,
{"location": location_id})
db.session.commit()
flash("Clocked in successfully.", "success")
return redirect(url_for("staff_portal.index"))
@staff_portal_bp.route("/clock-out", methods=["POST"])
@login_required
@require_role("tenant_staff")
def clock_out():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
clocking = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
if not clocking:
flash("You are not currently clocked in.", "warning")
return redirect(url_for("staff_portal.index"))
now = datetime.now(timezone.utc)
clocking.clocked_out_at = now
total_minutes = int((now - clocking.clocked_in_at.replace(
tzinfo=timezone.utc if clocking.clocked_in_at.tzinfo is None else clocking.clocked_in_at.tzinfo
)).total_seconds() / 60)
clocking.total_minutes = total_minutes
clocking.notes = request.form.get("notes", "").strip() or None
log_tenant_action("staff.clock_out", "staff", staff.id,
{"minutes": total_minutes})
db.session.commit()
hours = total_minutes // 60
mins = total_minutes % 60
flash(f"Clocked out. Shift duration: {hours}h {mins}m.", "success")
return redirect(url_for("staff_portal.index"))
@staff_portal_bp.route("/schedule")
@login_required
@require_role("tenant_staff")
def schedule():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Next 7 days of appointments
now = datetime.now(timezone.utc)
week_ahead = now + timedelta(days=7)
appts = Appointment.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id,
).filter(
Appointment.start_time >= now,
Appointment.start_time < week_ahead,
Appointment.status.notin_(["cancelled", "no_show"]),
).order_by(Appointment.start_time).all()
return render_template("tenant/staff_portal/schedule.html",
staff=staff, appointments=appts)
@staff_portal_bp.route("/commission")
@login_required
@require_role("tenant_staff")
def commission():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Current and previous pay period summary
from datetime import date
today = date.today()
# Current month period label
period = today.strftime("%Y-W%U")
logs = CommissionLog.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id
).order_by(CommissionLog.id.desc()).limit(50).all()
pay_periods = StaffPayPeriod.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id
).order_by(StaffPayPeriod.period_start.desc()).limit(12).all()
return render_template("tenant/staff_portal/commission.html",
staff=staff, commission_logs=logs,
pay_periods=pay_periods)
@staff_portal_bp.route("/profile")
@login_required
@require_role("tenant_staff")
def profile():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
return render_template("tenant/staff_portal/profile.html", staff=staff)
+152
View File
@@ -0,0 +1,152 @@
"""
app/tenant/utils.py
Shared helpers used across all tenant portal blueprints.
"""
import logging
from datetime import datetime, timezone
from functools import wraps
from flask import g, flash, redirect, url_for, request, jsonify
from flask_login import current_user
logger = logging.getLogger(__name__)
def log_tenant_action(action, target_type=None, target_id=None, details=None):
"""
Write a structured log entry for any tenant create/edit/delete action.
Not persisted to audit_log (tenant-level actions use application logs).
"""
logger.info(
"TENANT_ACTION action=%s target_type=%s target_id=%s tenant=%s user=%s details=%s",
action,
target_type,
target_id,
getattr(g, 'tenant', None) and g.tenant.id,
current_user.get_id() if current_user.is_authenticated else None,
details,
)
def plan_limit_check(resource: str) -> tuple[bool, str]:
"""
Check whether the current tenant has headroom to add one more of `resource`.
resource: 'staff' | 'location'
Returns (allowed: bool, error_message: str | None)
"""
from app.models.salon import Staff, Location
tenant = getattr(g, 'tenant', None)
if not tenant or not tenant.plan:
return True, None
plan = tenant.plan
if resource == 'staff':
if plan.max_staff is None:
return True, None
count = Staff.query.filter_by(
tenant_id=tenant.id, is_active=True
).filter(Staff.deleted_at.is_(None)).count()
if count >= plan.max_staff:
return False, (
f"Your plan allows a maximum of {plan.max_staff} active staff members. "
"Please upgrade your plan to add more."
)
elif resource == 'location':
if plan.max_locations is None:
return True, None
count = Location.query.filter_by(
tenant_id=tenant.id, is_active=True
).count()
if count >= plan.max_locations:
return False, (
f"Your plan allows a maximum of {plan.max_locations} locations. "
"Please upgrade your plan to add more."
)
return True, None
def get_active_promotion(tenant_id: int, item_id: int, item_type: str):
"""
Promotion engine resolves the single best active promotion for a
service or product line item at checkout time.
item_type: 'service' | 'product'
Priority:
1. Specific promotion targeting this exact item ID
2. 'all_services' / 'all_products' promotion
3. 'all' promotion (covers both services and products)
If multiple promotions match at the same priority level, the highest
discount_percent wins. Returns None if no active promotion applies.
"""
from app.models.salon import Promotion
now = datetime.now(timezone.utc)
active = Promotion.query.filter_by(
tenant_id=tenant_id, is_active=True
).filter(
Promotion.starts_at <= now,
Promotion.ends_at >= now,
).all()
if not active:
return None
# Collect all candidates
candidates = []
for promo in active:
at = promo.applies_to
if at == 'all':
candidates.append((0, promo))
elif at == 'all_services' and item_type == 'service':
candidates.append((1, promo))
elif at == 'all_products' and item_type == 'product':
candidates.append((1, promo))
elif at == item_type:
# Specific IDs — check membership
target_ids = promo.target_ids_json or []
if item_id in target_ids:
candidates.append((2, promo))
if not candidates:
return None
# Sort: highest specificity first (2 > 1 > 0), then highest discount
candidates.sort(key=lambda x: (x[0], x[1].discount_percent), reverse=True)
return candidates[0][1]
def apply_promotion_to_price(price, promotion):
"""
Apply a promotion to a unit price.
Returns (discounted_price, discount_percent).
If promotion is None, returns original price and 0.
"""
from decimal import Decimal, ROUND_HALF_UP
if promotion is None:
return price, 0
pct = promotion.discount_percent
price = Decimal(str(price))
discount = (price * Decimal(pct) / Decimal(100)).quantize(
Decimal('0.01'), rounding=ROUND_HALF_UP
)
return float(price - discount), pct
def is_api_request():
return request.path.startswith('/api/') or \
request.accept_mimetypes.best == 'application/json'
+126 -2
View File
@@ -1,7 +1,131 @@
""" """
app/tenant/waitlist/routes.py app/tenant/waitlist/routes.py
Phase 3+ implementation. Waitlist management: list, add, notify, mark booked, expire.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db, mail
from app.models.salon import Waitlist, Staff, Service
from app.decorators import require_role, demo_readonly, tenant_feature_required
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist") waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")
@waitlist_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
@tenant_feature_required("waitlist")
def index():
entries = Waitlist.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Waitlist.status.in_(["waiting", "notified"])
).order_by(Waitlist.created_at).all()
return render_template("tenant/waitlist/index.html", entries=entries)
@waitlist_bp.route("/add", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("waitlist")
def add():
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
if request.method == "POST":
name = request.form.get("customer_name", "").strip()
phone = request.form.get("customer_phone", "").strip() or None
email_addr = request.form.get("customer_email", "").strip() or None
if not name:
return render_template("tenant/waitlist/form.html",
staff_list=staff_list, services=services,
error="Customer name is required.")
entry = Waitlist(
tenant_id=g.tenant.id, location_id=g.location.id,
customer_name=name, customer_phone=phone,
customer_email=email_addr,
staff_id=request.form.get("staff_id", type=int) or None,
service_id=request.form.get("service_id", type=int) or None,
requested_date=_parse_date(request.form.get("requested_date", "")),
status="waiting",
)
db.session.add(entry)
db.session.flush()
log_tenant_action("waitlist.add", "waitlist", entry.id, {"name": name})
db.session.commit()
flash(f"'{name}' added to waitlist.", "success")
return redirect(url_for("waitlist.index"))
return render_template("tenant/waitlist/form.html",
staff_list=staff_list, services=services)
@waitlist_bp.route("/<int:entry_id>/notify", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("waitlist")
def notify(entry_id):
entry = Waitlist.query.filter_by(
id=entry_id, tenant_id=g.tenant.id).first_or_404()
entry.status = "notified"
entry.notified_at = datetime.now(timezone.utc)
# Send email notification if available
if entry.customer_email:
try:
from flask_mail import Message
msg = Message(
subject=f"A slot is available — {g.tenant.name}",
recipients=[entry.customer_email],
body=(
f"Hi {entry.customer_name},\n\n"
"A slot has opened up for you. Please call or book online to confirm.\n\n"
f"{g.tenant.name}"
),
)
mail.send(msg)
except Exception as exc:
logger.error("Waitlist notify email failed: %s", exc)
log_tenant_action("waitlist.notify", "waitlist", entry.id,
{"name": entry.customer_name})
db.session.commit()
flash(f"'{entry.customer_name}' notified.", "success")
return redirect(url_for("waitlist.index"))
@waitlist_bp.route("/<int:entry_id>/set-status", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("waitlist")
def set_status(entry_id):
entry = Waitlist.query.filter_by(
id=entry_id, tenant_id=g.tenant.id).first_or_404()
new_status = request.form.get("status", "")
if new_status in ("booked", "expired"):
entry.status = new_status
log_tenant_action("waitlist.set_status", "waitlist", entry.id,
{"status": new_status})
db.session.commit()
flash(f"Entry updated to '{new_status}'.", "success")
return redirect(url_for("waitlist.index"))
def _parse_date(value):
if not value:
return None
try:
from datetime import date
return date.fromisoformat(value)
except ValueError:
return None
+35 -20
View File
@@ -1,19 +1,23 @@
# ── Admin portal ──────────────────────────────────────────────── # ── Admin portal ────────────────────────────────────────────────
# NOTE: HTTP only upgrade to HTTPS with: sudo certbot --nginx -d admin.mydomain.com
server { server {
listen 80; listen 443 ssl;
server_name admin.mydomain.com; server_name posadmin.ngodanguyen.tech;
# IP allowlist office / VPN only ssl_certificate /etc/ssl/certs/mydomain.crt;
# allow 203.0.113.0/24; ssl_certificate_key /etc/ssl/private/mydomain.key;
# deny all; ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# add_header X-Frame-Options "DENY" always; allow 203.0.113.0/24;
# add_header X-Content-Type-Options "nosniff" always; deny all;
# add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
location / { location / {
proxy_pass http://unix:/run/salonpos/salon_pos_admin.sock; proxy_pass http://unix:/run/salon_pos_admin.sock;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -22,24 +26,29 @@ server {
} }
location /static/admin/ { location /static/admin/ {
alias /home/salonpos/app/static/admin/; alias /opt/salon_pos/app/static/admin/;
expires 7d; expires 7d;
} }
} }
# ── Tenant portal ──────────────────────────────────────────────── # ── Tenant portal ────────────────────────────────────────────────
# NOTE: HTTP only upgrade to HTTPS with: sudo certbot --nginx -d mydomain.com
server { server {
listen 80; listen 443 ssl;
server_name mydomain.com; server_name pos.ngodanguyen.tech;
# add_header X-Frame-Options "SAMEORIGIN" always; ssl_certificate /etc/ssl/certs/mydomain.crt;
# add_header X-Content-Type-Options "nosniff" always; ssl_certificate_key /etc/ssl/private/mydomain.key;
# add_header Referrer-Policy "strict-origin-when-cross-origin" always; ssl_protocols TLSv1.2 TLSv1.3;
# add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" always; ssl_ciphers HIGH:!aNULL:!MD5;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net; img-src 'self' data:; frame-ancestors 'none';" always;
location / { location / {
proxy_pass http://unix:/run/salonpos/salon_pos_tenant.sock; proxy_pass http://unix:/run/salon_pos_tenant.sock;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -48,7 +57,13 @@ server {
} }
location /static/tenant/ { location /static/tenant/ {
alias /home/salonpos/app/static/tenant/; alias /opt/salon_pos/app/static/tenant/;
expires 7d; expires 7d;
} }
} }
server {
listen 80;
server_name posadmin.ngodanguyen.tech pos.ngodanguyen.tech;
return 301 https://$host$request_uri;
}
+1 -1
View File
@@ -8,7 +8,7 @@ WorkingDirectory=/opt/salon_pos
EnvironmentFile=/opt/salon_pos/.env EnvironmentFile=/opt/salon_pos/.env
ExecStart=/opt/salon_pos/venv/bin/gunicorn \ ExecStart=/opt/salon_pos/venv/bin/gunicorn \
--workers 2 \ --workers 2 \
--bind unix:/run/salon_pos/salon_pos_admin.sock \ --bind unix:/run/salon_pos_admin.sock \
--timeout 120 \ --timeout 120 \
wsgi_admin:app wsgi_admin:app
Restart=on-failure Restart=on-failure
+1 -1
View File
@@ -8,7 +8,7 @@ WorkingDirectory=/opt/salon_pos
EnvironmentFile=/opt/salon_pos/.env EnvironmentFile=/opt/salon_pos/.env
ExecStart=/opt/salon_pos/venv/bin/gunicorn \ ExecStart=/opt/salon_pos/venv/bin/gunicorn \
--workers 4 \ --workers 4 \
--bind unix:/run/salon_pos/salon_pos_tenant.sock \ --bind unix:/run/salon_pos_tenant.sock \
--timeout 120 \ --timeout 120 \
wsgi_tenant:app wsgi_tenant:app
Restart=on-failure Restart=on-failure
+75
View File
@@ -0,0 +1,75 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Appointment{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-7">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Appointment</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 mb-3">
<div class="col-md-6">
<label class="form-label">Start Date & Time <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="start_time" required
value="{{ appointment.start_time.strftime('%Y-%m-%dT%H:%M') if appointment else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Service</label>
<select class="form-select" name="service_id">
<option value="">— None —</option>
{% for s in services %}
<option value="{{ s.id }}"
{{ 'selected' if appointment and appointment.service_id == s.id }}>
{{ s.name }} ({{ s.duration_min }} min)
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Customer</label>
<select class="form-select" name="customer_id">
<option value="">— Walk-in —</option>
{% for c in customers %}
<option value="{{ c.id }}"
{{ 'selected' if appointment and appointment.customer_id == c.id }}>
{{ c.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Any —</option>
{% for s in staff_list %}
<option value="{{ s.id }}"
{{ 'selected' if appointment and appointment.staff_id == s.id }}>
{{ s.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_walk_in" value="1"
id="is_walk_in" {{ 'checked' if appointment and appointment.is_walk_in }}>
<label class="form-check-label" for="is_walk_in">Walk-in (no appointment)</label>
</div>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="2">{{ appointment.notes if appointment else '' }}</textarea>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('appointments.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Appointments{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold mb-0"><i class="bi bi-calendar3 me-2"></i>Appointments</h4>
<div class="d-flex align-items-center gap-2">
<a href="{{ url_for('appointments.index', date=(view_date - timedelta(days=1)).isoformat()) }}"
class="btn btn-outline-secondary btn-sm"></a>
<input type="date" class="form-control form-control-sm" value="{{ view_date.isoformat() }}"
onchange="window.location='{{ url_for('appointments.index') }}?date='+this.value"
style="width:150px;">
<a href="{{ url_for('appointments.index', date=(view_date + timedelta(days=1)).isoformat()) }}"
class="btn btn-outline-secondary btn-sm"></a>
<a href="{{ url_for('appointments.create') }}" class="btn btn-primary btn-sm ms-2">+ New</a>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Time</th><th>Customer</th><th>Service</th><th>Staff</th><th>Status</th><th>Type</th><th></th></tr>
</thead>
<tbody>
{% for appt in appointments %}
<tr>
<td class="fw-semibold text-nowrap">{{ appt.start_time.strftime('%I:%M %p') }}</td>
<td>{{ appt.customer.name if appt.customer else '<em class="text-muted">Walk-in</em>' | safe }}</td>
<td class="small">{{ appt.service.name if appt.service else '—' }}</td>
<td class="small">{{ appt.staff.name if appt.staff else '—' }}</td>
<td>
<span class="badge bg-{{ {'pending':'warning','confirmed':'primary','in_progress':'info','completed':'success','cancelled':'danger','no_show':'dark'}.get(appt.status,'secondary') }}">
{{ appt.status }}
</span>
</td>
<td class="small">{{ 'Walk-in' if appt.is_walk_in else 'Booked' }}</td>
<td>
<a href="{{ url_for('appointments.view', appt_id=appt.id) }}" class="btn btn-outline-secondary btn-sm">View</a>
{% if appt.status in ['pending','confirmed','in_progress'] %}
<a href="{{ url_for('pos.checkout') }}?appointment_id={{ appt.id }}" class="btn btn-success btn-sm">Checkout</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No appointments for this day.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Import timedelta not available in Jinja — use JS for date nav
</script>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Appointment #{{ appointment.id }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0">Appointment #{{ appointment.id }}</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('appointments.edit', appt_id=appointment.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
{% if appointment.status in ['pending','confirmed','in_progress'] %}
<a href="{{ url_for('pos.checkout') }}?appointment_id={{ appointment.id }}" class="btn btn-success btn-sm">
<i class="bi bi-cash-register me-1"></i>Checkout
</a>
{% endif %}
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">Customer</dt><dd class="col-sm-8">{{ appointment.customer.name if appointment.customer else 'Walk-in' }}</dd>
<dt class="col-sm-4">Service</dt><dd class="col-sm-8">{{ appointment.service.name if appointment.service else '—' }}</dd>
<dt class="col-sm-4">Staff</dt><dd class="col-sm-8">{{ appointment.staff.name if appointment.staff else '—' }}</dd>
<dt class="col-sm-4">Start</dt><dd class="col-sm-8">{{ appointment.start_time.strftime('%Y-%m-%d %I:%M %p') }}</dd>
<dt class="col-sm-4">End</dt><dd class="col-sm-8">{{ appointment.end_time.strftime('%I:%M %p') if appointment.end_time else '—' }}</dd>
<dt class="col-sm-4">Type</dt><dd class="col-sm-8">{{ 'Walk-in' if appointment.is_walk_in else 'Booked' }}</dd>
<dt class="col-sm-4">Source</dt><dd class="col-sm-8">{{ appointment.rebook_source or 'manual' }}</dd>
{% if appointment.notes %}
<dt class="col-sm-4">Notes</dt><dd class="col-sm-8">{{ appointment.notes }}</dd>
{% endif %}
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Update Status</div>
<div class="card-body">
<form method="POST" action="{{ url_for('appointments.set_status', appt_id=appointment.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<select class="form-select" name="status">
{% for s in ['pending','confirmed','in_progress','completed','cancelled','no_show'] %}
<option value="{{ s }}" {{ 'selected' if appointment.status == s }}>{{ s.replace('_',' ').title() }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3" id="cancel-reason-field">
<label class="form-label small">Cancellation reason</label>
<input type="text" class="form-control form-control-sm" name="reason"
value="{{ appointment.cancellation_reason or '' }}">
</div>
<button type="submit" class="btn btn-primary btn-sm">Update Status</button>
</form>
</div>
</div>
</div>
</div>
<a href="{{ url_for('appointments.index', date=appointment.start_time.date().isoformat()) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back to Calendar
</a>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Book an Appointment — {{ tenant.name }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light">
<div class="container py-5" style="max-width:540px;">
{% if confirmed %}
<div class="card shadow text-center p-5">
<div class="display-1 mb-3"></div>
<h3 class="fw-bold">Booking Requested!</h3>
<p class="text-muted">We'll confirm your appointment shortly. Check your email for details.</p>
<a href="/book/{{ tenant.slug }}" class="btn btn-outline-primary mt-3">Book Another</a>
</div>
{% else %}
<div class="card shadow p-4">
<h3 class="fw-bold mb-1">{{ tenant.name }}</h3>
<p class="text-muted mb-4">Book an appointment online</p>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Your Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" required autofocus>
</div>
<div class="col">
<label class="form-label">Phone <span class="text-danger">*</span></label>
<input type="tel" class="form-control" name="phone" required inputmode="numeric">
</div>
</div>
<div class="mb-3">
<label class="form-label">Email <small class="text-muted">(for confirmation)</small></label>
<input type="email" class="form-control" name="email">
</div>
<div class="mb-3">
<label class="form-label">Service <span class="text-danger">*</span></label>
<select class="form-select" name="service_id" required>
<option value="">— Select a service —</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }} — ${{ "%.2f"|format(s.price) }} ({{ s.duration_min }} min)</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Preferred Staff <small class="text-muted">(optional)</small></label>
<select class="form-select" name="staff_id">
<option value="">— No preference —</option>
{% for s in staff_list %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Date <span class="text-danger">*</span></label>
<input type="date" class="form-control" name="preferred_date" required>
</div>
<div class="col">
<label class="form-label">Time <span class="text-danger">*</span></label>
<input type="time" class="form-control" name="preferred_time" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="2"></textarea>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">Request Appointment</button>
</div>
</form>
</div>
{% endif %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Not Found</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="text-center">
<h2 class="fw-bold">Booking Page Not Found</h2>
<p class="text-muted">This salon's booking page doesn't exist or is no longer available.</p>
</div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Booking Unavailable</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="text-center">
<h2 class="fw-bold">Online Booking Unavailable</h2>
<p class="text-muted">Online booking is not currently available for this salon. Please call to book.</p>
</div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Check-In Not Found</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="text-center">
<h2 class="fw-bold">Check-In Unavailable</h2>
<p class="text-muted">This check-in kiosk is not available. Please see a staff member.</p>
</div>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Customer{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Customer</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ customer.name if customer else '' }}" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" name="phone"
value="{{ customer.phone if customer else '' }}">
</div>
<div class="col">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email"
value="{{ customer.email if customer else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Date of Birth</label>
<input type="date" class="form-control" name="date_of_birth"
value="{{ customer.date_of_birth.isoformat() if customer and customer.date_of_birth else '' }}">
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="3">{{ customer.notes if customer else '' }}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Customers{% 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>Customers</h4>
<a href="{{ url_for('customers.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Customer
</a>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<form method="GET" class="d-flex gap-2">
<input type="search" class="form-control form-control-sm" name="q"
value="{{ q }}" placeholder="Search by name, phone, or email…" autofocus>
<button class="btn btn-outline-secondary btn-sm">Search</button>
{% if q %}<a href="{{ url_for('customers.index') }}" class="btn btn-outline-danger btn-sm">Clear</a>{% endif %}
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>Phone</th><th>Email</th><th>Loyalty</th><th>No-Shows</th><th></th></tr></thead>
<tbody>
{% for c in customers %}
<tr>
<td><a href="{{ url_for('customers.view', customer_id=c.id) }}" class="text-decoration-none fw-semibold">{{ c.name }}</a></td>
<td class="text-muted small">{{ c.phone or '—' }}</td>
<td class="text-muted small">{{ c.email or '—' }}</td>
<td><span class="badge bg-warning text-dark">{{ c.loyalty_points }} pts</span></td>
<td>{% if c.no_show_count > 0 %}<span class="badge bg-danger">{{ c.no_show_count }}</span>{% else %}0{% endif %}</td>
<td><a href="{{ url_for('customers.edit', customer_id=c.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No customers found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ customer.name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0">{{ customer.name }}</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<a href="{{ url_for('appointments.create') }}?customer_id={{ customer.id }}" class="btn btn-primary btn-sm">New Appointment</a>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<p class="mb-1"><i class="bi bi-telephone me-2 text-muted"></i>{{ customer.phone or '—' }}</p>
<p class="mb-1"><i class="bi bi-envelope me-2 text-muted"></i>{{ customer.email or '—' }}</p>
{% if customer.date_of_birth %}
<p class="mb-1"><i class="bi bi-cake me-2 text-muted"></i>{{ customer.date_of_birth.strftime('%B %d') }}</p>
{% endif %}
<p class="mb-1"><i class="bi bi-star me-2 text-warning"></i>{{ customer.loyalty_points }} loyalty points</p>
{% if customer.no_show_count > 0 %}
<p class="mb-0"><i class="bi bi-x-circle me-2 text-danger"></i>{{ customer.no_show_count }} no-show(s)</p>
{% endif %}
{% if customer.notes %}
<hr><p class="text-muted small mb-0">{{ customer.notes }}</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Recent Appointments</div>
{% if appointments %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Date</th><th>Service</th><th>Staff</th><th>Status</th></tr></thead>
<tbody>
{% for a in appointments %}
<tr>
<td class="small">{{ a.start_time.strftime('%Y-%m-%d %I:%M %p') }}</td>
<td class="small">{{ a.service.name if a.service else '—' }}</td>
<td class="small">{{ a.staff.name if a.staff else '—' }}</td>
<td><span class="badge bg-{{ {'completed':'success','cancelled':'danger','no_show':'warning'}.get(a.status,'secondary') }} small">{{ a.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-muted small">No appointments yet.</div>
{% endif %}
</div>
</div>
</div>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
{% endblock %}
+62 -6
View File
@@ -1,10 +1,66 @@
{% extends "tenant/layouts/base.html" %} {% extends "tenant/layouts/base.html" %}
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block content %} {% block content %}
<h4 class="mb-4">Dashboard <div class="d-flex justify-content-between align-items-center mb-4">
{% if location %}<small class="text-muted fs-6">— {{ location.name }}</small>{% endif %} <h4 class="fw-bold mb-0">
</h4> <i class="bi bi-speedometer2 me-2"></i>Dashboard
<div class="alert alert-info"> {% if location %}<small class="text-muted fs-6 ms-2">— {{ location.name }}</small>{% endif %}
Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts). </h4>
<span class="text-muted small">{{ today.strftime('%A, %B %d, %Y') }}</span>
</div> </div>
{% endblock %}
<!-- KPI cards -->
<div class="row g-3 mb-4">
{% set kpi_items = [
("Today's Revenue", "$" ~ "%.2f"|format(kpis.today_revenue), "graph-up-arrow", "success"),
("Appointments", kpis.total_appointments, "calendar3", "primary"),
("Completed", kpis.completed_appointments, "check-circle", "success"),
("Pending", kpis.pending_appointments, "hourglass-split", "warning"),
("Staff on Shift", kpis.staff_on_shift, "person-badge", "info"),
("Check-in Queue", kpis.queue_count, "door-open", "danger" if kpis.queue_count > 0 else "secondary"),
] %}
{% for label, value, icon, color in kpi_items %}
<div class="col-sm-6 col-lg-4 col-xl-2">
<div class="card border-0 shadow-sm h-100">
<div class="card-body d-flex align-items-center gap-3 py-3">
<div class="rounded-circle bg-{{ color }} bg-opacity-10 p-2">
<i class="bi bi-{{ icon }} fs-5 text-{{ color }}"></i>
</div>
<div>
<div class="fw-bold fs-5">{{ value }}</div>
<div class="text-muted" style="font-size:.75rem;">{{ label }}</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Upcoming appointments -->
<div class="card shadow-sm">
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
<span><i class="bi bi-calendar-check me-2"></i>Upcoming Today</span>
<a href="{{ url_for('appointments.index') }}" class="btn btn-outline-primary btn-sm">Full Calendar</a>
</div>
{% if upcoming_appointments %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Time</th><th>Customer</th><th>Service</th><th>Staff</th><th>Status</th></tr></thead>
<tbody>
{% for appt in upcoming_appointments %}
<tr>
<td class="fw-semibold">{{ appt.start_time.strftime('%I:%M %p') }}</td>
<td>{{ appt.customer.name if appt.customer else '—' }}</td>
<td>{{ appt.service.name if appt.service else '—' }}</td>
<td>{{ appt.staff.name if appt.staff else '—' }}</td>
<td><span class="badge bg-{{ {'pending':'warning','confirmed':'primary','in_progress':'info','completed':'success'}.get(appt.status,'secondary') }}">{{ appt.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-muted small">No upcoming appointments for today.</div>
{% endif %}
</div>
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Issue Gift Card{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Issue New Gift Card</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">Value ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" min="1" class="form-control"
name="value" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Customer <small class="text-muted">(optional)</small></label>
<select class="form-select" name="customer_id">
<option value="">— None —</option>
{% for c in customers %}
<option value="{{ c.id }}">{{ c.name }}{% if c.phone %} — {{ c.phone }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Expiry Date <small class="text-muted">(optional)</small></label>
<input type="datetime-local" class="form-control" name="expires_at">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Issue Card</button>
<a href="{{ url_for('gift_cards.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Gift Cards{% 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-gift me-2"></i>Gift Cards</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('gift_cards.lookup') }}" class="btn btn-outline-secondary btn-sm">Lookup Code</a>
<a href="{{ url_for('gift_cards.issue') }}" class="btn btn-primary btn-sm">+ Issue Gift Card</a>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Code</th><th>Original</th><th>Balance</th><th>Customer</th><th>Expires</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for card in cards %}
<tr class="{{ '' if card.is_active else 'text-muted' }}">
<td class="font-monospace fw-semibold">{{ card.code }}</td>
<td>${{ "%.2f"|format(card.original_value) }}</td>
<td class="fw-semibold {% if card.remaining_balance <= 0 %}text-danger{% endif %}">
${{ "%.2f"|format(card.remaining_balance) }}
</td>
<td class="small">{{ card.customer.name if card.issued_to_customer_id and card.customer else '—' }}</td>
<td class="small text-muted">{{ card.expires_at.strftime('%Y-%m-%d') if card.expires_at else 'No expiry' }}</td>
<td>
<span class="badge {{ 'bg-success' if card.is_active else 'bg-secondary' }}">
{{ 'Active' if card.is_active else 'Inactive' }}
</span>
</td>
<td>
{% if card.is_active %}
<form method="POST" action="{{ url_for('gift_cards.deactivate', card_id=card.id) }}"
class="d-inline" onsubmit="return confirm('Deactivate this gift card?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Deactivate</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No gift cards issued yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Gift Card Lookup{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Gift Card Lookup</div>
<div class="card-body">
<form method="GET" class="mb-3">
<div class="input-group">
<input type="text" class="form-control font-monospace" name="code"
value="{{ code }}" placeholder="XXXX-XXXX-XXXX" autofocus>
<button class="btn btn-primary">Look Up</button>
</div>
</form>
{% if code and card %}
<div class="alert {{ 'alert-success' if card.is_active else 'alert-warning' }}">
<dl class="row mb-0">
<dt class="col-sm-5">Code</dt><dd class="col-sm-7 font-monospace">{{ card.code }}</dd>
<dt class="col-sm-5">Original Value</dt><dd class="col-sm-7">${{ "%.2f"|format(card.original_value) }}</dd>
<dt class="col-sm-5">Remaining Balance</dt>
<dd class="col-sm-7 fw-bold {% if card.remaining_balance <= 0 %}text-danger{% endif %}">
${{ "%.2f"|format(card.remaining_balance) }}
</dd>
<dt class="col-sm-5">Status</dt>
<dd class="col-sm-7">{{ 'Active' if card.is_active else 'Inactive / Depleted' }}</dd>
{% if card.expires_at %}
<dt class="col-sm-5">Expires</dt><dd class="col-sm-7">{{ card.expires_at.strftime('%Y-%m-%d') }}</dd>
{% endif %}
</dl>
</div>
{% elif code %}
<div class="alert alert-danger">No gift card found with code <strong>{{ code }}</strong>.</div>
{% endif %}
<a href="{{ url_for('gift_cards.index') }}" class="btn btn-outline-secondary btn-sm">Back</a>
</div>
</div>
</div>
</div>
{% endblock %}
+10 -10
View File
@@ -37,7 +37,7 @@
{% for loc in locations %} {% for loc in locations %}
<li> <li>
<a class="dropdown-item {% if g.location and g.location.id == loc.id %}active{% endif %}" <a class="dropdown-item {% if g.location and g.location.id == loc.id %}active{% endif %}"
href="{{ '#' }}"> href="{{ url_for('locations.switch', location_id=loc.id) }}">
{{ loc.name }} {{ loc.name }}
{% if loc.is_primary %}<span class="badge bg-secondary ms-1">Primary</span>{% endif %} {% if loc.is_primary %}<span class="badge bg-secondary ms-1">Primary</span>{% endif %}
</a> </a>
@@ -86,31 +86,31 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'appointments' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'appointments' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('appointments.index') }}">
<i class="bi bi-calendar3 me-2"></i>Appointments <i class="bi bi-calendar3 me-2"></i>Appointments
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'pos' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'pos' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('pos.checkout') }}">
<i class="bi bi-cash-register me-2"></i>POS / Checkout <i class="bi bi-cash-register me-2"></i>POS / Checkout
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'customers' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'customers' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('customers.index') }}">
<i class="bi bi-people me-2"></i>Customers <i class="bi bi-people me-2"></i>Customers
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'staff' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'staff' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('staff.index') }}">
<i class="bi bi-person-badge me-2"></i>Staff <i class="bi bi-person-badge me-2"></i>Staff
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'services' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'services' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('services.index') }}">
<i class="bi bi-card-list me-2"></i>Services & Products <i class="bi bi-card-list me-2"></i>Services & Products
</a> </a>
</li> </li>
@@ -122,13 +122,13 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'gift_cards' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'gift_cards' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('gift_cards.index') }}">
<i class="bi bi-gift me-2"></i>Gift Cards <i class="bi bi-gift me-2"></i>Gift Cards
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'reconciliation' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'reconciliation' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('reconciliation.index') }}">
<i class="bi bi-calculator me-2"></i>Reconciliation <i class="bi bi-calculator me-2"></i>Reconciliation
</a> </a>
</li> </li>
@@ -147,13 +147,13 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'locations' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'locations' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('locations.index') }}">
<i class="bi bi-geo-alt me-2"></i>Locations <i class="bi bi-geo-alt me-2"></i>Locations
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.endpoint and 'settings' in request.endpoint %}active fw-bold{% endif %}" <a class="nav-link {% if request.endpoint and 'settings' in request.endpoint %}active fw-bold{% endif %}"
href="{{ '#' }}"> href="{{ url_for('settings.index') }}">
<i class="bi bi-gear me-2"></i>Settings <i class="bi bi-gear me-2"></i>Settings
</a> </a>
</li> </li>
+62
View File
@@ -0,0 +1,62 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Location{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Location</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ location.name if location else '' }}" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Address</label>
<input type="text" class="form-control" name="address"
value="{{ location.address if location else '' }}">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" name="phone"
value="{{ location.phone if location else '' }}">
</div>
<div class="col">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email"
value="{{ location.email if location else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Timezone</label>
<input type="text" class="form-control" name="timezone"
value="{{ location.timezone if location else 'America/New_York' }}">
</div>
{% if mode == 'edit' %}
<div class="mb-3 d-flex gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if location and location.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_primary" value="1"
id="is_primary" {{ 'checked' if location and location.is_primary }}>
<label class="form-check-label" for="is_primary">Set as Primary</label>
</div>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('locations.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Locations{% 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-geo-alt me-2"></i>Locations</h4>
<a href="{{ url_for('locations.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Location
</a>
</div>
<div class="row g-3">
{% for loc in locations %}
<div class="col-md-6 col-lg-4">
<div class="card shadow-sm h-100 {{ '' if loc.is_active else 'opacity-50' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>{{ loc.name }}</strong>
<div>
{% if loc.is_primary %}<span class="badge bg-primary me-1">Primary</span>{% endif %}
<span class="badge {{ 'bg-success' if loc.is_active else 'bg-secondary' }}">{{ 'Active' if loc.is_active else 'Inactive' }}</span>
</div>
</div>
<div class="card-body small text-muted">
{% if loc.address %}<p class="mb-1"><i class="bi bi-map me-1"></i>{{ loc.address }}</p>{% endif %}
{% if loc.phone %}<p class="mb-1"><i class="bi bi-telephone me-1"></i>{{ loc.phone }}</p>{% endif %}
{% if loc.email %}<p class="mb-1"><i class="bi bi-envelope me-1"></i>{{ loc.email }}</p>{% endif %}
<p class="mb-0"><i class="bi bi-clock me-1"></i>{{ loc.timezone }}</p>
</div>
<div class="card-footer d-flex gap-2">
<a href="{{ url_for('locations.edit', location_id=loc.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
{% if not loc.is_primary %}
<form method="POST" action="{{ url_for('locations.set_primary', location_id=loc.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-primary btn-sm">Set Primary</button>
</form>
{% endif %}
<a href="{{ url_for('locations.switch', location_id=loc.id) }}" class="btn btn-outline-success btn-sm ms-auto">Switch</a>
</div>
</div>
</div>
{% else %}
<div class="col"><p class="text-muted">No locations found.</p></div>
{% endfor %}
</div>
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}POS Checkout{% endblock %}
{% block content %}
<h4 class="fw-bold mb-4"><i class="bi bi-cash-register me-2"></i>Checkout</h4>
<form method="POST" action="{{ url_for('pos.submit') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if appointment %}
<input type="hidden" name="appointment_id" value="{{ appointment.id }}">
{% endif %}
<div class="row g-3">
<!-- Left: items -->
<div class="col-lg-7">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Services</div>
<div class="card-body">
{% for s in services %}
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" name="service_ids"
value="{{ s.id }}" id="svc_{{ s.id }}"
{{ 'checked' if appointment and appointment.service_id == s.id }}>
<label class="form-check-label" for="svc_{{ s.id }}">
{{ s.name }} <span class="text-muted small">({{ s.duration_min }}min)</span>
<span class="fw-semibold">${{ "%.2f"|format(s.price) }}</span>
</label>
</div>
{% endfor %}
</div>
</div>
{% if services %}
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Products</div>
<div class="card-body">
{% for p in products %}
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" name="product_ids"
value="{{ p.id }}" id="prod_{{ p.id }}">
<label class="form-check-label" for="prod_{{ p.id }}">
{{ p.name }} <span class="fw-semibold">${{ "%.2f"|format(p.sale_price) }}</span>
</label>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Right: payment details -->
<div class="col-lg-5">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Payment Details</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Customer</label>
<select class="form-select" name="customer_id">
<option value="">— Walk-in / No Customer —</option>
{% for c in customers %}
<option value="{{ c.id }}"
{{ 'selected' if appointment and appointment.customer_id == c.id }}>
{{ c.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Not assigned —</option>
{% for s in staff_list %}
<option value="{{ s.id }}"
{{ 'selected' if appointment and appointment.staff_id == s.id }}>
{{ s.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Payment Method</label>
<select class="form-select" name="payment_method">
{% for m in payment_methods %}
<option value="{{ m }}">{{ m.title() }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Payment Reference <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" name="payment_reference"
placeholder="Transaction ID, note…">
</div>
<div class="mb-3">
<label class="form-label">Tip ($)</label>
<input type="number" step="0.01" class="form-control" name="tip_amount"
value="0" min="0">
</div>
<div class="mb-3">
<label class="form-label">Gift Card Code</label>
<input type="text" class="form-control font-monospace" name="gift_card_code"
placeholder="XXXX-XXXX-XXXX">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="rebook" value="1" id="rebook">
<label class="form-check-label" for="rebook">Schedule next visit after checkout</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success btn-lg">
<i class="bi bi-check-circle me-2"></i>Complete Checkout
</button>
</div>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Schedule Next Visit{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-calendar-plus me-2"></i>Schedule Next Visit</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">Date & Time <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="start_time" required>
</div>
<div class="mb-3">
<label class="form-label">Service</label>
<select class="form-select" name="service_id">
<option value="">— Same as today —</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Any —</option>
{% for s in staff_list %}
<option value="{{ s.id }}" {{ 'selected' if transaction.staff_id == s.id }}>{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Schedule</button>
<a href="{{ url_for('pos.receipt', transaction_id=transaction.id) }}" class="btn btn-outline-secondary">Skip</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Receipt #{{ transaction.id }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<h5 class="fw-bold">{{ tenant.name }}</h5>
{% if location %}<p class="text-muted small mb-0">{{ location.name }}</p>{% endif %}
<p class="text-muted small">{{ transaction.created_at.strftime('%B %d, %Y %I:%M %p') }}</p>
<p class="text-muted small">Receipt #{{ transaction.id }}</p>
{% if transaction.voided_at %}
<div class="alert alert-danger py-1 mt-2">VOIDED — {{ transaction.void_reason }}</div>
{% endif %}
</div>
<table class="table table-sm mb-3">
<tbody>
{% for item in items %}
<tr>
<td>
{% if item.service %}{{ item.service.name }}
{% elif item.product %}{{ item.product.name }}
{% endif %}
{% if item.discount_percent > 0 %}
<span class="badge bg-success ms-1">{{ item.discount_percent }}% off</span>
{% endif %}
</td>
<td class="text-end">
{% if item.discount_percent > 0 %}
<del class="text-muted small">${{ "%.2f"|format(item.original_price) }}</del>
{% endif %}
${{ "%.2f"|format(item.unit_price) }}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="fw-semibold">
{% if transaction.discount > 0 %}
<tr class="text-success"><td>Savings</td><td class="text-end">-${{ "%.2f"|format(transaction.discount) }}</td></tr>
{% endif %}
{% if transaction.tip_amount > 0 %}
<tr><td>Tip</td><td class="text-end">${{ "%.2f"|format(transaction.tip_amount) }}</td></tr>
{% endif %}
{% if transaction.gift_card_amount > 0 %}
<tr class="text-info"><td>Gift Card</td><td class="text-end">-${{ "%.2f"|format(transaction.gift_card_amount) }}</td></tr>
{% endif %}
<tr class="table-dark fs-5"><td>Total</td><td class="text-end">${{ "%.2f"|format(transaction.total) }}</td></tr>
<tr><td>Payment</td><td class="text-end">{{ transaction.payment_method.title() }}</td></tr>
</tfoot>
</table>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="{{ url_for('pos.checkout') }}" class="btn btn-primary">New Transaction</a>
<a href="{{ url_for('pos.rebook', transaction_id=transaction.id) }}" class="btn btn-outline-primary">Rebook</a>
{% if not transaction.voided_at %}
<a href="{{ url_for('pos.void', transaction_id=transaction.id) }}" class="btn btn-outline-danger">Void</a>
{% endif %}
<a href="{{ url_for('pos.transactions') }}" class="btn btn-outline-secondary">Transactions</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Transactions{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold mb-0">Transactions</h4>
<input type="date" class="form-control form-control-sm" value="{{ view_date.isoformat() }}"
onchange="window.location='{{ url_for('pos.transactions') }}?date='+this.value"
style="width:150px;">
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Time</th><th>Customer</th><th>Staff</th><th>Total</th><th>Method</th><th>Tip</th><th></th></tr></thead>
<tbody>
{% for t in transactions %}
<tr>
<td class="small">{{ t.created_at.strftime('%I:%M %p') }}</td>
<td class="small">{{ t.customer.name if t.customer else '—' }}</td>
<td class="small">{{ t.staff.name if t.staff else '—' }}</td>
<td class="fw-semibold">${{ "%.2f"|format(t.total) }}</td>
<td class="small">{{ t.payment_method.title() }}</td>
<td class="small">{% if t.tip_amount > 0 %}${{ "%.2f"|format(t.tip_amount) }}{% else %}—{% endif %}</td>
<td><a href="{{ url_for('pos.receipt', transaction_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 transactions for this day.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Void Transaction{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm border-danger">
<div class="card-header bg-danger text-white fw-semibold">Void Transaction #{{ transaction.id }}</div>
<div class="card-body">
<p class="text-muted">Total: <strong>${{ "%.2f"|format(transaction.total) }}</strong></p>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate onsubmit="return confirm('Are you sure you want to void this transaction?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Reason <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="reason" required autofocus>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">Confirm Void</button>
<a href="{{ url_for('pos.receipt', transaction_id=transaction.id) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,42 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Close Day{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-calculator me-2"></i>Close Day — {{ today.strftime('%B %d, %Y') }}
</div>
<div class="card-body">
<!-- Summary from transactions -->
<table class="table table-sm mb-4">
<tbody>
<tr><td>Cash Sales</td><td class="text-end fw-semibold">${{ "%.2f"|format(total_cash) }}</td></tr>
<tr><td>App Payments (Zelle / Venmo / etc.)</td><td class="text-end">${{ "%.2f"|format(total_app) }}</td></tr>
<tr><td>Tips Collected</td><td class="text-end">${{ "%.2f"|format(total_tips) }}</td></tr>
<tr><td>Gift Card Redemptions</td><td class="text-end">${{ "%.2f"|format(total_gc) }}</td></tr>
<tr class="table-light fw-bold"><td>Expected Cash in Drawer</td><td class="text-end">${{ "%.2f"|format(expected_cash) }}</td></tr>
</tbody>
</table>
{% 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 fw-semibold">Actual Cash Counted ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" min="0" class="form-control form-control-lg"
name="actual_cash" required autofocus placeholder="0.00">
</div>
<div class="mb-3">
<label class="form-label">Notes <small class="text-muted">(optional)</small></label>
<textarea class="form-control" name="notes" rows="2"></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Close Day</button>
<a href="{{ url_for('reconciliation.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Reconciliation{% 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-calculator me-2"></i>End-of-Day Reconciliation</h4>
<a href="{{ url_for('reconciliation.close_day') }}" class="btn btn-primary btn-sm">Close Today</a>
</div>
{% if records %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Date</th><th>Cash</th><th>App Payments</th><th>Tips</th><th>Gift Cards</th><th>Expected</th><th>Actual</th><th>Variance</th><th>Closed By</th></tr>
</thead>
<tbody>
{% for r in records %}
<tr>
<td class="fw-semibold">{{ r.date.strftime('%Y-%m-%d') }}</td>
<td>${{ "%.2f"|format(r.total_cash) }}</td>
<td>${{ "%.2f"|format(r.total_app_payments) }}</td>
<td>${{ "%.2f"|format(r.total_tips) }}</td>
<td>${{ "%.2f"|format(r.total_gift_card_redemptions) }}</td>
<td>${{ "%.2f"|format(r.expected_cash_in_drawer) }}</td>
<td>${{ "%.2f"|format(r.actual_cash_counted) }}</td>
<td class="fw-semibold {{ 'text-danger' if r.variance < 0 else 'text-success' if r.variance > 0 else '' }}">
{{ '+' if r.variance > 0 else '' }}${{ "%.2f"|format(r.variance) }}
</td>
<td class="small text-muted">{{ r.closed_at.strftime('%I:%M %p') if r.closed_at else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No reconciliation records yet.</div>
{% endif %}
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Reviews{% 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-star me-2"></i>Customer Reviews</h4>
<div class="text-end">
<div class="fs-3 fw-bold text-warning">{{ avg_rating }} ★</div>
<div class="text-muted small">Average rating</div>
</div>
</div>
<!-- Rating distribution -->
<div class="card shadow-sm mb-4" style="max-width:400px;">
<div class="card-body py-2">
{% for star in [5,4,3,2,1] %}
{% set count = rating_dist.get(star, 0) %}
{% set total = reviews|length %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="text-muted small" style="width:20px;">{{ star }}★</span>
<div class="progress flex-grow-1" style="height:8px;">
<div class="progress-bar bg-warning"
style="width:{{ ((count / total * 100) | int) if total else 0 }}%"></div>
</div>
<span class="text-muted small" style="width:30px;">{{ count }}</span>
</div>
{% endfor %}
</div>
</div>
{% if reviews %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Date</th><th>Rating</th><th>Customer</th><th>Staff</th><th>Comment</th></tr></thead>
<tbody>
{% for r in reviews %}
<tr>
<td class="small text-muted">{{ r.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<span class="text-warning fw-bold">{{ r.rating }}★</span>
</td>
<td class="small">{{ r.customer.name if r.customer else '—' }}</td>
<td class="small">{{ r.staff.name if r.staff else '—' }}</td>
<td class="small">{{ r.comment or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No reviews yet.</div>
{% endif %}
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Services & Products{% 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>Services & Products</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('services.create_service') }}" class="btn btn-primary btn-sm">+ Service</a>
<a href="{{ url_for('services.create_product') }}" class="btn btn-outline-primary btn-sm">+ Product</a>
<a href="{{ url_for('services.create_promotion') }}" class="btn btn-outline-warning btn-sm">+ Promotion</a>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Services</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>Category</th><th>Duration</th><th>Price</th><th></th></tr></thead>
<tbody>
{% for s in services %}
<tr class="{{ '' if s.is_active else 'text-muted' }}">
<td class="fw-semibold">{{ s.name }}</td>
<td class="small">{{ s.category or '—' }}</td>
<td class="small">{{ s.duration_min }} min</td>
<td>${{ "%.2f"|format(s.price) }}</td>
<td>
<a href="{{ url_for('services.edit_service', service_id=s.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<form method="POST" action="{{ url_for('services.delete_service', service_id=s.id) }}" class="d-inline"
onsubmit="return confirm('Delete this service?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Del</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No services yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Products</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>SKU</th><th>Price</th><th></th></tr></thead>
<tbody>
{% for p in products %}
<tr class="{{ '' if p.is_active else 'text-muted' }}">
<td class="fw-semibold">{{ p.name }}</td>
<td class="small text-muted">{{ p.sku or '—' }}</td>
<td>${{ "%.2f"|format(p.sale_price) }}</td>
<td>
<a href="{{ url_for('services.edit_product', product_id=p.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<form method="POST" action="{{ url_for('services.delete_product', product_id=p.id) }}" class="d-inline"
onsubmit="return confirm('Delete this product?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Del</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-3">No products yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if promotions %}
<div class="card shadow-sm mt-3 border-warning">
<div class="card-header fw-semibold bg-warning-subtle">Active Promotions</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Name</th><th>Discount</th><th>Applies To</th><th>Ends</th><th></th></tr></thead>
<tbody>
{% for promo in promotions %}
<tr>
<td class="fw-semibold">{{ promo.name }}</td>
<td>{{ promo.discount_percent }}%</td>
<td class="small">{{ promo.applies_to }}</td>
<td class="small text-muted">{{ promo.ends_at.strftime('%Y-%m-%d') }}</td>
<td>
<form method="POST" action="{{ url_for('services.toggle_promotion', promo_id=promo.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-{{ 'danger' if promo.is_active else 'success' }} btn-sm">
{{ 'Deactivate' if promo.is_active else 'Activate' }}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Product{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Product</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ product.name if product else '' }}" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">SKU</label>
<input type="text" class="form-control" name="sku"
value="{{ product.sku if product else '' }}">
</div>
<div class="col">
<label class="form-label">Category</label>
<input type="text" class="form-control" name="category"
value="{{ product.category if product else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Sale Price ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" class="form-control" name="sale_price" min="0"
value="{{ "%.2f"|format(product.sale_price) if product else '' }}" required>
</div>
{% if mode == 'edit' %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if product and product.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,84 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}New Promotion{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">New Promotion</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">Promotion Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Discount % <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="discount_percent" min="1" max="100" required>
</div>
<div class="col">
<label class="form-label">Applies To <span class="text-danger">*</span></label>
<select class="form-select" name="applies_to" id="applies_to"
onchange="toggleTargets(this.value)">
<option value="all_services">All Services</option>
<option value="all_products">All Products</option>
<option value="all">All Services & Products</option>
<option value="service">Specific Services</option>
<option value="product">Specific Products</option>
</select>
</div>
</div>
<div id="target_services" class="mb-3 d-none">
<label class="form-label">Select Services</label>
{% for svc in services %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="target_ids"
value="{{ svc.id }}" id="svc_{{ svc.id }}">
<label class="form-check-label" for="svc_{{ svc.id }}">
{{ svc.name }} — ${{ "%.2f"|format(svc.price) }}
</label>
</div>
{% endfor %}
</div>
<div id="target_products" class="mb-3 d-none">
<label class="form-label">Select Products</label>
{% for p in products %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="target_ids"
value="{{ p.id }}" id="prod_{{ p.id }}">
<label class="form-check-label" for="prod_{{ p.id }}">
{{ p.name }} — ${{ "%.2f"|format(p.sale_price) }}
</label>
</div>
{% endfor %}
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Starts At <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="starts_at" required>
</div>
<div class="col">
<label class="form-label">Ends At <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="ends_at" required>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning">Create Promotion</button>
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleTargets(val) {
document.getElementById("target_services").classList.toggle("d-none", val !== "service");
document.getElementById("target_products").classList.toggle("d-none", val !== "product");
}
</script>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Service{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Service</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ service.name if service else '' }}" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<input type="text" class="form-control" name="category"
value="{{ service.category if service else '' }}" placeholder="e.g. Nails, Spa, Waxing">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Duration (min) <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="duration_min" min="5" max="480"
value="{{ service.duration_min if service else 30 }}" required>
</div>
<div class="col">
<label class="form-label">Price ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" class="form-control" name="price" min="0"
value="{{ "%.2f"|format(service.price) if service else '' }}" required>
</div>
</div>
{% if mode == 'edit' %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if service and service.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-7">
<h4 class="fw-bold mb-4"><i class="bi bi-gear me-2"></i>Settings</h4>
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="{{ url_for('settings.save') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h6 class="fw-semibold text-muted mb-3">Booking</h6>
<div class="mb-3">
<label class="form-label">Advance Booking Days</label>
<input type="number" class="form-control" name="booking_advance_days"
value="{{ settings.get('booking_advance_days', '30') }}" min="1" max="365">
<div class="form-text">How many days ahead customers can book online.</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="auto_confirm_bookings"
value="true" id="auto_confirm"
{{ 'checked' if settings.get('auto_confirm_bookings') == 'true' }}>
<label class="form-check-label" for="auto_confirm">Auto-confirm online bookings</label>
</div>
</div>
<h6 class="fw-semibold text-muted mb-3 mt-4">Reviews</h6>
<div class="mb-3">
<label class="form-label">Review Request Delay (minutes after checkout)</label>
<input type="number" class="form-control" name="review_request_delay_minutes"
value="{{ settings.get('review_request_delay_minutes', '60') }}" min="0">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Google Review URL</label>
<input type="url" class="form-control" name="google_review_url"
value="{{ settings.get('google_review_url', '') }}" placeholder="https://g.page/…">
</div>
<div class="col">
<label class="form-label">Yelp Review URL</label>
<input type="url" class="form-control" name="yelp_review_url"
value="{{ settings.get('yelp_review_url', '') }}" placeholder="https://yelp.com/…">
</div>
</div>
<h6 class="fw-semibold text-muted mb-3 mt-4">Receipt</h6>
<div class="mb-3">
<label class="form-label">Receipt Footer Note</label>
<textarea class="form-control" name="receipt_footer_note" rows="2">{{ settings.get('receipt_footer_note', '') }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member</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">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ staff.name if staff else '' }}" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Phone <span class="text-danger">*</span></label>
<input type="tel" class="form-control" name="phone"
value="{{ staff.phone if staff else '' }}" required>
</div>
{% if mode == 'create' %}
<div class="mb-3">
<label class="form-label">Passcode (46 digits) <span class="text-danger">*</span></label>
<input type="password" class="form-control" name="passcode"
inputmode="numeric" maxlength="6" required>
<div class="form-text">Staff will use this PIN to log in. Show it to them once, then discard it.</div>
</div>
{% endif %}
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Job Type</label>
<select class="form-select" name="staff_type">
{% for t in ['full_time','part_time','seasonal','receptionist','salon_manager'] %}
<option value="{{ t }}" {{ 'selected' if staff and staff.staff_type == t }}>
{{ t.replace('_',' ').title() }}
</option>
{% endfor %}
</select>
</div>
<div class="col">
<label class="form-label">Pay Type</label>
<select class="form-select" name="pay_type">
{% for t in ['hourly','salary','guarantee'] %}
<option value="{{ t }}" {{ 'selected' if staff and staff.pay_type == t }}>{{ t.title() }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Location Assignments</label>
{% for loc in locations %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="location_ids"
value="{{ loc.id }}" id="loc_{{ loc.id }}"
{{ 'checked' if loc.id in (assigned_ids or set()) }}>
<label class="form-check-label" for="loc_{{ loc.id }}">{{ loc.name }}</label>
</div>
{% endfor %}
</div>
{% if mode == 'edit' %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if staff and staff.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('staff.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Staff{% 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-person-badge me-2"></i>Staff</h4>
<a href="{{ url_for('staff.create') }}" class="btn btn-primary btn-sm">+ New Staff Member</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>Phone</th><th>Type</th><th>Pay Type</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{% for s in staff_list %}
<tr class="{{ '' if s.is_active else 'text-muted' }}">
<td class="fw-semibold">{{ s.name }}</td>
<td class="small">{{ s.phone }}</td>
<td class="small">{{ s.staff_type.replace('_',' ').title() }}</td>
<td class="small">{{ s.pay_type.title() }}</td>
<td><span class="badge {{ 'bg-success' if s.is_active else 'bg-secondary' }}">{{ 'Active' if s.is_active else 'Inactive' }}</span></td>
<td>
<a href="{{ url_for('staff.edit', staff_id=s.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<a href="{{ url_for('staff.reset_passcode', staff_id=s.id) }}" class="btn btn-outline-warning btn-sm">Reset PIN</a>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No staff members yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More