05/07 Phase 3: initial codes
This commit is contained in:
+2
-2
@@ -8,8 +8,8 @@ MAIL_USE_TLS=true
|
||||
MAIL_USERNAME=noreply@mydomain.com
|
||||
MAIL_PASSWORD=
|
||||
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
|
||||
ADMIN_DOMAIN=admin.mydomain.com
|
||||
TENANT_DOMAIN=mydomain.com
|
||||
ADMIN_DOMAIN=posadmin.ngodanguyen.tech
|
||||
TENANT_DOMAIN=pos.ngodanguyen.tech
|
||||
DEMO_TENANT_SLUG=demo
|
||||
BACKUP_DIR=/var/backups/salon_pos
|
||||
BACKUP_RETAIN_DAYS=30
|
||||
|
||||
@@ -682,8 +682,37 @@ WantedBy=multi-user.target
|
||||
- [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`
|
||||
|
||||
### Phase 3 — Multi-Location & Tenant Core Modules
|
||||
- [ ] Location management (CRUD, primary flag, per-location settings)
|
||||
### Phase 3 — Multi-Location & Tenant Core Modules ✅ COMPLETE
|
||||
- [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
|
||||
- [ ] Staff ↔ location assignment (many-to-many)
|
||||
- [ ] 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" %}`
|
||||
- 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
|
||||
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.
|
||||
|
||||
@@ -989,3 +1028,6 @@ All Phase 2+ blueprints are registered as stubs (blueprint object only, no route
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -104,3 +104,12 @@ def validate_setting_key(key: str) -> bool:
|
||||
"""Return True if key is a valid setting key (alphanumeric, underscores, dots)."""
|
||||
import re
|
||||
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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,10 +1,66 @@
|
||||
{% extends "tenant/layouts/base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Dashboard
|
||||
{% if location %}<small class="text-muted fs-6">— {{ location.name }}</small>{% endif %}
|
||||
</h4>
|
||||
<div class="alert alert-info">
|
||||
Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts).
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0">
|
||||
<i class="bi bi-speedometer2 me-2"></i>Dashboard
|
||||
{% if location %}<small class="text-muted fs-6 ms-2">— {{ location.name }}</small>{% endif %}
|
||||
</h4>
|
||||
<span class="text-muted small">{{ today.strftime('%A, %B %d, %Y') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 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 %}
|
||||
@@ -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 %}
|
||||
@@ -37,7 +37,7 @@
|
||||
{% for loc in locations %}
|
||||
<li>
|
||||
<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 }}
|
||||
{% if loc.is_primary %}<span class="badge bg-secondary ms-1">Primary</span>{% endif %}
|
||||
</a>
|
||||
@@ -86,31 +86,31 @@
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
@@ -122,13 +122,13 @@
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
@@ -147,13 +147,13 @@
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 (4–6 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 %}
|
||||
@@ -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 4–6 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -92,16 +92,36 @@ def create_tenant_app(config_override=None):
|
||||
from app.tenant.staff_auth.routes import staff_auth_bp
|
||||
from app.tenant.checkin.routes import checkin_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)
|
||||
flask_app.register_blueprint(staff_auth_bp)
|
||||
flask_app.register_blueprint(checkin_bp)
|
||||
flask_app.register_blueprint(dashboard_bp)
|
||||
for bp in [
|
||||
tenant_auth_bp, staff_auth_bp, checkin_bp, dashboard_bp,
|
||||
locations_bp, customers_bp, services_bp, appointments_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 3–6:
|
||||
# locations, customers, appointments, services, pos, staff,
|
||||
# staff_portal, booking, waitlist, gift_cards, reviews,
|
||||
# reconciliation, inventory, marketing, reports, settings
|
||||
# Phase 4+ stubs (inventory, marketing, reports)
|
||||
from app.tenant.inventory.routes import inventory_bp
|
||||
from app.tenant.marketing.routes import marketing_bp
|
||||
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 app.models # noqa: F401
|
||||
|
||||
@@ -1,7 +1,227 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,128 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,135 +1,102 @@
|
||||
"""
|
||||
app/tenant/checkin/routes.py — Customer self check-in kiosk.
|
||||
Route: GET/POST /checkin/<tenant_slug>
|
||||
app/tenant/checkin/routes.py
|
||||
Customer self check-in kiosk — /checkin/<tenant_slug>
|
||||
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
|
||||
from datetime import datetime, timezone
|
||||
from flask import (
|
||||
Blueprint, render_template, request, jsonify,
|
||||
current_app, abort,
|
||||
)
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, g
|
||||
from app.extensions import db, limiter, csrf
|
||||
from app.models.platform import Tenant
|
||||
from app.models.salon import Customer, CheckinQueue, Location
|
||||
from app.security import sanitise_string, validate_slug
|
||||
from app.models.salon import CheckinQueue, Customer, Service, Location
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
checkin_bp = Blueprint("checkin", __name__)
|
||||
|
||||
|
||||
@checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"])
|
||||
@csrf.exempt
|
||||
@limiter.limit("20 per minute")
|
||||
def kiosk(tenant_slug: str):
|
||||
"""
|
||||
Public kiosk page. No auth required.
|
||||
Tenant slug validated against known slugs (not guessable).
|
||||
Accepts: name, phone, service_requested — all other fields ignored.
|
||||
Phone is sanitised and validated before profile lookup.
|
||||
Page auto-resets after 10 seconds (configurable per tenant) via JS.
|
||||
"""
|
||||
# Validate slug format before hitting the DB
|
||||
if not validate_slug(tenant_slug):
|
||||
logger.warning("Kiosk: invalid slug format: %s", tenant_slug)
|
||||
abort(404)
|
||||
@csrf.exempt
|
||||
def kiosk(tenant_slug):
|
||||
# Validate slug against known tenants — prevents enumeration
|
||||
tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
|
||||
Tenant.status.in_(["active", "trial"])
|
||||
).first()
|
||||
if not tenant:
|
||||
return render_template("tenant/checkin/not_found.html"), 404
|
||||
|
||||
tenant = Tenant.query.filter_by(slug=tenant_slug, is_demo=False).first()
|
||||
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
|
||||
# Resolve location: session-stored or primary
|
||||
location = Location.query.filter_by(
|
||||
tenant_id=tenant.id,
|
||||
is_primary=True,
|
||||
is_active=True,
|
||||
).filter(Location.deleted_at.is_(None)).first()
|
||||
tenant_id=tenant.id, is_primary=True, is_active=True
|
||||
).first()
|
||||
if not location:
|
||||
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:
|
||||
abort(404)
|
||||
services = Service.query.filter_by(
|
||||
tenant_id=tenant.id, is_active=True).filter(
|
||||
Service.deleted_at.is_(None)).order_by(Service.name).all()
|
||||
|
||||
confirmed = False
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
raw_name = request.form.get("customer_name", "")
|
||||
raw_phone = request.form.get("customer_phone", "")
|
||||
raw_service = request.form.get("service_requested", "")
|
||||
|
||||
name = sanitise_string(raw_name, max_length=150)
|
||||
phone = sanitise_string(raw_phone, max_length=30)
|
||||
service = sanitise_string(raw_service, max_length=150)
|
||||
|
||||
# Validate required fields
|
||||
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,
|
||||
)
|
||||
# Accept ONLY these three fields — all others ignored
|
||||
customer_name = request.form.get("customer_name", "").strip()[:255]
|
||||
customer_phone = request.form.get("customer_phone", "").strip()[:30]
|
||||
service_requested = request.form.get("service_requested", "").strip()[:255] or None
|
||||
|
||||
if not customer_name or not customer_phone:
|
||||
error = "Please enter your name and phone number."
|
||||
else:
|
||||
# Look up or create customer profile
|
||||
customer = Customer.query.filter_by(
|
||||
tenant_id=tenant.id,
|
||||
phone=phone_digits,
|
||||
tenant_id=tenant.id, phone=customer_phone
|
||||
).filter(Customer.deleted_at.is_(None)).first()
|
||||
|
||||
if customer is None:
|
||||
customer = Customer(
|
||||
customer_id = None
|
||||
if customer:
|
||||
customer_id = customer.id
|
||||
else:
|
||||
# Auto-create profile
|
||||
new_cust = Customer(
|
||||
tenant_id=tenant.id,
|
||||
name=name,
|
||||
phone=phone_digits,
|
||||
)
|
||||
db.session.add(customer)
|
||||
db.session.flush() # get customer.id before committing
|
||||
logger.info(
|
||||
"Kiosk: new customer profile created for tenant %s", tenant.slug
|
||||
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)
|
||||
|
||||
# Queue the walk-in entry
|
||||
entry = CheckinQueue(
|
||||
tenant_id=tenant.id,
|
||||
location_id=location.id,
|
||||
customer_id=customer.id,
|
||||
customer_name=name,
|
||||
customer_phone=phone_digits,
|
||||
service_requested=service or None,
|
||||
customer_id=customer_id,
|
||||
customer_name=customer_name,
|
||||
customer_phone=customer_phone,
|
||||
service_requested=service_requested,
|
||||
checked_in_at=datetime.now(timezone.utc),
|
||||
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,
|
||||
)
|
||||
logger.info("Kiosk check-in: tenant=%s location=%s customer=%s service=%s",
|
||||
tenant.id, location.id, customer_id, service_requested)
|
||||
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(
|
||||
"tenant/checkin/kiosk.html",
|
||||
tenant=tenant,
|
||||
services=services,
|
||||
confirmed=confirmed,
|
||||
error=None,
|
||||
error=error,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,135 @@
|
||||
"""
|
||||
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.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
|
||||
|
||||
@@ -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
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from decimal import Decimal
|
||||
from flask import Blueprint, render_template, g
|
||||
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 sqlalchemy import func
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
dashboard_bp = Blueprint("dashboard", __name__)
|
||||
|
||||
|
||||
@@ -16,8 +22,72 @@ dashboard_bp = Blueprint("dashboard", __name__)
|
||||
@login_required
|
||||
@require_role("tenant_admin", "tenant_manager")
|
||||
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(
|
||||
"tenant/dashboard/index.html",
|
||||
tenant=g.tenant,
|
||||
location=g.location,
|
||||
kpis=kpis,
|
||||
upcoming_appointments=upcoming,
|
||||
today=today,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,119 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
"""
|
||||
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.route("/")
|
||||
@login_required
|
||||
@require_role("tenant_admin", "tenant_manager")
|
||||
def index():
|
||||
return render_template("tenant/feature_unavailable.html",
|
||||
feature="Inventory")
|
||||
|
||||
@@ -1,7 +1,117 @@
|
||||
"""
|
||||
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.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"))
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
"""
|
||||
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.route("/")
|
||||
@login_required
|
||||
@require_role("tenant_admin", "tenant_manager")
|
||||
def index():
|
||||
return render_template("tenant/feature_unavailable.html",
|
||||
feature="Marketing")
|
||||
|
||||
+370
-2
@@ -1,7 +1,375 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,127 @@
|
||||
"""
|
||||
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.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
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
"""
|
||||
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.route("/")
|
||||
@login_required
|
||||
@require_role("tenant_admin", "tenant_manager")
|
||||
def index():
|
||||
return render_template("tenant/feature_unavailable.html",
|
||||
feature="Reports")
|
||||
|
||||
@@ -1,7 +1,38 @@
|
||||
"""
|
||||
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.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)
|
||||
|
||||
@@ -1,7 +1,282 @@
|
||||
"""
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@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 1–100%.")
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
# 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
@@ -1,7 +1,195 @@
|
||||
"""
|
||||
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.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)
|
||||
|
||||
@@ -1,7 +1,188 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -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'
|
||||
@@ -1,7 +1,131 @@
|
||||
"""
|
||||
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.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
@@ -1,19 +1,23 @@
|
||||
# ── Admin portal ────────────────────────────────────────────────
|
||||
# NOTE: HTTP only — upgrade to HTTPS with: sudo certbot --nginx -d admin.mydomain.com
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.mydomain.com;
|
||||
listen 443 ssl;
|
||||
server_name posadmin.ngodanguyen.tech;
|
||||
|
||||
# IP allowlist — office / VPN only
|
||||
# allow 203.0.113.0/24;
|
||||
# deny all;
|
||||
ssl_certificate /etc/ssl/certs/mydomain.crt;
|
||||
ssl_certificate_key /etc/ssl/private/mydomain.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 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;
|
||||
allow 203.0.113.0/24;
|
||||
deny all;
|
||||
|
||||
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 / {
|
||||
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 X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -22,24 +26,29 @@ server {
|
||||
}
|
||||
|
||||
location /static/admin/ {
|
||||
alias /home/salonpos/app/static/admin/;
|
||||
alias /opt/salon_pos/app/static/admin/;
|
||||
expires 7d;
|
||||
}
|
||||
}
|
||||
|
||||
# ── Tenant portal ────────────────────────────────────────────────
|
||||
# NOTE: HTTP only — upgrade to HTTPS with: sudo certbot --nginx -d mydomain.com
|
||||
server {
|
||||
listen 80;
|
||||
server_name mydomain.com;
|
||||
listen 443 ssl;
|
||||
server_name pos.ngodanguyen.tech;
|
||||
|
||||
# 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 Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" always;
|
||||
ssl_certificate /etc/ssl/certs/mydomain.crt;
|
||||
ssl_certificate_key /etc/ssl/private/mydomain.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
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 / {
|
||||
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 X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -48,7 +57,13 @@ server {
|
||||
}
|
||||
|
||||
location /static/tenant/ {
|
||||
alias /home/salonpos/app/static/tenant/;
|
||||
alias /opt/salon_pos/app/static/tenant/;
|
||||
expires 7d;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name posadmin.ngodanguyen.tech pos.ngodanguyen.tech;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ WorkingDirectory=/opt/salon_pos
|
||||
EnvironmentFile=/opt/salon_pos/.env
|
||||
ExecStart=/opt/salon_pos/venv/bin/gunicorn \
|
||||
--workers 2 \
|
||||
--bind unix:/run/salon_pos/salon_pos_admin.sock \
|
||||
--bind unix:/run/salon_pos_admin.sock \
|
||||
--timeout 120 \
|
||||
wsgi_admin:app
|
||||
Restart=on-failure
|
||||
|
||||
@@ -8,7 +8,7 @@ WorkingDirectory=/opt/salon_pos
|
||||
EnvironmentFile=/opt/salon_pos/.env
|
||||
ExecStart=/opt/salon_pos/venv/bin/gunicorn \
|
||||
--workers 4 \
|
||||
--bind unix:/run/salon_pos/salon_pos_tenant.sock \
|
||||
--bind unix:/run/salon_pos_tenant.sock \
|
||||
--timeout 120 \
|
||||
wsgi_tenant:app
|
||||
Restart=on-failure
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,10 +1,66 @@
|
||||
{% extends "tenant/layouts/base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Dashboard
|
||||
{% if location %}<small class="text-muted fs-6">— {{ location.name }}</small>{% endif %}
|
||||
</h4>
|
||||
<div class="alert alert-info">
|
||||
Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts).
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0">
|
||||
<i class="bi bi-speedometer2 me-2"></i>Dashboard
|
||||
{% if location %}<small class="text-muted fs-6 ms-2">— {{ location.name }}</small>{% endif %}
|
||||
</h4>
|
||||
<span class="text-muted small">{{ today.strftime('%A, %B %d, %Y') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 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 %}
|
||||
@@ -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 %}
|
||||
@@ -37,7 +37,7 @@
|
||||
{% for loc in locations %}
|
||||
<li>
|
||||
<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 }}
|
||||
{% if loc.is_primary %}<span class="badge bg-secondary ms-1">Primary</span>{% endif %}
|
||||
</a>
|
||||
@@ -86,31 +86,31 @@
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
@@ -122,13 +122,13 @@
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
@@ -147,13 +147,13 @@
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 (4–6 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 %}
|
||||
@@ -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 4–6 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 %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user