diff --git a/.env.example b/.env.example index 4b24b78..6738352 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index fe6cd27..4960b61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/; auto-profile creation; queue entry; rate-limited; CSRF-exempt) +- [x] Queue polling API: GET /api/v1/checkin/queue + POST /api/v1/checkin/queue//acknowledge +- [x] Online booking (/book/; 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. @@ -988,4 +1027,7 @@ All Phase 2+ blueprints are registered as stubs (blueprint object only, no route | 24 | Admin login URL | Admin auth blueprint uses `url_prefix=""`. Login page is at `posadmin.ngodanguyen.tech/login`. Root `/` redirects to `/login` (unauthenticated) or `/dashboard` (authenticated). | | 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. | \ No newline at end of file +| 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. | diff --git a/README.md b/README.md index 12ab3ae..447e880 100644 --- a/README.md +++ b/README.md @@ -315,4 +315,4 @@ salon_pos/ ├── .env.example ├── deploy/ # systemd units, Nginx config, backup scripts └── tests/ # pytest test suites -``` \ No newline at end of file +``` diff --git a/app/admin/analytics/routes.py b/app/admin/analytics/routes.py index edb5835..a28d856 100644 --- a/app/admin/analytics/routes.py +++ b/app/admin/analytics/routes.py @@ -111,4 +111,4 @@ def index(): stats=stats, expiring_soon=expiring_soon, now=now, - ) \ No newline at end of file + ) diff --git a/app/admin/utils.py b/app/admin/utils.py index c07b3a4..a760b36 100644 --- a/app/admin/utils.py +++ b/app/admin/utils.py @@ -68,4 +68,4 @@ def model_to_dict(obj, fields): result[f] = float(val) else: result[f] = val - return result \ No newline at end of file + return result diff --git a/app/security.py b/app/security.py index ff4fd93..ee2c3f0 100644 --- a/app/security.py +++ b/app/security.py @@ -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 + ) diff --git a/app/templates/tenant/appointments/form.html b/app/templates/tenant/appointments/form.html new file mode 100644 index 0000000..fac8827 --- /dev/null +++ b/app/templates/tenant/appointments/form.html @@ -0,0 +1,75 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Appointment{% endblock %} +{% block content %} +
+
+
+
{{ 'Edit' if mode == 'edit' else 'New' }} Appointment
+
+ {% if error %}
{{ error }}
{% endif %} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/appointments/index.html b/app/templates/tenant/appointments/index.html new file mode 100644 index 0000000..e7ae811 --- /dev/null +++ b/app/templates/tenant/appointments/index.html @@ -0,0 +1,56 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Appointments{% endblock %} +{% block content %} +
+

Appointments

+
+ + + + + New +
+
+ +
+
+ + + + + + {% for appt in appointments %} + + + + + + + + + + {% else %} + + {% endfor %} + +
TimeCustomerServiceStaffStatusType
{{ appt.start_time.strftime('%I:%M %p') }}{{ appt.customer.name if appt.customer else 'Walk-in' | safe }}{{ appt.service.name if appt.service else '—' }}{{ appt.staff.name if appt.staff else '—' }} + + {{ appt.status }} + + {{ 'Walk-in' if appt.is_walk_in else 'Booked' }} + View + {% if appt.status in ['pending','confirmed','in_progress'] %} + Checkout + {% endif %} +
No appointments for this day.
+
+
+{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/appointments/view.html b/app/templates/tenant/appointments/view.html new file mode 100644 index 0000000..d23401b --- /dev/null +++ b/app/templates/tenant/appointments/view.html @@ -0,0 +1,64 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Appointment #{{ appointment.id }}{% endblock %} +{% block content %} +
+

Appointment #{{ appointment.id }}

+
+ Edit + {% if appointment.status in ['pending','confirmed','in_progress'] %} + + Checkout + + {% endif %} +
+
+ +
+
+
+
+
+
Customer
{{ appointment.customer.name if appointment.customer else 'Walk-in' }}
+
Service
{{ appointment.service.name if appointment.service else '—' }}
+
Staff
{{ appointment.staff.name if appointment.staff else '—' }}
+
Start
{{ appointment.start_time.strftime('%Y-%m-%d %I:%M %p') }}
+
End
{{ appointment.end_time.strftime('%I:%M %p') if appointment.end_time else '—' }}
+
Type
{{ 'Walk-in' if appointment.is_walk_in else 'Booked' }}
+
Source
{{ appointment.rebook_source or 'manual' }}
+ {% if appointment.notes %} +
Notes
{{ appointment.notes }}
+ {% endif %} +
+
+
+
+
+
+
Update Status
+
+
+ +
+ +
+
+ + +
+ +
+
+
+
+
+ + + Back to Calendar + +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/booking/form.html b/app/templates/tenant/booking/form.html new file mode 100644 index 0000000..ac75f5c --- /dev/null +++ b/app/templates/tenant/booking/form.html @@ -0,0 +1,79 @@ + + + + + + Book an Appointment — {{ tenant.name }} + + + +
+ {% if confirmed %} +
+
+

Booking Requested!

+

We'll confirm your appointment shortly. Check your email for details.

+ Book Another +
+ {% else %} +
+

{{ tenant.name }}

+

Book an appointment online

+ {% if error %}
{{ error }}
{% endif %} +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+
+ {% endif %} +
+ + + \ No newline at end of file diff --git a/app/templates/tenant/booking/not_found.html b/app/templates/tenant/booking/not_found.html new file mode 100644 index 0000000..909932e --- /dev/null +++ b/app/templates/tenant/booking/not_found.html @@ -0,0 +1,12 @@ + + +Not Found + + + +
+

Booking Page Not Found

+

This salon's booking page doesn't exist or is no longer available.

+
+ + \ No newline at end of file diff --git a/app/templates/tenant/booking/unavailable.html b/app/templates/tenant/booking/unavailable.html new file mode 100644 index 0000000..10c63f8 --- /dev/null +++ b/app/templates/tenant/booking/unavailable.html @@ -0,0 +1,12 @@ + + +Booking Unavailable + + + +
+

Online Booking Unavailable

+

Online booking is not currently available for this salon. Please call to book.

+
+ + \ No newline at end of file diff --git a/app/templates/tenant/checkin/not_found.html b/app/templates/tenant/checkin/not_found.html new file mode 100644 index 0000000..b88c958 --- /dev/null +++ b/app/templates/tenant/checkin/not_found.html @@ -0,0 +1,12 @@ + + +Check-In Not Found + + + +
+

Check-In Unavailable

+

This check-in kiosk is not available. Please see a staff member.

+
+ + \ No newline at end of file diff --git a/app/templates/tenant/customers/form.html b/app/templates/tenant/customers/form.html new file mode 100644 index 0000000..e8b6aa0 --- /dev/null +++ b/app/templates/tenant/customers/form.html @@ -0,0 +1,47 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Customer{% endblock %} +{% block content %} +
+
+
+
{{ 'Edit' if mode == 'edit' else 'New' }} Customer
+
+ {% if error %}
{{ error }}
{% endif %} +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/customers/index.html b/app/templates/tenant/customers/index.html new file mode 100644 index 0000000..d30dbf2 --- /dev/null +++ b/app/templates/tenant/customers/index.html @@ -0,0 +1,41 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Customers{% endblock %} +{% block content %} +
+

Customers

+ + New Customer + +
+
+
+
+ + + {% if q %}Clear{% endif %} +
+
+
+
+
+ + + + {% for c in customers %} + + + + + + + + + {% else %} + + {% endfor %} + +
NamePhoneEmailLoyaltyNo-Shows
{{ c.name }}{{ c.phone or '—' }}{{ c.email or '—' }}{{ c.loyalty_points }} pts{% if c.no_show_count > 0 %}{{ c.no_show_count }}{% else %}0{% endif %}Edit
No customers found.
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/customers/view.html b/app/templates/tenant/customers/view.html new file mode 100644 index 0000000..0bca188 --- /dev/null +++ b/app/templates/tenant/customers/view.html @@ -0,0 +1,58 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ customer.name }}{% endblock %} +{% block content %} +
+

{{ customer.name }}

+ +
+
+
+
+
+

{{ customer.phone or '—' }}

+

{{ customer.email or '—' }}

+ {% if customer.date_of_birth %} +

{{ customer.date_of_birth.strftime('%B %d') }}

+ {% endif %} +

{{ customer.loyalty_points }} loyalty points

+ {% if customer.no_show_count > 0 %} +

{{ customer.no_show_count }} no-show(s)

+ {% endif %} + {% if customer.notes %} +

{{ customer.notes }}

+ {% endif %} +
+
+
+
+
+
Recent Appointments
+ {% if appointments %} +
+ + + + {% for a in appointments %} + + + + + + + {% endfor %} + +
DateServiceStaffStatus
{{ a.start_time.strftime('%Y-%m-%d %I:%M %p') }}{{ a.service.name if a.service else '—' }}{{ a.staff.name if a.staff else '—' }}{{ a.status }}
+
+ {% else %} +
No appointments yet.
+ {% endif %} +
+
+
+ + Back + +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/dashboard/index.html b/app/templates/tenant/dashboard/index.html index ddc17d2..bf1fb53 100644 --- a/app/templates/tenant/dashboard/index.html +++ b/app/templates/tenant/dashboard/index.html @@ -1,10 +1,66 @@ {% extends "tenant/layouts/base.html" %} {% block title %}Dashboard{% endblock %} {% block content %} -

Dashboard - {% if location %}— {{ location.name }}{% endif %} -

-
- Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts). +
+

+ Dashboard + {% if location %}— {{ location.name }}{% endif %} +

+ {{ today.strftime('%A, %B %d, %Y') }}
-{% endblock %} + + +
+ {% 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 %} +
+
+
+
+ +
+
+
{{ value }}
+
{{ label }}
+
+
+
+
+ {% endfor %} +
+ + +
+
+ Upcoming Today + Full Calendar +
+ {% if upcoming_appointments %} +
+ + + + {% for appt in upcoming_appointments %} + + + + + + + + {% endfor %} + +
TimeCustomerServiceStaffStatus
{{ appt.start_time.strftime('%I:%M %p') }}{{ appt.customer.name if appt.customer else '—' }}{{ appt.service.name if appt.service else '—' }}{{ appt.staff.name if appt.staff else '—' }}{{ appt.status }}
+
+ {% else %} +
No upcoming appointments for today.
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/gift_cards/form.html b/app/templates/tenant/gift_cards/form.html new file mode 100644 index 0000000..d63c081 --- /dev/null +++ b/app/templates/tenant/gift_cards/form.html @@ -0,0 +1,39 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Issue Gift Card{% endblock %} +{% block content %} +
+
+
+
Issue New Gift Card
+
+ {% if error %}
{{ error }}
{% endif %} +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/gift_cards/index.html b/app/templates/tenant/gift_cards/index.html new file mode 100644 index 0000000..25014a4 --- /dev/null +++ b/app/templates/tenant/gift_cards/index.html @@ -0,0 +1,49 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Gift Cards{% endblock %} +{% block content %} +
+

Gift Cards

+ +
+
+
+ + + + + + {% for card in cards %} + + + + + + + + + + {% else %} + + {% endfor %} + +
CodeOriginalBalanceCustomerExpiresStatus
{{ card.code }}${{ "%.2f"|format(card.original_value) }} + ${{ "%.2f"|format(card.remaining_balance) }} + {{ card.customer.name if card.issued_to_customer_id and card.customer else '—' }}{{ card.expires_at.strftime('%Y-%m-%d') if card.expires_at else 'No expiry' }} + + {{ 'Active' if card.is_active else 'Inactive' }} + + + {% if card.is_active %} +
+ + +
+ {% endif %} +
No gift cards issued yet.
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/gift_cards/lookup.html b/app/templates/tenant/gift_cards/lookup.html new file mode 100644 index 0000000..9dd3185 --- /dev/null +++ b/app/templates/tenant/gift_cards/lookup.html @@ -0,0 +1,40 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Gift Card Lookup{% endblock %} +{% block content %} +
+
+
+
Gift Card Lookup
+
+
+
+ + +
+
+ {% if code and card %} +
+
+
Code
{{ card.code }}
+
Original Value
${{ "%.2f"|format(card.original_value) }}
+
Remaining Balance
+
+ ${{ "%.2f"|format(card.remaining_balance) }} +
+
Status
+
{{ 'Active' if card.is_active else 'Inactive / Depleted' }}
+ {% if card.expires_at %} +
Expires
{{ card.expires_at.strftime('%Y-%m-%d') }}
+ {% endif %} +
+
+ {% elif code %} +
No gift card found with code {{ code }}.
+ {% endif %} + Back +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/layouts/base.html b/app/templates/tenant/layouts/base.html index ed22b7f..7815faa 100644 --- a/app/templates/tenant/layouts/base.html +++ b/app/templates/tenant/layouts/base.html @@ -37,7 +37,7 @@ {% for loc in locations %}
  • + href="{{ url_for('locations.switch', location_id=loc.id) }}"> {{ loc.name }} {% if loc.is_primary %}Primary{% endif %} @@ -86,31 +86,31 @@
  • @@ -122,13 +122,13 @@ @@ -147,13 +147,13 @@ diff --git a/app/templates/tenant/locations/form.html b/app/templates/tenant/locations/form.html new file mode 100644 index 0000000..af8be83 --- /dev/null +++ b/app/templates/tenant/locations/form.html @@ -0,0 +1,62 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Location{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Location
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + {% if mode == 'edit' %} +
    +
    + + +
    +
    + + +
    +
    + {% endif %} +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/locations/index.html b/app/templates/tenant/locations/index.html new file mode 100644 index 0000000..1b9bafb --- /dev/null +++ b/app/templates/tenant/locations/index.html @@ -0,0 +1,43 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Locations{% endblock %} +{% block content %} +
    +

    Locations

    + + New Location + +
    +
    + {% for loc in locations %} +
    +
    +
    + {{ loc.name }} +
    + {% if loc.is_primary %}Primary{% endif %} + {{ 'Active' if loc.is_active else 'Inactive' }} +
    +
    +
    + {% if loc.address %}

    {{ loc.address }}

    {% endif %} + {% if loc.phone %}

    {{ loc.phone }}

    {% endif %} + {% if loc.email %}

    {{ loc.email }}

    {% endif %} +

    {{ loc.timezone }}

    +
    + +
    +
    + {% else %} +

    No locations found.

    + {% endfor %} +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/pos/checkout.html b/app/templates/tenant/pos/checkout.html new file mode 100644 index 0000000..96d5fe1 --- /dev/null +++ b/app/templates/tenant/pos/checkout.html @@ -0,0 +1,115 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}POS Checkout{% endblock %} +{% block content %} +

    Checkout

    + +
    + + {% if appointment %} + + {% endif %} + +
    + +
    +
    +
    Services
    +
    + {% for s in services %} +
    + + +
    + {% endfor %} +
    +
    + {% if services %} +
    +
    Products
    +
    + {% for p in products %} +
    + + +
    + {% endfor %} +
    +
    + {% endif %} +
    + + +
    +
    +
    Payment Details
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/pos/rebook.html b/app/templates/tenant/pos/rebook.html new file mode 100644 index 0000000..1e419a3 --- /dev/null +++ b/app/templates/tenant/pos/rebook.html @@ -0,0 +1,43 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Schedule Next Visit{% endblock %} +{% block content %} +
    +
    +
    +
    Schedule Next Visit
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + Skip +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/pos/receipt.html b/app/templates/tenant/pos/receipt.html new file mode 100644 index 0000000..b01082f --- /dev/null +++ b/app/templates/tenant/pos/receipt.html @@ -0,0 +1,64 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Receipt #{{ transaction.id }}{% endblock %} +{% block content %} +
    +
    +
    +
    +
    +
    {{ tenant.name }}
    + {% if location %}

    {{ location.name }}

    {% endif %} +

    {{ transaction.created_at.strftime('%B %d, %Y %I:%M %p') }}

    +

    Receipt #{{ transaction.id }}

    + {% if transaction.voided_at %} +
    VOIDED — {{ transaction.void_reason }}
    + {% endif %} +
    + + + {% for item in items %} + + + + + {% endfor %} + + + {% if transaction.discount > 0 %} + + {% endif %} + {% if transaction.tip_amount > 0 %} + + {% endif %} + {% if transaction.gift_card_amount > 0 %} + + {% endif %} + + + +
    + {% if item.service %}{{ item.service.name }} + {% elif item.product %}{{ item.product.name }} + {% endif %} + {% if item.discount_percent > 0 %} + {{ item.discount_percent }}% off + {% endif %} + + {% if item.discount_percent > 0 %} + ${{ "%.2f"|format(item.original_price) }} + {% endif %} + ${{ "%.2f"|format(item.unit_price) }} +
    Savings-${{ "%.2f"|format(transaction.discount) }}
    Tip${{ "%.2f"|format(transaction.tip_amount) }}
    Gift Card-${{ "%.2f"|format(transaction.gift_card_amount) }}
    Total${{ "%.2f"|format(transaction.total) }}
    Payment{{ transaction.payment_method.title() }}
    +
    + New Transaction + Rebook + {% if not transaction.voided_at %} + Void + {% endif %} + Transactions +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/pos/transactions.html b/app/templates/tenant/pos/transactions.html new file mode 100644 index 0000000..c2672b4 --- /dev/null +++ b/app/templates/tenant/pos/transactions.html @@ -0,0 +1,32 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Transactions{% endblock %} +{% block content %} +
    +

    Transactions

    + +
    +
    +
    + + + + {% for t in transactions %} + + + + + + + + + + {% else %} + + {% endfor %} + +
    TimeCustomerStaffTotalMethodTip
    {{ t.created_at.strftime('%I:%M %p') }}{{ t.customer.name if t.customer else '—' }}{{ t.staff.name if t.staff else '—' }}${{ "%.2f"|format(t.total) }}{{ t.payment_method.title() }}{% if t.tip_amount > 0 %}${{ "%.2f"|format(t.tip_amount) }}{% else %}—{% endif %}View
    No transactions for this day.
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/pos/void.html b/app/templates/tenant/pos/void.html new file mode 100644 index 0000000..040dd38 --- /dev/null +++ b/app/templates/tenant/pos/void.html @@ -0,0 +1,26 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Void Transaction{% endblock %} +{% block content %} +
    +
    +
    +
    Void Transaction #{{ transaction.id }}
    +
    +

    Total: ${{ "%.2f"|format(transaction.total) }}

    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/reconciliation/close.html b/app/templates/tenant/reconciliation/close.html new file mode 100644 index 0000000..10e949a --- /dev/null +++ b/app/templates/tenant/reconciliation/close.html @@ -0,0 +1,42 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Close Day{% endblock %} +{% block content %} +
    +
    +
    +
    + Close Day — {{ today.strftime('%B %d, %Y') }} +
    +
    + + + + + + + + + +
    Cash Sales${{ "%.2f"|format(total_cash) }}
    App Payments (Zelle / Venmo / etc.)${{ "%.2f"|format(total_app) }}
    Tips Collected${{ "%.2f"|format(total_tips) }}
    Gift Card Redemptions${{ "%.2f"|format(total_gc) }}
    Expected Cash in Drawer${{ "%.2f"|format(expected_cash) }}
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/reconciliation/index.html b/app/templates/tenant/reconciliation/index.html new file mode 100644 index 0000000..4f17f06 --- /dev/null +++ b/app/templates/tenant/reconciliation/index.html @@ -0,0 +1,38 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Reconciliation{% endblock %} +{% block content %} +
    +

    End-of-Day Reconciliation

    + Close Today +
    +{% if records %} +
    +
    + + + + + + {% for r in records %} + + + + + + + + + + + + {% endfor %} + +
    DateCashApp PaymentsTipsGift CardsExpectedActualVarianceClosed By
    {{ r.date.strftime('%Y-%m-%d') }}${{ "%.2f"|format(r.total_cash) }}${{ "%.2f"|format(r.total_app_payments) }}${{ "%.2f"|format(r.total_tips) }}${{ "%.2f"|format(r.total_gift_card_redemptions) }}${{ "%.2f"|format(r.expected_cash_in_drawer) }}${{ "%.2f"|format(r.actual_cash_counted) }} + {{ '+' if r.variance > 0 else '' }}${{ "%.2f"|format(r.variance) }} + {{ r.closed_at.strftime('%I:%M %p') if r.closed_at else '—' }}
    +
    +
    +{% else %} +
    No reconciliation records yet.
    +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/reviews/index.html b/app/templates/tenant/reviews/index.html new file mode 100644 index 0000000..291637a --- /dev/null +++ b/app/templates/tenant/reviews/index.html @@ -0,0 +1,54 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Reviews{% endblock %} +{% block content %} +
    +

    Customer Reviews

    +
    +
    {{ avg_rating }} ★
    +
    Average rating
    +
    +
    + + +
    +
    + {% for star in [5,4,3,2,1] %} + {% set count = rating_dist.get(star, 0) %} + {% set total = reviews|length %} +
    + {{ star }}★ +
    +
    +
    + {{ count }} +
    + {% endfor %} +
    +
    + +{% if reviews %} +
    +
    + + + + {% for r in reviews %} + + + + + + + + {% endfor %} + +
    DateRatingCustomerStaffComment
    {{ r.created_at.strftime('%Y-%m-%d') }} + {{ r.rating }}★ + {{ r.customer.name if r.customer else '—' }}{{ r.staff.name if r.staff else '—' }}{{ r.comment or '—' }}
    +
    +
    +{% else %} +
    No reviews yet.
    +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/services/index.html b/app/templates/tenant/services/index.html new file mode 100644 index 0000000..d6f0b92 --- /dev/null +++ b/app/templates/tenant/services/index.html @@ -0,0 +1,102 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Services & Products{% endblock %} +{% block content %} +
    +

    Services & Products

    + +
    + +
    +
    +
    +
    Services
    +
    + + + + {% for s in services %} + + + + + + + + {% else %} + + {% endfor %} + +
    NameCategoryDurationPrice
    {{ s.name }}{{ s.category or '—' }}{{ s.duration_min }} min${{ "%.2f"|format(s.price) }} + Edit +
    + + +
    +
    No services yet.
    +
    +
    +
    +
    +
    +
    Products
    +
    + + + + {% for p in products %} + + + + + + + {% else %} + + {% endfor %} + +
    NameSKUPrice
    {{ p.name }}{{ p.sku or '—' }}${{ "%.2f"|format(p.sale_price) }} + Edit +
    + + +
    +
    No products yet.
    +
    +
    + {% if promotions %} +
    +
    Active Promotions
    +
    + + + + {% for promo in promotions %} + + + + + + + + {% endfor %} + +
    NameDiscountApplies ToEnds
    {{ promo.name }}{{ promo.discount_percent }}%{{ promo.applies_to }}{{ promo.ends_at.strftime('%Y-%m-%d') }} +
    + + +
    +
    +
    +
    + {% endif %} +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/services/product_form.html b/app/templates/tenant/services/product_form.html new file mode 100644 index 0000000..dec46e6 --- /dev/null +++ b/app/templates/tenant/services/product_form.html @@ -0,0 +1,50 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Product{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Product
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + {% if mode == 'edit' %} +
    + + +
    + {% endif %} +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/services/promotion_form.html b/app/templates/tenant/services/promotion_form.html new file mode 100644 index 0000000..ca7bed6 --- /dev/null +++ b/app/templates/tenant/services/promotion_form.html @@ -0,0 +1,84 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}New Promotion{% endblock %} +{% block content %} +
    +
    +
    +
    New Promotion
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + {% for svc in services %} +
    + + +
    + {% endfor %} +
    +
    + + {% for p in products %} +
    + + +
    + {% endfor %} +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/services/service_form.html b/app/templates/tenant/services/service_form.html new file mode 100644 index 0000000..cb89d09 --- /dev/null +++ b/app/templates/tenant/services/service_form.html @@ -0,0 +1,50 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Service{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Service
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + {% if mode == 'edit' %} +
    + + +
    + {% endif %} +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/settings/index.html b/app/templates/tenant/settings/index.html new file mode 100644 index 0000000..daddf0e --- /dev/null +++ b/app/templates/tenant/settings/index.html @@ -0,0 +1,59 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Settings{% endblock %} +{% block content %} +
    +
    +

    Settings

    +
    +
    +
    + + +
    Booking
    +
    + + +
    How many days ahead customers can book online.
    +
    +
    +
    + + +
    +
    + +
    Reviews
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    Receipt
    +
    + + +
    + + +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/staff/form.html b/app/templates/tenant/staff/form.html new file mode 100644 index 0000000..8db6496 --- /dev/null +++ b/app/templates/tenant/staff/form.html @@ -0,0 +1,77 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Staff Member
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    + {% if mode == 'create' %} +
    + + +
    Staff will use this PIN to log in. Show it to them once, then discard it.
    +
    + {% endif %} +
    +
    + + +
    +
    + + +
    +
    +
    + + {% for loc in locations %} +
    + + +
    + {% endfor %} +
    + {% if mode == 'edit' %} +
    + + +
    + {% endif %} +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/staff/index.html b/app/templates/tenant/staff/index.html new file mode 100644 index 0000000..019e1e1 --- /dev/null +++ b/app/templates/tenant/staff/index.html @@ -0,0 +1,32 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Staff{% endblock %} +{% block content %} +
    +

    Staff

    + + New Staff Member +
    +
    +
    + + + + {% for s in staff_list %} + + + + + + + + + {% else %} + + {% endfor %} + +
    NamePhoneTypePay TypeStatusActions
    {{ s.name }}{{ s.phone }}{{ s.staff_type.replace('_',' ').title() }}{{ s.pay_type.title() }}{{ 'Active' if s.is_active else 'Inactive' }} + Edit + Reset PIN +
    No staff members yet.
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/staff/reset_passcode.html b/app/templates/tenant/staff/reset_passcode.html new file mode 100644 index 0000000..9efb79c --- /dev/null +++ b/app/templates/tenant/staff/reset_passcode.html @@ -0,0 +1,28 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Reset Passcode — {{ staff.name }}{% endblock %} +{% block content %} +
    +
    +
    +
    Reset Passcode — {{ staff.name }}
    +
    +

    Enter a new 4–6 digit PIN for this staff member. Show it to them once, then discard it.

    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/staff_portal/commission.html b/app/templates/tenant/staff_portal/commission.html new file mode 100644 index 0000000..d429d45 --- /dev/null +++ b/app/templates/tenant/staff_portal/commission.html @@ -0,0 +1,43 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Commission & Pay{% endblock %} +{% block content %} +

    Commission & Pay Summary

    +{% if pay_periods %} +
    +
    Pay Periods
    +
    + + + + {% for pp in pay_periods %} + + + + + + + + + {% endfor %} + +
    PeriodTypeBaseCommissionTotalStatus
    {{ pp.period_start.strftime('%b %d') }}–{{ pp.period_end.strftime('%b %d, %Y') }}{{ pp.pay_type.title() }}${{ "%.2f"|format(pp.base_amount) }}${{ "%.2f"|format(pp.commission_amount) }}${{ "%.2f"|format(pp.total_amount) }}{{ pp.status }}
    +
    +
    +{% endif %} +{% if commission_logs %} +
    +
    Recent Commissions
    +
    + + + + {% for log in commission_logs %} + + {% endfor %} + +
    PeriodAmount
    {{ log.period or '—' }}${{ "%.2f"|format(log.amount) }}
    +
    +
    +{% endif %} +Back +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/staff_portal/index.html b/app/templates/tenant/staff_portal/index.html new file mode 100644 index 0000000..f7448cb --- /dev/null +++ b/app/templates/tenant/staff_portal/index.html @@ -0,0 +1,69 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Staff Portal{% endblock %} +{% block content %} +
    +

    + Welcome, {{ staff.name }} +

    + {{ today.strftime('%A, %B %d') }} +
    + + +
    +
    +
    + {% if current_clocking %} + On Shift + Since {{ current_clocking.clocked_in_at.strftime('%I:%M %p') }} + {% else %} + Off Shift + {% endif %} +
    +
    + {% if current_clocking %} +
    + + +
    + {% else %} +
    + + +
    + {% endif %} +
    +
    +
    + + +
    +
    + Today's Appointments + Full Schedule +
    + {% if todays_appointments %} +
    + + + + {% for a in todays_appointments %} + + + + + + + {% endfor %} + +
    TimeCustomerServiceStatus
    {{ a.start_time.strftime('%I:%M %p') }}{{ a.customer.name if a.customer else 'Walk-in' }}{{ a.service.name if a.service else '—' }}{{ a.status }}
    +
    + {% else %} +
    No appointments scheduled for today.
    + {% endif %} +
    + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/staff_portal/profile.html b/app/templates/tenant/staff_portal/profile.html new file mode 100644 index 0000000..f6d3f6c --- /dev/null +++ b/app/templates/tenant/staff_portal/profile.html @@ -0,0 +1,16 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}My Profile{% endblock %} +{% block content %} +

    My Profile

    +
    +
    +
    +
    Name
    {{ staff.name }}
    +
    Phone
    {{ staff.phone }}
    +
    Type
    {{ staff.staff_type.replace('_',' ').title() }}
    +
    Pay Type
    {{ staff.pay_type.title() }}
    +
    +
    +
    +Back +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/staff_portal/schedule.html b/app/templates/tenant/staff_portal/schedule.html new file mode 100644 index 0000000..af15e1c --- /dev/null +++ b/app/templates/tenant/staff_portal/schedule.html @@ -0,0 +1,28 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}My Schedule{% endblock %} +{% block content %} +

    My Schedule — Next 7 Days

    +{% if appointments %} +
    +
    + + + + {% for a in appointments %} + + + + + + + + {% endfor %} + +
    DateTimeCustomerServiceStatus
    {{ a.start_time.strftime('%b %d') }}{{ a.start_time.strftime('%I:%M %p') }}{{ a.customer.name if a.customer else 'Walk-in' }}{{ a.service.name if a.service else '—' }}{{ a.status }}
    +
    +
    +{% else %} +
    No upcoming appointments in the next 7 days.
    +{% endif %} +Back +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/waitlist/form.html b/app/templates/tenant/waitlist/form.html new file mode 100644 index 0000000..3ceefb8 --- /dev/null +++ b/app/templates/tenant/waitlist/form.html @@ -0,0 +1,59 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Add to Waitlist{% endblock %} +{% block content %} +
    +
    +
    +
    Add to Waitlist
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/waitlist/index.html b/app/templates/tenant/waitlist/index.html new file mode 100644 index 0000000..f0e7c87 --- /dev/null +++ b/app/templates/tenant/waitlist/index.html @@ -0,0 +1,55 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Waitlist{% endblock %} +{% block content %} +
    +

    Waitlist

    + + Add to Waitlist +
    +{% if entries %} +
    +
    + + + + + + {% for e in entries %} + + + + + + + + + + {% endfor %} + +
    NamePhoneServiceStaffRequestedStatus
    {{ e.customer_name }}{{ e.customer_phone or '—' }}{{ e.service.name if e.service else '—' }}{{ e.staff.name if e.staff else '—' }}{{ e.requested_date.strftime('%b %d') if e.requested_date else '—' }} + + {{ e.status }} + + + {% if e.status == 'waiting' %} +
    + + +
    + {% endif %} +
    + + + +
    +
    + + + +
    +
    +
    +
    +{% else %} +
    No active waitlist entries.
    +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/tenant/__init__.py b/app/tenant/__init__.py index 0b384e1..a435c06 100644 --- a/app/tenant/__init__.py +++ b/app/tenant/__init__.py @@ -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 diff --git a/app/tenant/appointments/routes.py b/app/tenant/appointments/routes.py index e742b3c..9bd1038 100644 --- a/app/tenant/appointments/routes.py +++ b/app/tenant/appointments/routes.py @@ -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("/") +@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("//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("//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 diff --git a/app/tenant/booking/routes.py b/app/tenant/booking/routes.py index 1a1f691..7a4cd00 100644 --- a/app/tenant/booking/routes.py +++ b/app/tenant/booking/routes.py @@ -1,7 +1,128 @@ """ app/tenant/booking/routes.py -Phase 3+ implementation. +Public online customer booking — /book/ +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/", 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, + ) diff --git a/app/tenant/checkin/routes.py b/app/tenant/checkin/routes.py index 147b6e7..c6e9acf 100644 --- a/app/tenant/checkin/routes.py +++ b/app/tenant/checkin/routes.py @@ -1,135 +1,102 @@ """ -app/tenant/checkin/routes.py — Customer self check-in kiosk. -Route: GET/POST /checkin/ +app/tenant/checkin/routes.py +Customer self check-in kiosk — /checkin/ 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/", 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", "") + # 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 - name = sanitise_string(raw_name, max_length=150) - phone = sanitise_string(raw_phone, max_length=30) - service = sanitise_string(raw_service, max_length=150) + 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=customer_phone + ).filter(Customer.deleted_at.is_(None)).first() + customer_id = None + if customer: + customer_id = customer.id + else: + # Auto-create profile + new_cust = Customer( + tenant_id=tenant.id, + name=customer_name, + phone=customer_phone, + is_active=True, + loyalty_points=0, + no_show_count=0, + ) + db.session.add(new_cust) + db.session.flush() + customer_id = new_cust.id + logger.info("Kiosk: new customer profile created tenant=%s phone=%s", + tenant.id, customer_phone) - # Validate required fields - if not name or not phone: - return render_template( - "tenant/checkin/kiosk.html", - tenant=tenant, - error="Name and phone number are required.", - confirmed=False, - ) - - # Strip non-digit characters from phone for lookup consistency - phone_digits = "".join(filter(str.isdigit, phone)) - if len(phone_digits) < 7: - return render_template( - "tenant/checkin/kiosk.html", - tenant=tenant, - error="Please enter a valid phone number.", - confirmed=False, - ) - - # Look up or create customer profile - customer = Customer.query.filter_by( - tenant_id=tenant.id, - phone=phone_digits, - ).filter(Customer.deleted_at.is_(None)).first() - - if customer is None: - customer = Customer( + entry = CheckinQueue( 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 + location_id=location.id, + 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() - # 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, - status="waiting", - ) - db.session.add(entry) - db.session.commit() - - logger.info( - "Kiosk: check-in queued for tenant=%s location=%s customer=%s", - tenant.slug, location.id, customer.id, - ) - confirmed = True - - # Fetch services for the kiosk selector (if configured) - from app.models.salon import Service - services = Service.query.filter_by( - tenant_id=tenant.id, - is_active=True, - ).filter(Service.deleted_at.is_(None)).order_by(Service.name).all() + logger.info("Kiosk check-in: tenant=%s location=%s customer=%s service=%s", + tenant.id, location.id, customer_id, service_requested) + confirmed = True return render_template( "tenant/checkin/kiosk.html", tenant=tenant, services=services, confirmed=confirmed, - error=None, + error=error, ) diff --git a/app/tenant/customers/routes.py b/app/tenant/customers/routes.py index c4aeb48..c16224e 100644 --- a/app/tenant/customers/routes.py +++ b/app/tenant/customers/routes.py @@ -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("/") +@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("//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("//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 diff --git a/app/tenant/dashboard/routes.py b/app/tenant/dashboard/routes.py index 1cdd626..9f5cac9 100644 --- a/app/tenant/dashboard/routes.py +++ b/app/tenant/dashboard/routes.py @@ -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, ) diff --git a/app/tenant/gift_cards/routes.py b/app/tenant/gift_cards/routes.py index 318a20e..bb3d4a5 100644 --- a/app/tenant/gift_cards/routes.py +++ b/app/tenant/gift_cards/routes.py @@ -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("//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 diff --git a/app/tenant/inventory/routes.py b/app/tenant/inventory/routes.py index d34810c..56d917d 100644 --- a/app/tenant/inventory/routes.py +++ b/app/tenant/inventory/routes.py @@ -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") diff --git a/app/tenant/locations/routes.py b/app/tenant/locations/routes.py index 984f1ba..f0dd4dd 100644 --- a/app/tenant/locations/routes.py +++ b/app/tenant/locations/routes.py @@ -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/") +@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("//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("//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")) diff --git a/app/tenant/marketing/routes.py b/app/tenant/marketing/routes.py index 0821781..b36c23e 100644 --- a/app/tenant/marketing/routes.py +++ b/app/tenant/marketing/routes.py @@ -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") diff --git a/app/tenant/pos/routes.py b/app/tenant/pos/routes.py index 000e7a8..666ad1a 100644 --- a/app/tenant/pos/routes.py +++ b/app/tenant/pos/routes.py @@ -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/") +@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/", 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/", 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 diff --git a/app/tenant/reconciliation/routes.py b/app/tenant/reconciliation/routes.py index ae3890c..1015d33 100644 --- a/app/tenant/reconciliation/routes.py +++ b/app/tenant/reconciliation/routes.py @@ -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 diff --git a/app/tenant/reports/routes.py b/app/tenant/reports/routes.py index 28a034c..36605f2 100644 --- a/app/tenant/reports/routes.py +++ b/app/tenant/reports/routes.py @@ -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") diff --git a/app/tenant/reviews/routes.py b/app/tenant/reviews/routes.py index 7e1ed55..339643c 100644 --- a/app/tenant/reviews/routes.py +++ b/app/tenant/reviews/routes.py @@ -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) diff --git a/app/tenant/services/routes.py b/app/tenant/services/routes.py index dbe6d57..cc135b3 100644 --- a/app/tenant/services/routes.py +++ b/app/tenant/services/routes.py @@ -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//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//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//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//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//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 diff --git a/app/tenant/settings/routes.py b/app/tenant/settings/routes.py index d51c2cc..c8d69e2 100644 --- a/app/tenant/settings/routes.py +++ b/app/tenant/settings/routes.py @@ -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")) diff --git a/app/tenant/staff/routes.py b/app/tenant/staff/routes.py index dc52371..37fd51e 100644 --- a/app/tenant/staff/routes.py +++ b/app/tenant/staff/routes.py @@ -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("//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("//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) diff --git a/app/tenant/staff_portal/routes.py b/app/tenant/staff_portal/routes.py index 8c43ebf..ae71643 100644 --- a/app/tenant/staff_portal/routes.py +++ b/app/tenant/staff_portal/routes.py @@ -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) diff --git a/app/tenant/utils.py b/app/tenant/utils.py new file mode 100644 index 0000000..720384e --- /dev/null +++ b/app/tenant/utils.py @@ -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' diff --git a/app/tenant/waitlist/routes.py b/app/tenant/waitlist/routes.py index 5924488..5ab73e4 100644 --- a/app/tenant/waitlist/routes.py +++ b/app/tenant/waitlist/routes.py @@ -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("//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("//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 diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 7e8caa3..142f1fc 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -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; +} diff --git a/deploy/salon_pos_admin.service b/deploy/salon_pos_admin.service index 4a99177..281e049 100644 --- a/deploy/salon_pos_admin.service +++ b/deploy/salon_pos_admin.service @@ -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 diff --git a/deploy/salon_pos_tenant.service b/deploy/salon_pos_tenant.service index 2f16061..4918254 100644 --- a/deploy/salon_pos_tenant.service +++ b/deploy/salon_pos_tenant.service @@ -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 diff --git a/templates/tenant/appointments/form.html b/templates/tenant/appointments/form.html new file mode 100644 index 0000000..fac8827 --- /dev/null +++ b/templates/tenant/appointments/form.html @@ -0,0 +1,75 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Appointment{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Appointment
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/appointments/index.html b/templates/tenant/appointments/index.html new file mode 100644 index 0000000..e7ae811 --- /dev/null +++ b/templates/tenant/appointments/index.html @@ -0,0 +1,56 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Appointments{% endblock %} +{% block content %} +
    +

    Appointments

    +
    + + + + + New +
    +
    + +
    +
    + + + + + + {% for appt in appointments %} + + + + + + + + + + {% else %} + + {% endfor %} + +
    TimeCustomerServiceStaffStatusType
    {{ appt.start_time.strftime('%I:%M %p') }}{{ appt.customer.name if appt.customer else 'Walk-in' | safe }}{{ appt.service.name if appt.service else '—' }}{{ appt.staff.name if appt.staff else '—' }} + + {{ appt.status }} + + {{ 'Walk-in' if appt.is_walk_in else 'Booked' }} + View + {% if appt.status in ['pending','confirmed','in_progress'] %} + Checkout + {% endif %} +
    No appointments for this day.
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/appointments/view.html b/templates/tenant/appointments/view.html new file mode 100644 index 0000000..d23401b --- /dev/null +++ b/templates/tenant/appointments/view.html @@ -0,0 +1,64 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Appointment #{{ appointment.id }}{% endblock %} +{% block content %} +
    +

    Appointment #{{ appointment.id }}

    +
    + Edit + {% if appointment.status in ['pending','confirmed','in_progress'] %} + + Checkout + + {% endif %} +
    +
    + +
    +
    +
    +
    +
    +
    Customer
    {{ appointment.customer.name if appointment.customer else 'Walk-in' }}
    +
    Service
    {{ appointment.service.name if appointment.service else '—' }}
    +
    Staff
    {{ appointment.staff.name if appointment.staff else '—' }}
    +
    Start
    {{ appointment.start_time.strftime('%Y-%m-%d %I:%M %p') }}
    +
    End
    {{ appointment.end_time.strftime('%I:%M %p') if appointment.end_time else '—' }}
    +
    Type
    {{ 'Walk-in' if appointment.is_walk_in else 'Booked' }}
    +
    Source
    {{ appointment.rebook_source or 'manual' }}
    + {% if appointment.notes %} +
    Notes
    {{ appointment.notes }}
    + {% endif %} +
    +
    +
    +
    +
    +
    +
    Update Status
    +
    +
    + +
    + +
    +
    + + +
    + +
    +
    +
    +
    +
    + + + Back to Calendar + +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/booking/form.html b/templates/tenant/booking/form.html new file mode 100644 index 0000000..ac75f5c --- /dev/null +++ b/templates/tenant/booking/form.html @@ -0,0 +1,79 @@ + + + + + + Book an Appointment — {{ tenant.name }} + + + +
    + {% if confirmed %} +
    +
    +

    Booking Requested!

    +

    We'll confirm your appointment shortly. Check your email for details.

    + Book Another +
    + {% else %} +
    +

    {{ tenant.name }}

    +

    Book an appointment online

    + {% if error %}
    {{ error }}
    {% endif %} +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + +
    +
    +
    + {% endif %} +
    + + + \ No newline at end of file diff --git a/templates/tenant/booking/not_found.html b/templates/tenant/booking/not_found.html new file mode 100644 index 0000000..909932e --- /dev/null +++ b/templates/tenant/booking/not_found.html @@ -0,0 +1,12 @@ + + +Not Found + + + +
    +

    Booking Page Not Found

    +

    This salon's booking page doesn't exist or is no longer available.

    +
    + + \ No newline at end of file diff --git a/templates/tenant/booking/unavailable.html b/templates/tenant/booking/unavailable.html new file mode 100644 index 0000000..10c63f8 --- /dev/null +++ b/templates/tenant/booking/unavailable.html @@ -0,0 +1,12 @@ + + +Booking Unavailable + + + +
    +

    Online Booking Unavailable

    +

    Online booking is not currently available for this salon. Please call to book.

    +
    + + \ No newline at end of file diff --git a/templates/tenant/checkin/not_found.html b/templates/tenant/checkin/not_found.html new file mode 100644 index 0000000..b88c958 --- /dev/null +++ b/templates/tenant/checkin/not_found.html @@ -0,0 +1,12 @@ + + +Check-In Not Found + + + +
    +

    Check-In Unavailable

    +

    This check-in kiosk is not available. Please see a staff member.

    +
    + + \ No newline at end of file diff --git a/templates/tenant/customers/form.html b/templates/tenant/customers/form.html new file mode 100644 index 0000000..e8b6aa0 --- /dev/null +++ b/templates/tenant/customers/form.html @@ -0,0 +1,47 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Customer{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Customer
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/customers/index.html b/templates/tenant/customers/index.html new file mode 100644 index 0000000..d30dbf2 --- /dev/null +++ b/templates/tenant/customers/index.html @@ -0,0 +1,41 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Customers{% endblock %} +{% block content %} +
    +

    Customers

    + + New Customer + +
    +
    +
    +
    + + + {% if q %}Clear{% endif %} +
    +
    +
    +
    +
    + + + + {% for c in customers %} + + + + + + + + + {% else %} + + {% endfor %} + +
    NamePhoneEmailLoyaltyNo-Shows
    {{ c.name }}{{ c.phone or '—' }}{{ c.email or '—' }}{{ c.loyalty_points }} pts{% if c.no_show_count > 0 %}{{ c.no_show_count }}{% else %}0{% endif %}Edit
    No customers found.
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/customers/view.html b/templates/tenant/customers/view.html new file mode 100644 index 0000000..0bca188 --- /dev/null +++ b/templates/tenant/customers/view.html @@ -0,0 +1,58 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ customer.name }}{% endblock %} +{% block content %} +
    +

    {{ customer.name }}

    + +
    +
    +
    +
    +
    +

    {{ customer.phone or '—' }}

    +

    {{ customer.email or '—' }}

    + {% if customer.date_of_birth %} +

    {{ customer.date_of_birth.strftime('%B %d') }}

    + {% endif %} +

    {{ customer.loyalty_points }} loyalty points

    + {% if customer.no_show_count > 0 %} +

    {{ customer.no_show_count }} no-show(s)

    + {% endif %} + {% if customer.notes %} +

    {{ customer.notes }}

    + {% endif %} +
    +
    +
    +
    +
    +
    Recent Appointments
    + {% if appointments %} +
    + + + + {% for a in appointments %} + + + + + + + {% endfor %} + +
    DateServiceStaffStatus
    {{ a.start_time.strftime('%Y-%m-%d %I:%M %p') }}{{ a.service.name if a.service else '—' }}{{ a.staff.name if a.staff else '—' }}{{ a.status }}
    +
    + {% else %} +
    No appointments yet.
    + {% endif %} +
    +
    +
    + + Back + +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/dashboard/index.html b/templates/tenant/dashboard/index.html index ddc17d2..bf1fb53 100644 --- a/templates/tenant/dashboard/index.html +++ b/templates/tenant/dashboard/index.html @@ -1,10 +1,66 @@ {% extends "tenant/layouts/base.html" %} {% block title %}Dashboard{% endblock %} {% block content %} -

    Dashboard - {% if location %}— {{ location.name }}{% endif %} -

    -
    - Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts). +
    +

    + Dashboard + {% if location %}— {{ location.name }}{% endif %} +

    + {{ today.strftime('%A, %B %d, %Y') }}
    -{% endblock %} + + +
    + {% 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 %} +
    +
    +
    +
    + +
    +
    +
    {{ value }}
    +
    {{ label }}
    +
    +
    +
    +
    + {% endfor %} +
    + + +
    +
    + Upcoming Today + Full Calendar +
    + {% if upcoming_appointments %} +
    + + + + {% for appt in upcoming_appointments %} + + + + + + + + {% endfor %} + +
    TimeCustomerServiceStaffStatus
    {{ appt.start_time.strftime('%I:%M %p') }}{{ appt.customer.name if appt.customer else '—' }}{{ appt.service.name if appt.service else '—' }}{{ appt.staff.name if appt.staff else '—' }}{{ appt.status }}
    +
    + {% else %} +
    No upcoming appointments for today.
    + {% endif %} +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/gift_cards/form.html b/templates/tenant/gift_cards/form.html new file mode 100644 index 0000000..d63c081 --- /dev/null +++ b/templates/tenant/gift_cards/form.html @@ -0,0 +1,39 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Issue Gift Card{% endblock %} +{% block content %} +
    +
    +
    +
    Issue New Gift Card
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/gift_cards/index.html b/templates/tenant/gift_cards/index.html new file mode 100644 index 0000000..25014a4 --- /dev/null +++ b/templates/tenant/gift_cards/index.html @@ -0,0 +1,49 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Gift Cards{% endblock %} +{% block content %} +
    +

    Gift Cards

    + +
    +
    +
    + + + + + + {% for card in cards %} + + + + + + + + + + {% else %} + + {% endfor %} + +
    CodeOriginalBalanceCustomerExpiresStatus
    {{ card.code }}${{ "%.2f"|format(card.original_value) }} + ${{ "%.2f"|format(card.remaining_balance) }} + {{ card.customer.name if card.issued_to_customer_id and card.customer else '—' }}{{ card.expires_at.strftime('%Y-%m-%d') if card.expires_at else 'No expiry' }} + + {{ 'Active' if card.is_active else 'Inactive' }} + + + {% if card.is_active %} +
    + + +
    + {% endif %} +
    No gift cards issued yet.
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/gift_cards/lookup.html b/templates/tenant/gift_cards/lookup.html new file mode 100644 index 0000000..9dd3185 --- /dev/null +++ b/templates/tenant/gift_cards/lookup.html @@ -0,0 +1,40 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Gift Card Lookup{% endblock %} +{% block content %} +
    +
    +
    +
    Gift Card Lookup
    +
    +
    +
    + + +
    +
    + {% if code and card %} +
    +
    +
    Code
    {{ card.code }}
    +
    Original Value
    ${{ "%.2f"|format(card.original_value) }}
    +
    Remaining Balance
    +
    + ${{ "%.2f"|format(card.remaining_balance) }} +
    +
    Status
    +
    {{ 'Active' if card.is_active else 'Inactive / Depleted' }}
    + {% if card.expires_at %} +
    Expires
    {{ card.expires_at.strftime('%Y-%m-%d') }}
    + {% endif %} +
    +
    + {% elif code %} +
    No gift card found with code {{ code }}.
    + {% endif %} + Back +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/layouts/base.html b/templates/tenant/layouts/base.html index ed22b7f..7815faa 100644 --- a/templates/tenant/layouts/base.html +++ b/templates/tenant/layouts/base.html @@ -37,7 +37,7 @@ {% for loc in locations %}
  • + href="{{ url_for('locations.switch', location_id=loc.id) }}"> {{ loc.name }} {% if loc.is_primary %}Primary{% endif %} @@ -86,31 +86,31 @@
  • @@ -122,13 +122,13 @@ @@ -147,13 +147,13 @@ diff --git a/templates/tenant/locations/form.html b/templates/tenant/locations/form.html new file mode 100644 index 0000000..af8be83 --- /dev/null +++ b/templates/tenant/locations/form.html @@ -0,0 +1,62 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Location{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Location
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + {% if mode == 'edit' %} +
    +
    + + +
    +
    + + +
    +
    + {% endif %} +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/locations/index.html b/templates/tenant/locations/index.html new file mode 100644 index 0000000..1b9bafb --- /dev/null +++ b/templates/tenant/locations/index.html @@ -0,0 +1,43 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Locations{% endblock %} +{% block content %} +
    +

    Locations

    + + New Location + +
    +
    + {% for loc in locations %} +
    +
    +
    + {{ loc.name }} +
    + {% if loc.is_primary %}Primary{% endif %} + {{ 'Active' if loc.is_active else 'Inactive' }} +
    +
    +
    + {% if loc.address %}

    {{ loc.address }}

    {% endif %} + {% if loc.phone %}

    {{ loc.phone }}

    {% endif %} + {% if loc.email %}

    {{ loc.email }}

    {% endif %} +

    {{ loc.timezone }}

    +
    + +
    +
    + {% else %} +

    No locations found.

    + {% endfor %} +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/pos/checkout.html b/templates/tenant/pos/checkout.html new file mode 100644 index 0000000..96d5fe1 --- /dev/null +++ b/templates/tenant/pos/checkout.html @@ -0,0 +1,115 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}POS Checkout{% endblock %} +{% block content %} +

    Checkout

    + +
    + + {% if appointment %} + + {% endif %} + +
    + +
    +
    +
    Services
    +
    + {% for s in services %} +
    + + +
    + {% endfor %} +
    +
    + {% if services %} +
    +
    Products
    +
    + {% for p in products %} +
    + + +
    + {% endfor %} +
    +
    + {% endif %} +
    + + +
    +
    +
    Payment Details
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/pos/rebook.html b/templates/tenant/pos/rebook.html new file mode 100644 index 0000000..1e419a3 --- /dev/null +++ b/templates/tenant/pos/rebook.html @@ -0,0 +1,43 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Schedule Next Visit{% endblock %} +{% block content %} +
    +
    +
    +
    Schedule Next Visit
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    + + Skip +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/pos/receipt.html b/templates/tenant/pos/receipt.html new file mode 100644 index 0000000..b01082f --- /dev/null +++ b/templates/tenant/pos/receipt.html @@ -0,0 +1,64 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Receipt #{{ transaction.id }}{% endblock %} +{% block content %} +
    +
    +
    +
    +
    +
    {{ tenant.name }}
    + {% if location %}

    {{ location.name }}

    {% endif %} +

    {{ transaction.created_at.strftime('%B %d, %Y %I:%M %p') }}

    +

    Receipt #{{ transaction.id }}

    + {% if transaction.voided_at %} +
    VOIDED — {{ transaction.void_reason }}
    + {% endif %} +
    + + + {% for item in items %} + + + + + {% endfor %} + + + {% if transaction.discount > 0 %} + + {% endif %} + {% if transaction.tip_amount > 0 %} + + {% endif %} + {% if transaction.gift_card_amount > 0 %} + + {% endif %} + + + +
    + {% if item.service %}{{ item.service.name }} + {% elif item.product %}{{ item.product.name }} + {% endif %} + {% if item.discount_percent > 0 %} + {{ item.discount_percent }}% off + {% endif %} + + {% if item.discount_percent > 0 %} + ${{ "%.2f"|format(item.original_price) }} + {% endif %} + ${{ "%.2f"|format(item.unit_price) }} +
    Savings-${{ "%.2f"|format(transaction.discount) }}
    Tip${{ "%.2f"|format(transaction.tip_amount) }}
    Gift Card-${{ "%.2f"|format(transaction.gift_card_amount) }}
    Total${{ "%.2f"|format(transaction.total) }}
    Payment{{ transaction.payment_method.title() }}
    +
    + New Transaction + Rebook + {% if not transaction.voided_at %} + Void + {% endif %} + Transactions +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/pos/transactions.html b/templates/tenant/pos/transactions.html new file mode 100644 index 0000000..c2672b4 --- /dev/null +++ b/templates/tenant/pos/transactions.html @@ -0,0 +1,32 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Transactions{% endblock %} +{% block content %} +
    +

    Transactions

    + +
    +
    +
    + + + + {% for t in transactions %} + + + + + + + + + + {% else %} + + {% endfor %} + +
    TimeCustomerStaffTotalMethodTip
    {{ t.created_at.strftime('%I:%M %p') }}{{ t.customer.name if t.customer else '—' }}{{ t.staff.name if t.staff else '—' }}${{ "%.2f"|format(t.total) }}{{ t.payment_method.title() }}{% if t.tip_amount > 0 %}${{ "%.2f"|format(t.tip_amount) }}{% else %}—{% endif %}View
    No transactions for this day.
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/pos/void.html b/templates/tenant/pos/void.html new file mode 100644 index 0000000..040dd38 --- /dev/null +++ b/templates/tenant/pos/void.html @@ -0,0 +1,26 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Void Transaction{% endblock %} +{% block content %} +
    +
    +
    +
    Void Transaction #{{ transaction.id }}
    +
    +

    Total: ${{ "%.2f"|format(transaction.total) }}

    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/reconciliation/close.html b/templates/tenant/reconciliation/close.html new file mode 100644 index 0000000..10e949a --- /dev/null +++ b/templates/tenant/reconciliation/close.html @@ -0,0 +1,42 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Close Day{% endblock %} +{% block content %} +
    +
    +
    +
    + Close Day — {{ today.strftime('%B %d, %Y') }} +
    +
    + + + + + + + + + +
    Cash Sales${{ "%.2f"|format(total_cash) }}
    App Payments (Zelle / Venmo / etc.)${{ "%.2f"|format(total_app) }}
    Tips Collected${{ "%.2f"|format(total_tips) }}
    Gift Card Redemptions${{ "%.2f"|format(total_gc) }}
    Expected Cash in Drawer${{ "%.2f"|format(expected_cash) }}
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/reconciliation/index.html b/templates/tenant/reconciliation/index.html new file mode 100644 index 0000000..4f17f06 --- /dev/null +++ b/templates/tenant/reconciliation/index.html @@ -0,0 +1,38 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Reconciliation{% endblock %} +{% block content %} +
    +

    End-of-Day Reconciliation

    + Close Today +
    +{% if records %} +
    +
    + + + + + + {% for r in records %} + + + + + + + + + + + + {% endfor %} + +
    DateCashApp PaymentsTipsGift CardsExpectedActualVarianceClosed By
    {{ r.date.strftime('%Y-%m-%d') }}${{ "%.2f"|format(r.total_cash) }}${{ "%.2f"|format(r.total_app_payments) }}${{ "%.2f"|format(r.total_tips) }}${{ "%.2f"|format(r.total_gift_card_redemptions) }}${{ "%.2f"|format(r.expected_cash_in_drawer) }}${{ "%.2f"|format(r.actual_cash_counted) }} + {{ '+' if r.variance > 0 else '' }}${{ "%.2f"|format(r.variance) }} + {{ r.closed_at.strftime('%I:%M %p') if r.closed_at else '—' }}
    +
    +
    +{% else %} +
    No reconciliation records yet.
    +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/reviews/index.html b/templates/tenant/reviews/index.html new file mode 100644 index 0000000..291637a --- /dev/null +++ b/templates/tenant/reviews/index.html @@ -0,0 +1,54 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Reviews{% endblock %} +{% block content %} +
    +

    Customer Reviews

    +
    +
    {{ avg_rating }} ★
    +
    Average rating
    +
    +
    + + +
    +
    + {% for star in [5,4,3,2,1] %} + {% set count = rating_dist.get(star, 0) %} + {% set total = reviews|length %} +
    + {{ star }}★ +
    +
    +
    + {{ count }} +
    + {% endfor %} +
    +
    + +{% if reviews %} +
    +
    + + + + {% for r in reviews %} + + + + + + + + {% endfor %} + +
    DateRatingCustomerStaffComment
    {{ r.created_at.strftime('%Y-%m-%d') }} + {{ r.rating }}★ + {{ r.customer.name if r.customer else '—' }}{{ r.staff.name if r.staff else '—' }}{{ r.comment or '—' }}
    +
    +
    +{% else %} +
    No reviews yet.
    +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/services/index.html b/templates/tenant/services/index.html new file mode 100644 index 0000000..d6f0b92 --- /dev/null +++ b/templates/tenant/services/index.html @@ -0,0 +1,102 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Services & Products{% endblock %} +{% block content %} +
    +

    Services & Products

    + +
    + +
    +
    +
    +
    Services
    +
    + + + + {% for s in services %} + + + + + + + + {% else %} + + {% endfor %} + +
    NameCategoryDurationPrice
    {{ s.name }}{{ s.category or '—' }}{{ s.duration_min }} min${{ "%.2f"|format(s.price) }} + Edit +
    + + +
    +
    No services yet.
    +
    +
    +
    +
    +
    +
    Products
    +
    + + + + {% for p in products %} + + + + + + + {% else %} + + {% endfor %} + +
    NameSKUPrice
    {{ p.name }}{{ p.sku or '—' }}${{ "%.2f"|format(p.sale_price) }} + Edit +
    + + +
    +
    No products yet.
    +
    +
    + {% if promotions %} +
    +
    Active Promotions
    +
    + + + + {% for promo in promotions %} + + + + + + + + {% endfor %} + +
    NameDiscountApplies ToEnds
    {{ promo.name }}{{ promo.discount_percent }}%{{ promo.applies_to }}{{ promo.ends_at.strftime('%Y-%m-%d') }} +
    + + +
    +
    +
    +
    + {% endif %} +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/services/product_form.html b/templates/tenant/services/product_form.html new file mode 100644 index 0000000..dec46e6 --- /dev/null +++ b/templates/tenant/services/product_form.html @@ -0,0 +1,50 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Product{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Product
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    + {% if mode == 'edit' %} +
    + + +
    + {% endif %} +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/services/promotion_form.html b/templates/tenant/services/promotion_form.html new file mode 100644 index 0000000..ca7bed6 --- /dev/null +++ b/templates/tenant/services/promotion_form.html @@ -0,0 +1,84 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}New Promotion{% endblock %} +{% block content %} +
    +
    +
    +
    New Promotion
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + {% for svc in services %} +
    + + +
    + {% endfor %} +
    +
    + + {% for p in products %} +
    + + +
    + {% endfor %} +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/services/service_form.html b/templates/tenant/services/service_form.html new file mode 100644 index 0000000..cb89d09 --- /dev/null +++ b/templates/tenant/services/service_form.html @@ -0,0 +1,50 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Service{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Service
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + {% if mode == 'edit' %} +
    + + +
    + {% endif %} +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/settings/index.html b/templates/tenant/settings/index.html new file mode 100644 index 0000000..daddf0e --- /dev/null +++ b/templates/tenant/settings/index.html @@ -0,0 +1,59 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Settings{% endblock %} +{% block content %} +
    +
    +

    Settings

    +
    +
    +
    + + +
    Booking
    +
    + + +
    How many days ahead customers can book online.
    +
    +
    +
    + + +
    +
    + +
    Reviews
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    Receipt
    +
    + + +
    + + +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/staff/form.html b/templates/tenant/staff/form.html new file mode 100644 index 0000000..8db6496 --- /dev/null +++ b/templates/tenant/staff/form.html @@ -0,0 +1,77 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member{% endblock %} +{% block content %} +
    +
    +
    +
    {{ 'Edit' if mode == 'edit' else 'New' }} Staff Member
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + +
    + {% if mode == 'create' %} +
    + + +
    Staff will use this PIN to log in. Show it to them once, then discard it.
    +
    + {% endif %} +
    +
    + + +
    +
    + + +
    +
    +
    + + {% for loc in locations %} +
    + + +
    + {% endfor %} +
    + {% if mode == 'edit' %} +
    + + +
    + {% endif %} +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/staff/index.html b/templates/tenant/staff/index.html new file mode 100644 index 0000000..019e1e1 --- /dev/null +++ b/templates/tenant/staff/index.html @@ -0,0 +1,32 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Staff{% endblock %} +{% block content %} +
    +

    Staff

    + + New Staff Member +
    +
    +
    + + + + {% for s in staff_list %} + + + + + + + + + {% else %} + + {% endfor %} + +
    NamePhoneTypePay TypeStatusActions
    {{ s.name }}{{ s.phone }}{{ s.staff_type.replace('_',' ').title() }}{{ s.pay_type.title() }}{{ 'Active' if s.is_active else 'Inactive' }} + Edit + Reset PIN +
    No staff members yet.
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/staff/reset_passcode.html b/templates/tenant/staff/reset_passcode.html new file mode 100644 index 0000000..9efb79c --- /dev/null +++ b/templates/tenant/staff/reset_passcode.html @@ -0,0 +1,28 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Reset Passcode — {{ staff.name }}{% endblock %} +{% block content %} +
    +
    +
    +
    Reset Passcode — {{ staff.name }}
    +
    +

    Enter a new 4–6 digit PIN for this staff member. Show it to them once, then discard it.

    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/staff_portal/commission.html b/templates/tenant/staff_portal/commission.html new file mode 100644 index 0000000..d429d45 --- /dev/null +++ b/templates/tenant/staff_portal/commission.html @@ -0,0 +1,43 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Commission & Pay{% endblock %} +{% block content %} +

    Commission & Pay Summary

    +{% if pay_periods %} +
    +
    Pay Periods
    +
    + + + + {% for pp in pay_periods %} + + + + + + + + + {% endfor %} + +
    PeriodTypeBaseCommissionTotalStatus
    {{ pp.period_start.strftime('%b %d') }}–{{ pp.period_end.strftime('%b %d, %Y') }}{{ pp.pay_type.title() }}${{ "%.2f"|format(pp.base_amount) }}${{ "%.2f"|format(pp.commission_amount) }}${{ "%.2f"|format(pp.total_amount) }}{{ pp.status }}
    +
    +
    +{% endif %} +{% if commission_logs %} +
    +
    Recent Commissions
    +
    + + + + {% for log in commission_logs %} + + {% endfor %} + +
    PeriodAmount
    {{ log.period or '—' }}${{ "%.2f"|format(log.amount) }}
    +
    +
    +{% endif %} +Back +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/staff_portal/index.html b/templates/tenant/staff_portal/index.html new file mode 100644 index 0000000..f7448cb --- /dev/null +++ b/templates/tenant/staff_portal/index.html @@ -0,0 +1,69 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Staff Portal{% endblock %} +{% block content %} +
    +

    + Welcome, {{ staff.name }} +

    + {{ today.strftime('%A, %B %d') }} +
    + + +
    +
    +
    + {% if current_clocking %} + On Shift + Since {{ current_clocking.clocked_in_at.strftime('%I:%M %p') }} + {% else %} + Off Shift + {% endif %} +
    +
    + {% if current_clocking %} +
    + + +
    + {% else %} +
    + + +
    + {% endif %} +
    +
    +
    + + +
    +
    + Today's Appointments + Full Schedule +
    + {% if todays_appointments %} +
    + + + + {% for a in todays_appointments %} + + + + + + + {% endfor %} + +
    TimeCustomerServiceStatus
    {{ a.start_time.strftime('%I:%M %p') }}{{ a.customer.name if a.customer else 'Walk-in' }}{{ a.service.name if a.service else '—' }}{{ a.status }}
    +
    + {% else %} +
    No appointments scheduled for today.
    + {% endif %} +
    + + +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/staff_portal/profile.html b/templates/tenant/staff_portal/profile.html new file mode 100644 index 0000000..f6d3f6c --- /dev/null +++ b/templates/tenant/staff_portal/profile.html @@ -0,0 +1,16 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}My Profile{% endblock %} +{% block content %} +

    My Profile

    +
    +
    +
    +
    Name
    {{ staff.name }}
    +
    Phone
    {{ staff.phone }}
    +
    Type
    {{ staff.staff_type.replace('_',' ').title() }}
    +
    Pay Type
    {{ staff.pay_type.title() }}
    +
    +
    +
    +Back +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/staff_portal/schedule.html b/templates/tenant/staff_portal/schedule.html new file mode 100644 index 0000000..af15e1c --- /dev/null +++ b/templates/tenant/staff_portal/schedule.html @@ -0,0 +1,28 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}My Schedule{% endblock %} +{% block content %} +

    My Schedule — Next 7 Days

    +{% if appointments %} +
    +
    + + + + {% for a in appointments %} + + + + + + + + {% endfor %} + +
    DateTimeCustomerServiceStatus
    {{ a.start_time.strftime('%b %d') }}{{ a.start_time.strftime('%I:%M %p') }}{{ a.customer.name if a.customer else 'Walk-in' }}{{ a.service.name if a.service else '—' }}{{ a.status }}
    +
    +
    +{% else %} +
    No upcoming appointments in the next 7 days.
    +{% endif %} +Back +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/waitlist/form.html b/templates/tenant/waitlist/form.html new file mode 100644 index 0000000..3ceefb8 --- /dev/null +++ b/templates/tenant/waitlist/form.html @@ -0,0 +1,59 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Add to Waitlist{% endblock %} +{% block content %} +
    +
    +
    +
    Add to Waitlist
    +
    + {% if error %}
    {{ error }}
    {% endif %} +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tenant/waitlist/index.html b/templates/tenant/waitlist/index.html new file mode 100644 index 0000000..f0e7c87 --- /dev/null +++ b/templates/tenant/waitlist/index.html @@ -0,0 +1,55 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Waitlist{% endblock %} +{% block content %} +
    +

    Waitlist

    + + Add to Waitlist +
    +{% if entries %} +
    +
    + + + + + + {% for e in entries %} + + + + + + + + + + {% endfor %} + +
    NamePhoneServiceStaffRequestedStatus
    {{ e.customer_name }}{{ e.customer_phone or '—' }}{{ e.service.name if e.service else '—' }}{{ e.staff.name if e.staff else '—' }}{{ e.requested_date.strftime('%b %d') if e.requested_date else '—' }} + + {{ e.status }} + + + {% if e.status == 'waiting' %} +
    + + +
    + {% endif %} +
    + + + +
    +
    + + + +
    +
    +
    +
    +{% else %} +
    No active waitlist entries.
    +{% endif %} +{% endblock %} \ No newline at end of file