Compare commits
10
Commits
d5847108db
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf9b54f6b5 | ||
|
|
959f54ed84 | ||
|
|
61a1b83688 | ||
|
|
bac9f7ee2c | ||
|
|
ce462c57b2 | ||
|
|
6e5518c103 | ||
|
|
33010fd71b | ||
|
|
50347e95c6 | ||
|
|
9f7d656d5e | ||
|
|
5b26836399 |
+2
-2
@@ -8,8 +8,8 @@ MAIL_USE_TLS=true
|
|||||||
MAIL_USERNAME=noreply@mydomain.com
|
MAIL_USERNAME=noreply@mydomain.com
|
||||||
MAIL_PASSWORD=
|
MAIL_PASSWORD=
|
||||||
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
|
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
|
||||||
ADMIN_DOMAIN=admin.mydomain.com
|
ADMIN_DOMAIN=posadmin.ngodanguyen.tech
|
||||||
TENANT_DOMAIN=mydomain.com
|
TENANT_DOMAIN=pos.ngodanguyen.tech
|
||||||
DEMO_TENANT_SLUG=demo
|
DEMO_TENANT_SLUG=demo
|
||||||
BACKUP_DIR=/var/backups/salon_pos
|
BACKUP_DIR=/var/backups/salon_pos
|
||||||
BACKUP_RETAIN_DAYS=30
|
BACKUP_RETAIN_DAYS=30
|
||||||
|
|||||||
@@ -682,8 +682,37 @@ WantedBy=multi-user.target
|
|||||||
- [x] `tests/test_admin_phase2.py` — full test suite for all 7 modules
|
- [x] `tests/test_admin_phase2.py` — full test suite for all 7 modules
|
||||||
- [x] Demo account creation deferred — available via `POST /tenants/new` with `is_demo=1`
|
- [x] Demo account creation deferred — available via `POST /tenants/new` with `is_demo=1`
|
||||||
|
|
||||||
### Phase 3 — Multi-Location & Tenant Core Modules
|
### Phase 3 — Multi-Location & Tenant Core Modules ✅ COMPLETE
|
||||||
- [ ] Location management (CRUD, primary flag, per-location settings)
|
- [x] Location management (CRUD, primary flag, switch, plan limit check)
|
||||||
|
- [x] Location switcher UI + tenant_staff location restriction enforcement
|
||||||
|
- [x] Staff management (profiles, job type, location assignment, passcode set/reset)
|
||||||
|
- [x] Staff passcode management (validate_passcode helper in security.py)
|
||||||
|
- [x] Dashboard (Phase 3 KPIs: revenue, appointments, staff on shift, queue count, upcoming)
|
||||||
|
- [x] Customers (search, CRUD, visit history, no-show count, soft-delete)
|
||||||
|
- [x] Services & products (tenant-wide catalogue, soft-delete)
|
||||||
|
- [x] Promotions management (create, activate/deactivate, applies_to, target_ids_json)
|
||||||
|
- [x] Promotion engine: get_active_promotion() + apply_promotion_to_price() in app/tenant/utils.py
|
||||||
|
- [x] Appointments (calendar by day, create, edit, status workflow, no-show counter, cancellation)
|
||||||
|
- [x] Customer check-in kiosk (/checkin/<slug>; auto-profile creation; queue entry; rate-limited; CSRF-exempt)
|
||||||
|
- [x] Queue polling API: GET /api/v1/checkin/queue + POST /api/v1/checkin/queue/<id>/acknowledge
|
||||||
|
- [x] Online booking (/book/<slug>; no auth; availability check; confirmation email; tenant_feature_required)
|
||||||
|
- [x] Waitlist (add, notify via email, set booked/expired; tenant_feature_required)
|
||||||
|
- [x] Staff Portal (clock in/out, today's appointments, schedule, commission, profile)
|
||||||
|
- [x] POS/Checkout (services + products, auto-apply promotions, tip, gift card redemption, payment method)
|
||||||
|
- [x] Rebook at checkout (creates pending appointment + 24h reminder; linked to transaction)
|
||||||
|
- [x] Gift cards (unique code gen, issuance, balance lookup, POS redemption, deactivate)
|
||||||
|
- [x] Transaction void (reason required; gift card balance reversed)
|
||||||
|
- [x] End-of-day reconciliation (close day, cash count, variance, history)
|
||||||
|
- [x] Reviews dashboard (avg rating, distribution chart, list)
|
||||||
|
- [x] Settings (managed key/value pairs for booking, reviews, receipt)
|
||||||
|
- [x] Phase 4 stubs (inventory, marketing, reports) registered with feature_unavailable page
|
||||||
|
- [x] app/tenant/utils.py — log_tenant_action, plan_limit_check, get_active_promotion, apply_promotion_to_price
|
||||||
|
- [x] All 17 Phase 3 blueprints registered in create_tenant_app()
|
||||||
|
- [x] All nav links in tenant/layouts/base.html wired to Phase 3 routes
|
||||||
|
- [x] Both template trees (templates/ and app/templates/) in sync: 67 files each
|
||||||
|
- [x] All 46 render_template references verified against disk
|
||||||
|
|
||||||
|
### Phase 3 Location management (CRUD, primary flag, per-location settings)
|
||||||
- [ ] Location switcher UI + `tenant_staff` location restriction enforcement
|
- [ ] Location switcher UI + `tenant_staff` location restriction enforcement
|
||||||
- [ ] Staff ↔ location assignment (many-to-many)
|
- [ ] Staff ↔ location assignment (many-to-many)
|
||||||
- [ ] Staff passcode management (set at creation, reset by admin/manager, never stored in plaintext)
|
- [ ] Staff passcode management (set at creation, reset by admin/manager, never stored in plaintext)
|
||||||
@@ -702,8 +731,23 @@ WantedBy=multi-user.target
|
|||||||
- [ ] Gift cards (issuance with unique code; POS redemption; balance tracking; expiry)
|
- [ ] Gift cards (issuance with unique code; POS redemption; balance tracking; expiry)
|
||||||
- [ ] End-of-day reconciliation (Close Day flow; cash count entry; variance calculation; `daily_reconciliations` record)
|
- [ ] End-of-day reconciliation (Close Day flow; cash count entry; variance calculation; `daily_reconciliations` record)
|
||||||
|
|
||||||
### Phase 4 — Tenant Operations Modules
|
### Phase 4 — Tenant Operations Modules ✅ COMPLETE
|
||||||
- [ ] Staff management (profiles, job type, system role, location assignments, schedules)
|
- [x] Pay structure per staff (hourly_rate, salary_amount, guarantee_amount, commission_rate, commission_enabled, pay_period — editable via staff form)
|
||||||
|
- [x] Pay period calculation engine (app/tenant/pay_periods/routes.py): hourly × hours, salary fixed, guarantee = max(guarantee, commission); results written to staff_pay_periods
|
||||||
|
- [x] Pay period approval workflow (draft → approved → paid; tenant_admin only)
|
||||||
|
- [x] Inventory (CRUD, reorder alerts, manual adjustment log; tenant_feature_required("inventory"))
|
||||||
|
- [x] Automatic inventory deduction on POS product sale (matched by SKU or name; InventoryLog entry created)
|
||||||
|
- [x] Appointment reminder engine (APScheduler job every 5 min; sends 24h + 2h email reminders; status=pending|sent|failed|cancelled)
|
||||||
|
- [x] Appointment reminders scheduled on appointment create (24h + 2h ahead)
|
||||||
|
- [x] Customer review request engine (APScheduler job every 10 min; delay per tenant setting; smart routing ≥4 stars shows platform links; one send enforced by review_request_sent_at)
|
||||||
|
- [x] app/scheduler_jobs.py — send_appointment_reminders(), send_review_requests()
|
||||||
|
- [x] Scheduler init in create_tenant_app() with SCHEDULER_API_ENABLED=False
|
||||||
|
- [x] pay_periods_bp + inventory_bp registered in tenant factory
|
||||||
|
- [x] Nav links wired: inventory → inventory.index, pay_periods → pay_periods.index
|
||||||
|
- [x] Both template trees in sync: 73 files each
|
||||||
|
- [x] Full validation passed: 6 imports OK, 0 missing templates, 0 illegal Jinja2, 0 broken extends
|
||||||
|
|
||||||
|
### Phase 4 — Staff management (profiles, job type, system role, location assignments, schedules) (profiles, job type, system role, location assignments, schedules)
|
||||||
- [ ] Pay structure setup per staff member (pay type, rates, pay period, commission toggle)
|
- [ ] Pay structure setup per staff member (pay type, rates, pay period, commission toggle)
|
||||||
- [ ] Commission tracking (per transaction, period summary; respects `commission_enabled` flag per staff)
|
- [ ] Commission tracking (per transaction, period summary; respects `commission_enabled` flag per staff)
|
||||||
- [ ] Working hours / clockings (clock-in at login or manual; clock-out; total hours computed per period)
|
- [ ] Working hours / clockings (clock-in at login or manual; clock-out; total hours computed per period)
|
||||||
@@ -886,6 +930,49 @@ MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by `
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Development Rules
|
||||||
|
|
||||||
|
The following rules are mandatory for all development on this codebase. Violations have caused production 500 errors.
|
||||||
|
|
||||||
|
**Rule 1 — Never assume, always read first.**
|
||||||
|
Before fixing any error or touching any file, read the actual file content on disk. Do not assume the file matches what was previously written — the server may have a different version. Use `cat`, `grep`, or `sed -n` to read the exact content before making any changes.
|
||||||
|
|
||||||
|
**Rule 2 — No temporary fixes.**
|
||||||
|
All fixes must address the root cause. Workarounds that mask a problem without solving it are not permitted. If the root cause is unclear, investigate further before writing any code.
|
||||||
|
|
||||||
|
**Rule 3 — Never remove or change existing functionality unless explicitly instructed.**
|
||||||
|
All changes must be additive or corrective. Existing routes, function names, variable names, and model fields must be preserved unless a change is explicitly requested.
|
||||||
|
|
||||||
|
**Rule 4 — Always audit the full extends chain in every template you create or modify.**
|
||||||
|
Every `{% extends "..." %}` path must resolve relative to the Flask app's `template_folder` root. Before delivering any template, verify the parent template exists at that exact path. Correct convention for this project: `admin/layouts/base.html` for admin templates, `tenant/layouts/base.html` for tenant templates.
|
||||||
|
|
||||||
|
**Rule 5 — Never leave `url_for()` calls pointing at unregistered or stub-only endpoints.**
|
||||||
|
If a blueprint is registered as a stub (no routes yet), all `url_for()` references to its endpoints in templates must be replaced with `'#'` or guarded with `{% if %}` until the route is implemented. A `BuildError` from an unregistered endpoint is a 500 error in production.
|
||||||
|
|
||||||
|
**Rule 6 — Always add logging for create, edit, and delete actions.**
|
||||||
|
Every route that creates, edits, deletes, or changes the status of a record must call `logger.info()` or `logger.warning()` with the action, relevant IDs, and actor context.
|
||||||
|
|
||||||
|
**Rule 7 — All audit log values must be JSON-serialisable.**
|
||||||
|
When passing data to `AuditLog.log()` or any JSON column, always convert `datetime`/`date` to ISO-8601 strings via `.isoformat()` and `Decimal` to `float`. Use `model_to_dict()` from `app/admin/utils.py` — never pass raw ORM objects.
|
||||||
|
|
||||||
|
**Rule 8 — Use absolute paths for `template_folder` and `static_folder` in app factories.**
|
||||||
|
Never use relative paths (`"../templates"`) in Flask app factories. Flask resolves relative paths against the package directory, not the project root, which differs depending on where Gunicorn is started. Always use `os.path.dirname(os.path.abspath(__file__))` as the anchor.
|
||||||
|
|
||||||
|
**Rule 9 — Test all fixes before delivering them.**
|
||||||
|
Every fix must be validated by a programmatic test (import test, render test, or unit test) before being packaged for delivery. Do not ship code that has not been executed in the container.
|
||||||
|
|
||||||
|
**Rule 10 — Pack files when more than 5 files are changed.**
|
||||||
|
When a fix touches more than 5 files, compress them into a ZIP for delivery. Always include only the changed files — not the entire project.
|
||||||
|
|
||||||
|
**Rule 11 — Never call Python builtins or stdlib objects inside Jinja2 `{{ }}` expressions.**
|
||||||
|
Jinja2 does not have access to Python's standard library or builtins such as `set()`, `dict()`, `list()`, `int()`, `float()`, `str()`, `len()`, `sorted()`, `enumerate()`, `zip()`, `timedelta`, `datetime`, `date`. Any computation involving these must be done in the route and passed as a named template variable. Violations produce `UndefinedError` in production. Examples of what NOT to do: `(assigned_ids or set())`, `(view_date - timedelta(days=1))`. Correct approach: compute `prev_date = view_date - timedelta(days=1)` in the route and pass `prev_date=prev_date` to `render_template`.
|
||||||
|
|
||||||
|
**Rule 12 — Always pass every variable a template references, on every code path.**
|
||||||
|
Every `render_template` call for a given template must supply the same set of variables — including early-return error paths. If the template uses `assigned_ids`, every `render_template("...form.html", ...)` call in that view must include `assigned_ids=...`. Missing variables on error-return paths produce `UndefinedError` only when that path is hit, making them hard to catch in testing.
|
||||||
|
When a fix touches more than 5 files, compress them into a ZIP for delivery. Always include only the changed files — not the entire project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Phase 1 — Implementation Notes
|
## Phase 1 — Implementation Notes
|
||||||
|
|
||||||
The following decisions and resolutions were made during Phase 1 scaffold implementation.
|
The following decisions and resolutions were made during Phase 1 scaffold implementation.
|
||||||
@@ -917,6 +1004,16 @@ Both portal apps set `template_folder` to the project-root `templates/` director
|
|||||||
- Admin templates: `"admin/auth/login.html"`, `{% extends "admin/layouts/base.html" %}`
|
- Admin templates: `"admin/auth/login.html"`, `{% extends "admin/layouts/base.html" %}`
|
||||||
- Tenant templates: `"tenant/auth/login.html"`, `{% extends "tenant/layouts/base.html" %}`
|
- Tenant templates: `"tenant/auth/login.html"`, `{% extends "tenant/layouts/base.html" %}`
|
||||||
|
|
||||||
|
### Template Authoring Checklist
|
||||||
|
|
||||||
|
Every new template must satisfy all of the following before delivery:
|
||||||
|
|
||||||
|
1. File lives in `templates/` (project root) — not `app/templates/`
|
||||||
|
2. First line is `{% extends "admin/layouts/base.html" %}` (admin) or `{% extends "tenant/layouts/base.html" %}` (tenant)
|
||||||
|
3. Every `url_for('blueprint.endpoint')` call references a route that is actually registered (not a stub). Stubs use `'#'`
|
||||||
|
4. No bare `{{ expression }}` outside an HTML tag
|
||||||
|
5. No `{{ csrf_token() }}` rendered as standalone text — only inside `value="..."` of a hidden input
|
||||||
|
|
||||||
### Phase 2 Blueprint Stubs
|
### Phase 2 Blueprint Stubs
|
||||||
All Phase 2+ blueprints are registered as stubs (blueprint object only, no routes) so the app boots cleanly. The admin portal base template references to `url_for('tenants.index')`, `url_for('system_users.index')` etc. are replaced with `#` until Phase 2 routes are implemented.
|
All Phase 2+ blueprints are registered as stubs (blueprint object only, no routes) so the app boots cleanly. The admin portal base template references to `url_for('tenants.index')`, `url_for('system_users.index')` etc. are replaced with `#` until Phase 2 routes are implemented.
|
||||||
|
|
||||||
@@ -953,3 +1050,6 @@ All Phase 2+ blueprints are registered as stubs (blueprint object only, no route
|
|||||||
| 25 | JWT blocklist table location | `jwt_blocklist` defined in `platform.py` (not `salon.py`) — it is a platform-level concern shared across all tenants. DB-persisted (not in-memory) to survive Gunicorn worker restarts. |
|
| 25 | JWT blocklist table location | `jwt_blocklist` defined in `platform.py` (not `salon.py`) — it is a platform-level concern shared across all tenants. DB-persisted (not in-memory) to survive Gunicorn worker restarts. |
|
||||||
| 26 | Template path convention | `template_folder` points to project-root `templates/`. All `render_template()` calls and `{% extends %}` use full paths: `"admin/auth/login.html"`, `"admin/layouts/base.html"`, `"tenant/auth/login.html"`, etc. |
|
| 26 | Template path convention | `template_folder` points to project-root `templates/`. All `render_template()` calls and `{% extends %}` use full paths: `"admin/auth/login.html"`, `"admin/layouts/base.html"`, `"tenant/auth/login.html"`, etc. |
|
||||||
| 27 | Production domains | Admin portal: `posadmin.ngodanguyen.tech`. Tenant portal: `pos.ngodanguyen.tech`. Updated in `.env`, `nginx.conf`, and all documentation. |
|
| 27 | Production domains | Admin portal: `posadmin.ngodanguyen.tech`. Tenant portal: `pos.ngodanguyen.tech`. Updated in `.env`, `nginx.conf`, and all documentation. |
|
||||||
|
| 28 | Authoritative template tree | `templates/` (project root) is the **only** authoritative template tree. The app factories use `os.path.abspath(__file__)` to resolve this path absolutely. `app/templates/` exists as a mirror for legacy compatibility but `templates/` is the source of truth. All new templates go in `templates/` only. |
|
||||||
|
| 29 | Template extends convention | All admin templates extend `"admin/layouts/base.html"`. All tenant templates extend `"tenant/layouts/base.html"`. These paths are relative to the `templates/` root. Any other extends path (`"layouts/base.html"`, `"tenant/base.html"`, `"admin/base.html"`) is wrong and will produce a 500. |
|
||||||
|
| 30 | Phase 3+ nav links in base template | All Phase 3+ `url_for()` calls in `templates/tenant/layouts/base.html` are replaced with `'#'` until those blueprints are implemented. Leaving live `url_for()` calls pointing at stub-only blueprints causes `BuildError` 500s on every authenticated page load. |
|
||||||
|
|||||||
@@ -13,10 +13,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def create_admin_app(config_override=None):
|
def create_admin_app(config_override=None):
|
||||||
|
import os as _os
|
||||||
|
# Resolve paths relative to this file to avoid CWD-dependent behaviour.
|
||||||
|
# app/admin/__init__.py → ../../templates = <project_root>/templates
|
||||||
|
_here = _os.path.dirname(_os.path.abspath(__file__))
|
||||||
|
_root = _os.path.normpath(_os.path.join(_here, "..", ".."))
|
||||||
flask_app = Flask(
|
flask_app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
template_folder="../templates",
|
template_folder=_os.path.join(_root, "templates"),
|
||||||
static_folder="../static/admin",
|
static_folder=_os.path.join(_root, "static", "admin"),
|
||||||
static_url_path="/static",
|
static_url_path="/static",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -51,5 +51,21 @@ def log_admin_action(action, target_type=None, target_id=None, before=None, afte
|
|||||||
|
|
||||||
|
|
||||||
def model_to_dict(obj, fields):
|
def model_to_dict(obj, fields):
|
||||||
"""Return a plain dict of the named fields from an ORM object (for audit before/after)."""
|
"""
|
||||||
return {f: getattr(obj, f, None) for f in fields}
|
Return a plain JSON-serialisable dict of the named fields from an ORM object.
|
||||||
|
Converts datetime -> ISO-8601 string, Decimal -> float, all others pass through.
|
||||||
|
Safe to use directly as AuditLog before_json / after_json values.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for f in fields:
|
||||||
|
val = getattr(obj, f, None)
|
||||||
|
if isinstance(val, (datetime.datetime, datetime.date)):
|
||||||
|
result[f] = val.isoformat()
|
||||||
|
elif isinstance(val, Decimal):
|
||||||
|
result[f] = float(val)
|
||||||
|
else:
|
||||||
|
result[f] = val
|
||||||
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
app/scheduler_jobs.py
|
||||||
|
APScheduler job definitions for the tenant portal.
|
||||||
|
Registered in create_tenant_app() after scheduler.init_app().
|
||||||
|
|
||||||
|
Jobs:
|
||||||
|
- send_appointment_reminders every 5 minutes
|
||||||
|
- send_review_requests every 10 minutes
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def send_appointment_reminders(app):
|
||||||
|
"""
|
||||||
|
Scan appointment_reminders for pending reminders due to be sent.
|
||||||
|
Sends email via Flask-Mail and marks status = 'sent' or 'failed'.
|
||||||
|
Runs every 5 minutes.
|
||||||
|
"""
|
||||||
|
with app.app_context():
|
||||||
|
from app.extensions import db, mail
|
||||||
|
from app.models.salon import AppointmentReminder, Appointment, Customer
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
due = AppointmentReminder.query.filter_by(status="pending").filter(
|
||||||
|
AppointmentReminder.scheduled_for <= now
|
||||||
|
).limit(50).all()
|
||||||
|
|
||||||
|
if not due:
|
||||||
|
return
|
||||||
|
|
||||||
|
sent = failed = 0
|
||||||
|
for reminder in due:
|
||||||
|
appt = Appointment.query.get(reminder.appointment_id)
|
||||||
|
if not appt or appt.status in ("cancelled", "no_show"):
|
||||||
|
reminder.status = "cancelled"
|
||||||
|
continue
|
||||||
|
|
||||||
|
customer = Customer.query.get(appt.customer_id) if appt.customer_id else None
|
||||||
|
if not customer or not customer.email:
|
||||||
|
# No email — mark sent so we don't retry endlessly
|
||||||
|
reminder.status = "sent"
|
||||||
|
reminder.sent_at = now
|
||||||
|
sent += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_mail import Message
|
||||||
|
from app.models.platform import Tenant
|
||||||
|
tenant = Tenant.query.get(appt.tenant_id)
|
||||||
|
tenant_name = tenant.name if tenant else "Your salon"
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
subject=f"Appointment Reminder — {tenant_name}",
|
||||||
|
recipients=[customer.email],
|
||||||
|
body=(
|
||||||
|
f"Hi {customer.name},\n\n"
|
||||||
|
f"This is a reminder of your upcoming appointment:\n"
|
||||||
|
f"Date: {appt.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
|
||||||
|
f"Service: {appt.service.name if appt.service else 'N/A'}\n\n"
|
||||||
|
f"See you soon!\n{tenant_name}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
|
reminder.status = "sent"
|
||||||
|
reminder.sent_at = now
|
||||||
|
sent += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Reminder send failed id=%s: %s", reminder.id, exc)
|
||||||
|
reminder.status = "failed"
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
if sent or failed:
|
||||||
|
logger.info("Appointment reminders: sent=%d failed=%d", sent, failed)
|
||||||
|
|
||||||
|
|
||||||
|
def send_review_requests(app):
|
||||||
|
"""
|
||||||
|
Find completed transactions where review_request_sent_at IS NULL and
|
||||||
|
the transaction is old enough (per tenant setting review_request_delay_minutes).
|
||||||
|
Sends review email with smart routing:
|
||||||
|
- rating ≥ 4 → shows Google/Facebook/Yelp links
|
||||||
|
- rating < 4 → silent internal feedback only
|
||||||
|
One send per transaction enforced by review_request_sent_at.
|
||||||
|
Runs every 10 minutes.
|
||||||
|
"""
|
||||||
|
with app.app_context():
|
||||||
|
from app.extensions import db, mail
|
||||||
|
from app.models.salon import Transaction, Customer, TenantSetting, CheckoutReview
|
||||||
|
from app.models.platform import Tenant
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Find eligible transactions: completed, not voided, no review sent yet
|
||||||
|
candidates = Transaction.query.filter(
|
||||||
|
Transaction.voided_at.is_(None),
|
||||||
|
Transaction.review_request_sent_at.is_(None),
|
||||||
|
Transaction.customer_id.isnot(None),
|
||||||
|
).limit(100).all()
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
for txn in candidates:
|
||||||
|
# Get tenant-specific delay (default 60 minutes)
|
||||||
|
delay_setting = TenantSetting.query.filter_by(
|
||||||
|
tenant_id=txn.tenant_id,
|
||||||
|
setting_key="review_request_delay_minutes",
|
||||||
|
).first()
|
||||||
|
delay_minutes = int(delay_setting.setting_value or 60) if delay_setting else 60
|
||||||
|
eligible_after = txn.created_at.replace(tzinfo=timezone.utc) + timedelta(minutes=delay_minutes)
|
||||||
|
|
||||||
|
if now < eligible_after:
|
||||||
|
continue
|
||||||
|
|
||||||
|
customer = Customer.query.get(txn.customer_id)
|
||||||
|
if not customer or not customer.email:
|
||||||
|
# Mark sent to avoid retrying
|
||||||
|
txn.review_request_sent_at = now
|
||||||
|
continue
|
||||||
|
|
||||||
|
tenant = Tenant.query.get(txn.tenant_id)
|
||||||
|
tenant_name = tenant.name if tenant else "Your salon"
|
||||||
|
|
||||||
|
# Fetch review settings
|
||||||
|
def _get_setting(key):
|
||||||
|
s = TenantSetting.query.filter_by(
|
||||||
|
tenant_id=txn.tenant_id, setting_key=key).first()
|
||||||
|
return s.setting_value if s else None
|
||||||
|
|
||||||
|
google_url = _get_setting("google_review_url")
|
||||||
|
yelp_url = _get_setting("yelp_review_url")
|
||||||
|
facebook_url = _get_setting("facebook_review_url")
|
||||||
|
|
||||||
|
# Check if a review already submitted
|
||||||
|
existing_review = CheckoutReview.query.filter_by(
|
||||||
|
transaction_id=txn.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
if existing_review and existing_review.rating >= 4:
|
||||||
|
# High rating — encourage public review
|
||||||
|
links = []
|
||||||
|
if google_url:
|
||||||
|
links.append(f"Google: {google_url}")
|
||||||
|
if yelp_url:
|
||||||
|
links.append(f"Yelp: {yelp_url}")
|
||||||
|
if facebook_url:
|
||||||
|
links.append(f"Facebook: {facebook_url}")
|
||||||
|
|
||||||
|
body = (
|
||||||
|
f"Hi {customer.name},\n\n"
|
||||||
|
f"Thank you for visiting {tenant_name}! We're so glad you enjoyed your visit.\n"
|
||||||
|
f"Would you mind sharing your experience online?\n\n"
|
||||||
|
+ ("\n".join(links) if links else "(No review links configured)")
|
||||||
|
+ f"\n\nThank you!\n{tenant_name}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No review yet or low rating — generic thank you only
|
||||||
|
body = (
|
||||||
|
f"Hi {customer.name},\n\n"
|
||||||
|
f"Thank you for visiting {tenant_name}! We hope to see you again soon.\n\n"
|
||||||
|
f"{tenant_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
subject=f"Thank you for visiting {tenant_name}!",
|
||||||
|
recipients=[customer.email],
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
|
txn.review_request_sent_at = now
|
||||||
|
sent += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Review request failed txn=%s: %s", txn.id, exc)
|
||||||
|
# Don't mark sent_at — will retry next cycle
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
if sent:
|
||||||
|
logger.info("Review requests sent: %d", sent)
|
||||||
@@ -104,3 +104,12 @@ def validate_setting_key(key: str) -> bool:
|
|||||||
"""Return True if key is a valid setting key (alphanumeric, underscores, dots)."""
|
"""Return True if key is a valid setting key (alphanumeric, underscores, dots)."""
|
||||||
import re
|
import re
|
||||||
return bool(key and re.match(r'^[a-zA-Z0-9_.]+$', key) and len(key) <= 100)
|
return bool(key and re.match(r'^[a-zA-Z0-9_.]+$', key) and len(key) <= 100)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_passcode(passcode: str, min_len: int = 4, max_len: int = 6) -> bool:
|
||||||
|
"""Return True if passcode is digits only and within the allowed length range."""
|
||||||
|
return (
|
||||||
|
bool(passcode)
|
||||||
|
and passcode.isdigit()
|
||||||
|
and min_len <= len(passcode) <= max_len
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Platform Analytics{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="fw-bold mb-4"><i class="bi bi-bar-chart me-2"></i>Platform Analytics</h4>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
{% set kpis = [
|
||||||
|
('Total Tenants', stats.total_tenants, 'building', 'primary'),
|
||||||
|
('Active', stats.active_count, 'check-circle', 'success'),
|
||||||
|
('Trial', stats.trial_count, 'hourglass-split', 'info'),
|
||||||
|
('Suspended/Cancelled', stats.suspended_count + stats.cancelled_count, 'x-circle', 'danger'),
|
||||||
|
('MRR', '$' ~ "%.2f"|format(stats.mrr), 'currency-dollar', 'success'),
|
||||||
|
('Revenue (30d)', '$' ~ "%.2f"|format(stats.revenue_30d), 'graph-up', 'primary'),
|
||||||
|
('New Tenants (30d)', stats.new_tenants_30d, 'person-plus', 'secondary'),
|
||||||
|
('Conversions (30d)', stats.converted_30d, 'arrow-up-circle', 'success'),
|
||||||
|
('Churn (30d)', stats.churned_30d, 'arrow-down-circle', 'danger'),
|
||||||
|
] %}
|
||||||
|
{% for label, value, icon, color in kpis %}
|
||||||
|
<div class="col-md-4 col-lg-3">
|
||||||
|
<div class="card shadow-sm border-0">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<div class="rounded-circle bg-{{ color }} bg-opacity-10 p-3">
|
||||||
|
<i class="bi bi-{{ icon }} fs-4 text-{{ color }}"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fs-5 fw-bold">{{ value }}</div>
|
||||||
|
<div class="text-muted small">{{ label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Tenants by plan -->
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header fw-semibold">Active Tenants by Plan</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for plan_name, count in stats.plan_counts %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span>{{ plan_name }}</span>
|
||||||
|
<span class="badge bg-primary rounded-pill">{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small">No data yet.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trials expiring soon -->
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-header fw-semibold text-warning-emphasis">
|
||||||
|
<i class="bi bi-hourglass me-1"></i>Trials Expiring in 3 Days
|
||||||
|
</div>
|
||||||
|
{% if expiring_soon %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Tenant</th><th>Plan</th><th>Trial Ends</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in expiring_soon %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('tenants.detail', tenant_id=t.id) }}">{{ t.name }}</a></td>
|
||||||
|
<td>{{ t.plan.name if t.plan else '—' }}</td>
|
||||||
|
<td class="text-warning small fw-semibold">{{ t.trial_ends_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('tenants.set_status', tenant_id=t.id) }}"
|
||||||
|
class="btn btn-success btn-sm" style="display:inline-block;">Convert</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-muted small">No trials expiring in the next 3 days.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Audit Log{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-journal-text me-2"></i>Audit Log</h4>
|
||||||
|
<a href="{{ url_for('audit_log.export_csv', **filters) }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-download me-1"></i>Export CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<form method="GET" class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">Actor</label>
|
||||||
|
<select class="form-select form-select-sm" name="actor_id">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for u in system_users %}
|
||||||
|
<option value="{{ u.id }}" {{ 'selected' if filters.actor_id == u.id }}>{{ u.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">Action prefix</label>
|
||||||
|
<input type="text" class="form-control form-control-sm font-monospace" name="action"
|
||||||
|
value="{{ filters.action }}" placeholder="e.g. tenant.">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">Target type</label>
|
||||||
|
<input type="text" class="form-control form-control-sm font-monospace" name="target_type"
|
||||||
|
value="{{ filters.target_type }}" placeholder="tenant, plan…">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">From</label>
|
||||||
|
<input type="date" class="form-control form-control-sm" name="date_from" value="{{ filters.date_from }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small mb-1">To</label>
|
||||||
|
<input type="date" class="form-control form-control-sm" name="date_to" value="{{ filters.date_to }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-dark btn-sm w-100">Filter</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0 font-monospace" style="font-size:0.82rem;">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th><th>IP</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in entries %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">{{ e.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td>{{ e.actor_type }}:{{ e.actor_id }}</td>
|
||||||
|
<td>{{ e.action }}</td>
|
||||||
|
<td>{{ (e.target_type ~ ':' ~ e.target_id) if e.target_type else '—' }}</td>
|
||||||
|
<td class="text-muted">{{ e.ip_address or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-4">No entries match your filter.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<nav class="mt-3">
|
||||||
|
<ul class="pagination pagination-sm">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('audit_log.index', page=pagination.prev_num, **filters) }}">Previous</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">Page {{ pagination.page }} of {{ pagination.pages }}</span></li>
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('audit_log.index', page=pagination.next_num, **filters) }}">Next</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,26 +1,44 @@
|
|||||||
{% extends "admin/base.html" %}
|
{% extends "admin/layouts/base.html" %}
|
||||||
{% block title %}Admin Login{% endblock %}
|
{% block title %}Admin Login{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
<div class="col-md-4">
|
<div class="card shadow" style="width: 400px;">
|
||||||
<div class="card shadow-sm mt-5">
|
<div class="card-body p-4">
|
||||||
<div class="card-body p-4">
|
<div class="text-center mb-4">
|
||||||
<h4 class="card-title mb-4 text-center">Admin Portal</h4>
|
<i class="bi bi-shield-lock-fill fs-1 text-dark"></i>
|
||||||
{% if error %}
|
<h4 class="mt-2 fw-bold">Admin Portal</h4>
|
||||||
<div class="alert alert-danger">{{ error }}</div>
|
<p class="text-muted small">Restricted access</p>
|
||||||
{% endif %}
|
</div>
|
||||||
<form method="POST" action="{{ url_for('admin_auth.login') }}">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
{% if error %}
|
||||||
<div class="mb-3">
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
<label class="form-label">Email</label>
|
{% endif %}
|
||||||
<input type="email" name="email" class="form-control" required autofocus>
|
|
||||||
</div>
|
<form method="POST" action="{{ url_for('admin_auth.login') }}" novalidate>
|
||||||
<div class="mb-3">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<label class="form-label">Password</label>
|
|
||||||
<input type="password" name="password" class="form-control" required>
|
<div class="mb-3">
|
||||||
</div>
|
<label for="email" class="form-label">Email address</label>
|
||||||
<button type="submit" class="btn btn-dark w-100">Sign In</button>
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
</form>
|
autocomplete="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid mt-4">
|
||||||
|
<button type="submit" class="btn btn-dark">Sign In</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="{{ url_for('admin_auth.password_reset_request') }}" class="text-muted small">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Set New Password{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
|
<div class="card shadow" style="width: 420px;">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="fw-bold mb-3">Set New Password</h5>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">New password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
|
autocomplete="new-password" required autofocus>
|
||||||
|
<div class="form-text">Min 10 characters. Must include uppercase, lowercase, and a digit.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label">Confirm password</label>
|
||||||
|
<input type="password" class="form-control" id="confirm_password"
|
||||||
|
name="confirm_password" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-dark">Update Password</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Reset Password{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
|
<div class="card shadow" style="width: 400px;">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h5 class="fw-bold mb-3">Reset Admin Password</h5>
|
||||||
|
<p class="text-muted small">Enter your registered email address and we'll send a reset link.</p>
|
||||||
|
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email address</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-dark">Send Reset Link</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="{{ url_for('admin_auth.login') }}" class="text-muted small">Back to login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Add Billing Entry{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><h5 class="mb-0">Add Billing Entry — {{ tenant.name }}</h5></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Amount ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="amount" min="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" class="form-control" name="description" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Invoice Reference <small class="text-muted">(optional)</small></label>
|
||||||
|
<input type="text" class="form-control" name="invoice_ref">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Paid At <small class="text-muted">(optional)</small></label>
|
||||||
|
<input type="datetime-local" class="form-control" name="paid_at">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-dark">Save</button>
|
||||||
|
<a href="{{ url_for('billing.tenant_billing', tenant_id=tenant.id) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Billing History{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="fw-bold mb-4"><i class="bi bi-receipt me-2"></i>Billing History</h4>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr><th>Date</th><th>Tenant</th><th>Amount</th><th>Description</th><th>Invoice Ref</th><th>Paid</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in entries %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{{ b.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('tenants.detail', tenant_id=b.tenant_id) }}" class="text-decoration-none">
|
||||||
|
{{ b.tenant.name if b.tenant else b.tenant_id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="fw-semibold">${{ "%.2f"|format(b.amount) }}</td>
|
||||||
|
<td>{{ b.description }}</td>
|
||||||
|
<td class="text-muted small">{{ b.invoice_ref or '—' }}</td>
|
||||||
|
<td class="text-muted small">{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-4">No billing entries yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Billing — {{ tenant.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0">Billing — {{ tenant.name }}</h4>
|
||||||
|
<a href="{{ url_for('billing.add_entry', tenant_id=tenant.id) }}" class="btn btn-dark btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Add Entry
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr><th>Date</th><th>Amount</th><th>Description</th><th>Invoice Ref</th><th>Paid</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in entries %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{{ b.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td class="fw-semibold">${{ "%.2f"|format(b.amount) }}</td>
|
||||||
|
<td>{{ b.description }}</td>
|
||||||
|
<td class="text-muted small">{{ b.invoice_ref or '—' }}</td>
|
||||||
|
<td class="text-muted small">{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-4">No entries yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('tenants.detail', tenant_id=tenant.id) }}" class="btn btn-outline-secondary btn-sm mt-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Tenant
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Admin Portal{% endblock %} — Salon POS</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand fw-bold" href="#">
|
||||||
|
<i class="bi bi-shield-lock-fill me-2"></i>Admin Portal
|
||||||
|
</a>
|
||||||
|
<div class="navbar-nav ms-auto">
|
||||||
|
<span class="navbar-text text-light me-3">{{ current_user.name }}</span>
|
||||||
|
<a class="nav-link text-warning" href="{{ url_for('admin_auth.logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="col-md-2 d-none d-md-block bg-white border-end vh-100 pt-3 position-sticky top-0">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('tenants') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('tenants.index') }}">
|
||||||
|
<i class="bi bi-building me-2"></i>Tenants
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('system_users') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('system_users.index') }}">
|
||||||
|
<i class="bi bi-people me-2"></i>System Users
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('plans') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('plans.index') }}">
|
||||||
|
<i class="bi bi-card-list me-2"></i>Plans
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('billing') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('billing.index') }}">
|
||||||
|
<i class="bi bi-receipt me-2"></i>Billing
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('audit_log') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('audit_log.index') }}">
|
||||||
|
<i class="bi bi-journal-text me-2"></i>Audit Log
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('analytics') %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('analytics.index') }}">
|
||||||
|
<i class="bi bi-bar-chart me-2"></i>Analytics
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="col-md-10 ms-sm-auto px-4 py-3">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mt-2" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Plan{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><h5 class="mb-0">{{ 'Edit' if mode == 'edit' else 'New' }} Plan</h5></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Plan Name</label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ plan.name if plan else '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Monthly Price ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="price_monthly"
|
||||||
|
value="{{ plan.price_monthly if plan else '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Max Staff <small class="text-muted">(blank = unlimited)</small></label>
|
||||||
|
<input type="number" class="form-control" name="max_staff"
|
||||||
|
value="{{ plan.max_staff if plan and plan.max_staff else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Max Locations <small class="text-muted">(blank = unlimited)</small></label>
|
||||||
|
<input type="number" class="form-control" name="max_locations"
|
||||||
|
value="{{ plan.max_locations if plan and plan.max_locations else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label fw-semibold">Feature Flags</label>
|
||||||
|
<div class="row g-2">
|
||||||
|
{% for flag in all_features %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="feature_{{ flag }}" value="1"
|
||||||
|
id="f_{{ flag }}"
|
||||||
|
{{ 'checked' if plan and (plan.features_json or {}).get(flag) }}>
|
||||||
|
<label class="form-check-label small" for="f_{{ flag }}">{{ flag }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||||
|
id="is_active" {{ 'checked' if not plan or plan.is_active }}>
|
||||||
|
<label class="form-check-label" for="is_active">Active</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<button type="submit" class="btn btn-dark">Save</button>
|
||||||
|
<a href="{{ url_for('plans.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Plans{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-card-list me-2"></i>Subscription Plans</h4>
|
||||||
|
<a href="{{ url_for('plans.create') }}" class="btn btn-dark btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New Plan
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for plan in plans %}
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm h-100 {{ '' if plan.is_active else 'opacity-50' }}">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>{{ plan.name }}</strong>
|
||||||
|
<span class="badge {{ 'bg-success' if plan.is_active else 'bg-secondary' }}">
|
||||||
|
{{ 'Active' if plan.is_active else 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="fw-bold">${{ "%.2f"|format(plan.price_monthly) }}<small class="fs-6 text-muted">/mo</small></h3>
|
||||||
|
<p class="text-muted small mb-2">
|
||||||
|
Staff: {{ plan.max_staff or 'Unlimited' }} •
|
||||||
|
Locations: {{ plan.max_locations or 'Unlimited' }}
|
||||||
|
</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
{% for flag, enabled in (plan.features_json or {}).items() %}
|
||||||
|
<span class="badge {{ 'bg-primary' if enabled else 'bg-light text-muted' }} me-1 mb-1" style="font-size:0.7rem;">
|
||||||
|
{{ flag }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex gap-2">
|
||||||
|
<a href="{{ url_for('plans.edit', plan_id=plan.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<form method="POST" action="{{ url_for('plans.toggle_active', plan_id=plan.id) }}" class="d-inline"
|
||||||
|
onsubmit="return confirm('Toggle plan status?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-{{ 'danger' if plan.is_active else 'success' }} btn-sm">
|
||||||
|
{{ 'Deactivate' if plan.is_active else 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Set Override — {{ tenant.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><h5 class="mb-0">Set Setting Override — {{ tenant.name }}</h5></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Setting Key</label>
|
||||||
|
<input type="text" class="form-control font-monospace" name="setting_key"
|
||||||
|
placeholder="e.g. feature_marketing or business_hours" required>
|
||||||
|
<div class="form-text">Alphanumeric, underscores, dots. Use <code>feature_FLAG</code> to force-enable/disable a plan feature.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Value</label>
|
||||||
|
<input type="text" class="form-control" name="setting_value" required>
|
||||||
|
<div class="form-text">For feature flags: <code>true</code> or <code>false</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Note <small class="text-muted">(optional)</small></label>
|
||||||
|
<input type="text" class="form-control" name="note" placeholder="Reason for override">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-warning">Set Override</button>
|
||||||
|
<a href="{{ url_for('settings_override.tenant_overrides', tenant_id=tenant.id) }}"
|
||||||
|
class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Settings Overrides — {{ tenant.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0">Overrides — {{ tenant.name }}</h4>
|
||||||
|
<a href="{{ url_for('settings_override.set_override', tenant_id=tenant.id) }}" class="btn btn-warning btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Set Override
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if active %}
|
||||||
|
<div class="card shadow-sm mb-4 border-warning">
|
||||||
|
<div class="card-header bg-warning-subtle fw-semibold">Active Overrides</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Key</th><th>Value</th><th>Set by</th><th>Set at</th><th>Note</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ov in active %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ ov.setting_key }}</code></td>
|
||||||
|
<td>{{ ov.setting_value }}</td>
|
||||||
|
<td class="small">{{ ov.admin.name if ov.admin else ov.overridden_by }}</td>
|
||||||
|
<td class="small text-muted">{{ ov.overridden_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td class="small text-muted">{{ ov.note or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="{{ url_for('settings_override.lift_override', override_id=ov.id) }}"
|
||||||
|
onsubmit="return confirm('Lift this override?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-warning btn-sm">Lift</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-light">No active overrides for this tenant.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if history %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold text-muted">Override History</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Key</th><th>Value</th><th>Set at</th><th>Lifted at</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ov in history %}
|
||||||
|
<tr class="text-muted">
|
||||||
|
<td><code>{{ ov.setting_key }}</code></td>
|
||||||
|
<td>{{ ov.setting_value }}</td>
|
||||||
|
<td class="small">{{ ov.overridden_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td class="small">{{ ov.lifted_at.strftime('%Y-%m-%d %H:%M') if ov.lifted_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ url_for('tenants.detail', tenant_id=tenant.id) }}" class="btn btn-outline-secondary btn-sm mt-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Tenant
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} System User{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><h5 class="mb-0">{{ 'Edit' if mode == 'edit' else 'New' }} System User</h5></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" name="email"
|
||||||
|
value="{{ user.email if user else '' }}"
|
||||||
|
{{ 'readonly' if mode == 'edit' else '' }} required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ user.name if user else '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<select class="form-select" name="role">
|
||||||
|
<option value="superadmin" {{ 'selected' if user and user.role == 'superadmin' }}>superadmin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% if mode == 'create' %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" name="password" autocomplete="new-password" required>
|
||||||
|
<div class="form-text">Min 10 chars, uppercase, lowercase, digit.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Confirm Password</label>
|
||||||
|
<input type="password" class="form-control" name="confirm_password" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||||
|
id="is_active" {{ 'checked' if user and user.is_active }}>
|
||||||
|
<label class="form-check-label" for="is_active">Active</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-dark">Save</button>
|
||||||
|
<a href="{{ url_for('system_users.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}System Users{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-people me-2"></i>System Users</h4>
|
||||||
|
<a href="{{ url_for('system_users.create') }}" class="btn btn-dark btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New User
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Email</th><th>Name</th><th>Role</th><th>Status</th>
|
||||||
|
<th>Last Login</th><th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ u.email }}</td>
|
||||||
|
<td>{{ u.name }}</td>
|
||||||
|
<td><span class="badge bg-secondary">{{ u.role }}</span></td>
|
||||||
|
<td>
|
||||||
|
{% if u.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.is_locked() %}
|
||||||
|
<span class="badge bg-warning text-dark">Locked</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
{{ u.last_login_at.strftime('%Y-%m-%d %H:%M') if u.last_login_at else 'Never' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('system_users.edit', user_id=u.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<form method="POST" action="{{ url_for('system_users.toggle_active', user_id=u.id) }}" class="d-inline"
|
||||||
|
onsubmit="return confirm('Toggle active status?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-{{ 'danger' if u.is_active else 'success' }} btn-sm">
|
||||||
|
{{ 'Deactivate' if u.is_active else 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ url_for('system_users.force_password_reset', user_id=u.id) }}" class="d-inline"
|
||||||
|
onsubmit="return confirm('Send password reset email?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-warning btn-sm">Reset PW</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-4">No system users found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}{{ tenant.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="fw-bold mb-0">{{ tenant.name }}
|
||||||
|
<code class="fs-6 ms-2">{{ tenant.slug }}</code>
|
||||||
|
{% if tenant.is_demo %}<span class="badge bg-info ms-2">Demo</span>{% endif %}
|
||||||
|
</h4>
|
||||||
|
<span class="badge bg-{{ {'active':'success','trial':'primary','suspended':'warning','cancelled':'danger'}.get(tenant.status,'secondary') }} mt-1">
|
||||||
|
{{ tenant.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('tenants.edit', tenant_id=tenant.id) }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Edit
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('billing.add_entry', tenant_id=tenant.id) }}" class="btn btn-outline-primary btn-sm">
|
||||||
|
<i class="bi bi-receipt me-1"></i>Add Billing Entry
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('settings_override.set_override', tenant_id=tenant.id) }}" class="btn btn-outline-warning btn-sm">
|
||||||
|
<i class="bi bi-sliders me-1"></i>Set Override
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm text-center py-3">
|
||||||
|
<div class="fs-4 fw-bold">{{ tenant.plan.name if tenant.plan else '—' }}</div>
|
||||||
|
<div class="text-muted small">Plan</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm text-center py-3">
|
||||||
|
<div class="fs-4 fw-bold">{{ locations|length }}</div>
|
||||||
|
<div class="text-muted small">Locations</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm text-center py-3">
|
||||||
|
<div class="fs-4 fw-bold">{{ users|length }}</div>
|
||||||
|
<div class="text-muted small">Portal Users</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card shadow-sm text-center py-3">
|
||||||
|
<div class="fs-4 fw-bold">{{ overrides|length }}</div>
|
||||||
|
<div class="text-muted small">Active Overrides</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status change -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header fw-semibold">Change Status</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('tenants.set_status', tenant_id=tenant.id) }}"
|
||||||
|
class="d-flex gap-2" onsubmit="return confirm('Change tenant status?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<select class="form-select form-select-sm w-auto" name="status">
|
||||||
|
{% for s in ['active', 'trial', 'suspended', 'cancelled'] %}
|
||||||
|
<option value="{{ s }}" {{ 'selected' if tenant.status == s }}>{{ s.capitalize() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-dark btn-sm">Apply</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active overrides -->
|
||||||
|
{% if overrides %}
|
||||||
|
<div class="card shadow-sm mb-3 border-warning">
|
||||||
|
<div class="card-header fw-semibold text-warning-emphasis bg-warning-subtle">
|
||||||
|
<i class="bi bi-sliders me-1"></i>Active Setting Overrides
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Key</th><th>Value</th><th>Set by</th><th>Note</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for ov in overrides %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ ov.setting_key }}</code></td>
|
||||||
|
<td>{{ ov.setting_value }}</td>
|
||||||
|
<td class="text-muted small">{{ ov.admin.name if ov.admin else ov.overridden_by }}</td>
|
||||||
|
<td class="text-muted small">{{ ov.note or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="{{ url_for('settings_override.lift_override', override_id=ov.id) }}"
|
||||||
|
onsubmit="return confirm('Lift this override?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-warning btn-sm">Lift</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Billing history -->
|
||||||
|
{% if billing %}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header fw-semibold">Recent Billing</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Date</th><th>Amount</th><th>Description</th><th>Invoice Ref</th><th>Paid</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for b in billing %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{{ b.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>${{ "%.2f"|format(b.amount) }}</td>
|
||||||
|
<td>{{ b.description }}</td>
|
||||||
|
<td class="text-muted small">{{ b.invoice_ref or '—' }}</td>
|
||||||
|
<td>{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ url_for('tenants.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Tenants
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New Tenant' }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header"><h5 class="mb-0">{{ 'Edit Tenant' if mode == 'edit' else 'New Tenant' }}</h5></div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Business Name</label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ tenant.name if tenant else '' }}" required>
|
||||||
|
</div>
|
||||||
|
{% if mode == 'create' %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Slug <small class="text-muted">(URL-friendly, e.g. my-salon)</small></label>
|
||||||
|
<input type="text" class="form-control" name="slug" pattern="[a-z0-9\-]+"
|
||||||
|
placeholder="lowercase-letters-and-hyphens" required>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Owner Email</label>
|
||||||
|
<input type="email" class="form-control" name="owner_email"
|
||||||
|
value="{{ tenant.owner_email if tenant else '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Plan</label>
|
||||||
|
<select class="form-select" name="plan_id" required>
|
||||||
|
<option value="">— Select a plan —</option>
|
||||||
|
{% for p in plans %}
|
||||||
|
<option value="{{ p.id }}" {{ 'selected' if tenant and tenant.plan_id == p.id }}>
|
||||||
|
{{ p.name }} (${{ "%.2f"|format(p.price_monthly) }}/mo)
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% if mode == 'create' %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Trial Duration (days)</label>
|
||||||
|
<input type="number" class="form-control" name="trial_days" value="14" min="0" max="90">
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h6 class="fw-semibold mb-3">Owner Account Password</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" name="owner_password" autocomplete="new-password" required>
|
||||||
|
<div class="form-text">Min 10 chars, uppercase, lowercase, digit.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Confirm Password</label>
|
||||||
|
<input type="password" class="form-control" name="confirm_password" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Subscription Expires At</label>
|
||||||
|
<input type="datetime-local" class="form-control" name="subscription_expires_at"
|
||||||
|
value="{{ tenant.subscription_expires_at.strftime('%Y-%m-%dT%H:%M') if tenant and tenant.subscription_expires_at else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_demo" value="1" id="is_demo"
|
||||||
|
{{ 'checked' if tenant and tenant.is_demo }}>
|
||||||
|
<label class="form-check-label" for="is_demo">Demo tenant (read-only)</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-dark">Save</button>
|
||||||
|
<a href="{{ url_for('tenants.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "admin/layouts/base.html" %}
|
||||||
|
{% block title %}Tenants{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-building me-2"></i>Tenants</h4>
|
||||||
|
<a href="{{ url_for('tenants.create') }}" class="btn btn-dark btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New Tenant
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<form method="GET" class="d-flex gap-2 align-items-center">
|
||||||
|
<label class="text-muted small me-1">Filter by status:</label>
|
||||||
|
{% for s in ['', 'active', 'trial', 'suspended', 'cancelled'] %}
|
||||||
|
<a href="{{ url_for('tenants.index', status=s) }}"
|
||||||
|
class="btn btn-sm {{ 'btn-dark' if status_filter == s else 'btn-outline-secondary' }}">
|
||||||
|
{{ s.capitalize() if s else 'All' }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Slug</th><th>Name</th><th>Plan</th><th>Status</th>
|
||||||
|
<th>Owner</th><th>Created</th><th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in tenants %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ t.slug }}</code>{% if t.is_demo %}<span class="badge bg-info ms-1">Demo</span>{% endif %}</td>
|
||||||
|
<td>{{ t.name }}</td>
|
||||||
|
<td>{{ t.plan.name if t.plan else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ {'active':'success','trial':'primary','suspended':'warning','cancelled':'danger'}.get(t.status,'secondary') }}">
|
||||||
|
{{ t.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">{{ t.owner_email }}</td>
|
||||||
|
<td class="text-muted small">{{ t.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('tenants.detail', tenant_id=t.id) }}" class="btn btn-outline-secondary btn-sm">View</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-4">No tenants found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Appointment{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Appointment</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Start Date & Time <span class="text-danger">*</span></label>
|
||||||
|
<input type="datetime-local" class="form-control" name="start_time" required
|
||||||
|
value="{{ appointment.start_time.strftime('%Y-%m-%dT%H:%M') if appointment else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Service</label>
|
||||||
|
<select class="form-select" name="service_id">
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{% for s in services %}
|
||||||
|
<option value="{{ s.id }}"
|
||||||
|
{{ 'selected' if appointment and appointment.service_id == s.id }}>
|
||||||
|
{{ s.name }} ({{ s.duration_min }} min)
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Customer</label>
|
||||||
|
<select class="form-select" name="customer_id">
|
||||||
|
<option value="">— Walk-in —</option>
|
||||||
|
{% for c in customers %}
|
||||||
|
<option value="{{ c.id }}"
|
||||||
|
{{ 'selected' if appointment and appointment.customer_id == c.id }}>
|
||||||
|
{{ c.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Staff</label>
|
||||||
|
<select class="form-select" name="staff_id">
|
||||||
|
<option value="">— Any —</option>
|
||||||
|
{% for s in staff_list %}
|
||||||
|
<option value="{{ s.id }}"
|
||||||
|
{{ 'selected' if appointment and appointment.staff_id == s.id }}>
|
||||||
|
{{ s.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_walk_in" value="1"
|
||||||
|
id="is_walk_in" {{ 'checked' if appointment and appointment.is_walk_in }}>
|
||||||
|
<label class="form-check-label" for="is_walk_in">Walk-in (no appointment)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea class="form-control" name="notes" rows="2">{{ appointment.notes if appointment else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('appointments.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Appointments{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-calendar3 me-2"></i>Appointments</h4>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<a href="{{ url_for('appointments.index', date=prev_date.isoformat()) }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">‹</a>
|
||||||
|
<input type="date" class="form-control form-control-sm" value="{{ view_date.isoformat() }}"
|
||||||
|
onchange="window.location='{{ url_for('appointments.index') }}?date='+this.value"
|
||||||
|
style="width:150px;">
|
||||||
|
<a href="{{ url_for('appointments.index', date=next_date.isoformat()) }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">›</a>
|
||||||
|
<a href="{{ url_for('appointments.create') }}" class="btn btn-primary btn-sm ms-2">+ New</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Time</th><th>Customer</th><th>Service</th><th>Staff</th><th>Status</th><th>Type</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for appt in appointments %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold text-nowrap">{{ appt.start_time.strftime('%I:%M %p') }}</td>
|
||||||
|
<td>{{ appt.customer.name if appt.customer else '<em class="text-muted">Walk-in</em>' | safe }}</td>
|
||||||
|
<td class="small">{{ appt.service.name if appt.service else '—' }}</td>
|
||||||
|
<td class="small">{{ appt.staff.name if appt.staff else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ {'pending':'warning','confirmed':'primary','in_progress':'info','completed':'success','cancelled':'danger','no_show':'dark'}.get(appt.status,'secondary') }}">
|
||||||
|
{{ appt.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ 'Walk-in' if appt.is_walk_in else 'Booked' }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('appointments.view', appt_id=appt.id) }}" class="btn btn-outline-secondary btn-sm">View</a>
|
||||||
|
{% if appt.status in ['pending','confirmed','in_progress'] %}
|
||||||
|
<a href="{{ url_for('pos.checkout') }}?appointment_id={{ appt.id }}" class="btn btn-success btn-sm">Checkout</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-4">No appointments for this day.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Appointment #{{ appointment.id }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0">Appointment #{{ appointment.id }}</h4>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('appointments.edit', appt_id=appointment.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
{% if appointment.status in ['pending','confirmed','in_progress'] %}
|
||||||
|
<a href="{{ url_for('pos.checkout') }}?appointment_id={{ appointment.id }}" class="btn btn-success btn-sm">
|
||||||
|
<i class="bi bi-cash-register me-1"></i>Checkout
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-4">Customer</dt><dd class="col-sm-8">{{ appointment.customer.name if appointment.customer else 'Walk-in' }}</dd>
|
||||||
|
<dt class="col-sm-4">Service</dt><dd class="col-sm-8">{{ appointment.service.name if appointment.service else '—' }}</dd>
|
||||||
|
<dt class="col-sm-4">Staff</dt><dd class="col-sm-8">{{ appointment.staff.name if appointment.staff else '—' }}</dd>
|
||||||
|
<dt class="col-sm-4">Start</dt><dd class="col-sm-8">{{ appointment.start_time.strftime('%Y-%m-%d %I:%M %p') }}</dd>
|
||||||
|
<dt class="col-sm-4">End</dt><dd class="col-sm-8">{{ appointment.end_time.strftime('%I:%M %p') if appointment.end_time else '—' }}</dd>
|
||||||
|
<dt class="col-sm-4">Type</dt><dd class="col-sm-8">{{ 'Walk-in' if appointment.is_walk_in else 'Booked' }}</dd>
|
||||||
|
<dt class="col-sm-4">Source</dt><dd class="col-sm-8">{{ appointment.rebook_source or 'manual' }}</dd>
|
||||||
|
{% if appointment.notes %}
|
||||||
|
<dt class="col-sm-4">Notes</dt><dd class="col-sm-8">{{ appointment.notes }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Update Status</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('appointments.set_status', appt_id=appointment.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<select class="form-select" name="status">
|
||||||
|
{% for s in ['pending','confirmed','in_progress','completed','cancelled','no_show'] %}
|
||||||
|
<option value="{{ s }}" {{ 'selected' if appointment.status == s }}>{{ s.replace('_',' ').title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="cancel-reason-field">
|
||||||
|
<label class="form-label small">Cancellation reason</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" name="reason"
|
||||||
|
value="{{ appointment.cancellation_reason or '' }}">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Update Status</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('appointments.index', date=appointment.start_time.date().isoformat()) }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Calendar
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Account Suspended{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 80vh;">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-lock-fill display-1 text-danger"></i>
|
||||||
|
<h3 class="mt-3">Account Suspended</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
Your account has been suspended. Please contact support to resolve any outstanding issues.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('tenant_auth.logout') }}" class="btn btn-outline-secondary mt-2">
|
||||||
|
Sign Out
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "tenant/base.html" %}
|
{% extends "tenant/layouts/base.html" %}
|
||||||
{% block title %}Account Cancelled{% endblock %}
|
{% block title %}Account Cancelled{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center mt-5">
|
<div class="row justify-content-center mt-5">
|
||||||
|
|||||||
@@ -1,32 +1,50 @@
|
|||||||
{% extends "tenant/base.html" %}
|
{% extends "tenant/layouts/base.html" %}
|
||||||
{% block title %}Sign In{% endblock %}
|
{% block title %}Sign In{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
<div class="col-md-4">
|
<div class="card shadow" style="width: 420px;">
|
||||||
<div class="card shadow-sm mt-5">
|
<div class="card-body p-4">
|
||||||
<div class="card-body p-4">
|
<div class="text-center mb-4">
|
||||||
<h4 class="card-title mb-4 text-center">Salon Login</h4>
|
<i class="bi bi-scissors fs-1 text-primary"></i>
|
||||||
{% if error %}
|
<h4 class="mt-2 fw-bold">Salon POS</h4>
|
||||||
<div class="alert alert-danger">{{ error }}</div>
|
<p class="text-muted small">Sign in to your account</p>
|
||||||
{% endif %}
|
</div>
|
||||||
<form method="POST" action="{{ url_for('tenant_auth.login') }}">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
{% if error %}
|
||||||
<div class="mb-3">
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
<label class="form-label">Email</label>
|
{% endif %}
|
||||||
<input type="email" name="email" class="form-control" required autofocus>
|
|
||||||
</div>
|
<form method="POST" action="{{ url_for('tenant_auth.login') }}" novalidate>
|
||||||
<div class="mb-3">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<label class="form-label">Password</label>
|
|
||||||
<input type="password" name="password" class="form-control" required>
|
<div class="mb-3">
|
||||||
</div>
|
<label for="email" class="form-label">Email address</label>
|
||||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
<input type="email" class="form-control" id="email" name="email"
|
||||||
</form>
|
autocomplete="username" required autofocus>
|
||||||
<hr>
|
|
||||||
<div class="text-center small">
|
|
||||||
<a href="{{ url_for('tenant_auth.password_reset_request') }}">Forgot password?</a>
|
|
||||||
|
|
|
||||||
<a href="{{ url_for('staff_auth.staff_login') }}">Staff login</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
|
autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="{{ url_for('staff_auth.staff_login') }}" class="btn btn-outline-secondary btn-sm w-100 mb-2">
|
||||||
|
<i class="bi bi-person-badge me-1"></i>Staff Login (PIN)
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('tenant_auth.password_reset_request') }}" class="text-muted small d-block mt-2">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
{% extends "tenant/base.html" %}
|
{% extends "tenant/layouts/base.html" %}
|
||||||
{% block title %}Set New Password{% endblock %}
|
{% block title %}Set New Password{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
<div class="col-md-4">
|
<div class="card shadow" style="width: 420px;">
|
||||||
<div class="card shadow-sm mt-5">
|
<div class="card-body p-4">
|
||||||
<div class="card-body p-4">
|
<h5 class="fw-bold mb-3">Set New Password</h5>
|
||||||
<h5 class="card-title mb-3">Set New Password</h5>
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-danger">{{ error }}</div>
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="POST">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<form method="POST" novalidate>
|
||||||
<div class="mb-3">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<label class="form-label">New Password</label>
|
<div class="mb-3">
|
||||||
<input type="password" name="password" class="form-control" required minlength="10">
|
<label for="password" class="form-label">New password</label>
|
||||||
<div class="form-text">Min 10 chars, must include uppercase, lowercase, and a digit.</div>
|
<input type="password" class="form-control" id="password" name="password"
|
||||||
</div>
|
autocomplete="new-password" required autofocus>
|
||||||
<div class="mb-3">
|
<div class="form-text">Min 10 characters. Uppercase, lowercase, and a digit required.</div>
|
||||||
<label class="form-label">Confirm Password</label>
|
</div>
|
||||||
<input type="password" name="confirm_password" class="form-control" required>
|
<div class="mb-3">
|
||||||
</div>
|
<label for="confirm_password" class="form-label">Confirm password</label>
|
||||||
<button type="submit" class="btn btn-primary w-100">Update Password</button>
|
<input type="password" class="form-control" id="confirm_password"
|
||||||
</form>
|
name="confirm_password" autocomplete="new-password" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Update Password</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{% extends "tenant/base.html" %}
|
{% extends "tenant/layouts/base.html" %}
|
||||||
{% block title %}Reset Password{% endblock %}
|
{% block title %}Reset Password{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
<div class="col-md-4">
|
<div class="card shadow" style="width: 420px;">
|
||||||
<div class="card shadow-sm mt-5">
|
<div class="card-body p-4">
|
||||||
<div class="card-body p-4">
|
<h5 class="fw-bold mb-3">Reset Password</h5>
|
||||||
<h5 class="card-title mb-3">Reset Password</h5>
|
<p class="text-muted small">Enter your email address and we'll send a reset link.</p>
|
||||||
{% if message %}
|
|
||||||
<div class="alert alert-info">{{ message }}</div>
|
<form method="POST" novalidate>
|
||||||
{% else %}
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<form method="POST">
|
<div class="mb-3">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<label for="email" class="form-label">Email address</label>
|
||||||
<div class="mb-3">
|
<input type="email" class="form-control" id="email" name="email" required autofocus>
|
||||||
<label class="form-label">Email address</label>
|
|
||||||
<input type="email" name="email" class="form-control" required autofocus>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary w-100">Send Reset Link</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
<div class="text-center mt-3 small">
|
|
||||||
<a href="{{ url_for('tenant_auth.login') }}">Back to login</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Send Reset Link</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="{{ url_for('tenant_auth.login') }}" class="text-muted small">Back to login</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "tenant/base.html" %}
|
{% extends "tenant/layouts/base.html" %}
|
||||||
{% block title %}Account Suspended{% endblock %}
|
{% block title %}Account Suspended{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center mt-5">
|
<div class="row justify-content-center mt-5">
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Book an Appointment — {{ tenant.name }}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<div class="container py-5" style="max-width:540px;">
|
||||||
|
{% if confirmed %}
|
||||||
|
<div class="card shadow text-center p-5">
|
||||||
|
<div class="display-1 mb-3">✅</div>
|
||||||
|
<h3 class="fw-bold">Booking Requested!</h3>
|
||||||
|
<p class="text-muted">We'll confirm your appointment shortly. Check your email for details.</p>
|
||||||
|
<a href="/book/{{ tenant.slug }}" class="btn btn-outline-primary mt-3">Book Another</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card shadow p-4">
|
||||||
|
<h3 class="fw-bold mb-1">{{ tenant.name }}</h3>
|
||||||
|
<p class="text-muted mb-4">Book an appointment online</p>
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Your Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Phone <span class="text-danger">*</span></label>
|
||||||
|
<input type="tel" class="form-control" name="phone" required inputmode="numeric">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email <small class="text-muted">(for confirmation)</small></label>
|
||||||
|
<input type="email" class="form-control" name="email">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Service <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" name="service_id" required>
|
||||||
|
<option value="">— Select a service —</option>
|
||||||
|
{% for s in services %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }} — ${{ "%.2f"|format(s.price) }} ({{ s.duration_min }} min)</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Preferred Staff <small class="text-muted">(optional)</small></label>
|
||||||
|
<select class="form-select" name="staff_id">
|
||||||
|
<option value="">— No preference —</option>
|
||||||
|
{% for s in staff_list %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Date <span class="text-danger">*</span></label>
|
||||||
|
<input type="date" class="form-control" name="preferred_date" required>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Time <span class="text-danger">*</span></label>
|
||||||
|
<input type="time" class="form-control" name="preferred_time" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea class="form-control" name="notes" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Request Appointment</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Not Found</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="fw-bold">Booking Page Not Found</h2>
|
||||||
|
<p class="text-muted">This salon's booking page doesn't exist or is no longer available.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Booking Unavailable</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="fw-bold">Online Booking Unavailable</h2>
|
||||||
|
<p class="text-muted">Online booking is not currently available for this salon. Please call to book.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><title>Check-In Not Found</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
|
||||||
|
<div class="text-center">
|
||||||
|
<h2 class="fw-bold">Check-In Unavailable</h2>
|
||||||
|
<p class="text-muted">This check-in kiosk is not available. Please see a staff member.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Customer{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Customer</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ customer.name if customer else '' }}" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Phone</label>
|
||||||
|
<input type="tel" class="form-control" name="phone"
|
||||||
|
value="{{ customer.phone if customer else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" name="email"
|
||||||
|
value="{{ customer.email if customer else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Date of Birth</label>
|
||||||
|
<input type="date" class="form-control" name="date_of_birth"
|
||||||
|
value="{{ customer.date_of_birth.isoformat() if customer and customer.date_of_birth else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea class="form-control" name="notes" rows="3">{{ customer.notes if customer else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Customers{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-people me-2"></i>Customers</h4>
|
||||||
|
<a href="{{ url_for('customers.create') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New Customer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<form method="GET" class="d-flex gap-2">
|
||||||
|
<input type="search" class="form-control form-control-sm" name="q"
|
||||||
|
value="{{ q }}" placeholder="Search by name, phone, or email…" autofocus>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm">Search</button>
|
||||||
|
{% if q %}<a href="{{ url_for('customers.index') }}" class="btn btn-outline-danger btn-sm">Clear</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Name</th><th>Phone</th><th>Email</th><th>Loyalty</th><th>No-Shows</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in customers %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('customers.view', customer_id=c.id) }}" class="text-decoration-none fw-semibold">{{ c.name }}</a></td>
|
||||||
|
<td class="text-muted small">{{ c.phone or '—' }}</td>
|
||||||
|
<td class="text-muted small">{{ c.email or '—' }}</td>
|
||||||
|
<td><span class="badge bg-warning text-dark">{{ c.loyalty_points }} pts</span></td>
|
||||||
|
<td>{% if c.no_show_count > 0 %}<span class="badge bg-danger">{{ c.no_show_count }}</span>{% else %}0{% endif %}</td>
|
||||||
|
<td><a href="{{ url_for('customers.edit', customer_id=c.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-4">No customers found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ customer.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0">{{ customer.name }}</h4>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<a href="{{ url_for('appointments.create') }}?customer_id={{ customer.id }}" class="btn btn-primary btn-sm">New Appointment</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-1"><i class="bi bi-telephone me-2 text-muted"></i>{{ customer.phone or '—' }}</p>
|
||||||
|
<p class="mb-1"><i class="bi bi-envelope me-2 text-muted"></i>{{ customer.email or '—' }}</p>
|
||||||
|
{% if customer.date_of_birth %}
|
||||||
|
<p class="mb-1"><i class="bi bi-cake me-2 text-muted"></i>{{ customer.date_of_birth.strftime('%B %d') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="mb-1"><i class="bi bi-star me-2 text-warning"></i>{{ customer.loyalty_points }} loyalty points</p>
|
||||||
|
{% if customer.no_show_count > 0 %}
|
||||||
|
<p class="mb-0"><i class="bi bi-x-circle me-2 text-danger"></i>{{ customer.no_show_count }} no-show(s)</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if customer.notes %}
|
||||||
|
<hr><p class="text-muted small mb-0">{{ customer.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header fw-semibold">Recent Appointments</div>
|
||||||
|
{% if appointments %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Date</th><th>Service</th><th>Staff</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in appointments %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{{ a.start_time.strftime('%Y-%m-%d %I:%M %p') }}</td>
|
||||||
|
<td class="small">{{ a.service.name if a.service else '—' }}</td>
|
||||||
|
<td class="small">{{ a.staff.name if a.staff else '—' }}</td>
|
||||||
|
<td><span class="badge bg-{{ {'completed':'success','cancelled':'danger','no_show':'warning'}.get(a.status,'secondary') }} small">{{ a.status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-muted small">No appointments yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,10 +1,66 @@
|
|||||||
{% extends "tenant/base.html" %}
|
{% extends "tenant/layouts/base.html" %}
|
||||||
{% block title %}Dashboard{% endblock %}
|
{% block title %}Dashboard{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4 class="mb-4">Dashboard
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
{% if location %}<small class="text-muted fs-6">— {{ location.name }}</small>{% endif %}
|
<h4 class="fw-bold mb-0">
|
||||||
</h4>
|
<i class="bi bi-speedometer2 me-2"></i>Dashboard
|
||||||
<div class="alert alert-info">
|
{% if location %}<small class="text-muted fs-6 ms-2">— {{ location.name }}</small>{% endif %}
|
||||||
Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts).
|
</h4>
|
||||||
|
<span class="text-muted small">{{ today.strftime('%A, %B %d, %Y') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI cards -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
{% set kpi_items = [
|
||||||
|
("Today's Revenue", "$" ~ "%.2f"|format(kpis.today_revenue), "graph-up-arrow", "success"),
|
||||||
|
("Appointments", kpis.total_appointments, "calendar3", "primary"),
|
||||||
|
("Completed", kpis.completed_appointments, "check-circle", "success"),
|
||||||
|
("Pending", kpis.pending_appointments, "hourglass-split", "warning"),
|
||||||
|
("Staff on Shift", kpis.staff_on_shift, "person-badge", "info"),
|
||||||
|
("Check-in Queue", kpis.queue_count, "door-open", "danger" if kpis.queue_count > 0 else "secondary"),
|
||||||
|
] %}
|
||||||
|
{% for label, value, icon, color in kpi_items %}
|
||||||
|
<div class="col-sm-6 col-lg-4 col-xl-2">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3 py-3">
|
||||||
|
<div class="rounded-circle bg-{{ color }} bg-opacity-10 p-2">
|
||||||
|
<i class="bi bi-{{ icon }} fs-5 text-{{ color }}"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold fs-5">{{ value }}</div>
|
||||||
|
<div class="text-muted" style="font-size:.75rem;">{{ label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upcoming appointments -->
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-calendar-check me-2"></i>Upcoming Today</span>
|
||||||
|
<a href="{{ url_for('appointments.index') }}" class="btn btn-outline-primary btn-sm">Full Calendar</a>
|
||||||
|
</div>
|
||||||
|
{% if upcoming_appointments %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Time</th><th>Customer</th><th>Service</th><th>Staff</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for appt in upcoming_appointments %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ appt.start_time.strftime('%I:%M %p') }}</td>
|
||||||
|
<td>{{ appt.customer.name if appt.customer else '—' }}</td>
|
||||||
|
<td>{{ appt.service.name if appt.service else '—' }}</td>
|
||||||
|
<td>{{ appt.staff.name if appt.staff else '—' }}</td>
|
||||||
|
<td><span class="badge bg-{{ {'pending':'warning','confirmed':'primary','in_progress':'info','completed':'success'}.get(appt.status,'secondary') }}">{{ appt.status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-muted small">No upcoming appointments for today.</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Feature Unavailable{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 70vh;">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="bi bi-lock-fill display-1 text-warning"></i>
|
||||||
|
<h3 class="mt-3">Feature Not Available on Your Plan</h3>
|
||||||
|
<p class="text-muted">
|
||||||
|
The <strong>{{ feature }}</strong> feature is not included in your current subscription plan.
|
||||||
|
Please contact your account administrator to upgrade.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('dashboard.index') }}" class="btn btn-primary mt-2">
|
||||||
|
Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Issue Gift Card{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Issue New Gift Card</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Value ($) <span class="text-danger">*</span></label>
|
||||||
|
<input type="number" step="0.01" min="1" class="form-control"
|
||||||
|
name="value" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Customer <small class="text-muted">(optional)</small></label>
|
||||||
|
<select class="form-select" name="customer_id">
|
||||||
|
<option value="">— None —</option>
|
||||||
|
{% for c in customers %}
|
||||||
|
<option value="{{ c.id }}">{{ c.name }}{% if c.phone %} — {{ c.phone }}{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Expiry Date <small class="text-muted">(optional)</small></label>
|
||||||
|
<input type="datetime-local" class="form-control" name="expires_at">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Issue Card</button>
|
||||||
|
<a href="{{ url_for('gift_cards.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Gift Cards{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-gift me-2"></i>Gift Cards</h4>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('gift_cards.lookup') }}" class="btn btn-outline-secondary btn-sm">Lookup Code</a>
|
||||||
|
<a href="{{ url_for('gift_cards.issue') }}" class="btn btn-primary btn-sm">+ Issue Gift Card</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Code</th><th>Original</th><th>Balance</th><th>Customer</th><th>Expires</th><th>Status</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for card in cards %}
|
||||||
|
<tr class="{{ '' if card.is_active else 'text-muted' }}">
|
||||||
|
<td class="font-monospace fw-semibold">{{ card.code }}</td>
|
||||||
|
<td>${{ "%.2f"|format(card.original_value) }}</td>
|
||||||
|
<td class="fw-semibold {% if card.remaining_balance <= 0 %}text-danger{% endif %}">
|
||||||
|
${{ "%.2f"|format(card.remaining_balance) }}
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ card.customer.name if card.issued_to_customer_id and card.customer else '—' }}</td>
|
||||||
|
<td class="small text-muted">{{ card.expires_at.strftime('%Y-%m-%d') if card.expires_at else 'No expiry' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ 'bg-success' if card.is_active else 'bg-secondary' }}">
|
||||||
|
{{ 'Active' if card.is_active else 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if card.is_active %}
|
||||||
|
<form method="POST" action="{{ url_for('gift_cards.deactivate', card_id=card.id) }}"
|
||||||
|
class="d-inline" onsubmit="return confirm('Deactivate this gift card?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm">Deactivate</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-4">No gift cards issued yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Gift Card Lookup{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Gift Card Lookup</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="GET" class="mb-3">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control font-monospace" name="code"
|
||||||
|
value="{{ code }}" placeholder="XXXX-XXXX-XXXX" autofocus>
|
||||||
|
<button class="btn btn-primary">Look Up</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if code and card %}
|
||||||
|
<div class="alert {{ 'alert-success' if card.is_active else 'alert-warning' }}">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-5">Code</dt><dd class="col-sm-7 font-monospace">{{ card.code }}</dd>
|
||||||
|
<dt class="col-sm-5">Original Value</dt><dd class="col-sm-7">${{ "%.2f"|format(card.original_value) }}</dd>
|
||||||
|
<dt class="col-sm-5">Remaining Balance</dt>
|
||||||
|
<dd class="col-sm-7 fw-bold {% if card.remaining_balance <= 0 %}text-danger{% endif %}">
|
||||||
|
${{ "%.2f"|format(card.remaining_balance) }}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-sm-5">Status</dt>
|
||||||
|
<dd class="col-sm-7">{{ 'Active' if card.is_active else 'Inactive / Depleted' }}</dd>
|
||||||
|
{% if card.expires_at %}
|
||||||
|
<dt class="col-sm-5">Expires</dt><dd class="col-sm-7">{{ card.expires_at.strftime('%Y-%m-%d') }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{% elif code %}
|
||||||
|
<div class="alert alert-danger">No gift card found with code <strong>{{ code }}</strong>.</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('gift_cards.index') }}" class="btn btn-outline-secondary btn-sm">Back</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Adjust Stock — {{ item.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Adjust Stock — {{ item.name }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Current quantity: <strong>{{ item.qty_on_hand }}</strong></p>
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Adjustment <small class="text-muted">(positive = add, negative = remove)</small></label>
|
||||||
|
<input type="number" class="form-control form-control-lg text-center" name="delta"
|
||||||
|
placeholder="e.g. +10 or -3" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Reason</label>
|
||||||
|
<input type="text" class="form-control" name="reason"
|
||||||
|
placeholder="e.g. Stock count, Supplier delivery, Damage">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Apply Adjustment</button>
|
||||||
|
<a href="{{ url_for('inventory.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Inventory Item{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'Add' }} Inventory Item</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ item.name if item else '' }}" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">SKU</label>
|
||||||
|
<input type="text" class="form-control font-monospace" name="sku"
|
||||||
|
value="{{ item.sku if item else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input type="text" class="form-control" name="category"
|
||||||
|
value="{{ item.category if item else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if mode == 'create' %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Initial Quantity</label>
|
||||||
|
<input type="number" class="form-control" name="qty_on_hand" min="0" value="0">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Reorder Level <small class="text-muted">(alert when stock reaches this)</small></label>
|
||||||
|
<input type="number" class="form-control" name="reorder_level" min="0"
|
||||||
|
value="{{ item.reorder_level if item else 5 }}">
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Cost Price ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="cost_price" min="0"
|
||||||
|
value="{{ "%.2f"|format(item.cost_price) if item and item.cost_price else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Sale Price ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="sale_price" min="0"
|
||||||
|
value="{{ "%.2f"|format(item.sale_price) if item and item.sale_price else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('inventory.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Inventory{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-box-seam me-2"></i>Inventory</h4>
|
||||||
|
<a href="{{ url_for('inventory.create') }}" class="btn btn-primary btn-sm">+ Add Item</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if low_stock %}
|
||||||
|
<div class="alert alert-warning d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<div>
|
||||||
|
<strong>{{ low_stock|length }} item(s) at or below reorder level:</strong>
|
||||||
|
{{ low_stock|map(attribute='name')|join(', ') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Name</th><th>SKU</th><th>Category</th><th>On Hand</th><th>Reorder At</th><th>Cost</th><th>Sale</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr class="{{ 'table-warning' if item.qty_on_hand <= item.reorder_level else '' }}">
|
||||||
|
<td class="fw-semibold">{{ item.name }}</td>
|
||||||
|
<td class="small text-muted font-monospace">{{ item.sku or '—' }}</td>
|
||||||
|
<td class="small">{{ item.category or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="fw-bold {{ 'text-danger' if item.qty_on_hand <= item.reorder_level else '' }}">
|
||||||
|
{{ item.qty_on_hand }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ item.reorder_level }}</td>
|
||||||
|
<td class="small">{{ '$' ~ "%.2f"|format(item.cost_price) if item.cost_price else '—' }}</td>
|
||||||
|
<td class="small">{{ '$' ~ "%.2f"|format(item.sale_price) if item.sale_price else '—' }}</td>
|
||||||
|
<td class="d-flex gap-1">
|
||||||
|
<a href="{{ url_for('inventory.adjust', item_id=item.id) }}" class="btn btn-outline-primary btn-sm">Adjust</a>
|
||||||
|
<a href="{{ url_for('inventory.edit', item_id=item.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<a href="{{ url_for('inventory.log', item_id=item.id) }}" class="btn btn-outline-secondary btn-sm">Log</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4">No inventory items yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Stock Log — {{ item.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0">Stock Log — {{ item.name }}</h4>
|
||||||
|
<span class="badge bg-primary fs-6">Current: {{ item.qty_on_hand }} units</span>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Date</th><th>Change</th><th>Reason</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in entries %}
|
||||||
|
<tr>
|
||||||
|
<td class="small text-muted">{{ e.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td class="fw-bold {{ 'text-success' if e.delta > 0 else 'text-danger' }}">
|
||||||
|
{{ '+' if e.delta > 0 else '' }}{{ e.delta }}
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ e.reason or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="text-center text-muted py-4">No log entries.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('inventory.index') }}" class="btn btn-outline-secondary btn-sm mt-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Inventory
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Salon POS{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/tenant.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<!-- Top navbar -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand fw-bold" href="{{ url_for('dashboard.index') }}">
|
||||||
|
<i class="bi bi-scissors me-2"></i>
|
||||||
|
{% if g.tenant is defined and g.tenant %}{{ g.tenant.name }}{% else %}Salon POS{% endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarMain">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarMain">
|
||||||
|
<!-- Location switcher (tenant_admin and tenant_manager only) -->
|
||||||
|
{% if current_user.role in ('tenant_admin', 'tenant_manager') %}
|
||||||
|
<div class="navbar-nav me-3">
|
||||||
|
<div class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle text-white" href="#" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-geo-alt me-1"></i>
|
||||||
|
{% if g.location is defined and g.location %}{{ g.location.name }}{% else %}Select Location{% endif %}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
{% if locations is defined %}
|
||||||
|
{% for loc in locations %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {% if g.location and g.location.id == loc.id %}active{% endif %}"
|
||||||
|
href="{{ url_for('locations.switch', location_id=loc.id) }}">
|
||||||
|
{{ loc.name }}
|
||||||
|
{% if loc.is_primary %}<span class="badge bg-secondary ms-1">Primary</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Demo badge -->
|
||||||
|
{% if g.tenant is defined and g.tenant and g.tenant.is_demo %}
|
||||||
|
<span class="badge bg-warning text-dark me-3">Demo Mode — Read Only</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="navbar-nav ms-auto">
|
||||||
|
<span class="navbar-text text-white me-3">
|
||||||
|
<i class="bi bi-person-circle me-1"></i>{{ current_user.name if current_user.name is defined else current_user.email }}
|
||||||
|
</span>
|
||||||
|
{% if current_user.role == 'tenant_staff' %}
|
||||||
|
<a class="nav-link text-warning" href="{{ url_for('staff_auth.staff_logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Logout
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="nav-link text-warning" href="{{ url_for('tenant_auth.logout') }}">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Logout
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Sidebar (hidden for tenant_staff) -->
|
||||||
|
{% if current_user.role != 'tenant_staff' %}
|
||||||
|
<nav class="col-md-2 d-none d-md-block bg-white border-end sidebar pt-3">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'dashboard' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('dashboard.index') }}">
|
||||||
|
<i class="bi bi-speedometer2 me-2"></i>Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'appointments' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('appointments.index') }}">
|
||||||
|
<i class="bi bi-calendar3 me-2"></i>Appointments
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'pos' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('pos.checkout') }}">
|
||||||
|
<i class="bi bi-cash-register me-2"></i>POS / Checkout
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'customers' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('customers.index') }}">
|
||||||
|
<i class="bi bi-people me-2"></i>Customers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'staff' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('staff.index') }}">
|
||||||
|
<i class="bi bi-person-badge me-2"></i>Staff
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'services' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('services.index') }}">
|
||||||
|
<i class="bi bi-card-list me-2"></i>Services & Products
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'inventory' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('inventory.index') }}">
|
||||||
|
<i class="bi bi-box-seam me-2"></i>Inventory
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'gift_cards' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('gift_cards.index') }}">
|
||||||
|
<i class="bi bi-gift me-2"></i>Gift Cards
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'reconciliation' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('reconciliation.index') }}">
|
||||||
|
<i class="bi bi-calculator me-2"></i>Reconciliation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'pay_periods' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('pay_periods.index') }}">
|
||||||
|
<i class="bi bi-wallet2 me-2"></i>Pay Periods
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'reports' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ '#' }}">
|
||||||
|
<i class="bi bi-bar-chart-line me-2"></i>Reports
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if current_user.role == 'tenant_admin' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'marketing' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ '#' }}">
|
||||||
|
<i class="bi bi-megaphone me-2"></i>Marketing
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'locations' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('locations.index') }}">
|
||||||
|
<i class="bi bi-geo-alt me-2"></i>Locations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint and 'settings' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
|
href="{{ url_for('settings.index') }}">
|
||||||
|
<i class="bi bi-gear me-2"></i>Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="{% if current_user.role != 'tenant_staff' %}col-md-10{% else %}col-12{% endif %} ms-sm-auto px-4 py-3">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Location{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Location</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ location.name if location else '' }}" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Address</label>
|
||||||
|
<input type="text" class="form-control" name="address"
|
||||||
|
value="{{ location.address if location else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Phone</label>
|
||||||
|
<input type="tel" class="form-control" name="phone"
|
||||||
|
value="{{ location.phone if location else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" name="email"
|
||||||
|
value="{{ location.email if location else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Timezone</label>
|
||||||
|
<input type="text" class="form-control" name="timezone"
|
||||||
|
value="{{ location.timezone if location else 'America/New_York' }}">
|
||||||
|
</div>
|
||||||
|
{% if mode == 'edit' %}
|
||||||
|
<div class="mb-3 d-flex gap-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||||
|
id="is_active" {{ 'checked' if location and location.is_active }}>
|
||||||
|
<label class="form-check-label" for="is_active">Active</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_primary" value="1"
|
||||||
|
id="is_primary" {{ 'checked' if location and location.is_primary }}>
|
||||||
|
<label class="form-check-label" for="is_primary">Set as Primary</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('locations.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Locations{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-geo-alt me-2"></i>Locations</h4>
|
||||||
|
<a href="{{ url_for('locations.create') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>New Location
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for loc in locations %}
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card shadow-sm h-100 {{ '' if loc.is_active else 'opacity-50' }}">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<strong>{{ loc.name }}</strong>
|
||||||
|
<div>
|
||||||
|
{% if loc.is_primary %}<span class="badge bg-primary me-1">Primary</span>{% endif %}
|
||||||
|
<span class="badge {{ 'bg-success' if loc.is_active else 'bg-secondary' }}">{{ 'Active' if loc.is_active else 'Inactive' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body small text-muted">
|
||||||
|
{% if loc.address %}<p class="mb-1"><i class="bi bi-map me-1"></i>{{ loc.address }}</p>{% endif %}
|
||||||
|
{% if loc.phone %}<p class="mb-1"><i class="bi bi-telephone me-1"></i>{{ loc.phone }}</p>{% endif %}
|
||||||
|
{% if loc.email %}<p class="mb-1"><i class="bi bi-envelope me-1"></i>{{ loc.email }}</p>{% endif %}
|
||||||
|
<p class="mb-0"><i class="bi bi-clock me-1"></i>{{ loc.timezone }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex gap-2">
|
||||||
|
<a href="{{ url_for('locations.edit', location_id=loc.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
{% if not loc.is_primary %}
|
||||||
|
<form method="POST" action="{{ url_for('locations.set_primary', location_id=loc.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-primary btn-sm">Set Primary</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('locations.switch', location_id=loc.id) }}" class="btn btn-outline-success btn-sm ms-auto">Switch</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="col"><p class="text-muted">No locations found.</p></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Calculate Pay Period{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header fw-semibold">Calculate Pay Period</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Period Start <span class="text-danger">*</span></label>
|
||||||
|
<input type="date" class="form-control" name="period_start"
|
||||||
|
value="{{ period_start.isoformat() if period_start else '' }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Period End <span class="text-danger">*</span></label>
|
||||||
|
<input type="date" class="form-control" name="period_end"
|
||||||
|
value="{{ period_end.isoformat() if period_end else '' }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Staff Members <small class="text-muted">(leave blank for all)</small></label>
|
||||||
|
<select class="form-select" name="staff_ids" multiple size="6">
|
||||||
|
{% for s in staff_list %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }} — {{ s.pay_type.title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Hold Ctrl/Cmd to select multiple.</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Calculate</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if results %}
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">
|
||||||
|
Results — {{ period_start.strftime('%b %d') if period_start else '' }}–{{ period_end.strftime('%b %d, %Y') if period_end else '' }}
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Staff</th><th>Type</th><th>Hours</th><th>Base</th><th>Commission</th><th>Total</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in results %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ row.staff.name }}</td>
|
||||||
|
<td class="small">{{ row.calc.pay_type.title() }}</td>
|
||||||
|
<td class="small">{{ "%.1f"|format(row.calc.total_hours) }}h</td>
|
||||||
|
<td>${{ "%.2f"|format(row.calc.base_amount) }}</td>
|
||||||
|
<td>${{ "%.2f"|format(row.calc.commission_amount) }}</td>
|
||||||
|
<td class="fw-bold">${{ "%.2f"|format(row.calc.total_amount) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="table-light fw-bold">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">Grand Total</td>
|
||||||
|
<td>${{ "%.2f"|format(results|sum(attribute='calc.total_amount')) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted small">
|
||||||
|
Records saved as drafts. Go to <a href="{{ url_for('pay_periods.index') }}">Pay Periods</a> to approve and mark as paid.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Pay Periods{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-wallet2 me-2"></i>Pay Periods</h4>
|
||||||
|
<a href="{{ url_for('pay_periods.calculate') }}" class="btn btn-primary btn-sm">Calculate Period</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if periods %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Staff</th><th>Period</th><th>Type</th><th>Base</th><th>Commission</th><th>Topup</th><th>Total</th><th>Status</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pp in periods %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ pp.staff.name if pp.staff else pp.staff_id }}</td>
|
||||||
|
<td class="small text-nowrap">{{ pp.period_start.strftime('%b %d') }}–{{ pp.period_end.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td class="small">{{ pp.pay_type.title() }}</td>
|
||||||
|
<td>${{ "%.2f"|format(pp.base_amount) }}</td>
|
||||||
|
<td>${{ "%.2f"|format(pp.commission_amount) }}</td>
|
||||||
|
<td>{{ '$' ~ "%.2f"|format(pp.guarantee_topup) if pp.guarantee_topup > 0 else '—' }}</td>
|
||||||
|
<td class="fw-bold">${{ "%.2f"|format(pp.total_amount) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ {'draft':'secondary','approved':'primary','paid':'success'}.get(pp.status,'secondary') }}">
|
||||||
|
{{ pp.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="d-flex gap-1">
|
||||||
|
{% if pp.status == 'draft' %}
|
||||||
|
<form method="POST" action="{{ url_for('pay_periods.approve', pp_id=pp.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-primary btn-sm">Approve</button>
|
||||||
|
</form>
|
||||||
|
{% elif pp.status == 'approved' %}
|
||||||
|
<form method="POST" action="{{ url_for('pay_periods.mark_paid', pp_id=pp.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-success btn-sm">Mark Paid</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-light">No pay periods calculated yet. Use <strong>Calculate Period</strong> to get started.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}POS Checkout{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="fw-bold mb-4"><i class="bi bi-cash-register me-2"></i>Checkout</h4>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('pos.submit') }}" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
{% if appointment %}
|
||||||
|
<input type="hidden" name="appointment_id" value="{{ appointment.id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Left: items -->
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header fw-semibold">Services</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for s in services %}
|
||||||
|
<div class="form-check mb-1">
|
||||||
|
<input class="form-check-input" type="checkbox" name="service_ids"
|
||||||
|
value="{{ s.id }}" id="svc_{{ s.id }}"
|
||||||
|
{{ 'checked' if appointment and appointment.service_id == s.id }}>
|
||||||
|
<label class="form-check-label" for="svc_{{ s.id }}">
|
||||||
|
{{ s.name }} <span class="text-muted small">({{ s.duration_min }}min)</span>
|
||||||
|
<span class="fw-semibold">${{ "%.2f"|format(s.price) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if services %}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header fw-semibold">Products</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for p in products %}
|
||||||
|
<div class="form-check mb-1">
|
||||||
|
<input class="form-check-input" type="checkbox" name="product_ids"
|
||||||
|
value="{{ p.id }}" id="prod_{{ p.id }}">
|
||||||
|
<label class="form-check-label" for="prod_{{ p.id }}">
|
||||||
|
{{ p.name }} <span class="fw-semibold">${{ "%.2f"|format(p.sale_price) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: payment details -->
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header fw-semibold">Payment Details</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Customer</label>
|
||||||
|
<select class="form-select" name="customer_id">
|
||||||
|
<option value="">— Walk-in / No Customer —</option>
|
||||||
|
{% for c in customers %}
|
||||||
|
<option value="{{ c.id }}"
|
||||||
|
{{ 'selected' if appointment and appointment.customer_id == c.id }}>
|
||||||
|
{{ c.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Staff</label>
|
||||||
|
<select class="form-select" name="staff_id">
|
||||||
|
<option value="">— Not assigned —</option>
|
||||||
|
{% for s in staff_list %}
|
||||||
|
<option value="{{ s.id }}"
|
||||||
|
{{ 'selected' if appointment and appointment.staff_id == s.id }}>
|
||||||
|
{{ s.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Payment Method</label>
|
||||||
|
<select class="form-select" name="payment_method">
|
||||||
|
{% for m in payment_methods %}
|
||||||
|
<option value="{{ m }}">{{ m.title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Payment Reference <small class="text-muted">(optional)</small></label>
|
||||||
|
<input type="text" class="form-control" name="payment_reference"
|
||||||
|
placeholder="Transaction ID, note…">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Tip ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="tip_amount"
|
||||||
|
value="0" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Gift Card Code</label>
|
||||||
|
<input type="text" class="form-control font-monospace" name="gift_card_code"
|
||||||
|
placeholder="XXXX-XXXX-XXXX">
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="rebook" value="1" id="rebook">
|
||||||
|
<label class="form-check-label" for="rebook">Schedule next visit after checkout</label>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-success btn-lg">
|
||||||
|
<i class="bi bi-check-circle me-2"></i>Complete Checkout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Schedule Next Visit{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold"><i class="bi bi-calendar-plus me-2"></i>Schedule Next Visit</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Date & Time <span class="text-danger">*</span></label>
|
||||||
|
<input type="datetime-local" class="form-control" name="start_time" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Service</label>
|
||||||
|
<select class="form-select" name="service_id">
|
||||||
|
<option value="">— Same as today —</option>
|
||||||
|
{% for s in services %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Staff</label>
|
||||||
|
<select class="form-select" name="staff_id">
|
||||||
|
<option value="">— Any —</option>
|
||||||
|
{% for s in staff_list %}
|
||||||
|
<option value="{{ s.id }}" {{ 'selected' if transaction.staff_id == s.id }}>{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Schedule</button>
|
||||||
|
<a href="{{ url_for('pos.receipt', transaction_id=transaction.id) }}" class="btn btn-outline-secondary">Skip</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Receipt #{{ transaction.id }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h5 class="fw-bold">{{ tenant.name }}</h5>
|
||||||
|
{% if location %}<p class="text-muted small mb-0">{{ location.name }}</p>{% endif %}
|
||||||
|
<p class="text-muted small">{{ transaction.created_at.strftime('%B %d, %Y %I:%M %p') }}</p>
|
||||||
|
<p class="text-muted small">Receipt #{{ transaction.id }}</p>
|
||||||
|
{% if transaction.voided_at %}
|
||||||
|
<div class="alert alert-danger py-1 mt-2">VOIDED — {{ transaction.void_reason }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<table class="table table-sm mb-3">
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if item.service %}{{ item.service.name }}
|
||||||
|
{% elif item.product %}{{ item.product.name }}
|
||||||
|
{% endif %}
|
||||||
|
{% if item.discount_percent > 0 %}
|
||||||
|
<span class="badge bg-success ms-1">{{ item.discount_percent }}% off</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
{% if item.discount_percent > 0 %}
|
||||||
|
<del class="text-muted small">${{ "%.2f"|format(item.original_price) }}</del>
|
||||||
|
{% endif %}
|
||||||
|
${{ "%.2f"|format(item.unit_price) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="fw-semibold">
|
||||||
|
{% if transaction.discount > 0 %}
|
||||||
|
<tr class="text-success"><td>Savings</td><td class="text-end">-${{ "%.2f"|format(transaction.discount) }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if transaction.tip_amount > 0 %}
|
||||||
|
<tr><td>Tip</td><td class="text-end">${{ "%.2f"|format(transaction.tip_amount) }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if transaction.gift_card_amount > 0 %}
|
||||||
|
<tr class="text-info"><td>Gift Card</td><td class="text-end">-${{ "%.2f"|format(transaction.gift_card_amount) }}</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr class="table-dark fs-5"><td>Total</td><td class="text-end">${{ "%.2f"|format(transaction.total) }}</td></tr>
|
||||||
|
<tr><td>Payment</td><td class="text-end">{{ transaction.payment_method.title() }}</td></tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
<div class="d-flex gap-2 justify-content-center flex-wrap">
|
||||||
|
<a href="{{ url_for('pos.checkout') }}" class="btn btn-primary">New Transaction</a>
|
||||||
|
<a href="{{ url_for('pos.rebook', transaction_id=transaction.id) }}" class="btn btn-outline-primary">Rebook</a>
|
||||||
|
{% if not transaction.voided_at %}
|
||||||
|
<a href="{{ url_for('pos.void', transaction_id=transaction.id) }}" class="btn btn-outline-danger">Void</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('pos.transactions') }}" class="btn btn-outline-secondary">Transactions</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Transactions{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h4 class="fw-bold mb-0">Transactions</h4>
|
||||||
|
<input type="date" class="form-control form-control-sm" value="{{ view_date.isoformat() }}"
|
||||||
|
onchange="window.location='{{ url_for('pos.transactions') }}?date='+this.value"
|
||||||
|
style="width:150px;">
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Time</th><th>Customer</th><th>Staff</th><th>Total</th><th>Method</th><th>Tip</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in transactions %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{{ t.created_at.strftime('%I:%M %p') }}</td>
|
||||||
|
<td class="small">{{ t.customer.name if t.customer else '—' }}</td>
|
||||||
|
<td class="small">{{ t.staff.name if t.staff else '—' }}</td>
|
||||||
|
<td class="fw-semibold">${{ "%.2f"|format(t.total) }}</td>
|
||||||
|
<td class="small">{{ t.payment_method.title() }}</td>
|
||||||
|
<td class="small">{% if t.tip_amount > 0 %}${{ "%.2f"|format(t.tip_amount) }}{% else %}—{% endif %}</td>
|
||||||
|
<td><a href="{{ url_for('pos.receipt', transaction_id=t.id) }}" class="btn btn-outline-secondary btn-sm">View</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-4">No transactions for this day.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Void Transaction{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm border-danger">
|
||||||
|
<div class="card-header bg-danger text-white fw-semibold">Void Transaction #{{ transaction.id }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Total: <strong>${{ "%.2f"|format(transaction.total) }}</strong></p>
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate onsubmit="return confirm('Are you sure you want to void this transaction?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Reason <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="reason" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-danger">Confirm Void</button>
|
||||||
|
<a href="{{ url_for('pos.receipt', transaction_id=transaction.id) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Close Day{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">
|
||||||
|
<i class="bi bi-calculator me-2"></i>Close Day — {{ today.strftime('%B %d, %Y') }}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Summary from transactions -->
|
||||||
|
<table class="table table-sm mb-4">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Cash Sales</td><td class="text-end fw-semibold">${{ "%.2f"|format(total_cash) }}</td></tr>
|
||||||
|
<tr><td>App Payments (Zelle / Venmo / etc.)</td><td class="text-end">${{ "%.2f"|format(total_app) }}</td></tr>
|
||||||
|
<tr><td>Tips Collected</td><td class="text-end">${{ "%.2f"|format(total_tips) }}</td></tr>
|
||||||
|
<tr><td>Gift Card Redemptions</td><td class="text-end">${{ "%.2f"|format(total_gc) }}</td></tr>
|
||||||
|
<tr class="table-light fw-bold"><td>Expected Cash in Drawer</td><td class="text-end">${{ "%.2f"|format(expected_cash) }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Actual Cash Counted ($) <span class="text-danger">*</span></label>
|
||||||
|
<input type="number" step="0.01" min="0" class="form-control form-control-lg"
|
||||||
|
name="actual_cash" required autofocus placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Notes <small class="text-muted">(optional)</small></label>
|
||||||
|
<textarea class="form-control" name="notes" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Close Day</button>
|
||||||
|
<a href="{{ url_for('reconciliation.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Reconciliation{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-calculator me-2"></i>End-of-Day Reconciliation</h4>
|
||||||
|
<a href="{{ url_for('reconciliation.close_day') }}" class="btn btn-primary btn-sm">Close Today</a>
|
||||||
|
</div>
|
||||||
|
{% if records %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Date</th><th>Cash</th><th>App Payments</th><th>Tips</th><th>Gift Cards</th><th>Expected</th><th>Actual</th><th>Variance</th><th>Closed By</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in records %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ r.date.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>${{ "%.2f"|format(r.total_cash) }}</td>
|
||||||
|
<td>${{ "%.2f"|format(r.total_app_payments) }}</td>
|
||||||
|
<td>${{ "%.2f"|format(r.total_tips) }}</td>
|
||||||
|
<td>${{ "%.2f"|format(r.total_gift_card_redemptions) }}</td>
|
||||||
|
<td>${{ "%.2f"|format(r.expected_cash_in_drawer) }}</td>
|
||||||
|
<td>${{ "%.2f"|format(r.actual_cash_counted) }}</td>
|
||||||
|
<td class="fw-semibold {{ 'text-danger' if r.variance < 0 else 'text-success' if r.variance > 0 else '' }}">
|
||||||
|
{{ '+' if r.variance > 0 else '' }}${{ "%.2f"|format(r.variance) }}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">{{ r.closed_at.strftime('%I:%M %p') if r.closed_at else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-light">No reconciliation records yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Reviews{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-star me-2"></i>Customer Reviews</h4>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="fs-3 fw-bold text-warning">{{ avg_rating }} ★</div>
|
||||||
|
<div class="text-muted small">Average rating</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rating distribution -->
|
||||||
|
<div class="card shadow-sm mb-4" style="max-width:400px;">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
{% for star in [5,4,3,2,1] %}
|
||||||
|
{% set count = rating_dist.get(star, 0) %}
|
||||||
|
{% set total = reviews|length %}
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span class="text-muted small" style="width:20px;">{{ star }}★</span>
|
||||||
|
<div class="progress flex-grow-1" style="height:8px;">
|
||||||
|
<div class="progress-bar bg-warning"
|
||||||
|
style="width:{{ ((count / total * 100) | int) if total else 0 }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted small" style="width:30px;">{{ count }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if reviews %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Date</th><th>Rating</th><th>Customer</th><th>Staff</th><th>Comment</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in reviews %}
|
||||||
|
<tr>
|
||||||
|
<td class="small text-muted">{{ r.created_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="text-warning fw-bold">{{ r.rating }}★</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ r.customer.name if r.customer else '—' }}</td>
|
||||||
|
<td class="small">{{ r.staff.name if r.staff else '—' }}</td>
|
||||||
|
<td class="small">{{ r.comment or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-light">No reviews yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Services & Products{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-card-list me-2"></i>Services & Products</h4>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('services.create_service') }}" class="btn btn-primary btn-sm">+ Service</a>
|
||||||
|
<a href="{{ url_for('services.create_product') }}" class="btn btn-outline-primary btn-sm">+ Product</a>
|
||||||
|
<a href="{{ url_for('services.create_promotion') }}" class="btn btn-outline-warning btn-sm">+ Promotion</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Services</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Name</th><th>Category</th><th>Duration</th><th>Price</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in services %}
|
||||||
|
<tr class="{{ '' if s.is_active else 'text-muted' }}">
|
||||||
|
<td class="fw-semibold">{{ s.name }}</td>
|
||||||
|
<td class="small">{{ s.category or '—' }}</td>
|
||||||
|
<td class="small">{{ s.duration_min }} min</td>
|
||||||
|
<td>${{ "%.2f"|format(s.price) }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('services.edit_service', service_id=s.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<form method="POST" action="{{ url_for('services.delete_service', service_id=s.id) }}" class="d-inline"
|
||||||
|
onsubmit="return confirm('Delete this service?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm">Del</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-3">No services yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Products</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Name</th><th>SKU</th><th>Price</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in products %}
|
||||||
|
<tr class="{{ '' if p.is_active else 'text-muted' }}">
|
||||||
|
<td class="fw-semibold">{{ p.name }}</td>
|
||||||
|
<td class="small text-muted">{{ p.sku or '—' }}</td>
|
||||||
|
<td>${{ "%.2f"|format(p.sale_price) }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('services.edit_product', product_id=p.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<form method="POST" action="{{ url_for('services.delete_product', product_id=p.id) }}" class="d-inline"
|
||||||
|
onsubmit="return confirm('Delete this product?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-danger btn-sm">Del</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="4" class="text-center text-muted py-3">No products yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if promotions %}
|
||||||
|
<div class="card shadow-sm mt-3 border-warning">
|
||||||
|
<div class="card-header fw-semibold bg-warning-subtle">Active Promotions</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Name</th><th>Discount</th><th>Applies To</th><th>Ends</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for promo in promotions %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ promo.name }}</td>
|
||||||
|
<td>{{ promo.discount_percent }}%</td>
|
||||||
|
<td class="small">{{ promo.applies_to }}</td>
|
||||||
|
<td class="small text-muted">{{ promo.ends_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="{{ url_for('services.toggle_promotion', promo_id=promo.id) }}" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-{{ 'danger' if promo.is_active else 'success' }} btn-sm">
|
||||||
|
{{ 'Deactivate' if promo.is_active else 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Product{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Product</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ product.name if product else '' }}" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">SKU</label>
|
||||||
|
<input type="text" class="form-control" name="sku"
|
||||||
|
value="{{ product.sku if product else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input type="text" class="form-control" name="category"
|
||||||
|
value="{{ product.category if product else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Sale Price ($) <span class="text-danger">*</span></label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="sale_price" min="0"
|
||||||
|
value="{{ "%.2f"|format(product.sale_price) if product else '' }}" required>
|
||||||
|
</div>
|
||||||
|
{% if mode == 'edit' %}
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||||
|
id="is_active" {{ 'checked' if product and product.is_active }}>
|
||||||
|
<label class="form-check-label" for="is_active">Active</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}New Promotion{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">New Promotion</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Promotion Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Discount % <span class="text-danger">*</span></label>
|
||||||
|
<input type="number" class="form-control" name="discount_percent" min="1" max="100" required>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Applies To <span class="text-danger">*</span></label>
|
||||||
|
<select class="form-select" name="applies_to" id="applies_to"
|
||||||
|
onchange="toggleTargets(this.value)">
|
||||||
|
<option value="all_services">All Services</option>
|
||||||
|
<option value="all_products">All Products</option>
|
||||||
|
<option value="all">All Services & Products</option>
|
||||||
|
<option value="service">Specific Services</option>
|
||||||
|
<option value="product">Specific Products</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="target_services" class="mb-3 d-none">
|
||||||
|
<label class="form-label">Select Services</label>
|
||||||
|
{% for svc in services %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="target_ids"
|
||||||
|
value="{{ svc.id }}" id="svc_{{ svc.id }}">
|
||||||
|
<label class="form-check-label" for="svc_{{ svc.id }}">
|
||||||
|
{{ svc.name }} — ${{ "%.2f"|format(svc.price) }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div id="target_products" class="mb-3 d-none">
|
||||||
|
<label class="form-label">Select Products</label>
|
||||||
|
{% for p in products %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="target_ids"
|
||||||
|
value="{{ p.id }}" id="prod_{{ p.id }}">
|
||||||
|
<label class="form-check-label" for="prod_{{ p.id }}">
|
||||||
|
{{ p.name }} — ${{ "%.2f"|format(p.sale_price) }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Starts At <span class="text-danger">*</span></label>
|
||||||
|
<input type="datetime-local" class="form-control" name="starts_at" required>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Ends At <span class="text-danger">*</span></label>
|
||||||
|
<input type="datetime-local" class="form-control" name="ends_at" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-warning">Create Promotion</button>
|
||||||
|
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function toggleTargets(val) {
|
||||||
|
document.getElementById("target_services").classList.toggle("d-none", val !== "service");
|
||||||
|
document.getElementById("target_products").classList.toggle("d-none", val !== "product");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Service{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Service</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ service.name if service else '' }}" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input type="text" class="form-control" name="category"
|
||||||
|
value="{{ service.category if service else '' }}" placeholder="e.g. Nails, Spa, Waxing">
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Duration (min) <span class="text-danger">*</span></label>
|
||||||
|
<input type="number" class="form-control" name="duration_min" min="5" max="480"
|
||||||
|
value="{{ service.duration_min if service else 30 }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Price ($) <span class="text-danger">*</span></label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="price" min="0"
|
||||||
|
value="{{ "%.2f"|format(service.price) if service else '' }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if mode == 'edit' %}
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||||
|
id="is_active" {{ 'checked' if service and service.is_active }}>
|
||||||
|
<label class="form-check-label" for="is_active">Active</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Settings{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-7">
|
||||||
|
<h4 class="fw-bold mb-4"><i class="bi bi-gear me-2"></i>Settings</h4>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('settings.save') }}" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<h6 class="fw-semibold text-muted mb-3">Booking</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Advance Booking Days</label>
|
||||||
|
<input type="number" class="form-control" name="booking_advance_days"
|
||||||
|
value="{{ settings.get('booking_advance_days', '30') }}" min="1" max="365">
|
||||||
|
<div class="form-text">How many days ahead customers can book online.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="auto_confirm_bookings"
|
||||||
|
value="true" id="auto_confirm"
|
||||||
|
{{ 'checked' if settings.get('auto_confirm_bookings') == 'true' }}>
|
||||||
|
<label class="form-check-label" for="auto_confirm">Auto-confirm online bookings</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="fw-semibold text-muted mb-3 mt-4">Reviews</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Review Request Delay (minutes after checkout)</label>
|
||||||
|
<input type="number" class="form-control" name="review_request_delay_minutes"
|
||||||
|
value="{{ settings.get('review_request_delay_minutes', '60') }}" min="0">
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Google Review URL</label>
|
||||||
|
<input type="url" class="form-control" name="google_review_url"
|
||||||
|
value="{{ settings.get('google_review_url', '') }}" placeholder="https://g.page/…">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Yelp Review URL</label>
|
||||||
|
<input type="url" class="form-control" name="yelp_review_url"
|
||||||
|
value="{{ settings.get('yelp_review_url', '') }}" placeholder="https://yelp.com/…">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="fw-semibold text-muted mb-3 mt-4">Receipt</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Receipt Footer Note</label>
|
||||||
|
<textarea class="form-control" name="receipt_footer_note" rows="2">{{ settings.get('receipt_footer_note', '') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member{% endblock %}
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function togglePayFields(val) {
|
||||||
|
document.getElementById("field_hourly_rate").classList.toggle("d-none", val !== "hourly");
|
||||||
|
document.getElementById("field_salary_amount").classList.toggle("d-none", val !== "salary");
|
||||||
|
document.getElementById("field_guarantee_amount").classList.toggle("d-none", val !== "guarantee");
|
||||||
|
}
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
var pt = document.getElementById("pay_type");
|
||||||
|
if (pt) togglePayFields(pt.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ staff.name if staff else '' }}" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Phone <span class="text-danger">*</span></label>
|
||||||
|
<input type="tel" class="form-control" name="phone"
|
||||||
|
value="{{ staff.phone if staff else '' }}" required>
|
||||||
|
</div>
|
||||||
|
{% if mode == 'create' %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Passcode (4–6 digits) <span class="text-danger">*</span></label>
|
||||||
|
<input type="password" class="form-control" name="passcode"
|
||||||
|
inputmode="numeric" maxlength="6" required>
|
||||||
|
<div class="form-text">Staff will use this PIN to log in. Show it to them once, then discard it.</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Job Type</label>
|
||||||
|
<select class="form-select" name="staff_type">
|
||||||
|
{% for t in ['full_time','part_time','seasonal','receptionist','salon_manager'] %}
|
||||||
|
<option value="{{ t }}" {{ 'selected' if staff and staff.staff_type == t }}>
|
||||||
|
{{ t.replace('_',' ').title() }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Pay Type</label>
|
||||||
|
<select class="form-select" name="pay_type">
|
||||||
|
{% for t in ['hourly','salary','guarantee'] %}
|
||||||
|
<option value="{{ t }}" {{ 'selected' if staff and staff.pay_type == t }}>{{ t.title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-3">
|
||||||
|
<h6 class="fw-semibold text-muted mb-3">Pay Structure</h6>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Pay Type</label>
|
||||||
|
<select class="form-select" name="pay_type" id="pay_type" onchange="togglePayFields(this.value)">
|
||||||
|
{% for t in ['hourly','salary','guarantee'] %}
|
||||||
|
<option value="{{ t }}" {{ 'selected' if staff and staff.pay_type == t }}>{{ t.title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Pay Period</label>
|
||||||
|
<select class="form-select" name="pay_period">
|
||||||
|
{% for t in ['weekly','biweekly','monthly'] %}
|
||||||
|
<option value="{{ t }}" {{ 'selected' if staff and staff.pay_period == t }}>{{ t.title() }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="field_hourly_rate" class="mb-3">
|
||||||
|
<label class="form-label">Hourly Rate ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="hourly_rate" min="0"
|
||||||
|
value="{{ "%.2f"|format(staff.hourly_rate) if staff and staff.hourly_rate else '' }}">
|
||||||
|
</div>
|
||||||
|
<div id="field_salary_amount" class="mb-3 d-none">
|
||||||
|
<label class="form-label">Salary Amount ($ per pay period)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="salary_amount" min="0"
|
||||||
|
value="{{ "%.2f"|format(staff.salary_amount) if staff and staff.salary_amount else '' }}">
|
||||||
|
</div>
|
||||||
|
<div id="field_guarantee_amount" class="mb-3 d-none">
|
||||||
|
<label class="form-label">Guarantee Amount ($ per pay period)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="guarantee_amount" min="0"
|
||||||
|
value="{{ "%.2f"|format(staff.guarantee_amount) if staff and staff.guarantee_amount else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Commission Rate (%)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="commission_rate" min="0" max="100"
|
||||||
|
value="{{ "%.2f"|format(staff.commission_rate) if staff and staff.commission_rate else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col d-flex align-items-end mb-1">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="commission_enabled" value="1"
|
||||||
|
id="commission_enabled"
|
||||||
|
{{ 'checked' if not staff or staff.commission_enabled }}>
|
||||||
|
<label class="form-check-label" for="commission_enabled">Commission Enabled</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Location Assignments</label>
|
||||||
|
{% for loc in locations %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="location_ids"
|
||||||
|
value="{{ loc.id }}" id="loc_{{ loc.id }}"
|
||||||
|
{{ 'checked' if assigned_ids and loc.id in assigned_ids }}>
|
||||||
|
<label class="form-check-label" for="loc_{{ loc.id }}">{{ loc.name }}</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if mode == 'edit' %}
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||||
|
id="is_active" {{ 'checked' if staff and staff.is_active }}>
|
||||||
|
<label class="form-check-label" for="is_active">Active</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('staff.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Staff{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-person-badge me-2"></i>Staff</h4>
|
||||||
|
<a href="{{ url_for('staff.create') }}" class="btn btn-primary btn-sm">+ New Staff Member</a>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Name</th><th>Phone</th><th>Type</th><th>Pay Type</th><th>Status</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in staff_list %}
|
||||||
|
<tr class="{{ '' if s.is_active else 'text-muted' }}">
|
||||||
|
<td class="fw-semibold">{{ s.name }}</td>
|
||||||
|
<td class="small">{{ s.phone }}</td>
|
||||||
|
<td class="small">{{ s.staff_type.replace('_',' ').title() }}</td>
|
||||||
|
<td class="small">{{ s.pay_type.title() }}</td>
|
||||||
|
<td><span class="badge {{ 'bg-success' if s.is_active else 'bg-secondary' }}">{{ 'Active' if s.is_active else 'Inactive' }}</span></td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('staff.edit', staff_id=s.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<a href="{{ url_for('staff.reset_passcode', staff_id=s.id) }}" class="btn btn-outline-warning btn-sm">Reset PIN</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-4">No staff members yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Reset Passcode — {{ staff.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm border-warning">
|
||||||
|
<div class="card-header bg-warning-subtle fw-semibold">Reset Passcode — {{ staff.name }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">Enter a new 4–6 digit PIN for this staff member. Show it to them once, then discard it.</p>
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">New Passcode</label>
|
||||||
|
<input type="text" class="form-control form-control-lg text-center font-monospace"
|
||||||
|
name="passcode" inputmode="numeric" maxlength="6"
|
||||||
|
placeholder="••••" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-warning">Set New Passcode</button>
|
||||||
|
<a href="{{ url_for('staff.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "tenant/base.html" %}
|
{% extends "tenant/layouts/base.html" %}
|
||||||
{% block title %}Staff Login{% endblock %}
|
{% block title %}Staff Login{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Staff Login{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||||
|
<div class="card shadow" style="width: 380px;">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="bi bi-person-badge fs-1 text-primary"></i>
|
||||||
|
<h4 class="mt-2 fw-bold">Staff Login</h4>
|
||||||
|
<p class="text-muted small">Enter your phone number and PIN</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('staff_auth.staff_login') }}" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="phone" class="form-label">Phone number</label>
|
||||||
|
<input type="tel" class="form-control form-control-lg" id="phone" name="phone"
|
||||||
|
autocomplete="tel" placeholder="e.g. 555-123-4567" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="passcode" class="form-label">PIN</label>
|
||||||
|
<input type="password" class="form-control form-control-lg text-center"
|
||||||
|
id="passcode" name="passcode"
|
||||||
|
inputmode="numeric" pattern="[0-9]*"
|
||||||
|
minlength="4" maxlength="6"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
placeholder="••••" required>
|
||||||
|
<div class="form-text text-center">4–6 digit PIN</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">Sign In</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="{{ url_for('tenant_auth.login') }}" class="text-muted small">
|
||||||
|
Manager / Owner login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Commission & Pay{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="fw-bold mb-4">Commission & Pay Summary</h4>
|
||||||
|
{% if pay_periods %}
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header fw-semibold">Pay Periods</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Period</th><th>Type</th><th>Base</th><th>Commission</th><th>Total</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for pp in pay_periods %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{{ pp.period_start.strftime('%b %d') }}–{{ pp.period_end.strftime('%b %d, %Y') }}</td>
|
||||||
|
<td class="small">{{ pp.pay_type.title() }}</td>
|
||||||
|
<td>${{ "%.2f"|format(pp.base_amount) }}</td>
|
||||||
|
<td>${{ "%.2f"|format(pp.commission_amount) }}</td>
|
||||||
|
<td class="fw-semibold">${{ "%.2f"|format(pp.total_amount) }}</td>
|
||||||
|
<td><span class="badge bg-{{ {'paid':'success','approved':'primary','draft':'secondary'}.get(pp.status,'secondary') }}">{{ pp.status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if commission_logs %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Recent Commissions</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead><tr><th>Period</th><th>Amount</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in commission_logs %}
|
||||||
|
<tr><td class="small">{{ log.period or '—' }}</td><td>${{ "%.2f"|format(log.amount) }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('staff_portal.index') }}" class="btn btn-outline-secondary btn-sm mt-3">Back</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Staff Portal{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0">
|
||||||
|
<i class="bi bi-person-badge me-2"></i>Welcome, {{ staff.name }}
|
||||||
|
</h4>
|
||||||
|
<span class="text-muted small">{{ today.strftime('%A, %B %d') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clock in/out -->
|
||||||
|
<div class="card shadow-sm mb-3 {{ 'border-success' if current_clocking else '' }}">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
{% if current_clocking %}
|
||||||
|
<span class="badge bg-success fs-6 me-2">On Shift</span>
|
||||||
|
<span class="text-muted small">Since {{ current_clocking.clocked_in_at.strftime('%I:%M %p') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary fs-6">Off Shift</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if current_clocking %}
|
||||||
|
<form method="POST" action="{{ url_for('staff_portal.clock_out') }}" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-danger">Clock Out</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" action="{{ url_for('staff_portal.clock_in') }}" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-success">Clock In</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Today's appointments -->
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header fw-semibold">
|
||||||
|
Today's Appointments
|
||||||
|
<a href="{{ url_for('staff_portal.schedule') }}" class="btn btn-outline-secondary btn-sm float-end">Full Schedule</a>
|
||||||
|
</div>
|
||||||
|
{% if todays_appointments %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Time</th><th>Customer</th><th>Service</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in todays_appointments %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ a.start_time.strftime('%I:%M %p') }}</td>
|
||||||
|
<td>{{ a.customer.name if a.customer else 'Walk-in' }}</td>
|
||||||
|
<td class="small">{{ a.service.name if a.service else '—' }}</td>
|
||||||
|
<td><span class="badge bg-{{ {'confirmed':'primary','in_progress':'info','pending':'warning'}.get(a.status,'secondary') }}">{{ a.status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-muted small">No appointments scheduled for today.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6"><a href="{{ url_for('staff_portal.commission') }}" class="btn btn-outline-primary w-100">Commission & Pay</a></div>
|
||||||
|
<div class="col-6"><a href="{{ url_for('staff_portal.profile') }}" class="btn btn-outline-secondary w-100">My Profile</a></div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}My Profile{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="fw-bold mb-4">My Profile</h4>
|
||||||
|
<div class="card shadow-sm" style="max-width:400px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-4">Name</dt><dd class="col-sm-8">{{ staff.name }}</dd>
|
||||||
|
<dt class="col-sm-4">Phone</dt><dd class="col-sm-8">{{ staff.phone }}</dd>
|
||||||
|
<dt class="col-sm-4">Type</dt><dd class="col-sm-8">{{ staff.staff_type.replace('_',' ').title() }}</dd>
|
||||||
|
<dt class="col-sm-4">Pay Type</dt><dd class="col-sm-8">{{ staff.pay_type.title() }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('staff_portal.index') }}" class="btn btn-outline-secondary btn-sm mt-3">Back</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}My Schedule{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="fw-bold mb-4">My Schedule — Next 7 Days</h4>
|
||||||
|
{% if appointments %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Date</th><th>Time</th><th>Customer</th><th>Service</th><th>Status</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in appointments %}
|
||||||
|
<tr>
|
||||||
|
<td class="small">{{ a.start_time.strftime('%b %d') }}</td>
|
||||||
|
<td class="fw-semibold">{{ a.start_time.strftime('%I:%M %p') }}</td>
|
||||||
|
<td>{{ a.customer.name if a.customer else 'Walk-in' }}</td>
|
||||||
|
<td class="small">{{ a.service.name if a.service else '—' }}</td>
|
||||||
|
<td><span class="badge bg-{{ {'confirmed':'primary','in_progress':'info','pending':'warning'}.get(a.status,'secondary') }}">{{ a.status }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-light">No upcoming appointments in the next 7 days.</div>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('staff_portal.index') }}" class="btn btn-outline-secondary btn-sm mt-3">Back</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Add to Waitlist{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Add to Waitlist</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Customer Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="customer_name" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Phone</label>
|
||||||
|
<input type="tel" class="form-control" name="customer_phone">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" name="customer_email">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Preferred Service</label>
|
||||||
|
<select class="form-select" name="service_id">
|
||||||
|
<option value="">— Any —</option>
|
||||||
|
{% for s in services %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Preferred Staff</label>
|
||||||
|
<select class="form-select" name="staff_id">
|
||||||
|
<option value="">— Any —</option>
|
||||||
|
{% for s in staff_list %}
|
||||||
|
<option value="{{ s.id }}">{{ s.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Requested Date</label>
|
||||||
|
<input type="date" class="form-control" name="requested_date">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Add to Waitlist</button>
|
||||||
|
<a href="{{ url_for('waitlist.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Waitlist{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-clock-history me-2"></i>Waitlist</h4>
|
||||||
|
<a href="{{ url_for('waitlist.add') }}" class="btn btn-primary btn-sm">+ Add to Waitlist</a>
|
||||||
|
</div>
|
||||||
|
{% if entries %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Name</th><th>Phone</th><th>Service</th><th>Staff</th><th>Requested</th><th>Status</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in entries %}
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">{{ e.customer_name }}</td>
|
||||||
|
<td class="small">{{ e.customer_phone or '—' }}</td>
|
||||||
|
<td class="small">{{ e.service.name if e.service else '—' }}</td>
|
||||||
|
<td class="small">{{ e.staff.name if e.staff else '—' }}</td>
|
||||||
|
<td class="small text-muted">{{ e.requested_date.strftime('%b %d') if e.requested_date else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ 'bg-warning text-dark' if e.status == 'waiting' else 'bg-info' }}">
|
||||||
|
{{ e.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="d-flex gap-1">
|
||||||
|
{% if e.status == 'waiting' %}
|
||||||
|
<form method="POST" action="{{ url_for('waitlist.notify', entry_id=e.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="btn btn-outline-primary btn-sm">Notify</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ url_for('waitlist.set_status', entry_id=e.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="status" value="booked">
|
||||||
|
<button class="btn btn-outline-success btn-sm">Booked</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{ url_for('waitlist.set_status', entry_id=e.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="status" value="expired">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm">Expire</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-light">No active waitlist entries.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
+61
-10
@@ -18,10 +18,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def create_tenant_app(config_override=None):
|
def create_tenant_app(config_override=None):
|
||||||
|
import os as _os
|
||||||
|
# Resolve paths relative to this file to avoid CWD-dependent behaviour.
|
||||||
|
# app/tenant/__init__.py → ../../templates = <project_root>/templates
|
||||||
|
_here = _os.path.dirname(_os.path.abspath(__file__))
|
||||||
|
_root = _os.path.normpath(_os.path.join(_here, "..", ".."))
|
||||||
flask_app = Flask(
|
flask_app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
template_folder="../templates",
|
template_folder=_os.path.join(_root, "templates"),
|
||||||
static_folder="../static/tenant",
|
static_folder=_os.path.join(_root, "static", "tenant"),
|
||||||
static_url_path="/static",
|
static_url_path="/static",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,16 +92,62 @@ def create_tenant_app(config_override=None):
|
|||||||
from app.tenant.staff_auth.routes import staff_auth_bp
|
from app.tenant.staff_auth.routes import staff_auth_bp
|
||||||
from app.tenant.checkin.routes import checkin_bp
|
from app.tenant.checkin.routes import checkin_bp
|
||||||
from app.tenant.dashboard.routes import dashboard_bp
|
from app.tenant.dashboard.routes import dashboard_bp
|
||||||
|
from app.tenant.locations.routes import locations_bp
|
||||||
|
from app.tenant.customers.routes import customers_bp
|
||||||
|
from app.tenant.services.routes import services_bp
|
||||||
|
from app.tenant.appointments.routes import appointments_bp
|
||||||
|
from app.tenant.pos.routes import pos_bp
|
||||||
|
from app.tenant.gift_cards.routes import gift_cards_bp
|
||||||
|
from app.tenant.staff.routes import staff_bp
|
||||||
|
from app.tenant.staff_portal.routes import staff_portal_bp
|
||||||
|
from app.tenant.booking.routes import booking_bp
|
||||||
|
from app.tenant.waitlist.routes import waitlist_bp
|
||||||
|
from app.tenant.reviews.routes import reviews_bp
|
||||||
|
from app.tenant.reconciliation.routes import reconciliation_bp
|
||||||
|
from app.tenant.settings.routes import settings_bp
|
||||||
|
|
||||||
flask_app.register_blueprint(tenant_auth_bp)
|
for bp in [
|
||||||
flask_app.register_blueprint(staff_auth_bp)
|
tenant_auth_bp, staff_auth_bp, checkin_bp, dashboard_bp,
|
||||||
flask_app.register_blueprint(checkin_bp)
|
locations_bp, customers_bp, services_bp, appointments_bp,
|
||||||
flask_app.register_blueprint(dashboard_bp)
|
pos_bp, gift_cards_bp, staff_bp, staff_portal_bp,
|
||||||
|
booking_bp, waitlist_bp, reviews_bp, reconciliation_bp,
|
||||||
|
settings_bp,
|
||||||
|
]:
|
||||||
|
flask_app.register_blueprint(bp)
|
||||||
|
|
||||||
# Remaining blueprints registered in Phases 3–6:
|
# Phase 4+ stubs (inventory, marketing, reports)
|
||||||
# locations, customers, appointments, services, pos, staff,
|
from app.tenant.inventory.routes import inventory_bp
|
||||||
# staff_portal, booking, waitlist, gift_cards, reviews,
|
from app.tenant.marketing.routes import marketing_bp
|
||||||
# reconciliation, inventory, marketing, reports, settings
|
from app.tenant.reports.routes import reports_bp
|
||||||
|
from app.tenant.pay_periods.routes import pay_periods_bp
|
||||||
|
flask_app.register_blueprint(inventory_bp)
|
||||||
|
flask_app.register_blueprint(marketing_bp)
|
||||||
|
flask_app.register_blueprint(reports_bp)
|
||||||
|
flask_app.register_blueprint(pay_periods_bp)
|
||||||
|
|
||||||
|
# ── APScheduler ───────────────────────────────────────────
|
||||||
|
flask_app.config.setdefault("SCHEDULER_API_ENABLED", False)
|
||||||
|
flask_app.config.setdefault("SCHEDULER_TIMEZONE", "UTC")
|
||||||
|
from app.scheduler_jobs import send_appointment_reminders, send_review_requests
|
||||||
|
if not scheduler.running:
|
||||||
|
scheduler.init_app(flask_app)
|
||||||
|
scheduler.add_job(
|
||||||
|
id="appointment_reminders",
|
||||||
|
func=send_appointment_reminders,
|
||||||
|
args=[flask_app],
|
||||||
|
trigger="interval",
|
||||||
|
minutes=5,
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.add_job(
|
||||||
|
id="review_requests",
|
||||||
|
func=send_review_requests,
|
||||||
|
args=[flask_app],
|
||||||
|
trigger="interval",
|
||||||
|
minutes=10,
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
# ── Import all models for Migrate ─────────────────────────
|
# ── Import all models for Migrate ─────────────────────────
|
||||||
import app.models # noqa: F401
|
import app.models # noqa: F401
|
||||||
|
|||||||
@@ -1,7 +1,247 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/appointments/routes.py
|
app/tenant/appointments/routes.py
|
||||||
Phase 3+ implementation.
|
Appointment management: calendar view, create, edit, status workflow,
|
||||||
|
cancellation reason, no-show capture.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta, date
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g, jsonify
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import Appointment, Customer, Staff, Service, Location
|
||||||
|
from app.decorators import require_role, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments")
|
appointments_bp = Blueprint("appointments", __name__, url_prefix="/appointments")
|
||||||
|
|
||||||
|
VALID_STATUSES = {"pending", "confirmed", "in_progress", "completed", "cancelled", "no_show"}
|
||||||
|
|
||||||
|
|
||||||
|
@appointments_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
# Default to today
|
||||||
|
date_str = request.args.get("date", date.today().isoformat())
|
||||||
|
try:
|
||||||
|
view_date = date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
view_date = date.today()
|
||||||
|
|
||||||
|
day_start = datetime.combine(view_date, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
|
||||||
|
appts = Appointment.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id
|
||||||
|
).filter(
|
||||||
|
Appointment.start_time >= day_start,
|
||||||
|
Appointment.start_time < day_end,
|
||||||
|
).order_by(Appointment.start_time).all()
|
||||||
|
|
||||||
|
staff_list = Staff.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
|
||||||
|
services = Service.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Service.deleted_at.is_(None)).order_by(Service.name).all()
|
||||||
|
|
||||||
|
prev_date = view_date - timedelta(days=1)
|
||||||
|
next_date = view_date + timedelta(days=1)
|
||||||
|
|
||||||
|
return render_template("tenant/appointments/index.html",
|
||||||
|
appointments=appts, view_date=view_date,
|
||||||
|
prev_date=prev_date, next_date=next_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})
|
||||||
|
|
||||||
|
# Schedule 24h and 2h email reminders
|
||||||
|
from app.models.salon import AppointmentReminder as AR
|
||||||
|
for hours, rtype in [(24, "24h"), (2, "2h")]:
|
||||||
|
remind_at = start_time - timedelta(hours=hours)
|
||||||
|
if remind_at > datetime.now(timezone.utc):
|
||||||
|
db.session.add(AR(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
location_id=g.location.id,
|
||||||
|
appointment_id=appt.id,
|
||||||
|
reminder_type=rtype,
|
||||||
|
scheduled_for=remind_at,
|
||||||
|
channel="email",
|
||||||
|
status="pending",
|
||||||
|
))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash("Appointment created.", "success")
|
||||||
|
return redirect(url_for("appointments.index",
|
||||||
|
date=start_time.date().isoformat()))
|
||||||
|
return render_template("tenant/appointments/form.html",
|
||||||
|
mode="create", staff_list=staff_list,
|
||||||
|
services=services, customers=customers)
|
||||||
|
|
||||||
|
|
||||||
|
@appointments_bp.route("/<int:appt_id>")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def view(appt_id):
|
||||||
|
appt = Appointment.query.filter_by(
|
||||||
|
id=appt_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
return render_template("tenant/appointments/view.html", appointment=appt)
|
||||||
|
|
||||||
|
|
||||||
|
@appointments_bp.route("/<int:appt_id>/status", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def set_status(appt_id):
|
||||||
|
appt = Appointment.query.filter_by(
|
||||||
|
id=appt_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
new_status = request.form.get("status", "").strip()
|
||||||
|
if new_status not in VALID_STATUSES:
|
||||||
|
flash("Invalid status.", "danger")
|
||||||
|
return redirect(url_for("appointments.view", appt_id=appt_id))
|
||||||
|
|
||||||
|
old_status = appt.status
|
||||||
|
appt.status = new_status
|
||||||
|
|
||||||
|
if new_status == "cancelled":
|
||||||
|
appt.cancellation_reason = request.form.get("reason", "").strip() or None
|
||||||
|
appt.cancelled_at = datetime.now(timezone.utc)
|
||||||
|
# Cancel pending reminders
|
||||||
|
from app.models.salon import AppointmentReminder
|
||||||
|
AppointmentReminder.query.filter_by(
|
||||||
|
appointment_id=appt_id, status="pending"
|
||||||
|
).update({"status": "cancelled"})
|
||||||
|
|
||||||
|
elif new_status == "no_show" and appt.customer_id:
|
||||||
|
# Increment no-show counter on customer record
|
||||||
|
Customer.query.filter_by(id=appt.customer_id).update(
|
||||||
|
{"no_show_count": Customer.no_show_count + 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
log_tenant_action("appointment.set_status", "appointment", appt.id,
|
||||||
|
{"old": old_status, "new": new_status})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Appointment status updated to '{new_status}'.", "success")
|
||||||
|
return redirect(url_for("appointments.view", appt_id=appt_id))
|
||||||
|
|
||||||
|
|
||||||
|
@appointments_bp.route("/<int:appt_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def edit(appt_id):
|
||||||
|
appt = Appointment.query.filter_by(
|
||||||
|
id=appt_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
staff_list = Staff.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
|
||||||
|
services = Service.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Service.deleted_at.is_(None)).order_by(Service.name).all()
|
||||||
|
customers = Customer.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
start_raw = request.form.get("start_time", "")
|
||||||
|
try:
|
||||||
|
start_time = datetime.fromisoformat(start_raw)
|
||||||
|
if start_time.tzinfo is None:
|
||||||
|
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/appointments/form.html",
|
||||||
|
mode="edit", appointment=appt,
|
||||||
|
staff_list=staff_list, services=services,
|
||||||
|
customers=customers,
|
||||||
|
error="Invalid date/time.")
|
||||||
|
service_id = request.form.get("service_id", type=int)
|
||||||
|
svc = Service.query.get(service_id) if service_id else None
|
||||||
|
duration = svc.duration_min if svc else 30
|
||||||
|
|
||||||
|
appt.customer_id = request.form.get("customer_id", type=int) or None
|
||||||
|
appt.staff_id = request.form.get("staff_id", type=int) or None
|
||||||
|
appt.service_id = service_id
|
||||||
|
appt.start_time = start_time
|
||||||
|
appt.end_time = start_time + timedelta(minutes=duration)
|
||||||
|
appt.notes = request.form.get("notes", "").strip() or None
|
||||||
|
|
||||||
|
log_tenant_action("appointment.edit", "appointment", appt.id,
|
||||||
|
{"start": start_raw})
|
||||||
|
db.session.commit()
|
||||||
|
flash("Appointment updated.", "success")
|
||||||
|
return redirect(url_for("appointments.view", appt_id=appt_id))
|
||||||
|
|
||||||
|
return render_template("tenant/appointments/form.html",
|
||||||
|
mode="edit", appointment=appt,
|
||||||
|
staff_list=staff_list, services=services,
|
||||||
|
customers=customers)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_db_id():
|
||||||
|
from flask_login import current_user
|
||||||
|
try:
|
||||||
|
uid_str = current_user.get_id()
|
||||||
|
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,7 +1,128 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/booking/routes.py
|
app/tenant/booking/routes.py
|
||||||
Phase 3+ implementation.
|
Public online customer booking — /book/<tenant_slug>
|
||||||
|
No authentication required.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta, date
|
||||||
|
from flask import Blueprint, render_template, request, g
|
||||||
|
from app.extensions import db, limiter, csrf, mail
|
||||||
|
from app.models.platform import Tenant
|
||||||
|
from app.models.salon import Appointment, Customer, Staff, Service, Location
|
||||||
|
from app.decorators import tenant_feature_required
|
||||||
|
|
||||||
booking_bp = Blueprint("booking", __name__, url_prefix="")
|
logger = logging.getLogger(__name__)
|
||||||
|
booking_bp = Blueprint("booking", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@booking_bp.route("/book/<tenant_slug>", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("15 per minute")
|
||||||
|
@csrf.exempt
|
||||||
|
def public_booking(tenant_slug):
|
||||||
|
tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
|
||||||
|
Tenant.status.in_(["active", "trial"])
|
||||||
|
).first()
|
||||||
|
if not tenant:
|
||||||
|
return render_template("tenant/booking/not_found.html"), 404
|
||||||
|
|
||||||
|
if not tenant.has_feature("online_booking"):
|
||||||
|
return render_template("tenant/booking/unavailable.html", tenant=tenant), 403
|
||||||
|
|
||||||
|
location = Location.query.filter_by(
|
||||||
|
tenant_id=tenant.id, is_primary=True, is_active=True
|
||||||
|
).first() or Location.query.filter_by(
|
||||||
|
tenant_id=tenant.id, is_active=True).first()
|
||||||
|
if not location:
|
||||||
|
return render_template("tenant/booking/unavailable.html", tenant=tenant), 403
|
||||||
|
|
||||||
|
services = Service.query.filter_by(
|
||||||
|
tenant_id=tenant.id, is_active=True).filter(
|
||||||
|
Service.deleted_at.is_(None)).order_by(Service.name).all()
|
||||||
|
staff_list = Staff.query.filter_by(
|
||||||
|
tenant_id=tenant.id, is_active=True).filter(
|
||||||
|
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
|
||||||
|
|
||||||
|
confirmed = False
|
||||||
|
error = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()[:255]
|
||||||
|
phone = request.form.get("phone", "").strip()[:30]
|
||||||
|
email = request.form.get("email", "").strip()[:255] or None
|
||||||
|
service_id = request.form.get("service_id", type=int)
|
||||||
|
staff_id = request.form.get("staff_id", type=int) or None
|
||||||
|
date_raw = request.form.get("preferred_date", "")
|
||||||
|
time_raw = request.form.get("preferred_time", "")
|
||||||
|
notes = request.form.get("notes", "").strip()[:500] or None
|
||||||
|
|
||||||
|
if not name or not phone or not service_id or not date_raw or not time_raw:
|
||||||
|
error = "Please complete all required fields."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
start_time = datetime.fromisoformat(f"{date_raw}T{time_raw}")
|
||||||
|
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
error = "Invalid date or time."
|
||||||
|
start_time = None
|
||||||
|
|
||||||
|
if start_time and start_time < datetime.now(timezone.utc):
|
||||||
|
error = "Please choose a future date and time."
|
||||||
|
|
||||||
|
if not error:
|
||||||
|
svc = Service.query.filter_by(
|
||||||
|
id=service_id, tenant_id=tenant.id).first()
|
||||||
|
duration = svc.duration_min if svc else 30
|
||||||
|
end_time = start_time + timedelta(minutes=duration)
|
||||||
|
|
||||||
|
# Look up or create customer
|
||||||
|
customer = Customer.query.filter_by(
|
||||||
|
tenant_id=tenant.id, phone=phone).filter(
|
||||||
|
Customer.deleted_at.is_(None)).first()
|
||||||
|
if not customer:
|
||||||
|
customer = Customer(
|
||||||
|
tenant_id=tenant.id, name=name, phone=phone,
|
||||||
|
email=email, is_active=True, loyalty_points=0,
|
||||||
|
no_show_count=0,
|
||||||
|
)
|
||||||
|
db.session.add(customer)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
appt = Appointment(
|
||||||
|
tenant_id=tenant.id, location_id=location.id,
|
||||||
|
customer_id=customer.id,
|
||||||
|
staff_id=staff_id, service_id=service_id,
|
||||||
|
start_time=start_time, end_time=end_time,
|
||||||
|
is_walk_in=False, status="pending",
|
||||||
|
notes=notes, rebook_source="online",
|
||||||
|
)
|
||||||
|
db.session.add(appt)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info("Online booking: tenant=%s appt=%s customer=%s",
|
||||||
|
tenant.id, appt.id, customer.id)
|
||||||
|
|
||||||
|
# Send confirmation email
|
||||||
|
if email:
|
||||||
|
try:
|
||||||
|
from flask_mail import Message
|
||||||
|
msg = Message(
|
||||||
|
subject=f"Booking Confirmed — {tenant.name}",
|
||||||
|
recipients=[email],
|
||||||
|
body=(
|
||||||
|
f"Hi {name},\n\nYour appointment has been requested.\n"
|
||||||
|
f"Service: {svc.name if svc else 'N/A'}\n"
|
||||||
|
f"Date: {start_time.strftime('%B %d, %Y at %I:%M %p')}\n\n"
|
||||||
|
f"We'll confirm your booking shortly. Thank you!"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Booking confirmation email failed: %s", exc)
|
||||||
|
|
||||||
|
confirmed = True
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"tenant/booking/form.html",
|
||||||
|
tenant=tenant, services=services, staff_list=staff_list,
|
||||||
|
confirmed=confirmed, error=error,
|
||||||
|
)
|
||||||
|
|||||||
+69
-102
@@ -1,135 +1,102 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/checkin/routes.py — Customer self check-in kiosk.
|
app/tenant/checkin/routes.py
|
||||||
Route: GET/POST /checkin/<tenant_slug>
|
Customer self check-in kiosk — /checkin/<tenant_slug>
|
||||||
No authentication required. CSRF-exempt. Rate-limited.
|
No authentication required. CSRF-exempt. Rate-limited.
|
||||||
Alert delivery: 5-second polling via GET /api/v1/checkin/queue?status=waiting.
|
Receptionist alert delivered via 5-second polling: GET /api/v1/checkin/queue?status=waiting
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask import (
|
from flask import Blueprint, render_template, request, redirect, url_for, g
|
||||||
Blueprint, render_template, request, jsonify,
|
|
||||||
current_app, abort,
|
|
||||||
)
|
|
||||||
from app.extensions import db, limiter, csrf
|
from app.extensions import db, limiter, csrf
|
||||||
from app.models.platform import Tenant
|
from app.models.platform import Tenant
|
||||||
from app.models.salon import Customer, CheckinQueue, Location
|
from app.models.salon import CheckinQueue, Customer, Service, Location
|
||||||
from app.security import sanitise_string, validate_slug
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
checkin_bp = Blueprint("checkin", __name__)
|
checkin_bp = Blueprint("checkin", __name__)
|
||||||
|
|
||||||
|
|
||||||
@checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"])
|
@checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"])
|
||||||
@csrf.exempt
|
|
||||||
@limiter.limit("20 per minute")
|
@limiter.limit("20 per minute")
|
||||||
def kiosk(tenant_slug: str):
|
@csrf.exempt
|
||||||
"""
|
def kiosk(tenant_slug):
|
||||||
Public kiosk page. No auth required.
|
# Validate slug against known tenants — prevents enumeration
|
||||||
Tenant slug validated against known slugs (not guessable).
|
tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
|
||||||
Accepts: name, phone, service_requested — all other fields ignored.
|
Tenant.status.in_(["active", "trial"])
|
||||||
Phone is sanitised and validated before profile lookup.
|
).first()
|
||||||
Page auto-resets after 10 seconds (configurable per tenant) via JS.
|
if not tenant:
|
||||||
"""
|
return render_template("tenant/checkin/not_found.html"), 404
|
||||||
# Validate slug format before hitting the DB
|
|
||||||
if not validate_slug(tenant_slug):
|
|
||||||
logger.warning("Kiosk: invalid slug format: %s", tenant_slug)
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
tenant = Tenant.query.filter_by(slug=tenant_slug, is_demo=False).first()
|
# Resolve location: session-stored or primary
|
||||||
if not tenant or not tenant.is_active_status():
|
|
||||||
logger.warning("Kiosk: tenant not found or inactive: %s", tenant_slug)
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
# Resolve the primary location for this tenant
|
|
||||||
location = Location.query.filter_by(
|
location = Location.query.filter_by(
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id, is_primary=True, is_active=True
|
||||||
is_primary=True,
|
).first()
|
||||||
is_active=True,
|
if not location:
|
||||||
).filter(Location.deleted_at.is_(None)).first()
|
location = Location.query.filter_by(
|
||||||
|
tenant_id=tenant.id, is_active=True
|
||||||
|
).first()
|
||||||
|
if not location:
|
||||||
|
return render_template("tenant/checkin/not_found.html"), 404
|
||||||
|
|
||||||
if location is None:
|
services = Service.query.filter_by(
|
||||||
abort(404)
|
tenant_id=tenant.id, is_active=True).filter(
|
||||||
|
Service.deleted_at.is_(None)).order_by(Service.name).all()
|
||||||
|
|
||||||
confirmed = False
|
confirmed = False
|
||||||
|
error = None
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
raw_name = request.form.get("customer_name", "")
|
# Accept ONLY these three fields — all others ignored
|
||||||
raw_phone = request.form.get("customer_phone", "")
|
customer_name = request.form.get("customer_name", "").strip()[:255]
|
||||||
raw_service = request.form.get("service_requested", "")
|
customer_phone = request.form.get("customer_phone", "").strip()[:30]
|
||||||
|
service_requested = request.form.get("service_requested", "").strip()[:255] or None
|
||||||
|
|
||||||
name = sanitise_string(raw_name, max_length=150)
|
if not customer_name or not customer_phone:
|
||||||
phone = sanitise_string(raw_phone, max_length=30)
|
error = "Please enter your name and phone number."
|
||||||
service = sanitise_string(raw_service, max_length=150)
|
else:
|
||||||
|
# Look up or create customer profile
|
||||||
|
customer = Customer.query.filter_by(
|
||||||
|
tenant_id=tenant.id, phone=customer_phone
|
||||||
|
).filter(Customer.deleted_at.is_(None)).first()
|
||||||
|
customer_id = None
|
||||||
|
if customer:
|
||||||
|
customer_id = customer.id
|
||||||
|
else:
|
||||||
|
# Auto-create profile
|
||||||
|
new_cust = Customer(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
name=customer_name,
|
||||||
|
phone=customer_phone,
|
||||||
|
is_active=True,
|
||||||
|
loyalty_points=0,
|
||||||
|
no_show_count=0,
|
||||||
|
)
|
||||||
|
db.session.add(new_cust)
|
||||||
|
db.session.flush()
|
||||||
|
customer_id = new_cust.id
|
||||||
|
logger.info("Kiosk: new customer profile created tenant=%s phone=%s",
|
||||||
|
tenant.id, customer_phone)
|
||||||
|
|
||||||
# Validate required fields
|
entry = CheckinQueue(
|
||||||
if not name or not phone:
|
|
||||||
return render_template(
|
|
||||||
"tenant/checkin/kiosk.html",
|
|
||||||
tenant=tenant,
|
|
||||||
error="Name and phone number are required.",
|
|
||||||
confirmed=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Strip non-digit characters from phone for lookup consistency
|
|
||||||
phone_digits = "".join(filter(str.isdigit, phone))
|
|
||||||
if len(phone_digits) < 7:
|
|
||||||
return render_template(
|
|
||||||
"tenant/checkin/kiosk.html",
|
|
||||||
tenant=tenant,
|
|
||||||
error="Please enter a valid phone number.",
|
|
||||||
confirmed=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Look up or create customer profile
|
|
||||||
customer = Customer.query.filter_by(
|
|
||||||
tenant_id=tenant.id,
|
|
||||||
phone=phone_digits,
|
|
||||||
).filter(Customer.deleted_at.is_(None)).first()
|
|
||||||
|
|
||||||
if customer is None:
|
|
||||||
customer = Customer(
|
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
name=name,
|
location_id=location.id,
|
||||||
phone=phone_digits,
|
customer_id=customer_id,
|
||||||
)
|
customer_name=customer_name,
|
||||||
db.session.add(customer)
|
customer_phone=customer_phone,
|
||||||
db.session.flush() # get customer.id before committing
|
service_requested=service_requested,
|
||||||
logger.info(
|
checked_in_at=datetime.now(timezone.utc),
|
||||||
"Kiosk: new customer profile created for tenant %s", tenant.slug
|
status="waiting",
|
||||||
)
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
# Queue the walk-in entry
|
logger.info("Kiosk check-in: tenant=%s location=%s customer=%s service=%s",
|
||||||
entry = CheckinQueue(
|
tenant.id, location.id, customer_id, service_requested)
|
||||||
tenant_id=tenant.id,
|
confirmed = True
|
||||||
location_id=location.id,
|
|
||||||
customer_id=customer.id,
|
|
||||||
customer_name=name,
|
|
||||||
customer_phone=phone_digits,
|
|
||||||
service_requested=service or None,
|
|
||||||
status="waiting",
|
|
||||||
)
|
|
||||||
db.session.add(entry)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Kiosk: check-in queued for tenant=%s location=%s customer=%s",
|
|
||||||
tenant.slug, location.id, customer.id,
|
|
||||||
)
|
|
||||||
confirmed = True
|
|
||||||
|
|
||||||
# Fetch services for the kiosk selector (if configured)
|
|
||||||
from app.models.salon import Service
|
|
||||||
services = Service.query.filter_by(
|
|
||||||
tenant_id=tenant.id,
|
|
||||||
is_active=True,
|
|
||||||
).filter(Service.deleted_at.is_(None)).order_by(Service.name).all()
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"tenant/checkin/kiosk.html",
|
"tenant/checkin/kiosk.html",
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
services=services,
|
services=services,
|
||||||
confirmed=confirmed,
|
confirmed=confirmed,
|
||||||
error=None,
|
error=error,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,135 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/customers/routes.py
|
app/tenant/customers/routes.py
|
||||||
Phase 3+ implementation.
|
Customer management: list (search), create, view, edit, soft-delete, restore.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import Customer, Appointment, Transaction
|
||||||
|
from app.decorators import require_role, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
|
customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
query = Customer.query.filter_by(tenant_id=g.tenant.id).filter(
|
||||||
|
Customer.deleted_at.is_(None))
|
||||||
|
if q:
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Customer.name.ilike(f"%{q}%"),
|
||||||
|
Customer.phone.ilike(f"%{q}%"),
|
||||||
|
Customer.email.ilike(f"%{q}%"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
customers = query.order_by(Customer.name).limit(200).all()
|
||||||
|
return render_template("tenant/customers/index.html", customers=customers, q=q)
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route("/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def create():
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
phone = request.form.get("phone", "").strip() or None
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/customers/form.html",
|
||||||
|
mode="create", error="Name is required.")
|
||||||
|
customer = Customer(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
name=name, phone=phone,
|
||||||
|
email=request.form.get("email", "").strip() or None,
|
||||||
|
date_of_birth=_parse_date(request.form.get("date_of_birth", "")),
|
||||||
|
notes=request.form.get("notes", "").strip() or None,
|
||||||
|
is_active=True, loyalty_points=0, no_show_count=0,
|
||||||
|
)
|
||||||
|
db.session.add(customer)
|
||||||
|
db.session.flush()
|
||||||
|
log_tenant_action("customer.create", "customer", customer.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Customer \'{name}\' created.", "success")
|
||||||
|
return redirect(url_for("customers.view", customer_id=customer.id))
|
||||||
|
return render_template("tenant/customers/form.html", mode="create")
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route("/<int:customer_id>")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def view(customer_id):
|
||||||
|
customer = Customer.query.filter_by(
|
||||||
|
id=customer_id, tenant_id=g.tenant.id).filter(
|
||||||
|
Customer.deleted_at.is_(None)).first_or_404()
|
||||||
|
appointments = Appointment.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, customer_id=customer_id
|
||||||
|
).order_by(Appointment.start_time.desc()).limit(20).all()
|
||||||
|
transactions = Transaction.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, customer_id=customer_id
|
||||||
|
).filter(Transaction.voided_at.is_(None)).order_by(
|
||||||
|
Transaction.created_at.desc()).limit(20).all()
|
||||||
|
return render_template("tenant/customers/view.html",
|
||||||
|
customer=customer,
|
||||||
|
appointments=appointments,
|
||||||
|
transactions=transactions)
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route("/<int:customer_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def edit(customer_id):
|
||||||
|
customer = Customer.query.filter_by(
|
||||||
|
id=customer_id, tenant_id=g.tenant.id).filter(
|
||||||
|
Customer.deleted_at.is_(None)).first_or_404()
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/customers/form.html",
|
||||||
|
mode="edit", customer=customer,
|
||||||
|
error="Name is required.")
|
||||||
|
customer.name = name
|
||||||
|
customer.phone = request.form.get("phone", "").strip() or None
|
||||||
|
customer.email = request.form.get("email", "").strip() or None
|
||||||
|
customer.date_of_birth = _parse_date(request.form.get("date_of_birth", ""))
|
||||||
|
customer.notes = request.form.get("notes", "").strip() or None
|
||||||
|
log_tenant_action("customer.edit", "customer", customer.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Customer \'{name}\' updated.", "success")
|
||||||
|
return redirect(url_for("customers.view", customer_id=customer.id))
|
||||||
|
return render_template("tenant/customers/form.html",
|
||||||
|
mode="edit", customer=customer)
|
||||||
|
|
||||||
|
|
||||||
|
@customers_bp.route("/<int:customer_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def soft_delete(customer_id):
|
||||||
|
customer = Customer.query.filter_by(
|
||||||
|
id=customer_id, tenant_id=g.tenant.id).filter(
|
||||||
|
Customer.deleted_at.is_(None)).first_or_404()
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
customer.deleted_at = datetime.now(timezone.utc)
|
||||||
|
log_tenant_action("customer.delete", "customer", customer.id,
|
||||||
|
{"name": customer.name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Customer \'{customer.name}\' deleted.", "success")
|
||||||
|
return redirect(url_for("customers.index"))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from datetime import date
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/dashboard/routes.py — Tenant dashboard (placeholder for Phase 3 KPIs).
|
app/tenant/dashboard/routes.py
|
||||||
|
Dashboard with Phase 3 KPI widgets scoped to active location.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta, date
|
||||||
|
from decimal import Decimal
|
||||||
from flask import Blueprint, render_template, g
|
from flask import Blueprint, render_template, g
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import (
|
||||||
|
Appointment, Transaction, Staff, CheckinQueue, StaffClocking,
|
||||||
|
)
|
||||||
from app.decorators import require_role
|
from app.decorators import require_role
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
dashboard_bp = Blueprint("dashboard", __name__)
|
dashboard_bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -16,8 +22,72 @@ dashboard_bp = Blueprint("dashboard", __name__)
|
|||||||
@login_required
|
@login_required
|
||||||
@require_role("tenant_admin", "tenant_manager")
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
def index():
|
def index():
|
||||||
|
today = date.today()
|
||||||
|
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
location_id = g.location.id if g.location else None
|
||||||
|
|
||||||
|
# ── Today's revenue ──────────────────────────────────────
|
||||||
|
revenue_row = db.session.query(
|
||||||
|
func.sum(Transaction.total)
|
||||||
|
).filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=location_id
|
||||||
|
).filter(
|
||||||
|
Transaction.created_at >= day_start,
|
||||||
|
Transaction.created_at < day_end,
|
||||||
|
Transaction.voided_at.is_(None),
|
||||||
|
).scalar()
|
||||||
|
today_revenue = float(revenue_row or 0)
|
||||||
|
|
||||||
|
# ── Today's appointments ─────────────────────────────────
|
||||||
|
appt_counts = dict(
|
||||||
|
db.session.query(Appointment.status, func.count(Appointment.id))
|
||||||
|
.filter_by(tenant_id=g.tenant.id, location_id=location_id)
|
||||||
|
.filter(
|
||||||
|
Appointment.start_time >= day_start,
|
||||||
|
Appointment.start_time < day_end,
|
||||||
|
)
|
||||||
|
.group_by(Appointment.status)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_appts = sum(appt_counts.values())
|
||||||
|
completed_appts = appt_counts.get("completed", 0)
|
||||||
|
pending_appts = appt_counts.get("pending", 0) + appt_counts.get("confirmed", 0)
|
||||||
|
|
||||||
|
# ── Staff on shift ────────────────────────────────────────
|
||||||
|
staff_on_shift = StaffClocking.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=location_id
|
||||||
|
).filter(StaffClocking.clocked_out_at.is_(None)).count()
|
||||||
|
|
||||||
|
# ── Check-in queue ─────────────────────────────────────────
|
||||||
|
queue_count = CheckinQueue.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=location_id, status="waiting"
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# ── Upcoming appointments today (next 3) ──────────────────
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
upcoming = Appointment.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=location_id
|
||||||
|
).filter(
|
||||||
|
Appointment.start_time >= now,
|
||||||
|
Appointment.start_time < day_end,
|
||||||
|
Appointment.status.in_(["pending", "confirmed"]),
|
||||||
|
).order_by(Appointment.start_time).limit(5).all()
|
||||||
|
|
||||||
|
kpis = {
|
||||||
|
"today_revenue": today_revenue,
|
||||||
|
"total_appointments": total_appts,
|
||||||
|
"completed_appointments": completed_appts,
|
||||||
|
"pending_appointments": pending_appts,
|
||||||
|
"staff_on_shift": staff_on_shift,
|
||||||
|
"queue_count": queue_count,
|
||||||
|
}
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"tenant/dashboard/index.html",
|
"tenant/dashboard/index.html",
|
||||||
tenant=g.tenant,
|
tenant=g.tenant,
|
||||||
location=g.location,
|
location=g.location,
|
||||||
|
kpis=kpis,
|
||||||
|
upcoming_appointments=upcoming,
|
||||||
|
today=today,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,119 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/gift_cards/routes.py
|
app/tenant/gift_cards/routes.py
|
||||||
Phase 3+ implementation.
|
Gift card issuance, management, and balance enquiry.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import GiftCard, Customer
|
||||||
|
from app.decorators import require_role, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards")
|
gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards")
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_code(tenant_id: int) -> str:
|
||||||
|
"""Generate a unique gift card code for this tenant."""
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
for _ in range(20):
|
||||||
|
code = "".join(secrets.choice(alphabet) for _ in range(12))
|
||||||
|
code = f"{code[:4]}-{code[4:8]}-{code[8:12]}"
|
||||||
|
if not GiftCard.query.filter_by(tenant_id=tenant_id, code=code).first():
|
||||||
|
return code
|
||||||
|
raise RuntimeError("Failed to generate unique gift card code")
|
||||||
|
|
||||||
|
|
||||||
|
@gift_cards_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
cards = GiftCard.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id
|
||||||
|
).order_by(GiftCard.created_at.desc()).limit(200).all()
|
||||||
|
return render_template("tenant/gift_cards/index.html", cards=cards)
|
||||||
|
|
||||||
|
|
||||||
|
@gift_cards_bp.route("/issue", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def issue():
|
||||||
|
customers = Customer.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
value = float(request.form.get("value", 0))
|
||||||
|
assert value > 0
|
||||||
|
except (ValueError, AssertionError):
|
||||||
|
return render_template("tenant/gift_cards/form.html",
|
||||||
|
customers=customers, error="Value must be greater than 0.")
|
||||||
|
|
||||||
|
customer_id = request.form.get("customer_id", type=int) or None
|
||||||
|
expires_raw = request.form.get("expires_at", "").strip()
|
||||||
|
expires_at = None
|
||||||
|
if expires_raw:
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(expires_raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
code = _generate_code(g.tenant.id)
|
||||||
|
card = GiftCard(
|
||||||
|
tenant_id=g.tenant.id, code=code,
|
||||||
|
original_value=value, remaining_balance=value,
|
||||||
|
issued_by=_user_db_id(),
|
||||||
|
issued_to_customer_id=customer_id,
|
||||||
|
expires_at=expires_at, is_active=True,
|
||||||
|
)
|
||||||
|
db.session.add(card)
|
||||||
|
db.session.flush()
|
||||||
|
log_tenant_action("gift_card.issue", "gift_card", card.id,
|
||||||
|
{"code": code, "value": value})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Gift card issued: {code} (${value:.2f})", "success")
|
||||||
|
return redirect(url_for("gift_cards.index"))
|
||||||
|
return render_template("tenant/gift_cards/form.html", customers=customers)
|
||||||
|
|
||||||
|
|
||||||
|
@gift_cards_bp.route("/lookup")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def lookup():
|
||||||
|
code = request.args.get("code", "").strip().upper()
|
||||||
|
card = None
|
||||||
|
if code:
|
||||||
|
card = GiftCard.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, code=code).first()
|
||||||
|
return render_template("tenant/gift_cards/lookup.html",
|
||||||
|
card=card, code=code)
|
||||||
|
|
||||||
|
|
||||||
|
@gift_cards_bp.route("/<int:card_id>/deactivate", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def deactivate(card_id):
|
||||||
|
card = GiftCard.query.filter_by(
|
||||||
|
id=card_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
card.is_active = False
|
||||||
|
log_tenant_action("gift_card.deactivate", "gift_card", card.id,
|
||||||
|
{"code": card.code})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Gift card {card.code} deactivated.", "success")
|
||||||
|
return redirect(url_for("gift_cards.index"))
|
||||||
|
|
||||||
|
|
||||||
|
def _user_db_id():
|
||||||
|
from flask_login import current_user
|
||||||
|
try:
|
||||||
|
uid_str = current_user.get_id()
|
||||||
|
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,7 +1,174 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/inventory/routes.py
|
app/tenant/inventory/routes.py
|
||||||
Phase 3+ implementation.
|
Inventory management: list, create, edit, adjust stock, reorder alerts.
|
||||||
|
Phase 4: automatic deduction on POS sale handled by pos/routes.py.
|
||||||
"""
|
"""
|
||||||
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 Inventory, InventoryLog
|
||||||
|
from app.decorators import require_role, demo_readonly, tenant_feature_required
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
|
inventory_bp = Blueprint("inventory", __name__, url_prefix="/inventory")
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@tenant_feature_required("inventory")
|
||||||
|
def index():
|
||||||
|
items = Inventory.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id
|
||||||
|
).order_by(Inventory.category, Inventory.name).all()
|
||||||
|
low_stock = [i for i in items if i.qty_on_hand <= i.reorder_level]
|
||||||
|
return render_template("tenant/inventory/index.html",
|
||||||
|
items=items, low_stock=low_stock)
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route("/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
@tenant_feature_required("inventory")
|
||||||
|
def create():
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/inventory/form.html",
|
||||||
|
mode="create", error="Name is required.")
|
||||||
|
try:
|
||||||
|
qty = int(request.form.get("qty_on_hand", 0))
|
||||||
|
reorder = int(request.form.get("reorder_level", 5))
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/inventory/form.html",
|
||||||
|
mode="create", error="Invalid quantity or reorder level.")
|
||||||
|
|
||||||
|
item = Inventory(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
location_id=g.location.id,
|
||||||
|
name=name,
|
||||||
|
sku=request.form.get("sku", "").strip() or None,
|
||||||
|
category=request.form.get("category", "").strip() or None,
|
||||||
|
qty_on_hand=qty,
|
||||||
|
reorder_level=reorder,
|
||||||
|
cost_price=_parse_decimal(request.form.get("cost_price", "")),
|
||||||
|
sale_price=_parse_decimal(request.form.get("sale_price", "")),
|
||||||
|
)
|
||||||
|
db.session.add(item)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
if qty > 0:
|
||||||
|
db.session.add(InventoryLog(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id,
|
||||||
|
inventory_id=item.id, delta=qty, reason="Initial stock",
|
||||||
|
))
|
||||||
|
|
||||||
|
log_tenant_action("inventory.create", "inventory", item.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Item \'{name}\' added to inventory.", "success")
|
||||||
|
return redirect(url_for("inventory.index"))
|
||||||
|
|
||||||
|
return render_template("tenant/inventory/form.html", mode="create")
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route("/<int:item_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
@tenant_feature_required("inventory")
|
||||||
|
def edit(item_id):
|
||||||
|
item = Inventory.query.filter_by(
|
||||||
|
id=item_id, tenant_id=g.tenant.id, location_id=g.location.id
|
||||||
|
).first_or_404()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/inventory/form.html",
|
||||||
|
mode="edit", item=item, error="Name is required.")
|
||||||
|
try:
|
||||||
|
reorder = int(request.form.get("reorder_level", item.reorder_level))
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/inventory/form.html",
|
||||||
|
mode="edit", item=item,
|
||||||
|
error="Invalid reorder level.")
|
||||||
|
|
||||||
|
item.name = name
|
||||||
|
item.sku = request.form.get("sku", "").strip() or None
|
||||||
|
item.category = request.form.get("category", "").strip() or None
|
||||||
|
item.reorder_level = reorder
|
||||||
|
item.cost_price = _parse_decimal(request.form.get("cost_price", ""))
|
||||||
|
item.sale_price = _parse_decimal(request.form.get("sale_price", ""))
|
||||||
|
|
||||||
|
log_tenant_action("inventory.edit", "inventory", item.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"\'{name}\' updated.", "success")
|
||||||
|
return redirect(url_for("inventory.index"))
|
||||||
|
|
||||||
|
return render_template("tenant/inventory/form.html", mode="edit", item=item)
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route("/<int:item_id>/adjust", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
@tenant_feature_required("inventory")
|
||||||
|
def adjust(item_id):
|
||||||
|
item = Inventory.query.filter_by(
|
||||||
|
id=item_id, tenant_id=g.tenant.id, location_id=g.location.id
|
||||||
|
).first_or_404()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
delta = int(request.form.get("delta", 0))
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/inventory/adjust.html",
|
||||||
|
item=item, error="Invalid adjustment quantity.")
|
||||||
|
|
||||||
|
reason = request.form.get("reason", "").strip() or "Manual adjustment"
|
||||||
|
new_qty = item.qty_on_hand + delta
|
||||||
|
if new_qty < 0:
|
||||||
|
return render_template("tenant/inventory/adjust.html",
|
||||||
|
item=item,
|
||||||
|
error=f"Adjustment would result in negative stock ({new_qty}).")
|
||||||
|
|
||||||
|
item.qty_on_hand = new_qty
|
||||||
|
db.session.add(InventoryLog(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id,
|
||||||
|
inventory_id=item.id, delta=delta, reason=reason,
|
||||||
|
))
|
||||||
|
log_tenant_action("inventory.adjust", "inventory", item.id,
|
||||||
|
{"delta": delta, "new_qty": new_qty, "reason": reason})
|
||||||
|
db.session.commit()
|
||||||
|
logger.info("Inventory adjusted: item=%s delta=%s new_qty=%s",
|
||||||
|
item.id, delta, new_qty)
|
||||||
|
flash(f"Stock adjusted: {item.name} now has {new_qty} units.", "success")
|
||||||
|
return redirect(url_for("inventory.index"))
|
||||||
|
|
||||||
|
return render_template("tenant/inventory/adjust.html", item=item)
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route("/<int:item_id>/log")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@tenant_feature_required("inventory")
|
||||||
|
def log(item_id):
|
||||||
|
item = Inventory.query.filter_by(
|
||||||
|
id=item_id, tenant_id=g.tenant.id, location_id=g.location.id
|
||||||
|
).first_or_404()
|
||||||
|
entries = InventoryLog.query.filter_by(
|
||||||
|
inventory_id=item_id, tenant_id=g.tenant.id
|
||||||
|
).order_by(InventoryLog.created_at.desc()).limit(100).all()
|
||||||
|
return render_template("tenant/inventory/log.html", item=item, entries=entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_decimal(value):
|
||||||
|
if not value or not str(value).strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,7 +1,117 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/locations/routes.py
|
app/tenant/locations/routes.py
|
||||||
Phase 3+ implementation.
|
Location management: list, create, edit, set primary, switch.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, g
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import Location
|
||||||
|
from app.decorators import require_role, tenant_feature_required, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action, plan_limit_check
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
|
locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
|
||||||
|
|
||||||
|
|
||||||
|
@locations_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
locs = Location.query.filter_by(tenant_id=g.tenant.id).order_by(
|
||||||
|
Location.is_primary.desc(), Location.name).all()
|
||||||
|
return render_template("tenant/locations/index.html", locations=locs)
|
||||||
|
|
||||||
|
|
||||||
|
@locations_bp.route("/switch/<int:location_id>")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def switch(location_id):
|
||||||
|
loc = Location.query.filter_by(
|
||||||
|
id=location_id, tenant_id=g.tenant.id, is_active=True).first_or_404()
|
||||||
|
session["active_location_id"] = loc.id
|
||||||
|
logger.info("Location switched: location=%s tenant=%s user=%s",
|
||||||
|
loc.id, g.tenant.id, current_user.get_id())
|
||||||
|
flash(f"Switched to {loc.name}.", "info")
|
||||||
|
return redirect(request.referrer or url_for("dashboard.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@locations_bp.route("/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def create():
|
||||||
|
allowed, err = plan_limit_check("location")
|
||||||
|
if not allowed:
|
||||||
|
flash(err, "warning")
|
||||||
|
return redirect(url_for("locations.index"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/locations/form.html",
|
||||||
|
mode="create", error="Name is required.")
|
||||||
|
allowed, err = plan_limit_check("location")
|
||||||
|
if not allowed:
|
||||||
|
return render_template("tenant/locations/form.html",
|
||||||
|
mode="create", error=err)
|
||||||
|
loc = Location(
|
||||||
|
tenant_id=g.tenant.id, name=name,
|
||||||
|
address=request.form.get("address", "").strip() or None,
|
||||||
|
phone=request.form.get("phone", "").strip() or None,
|
||||||
|
email=request.form.get("email", "").strip() or None,
|
||||||
|
timezone=request.form.get("timezone", "America/New_York"),
|
||||||
|
is_active=True, is_primary=False,
|
||||||
|
)
|
||||||
|
db.session.add(loc)
|
||||||
|
db.session.flush()
|
||||||
|
log_tenant_action("location.create", "location", loc.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Location \'{name}\' created.", "success")
|
||||||
|
return redirect(url_for("locations.index"))
|
||||||
|
return render_template("tenant/locations/form.html", mode="create")
|
||||||
|
|
||||||
|
|
||||||
|
@locations_bp.route("/<int:location_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def edit(location_id):
|
||||||
|
loc = Location.query.filter_by(
|
||||||
|
id=location_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/locations/form.html",
|
||||||
|
mode="edit", location=loc, error="Name is required.")
|
||||||
|
old_name = loc.name
|
||||||
|
loc.name = name
|
||||||
|
loc.address = request.form.get("address", "").strip() or None
|
||||||
|
loc.phone = request.form.get("phone", "").strip() or None
|
||||||
|
loc.email = request.form.get("email", "").strip() or None
|
||||||
|
loc.timezone = request.form.get("timezone", loc.timezone)
|
||||||
|
loc.is_active = request.form.get("is_active") == "1"
|
||||||
|
if request.form.get("is_primary") == "1" and not loc.is_primary:
|
||||||
|
Location.query.filter_by(tenant_id=g.tenant.id, is_primary=True).update({"is_primary": False})
|
||||||
|
loc.is_primary = True
|
||||||
|
log_tenant_action("location.edit", "location", loc.id,
|
||||||
|
{"old_name": old_name, "new_name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Location \'{name}\' updated.", "success")
|
||||||
|
return redirect(url_for("locations.index"))
|
||||||
|
return render_template("tenant/locations/form.html", mode="edit", location=loc)
|
||||||
|
|
||||||
|
|
||||||
|
@locations_bp.route("/<int:location_id>/set-primary", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def set_primary(location_id):
|
||||||
|
loc = Location.query.filter_by(
|
||||||
|
id=location_id, tenant_id=g.tenant.id, is_active=True).first_or_404()
|
||||||
|
Location.query.filter_by(tenant_id=g.tenant.id, is_primary=True).update({"is_primary": False})
|
||||||
|
loc.is_primary = True
|
||||||
|
log_tenant_action("location.set_primary", "location", loc.id)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"\'{loc.name}\' set as primary.", "success")
|
||||||
|
return redirect(url_for("locations.index"))
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/marketing/routes.py
|
app/tenant/marketing/routes.py
|
||||||
Phase 3+ implementation.
|
Phase 4 stub — implemented in Phase 4.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
from flask import Blueprint, render_template, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.decorators import require_role
|
||||||
|
|
||||||
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
|
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
|
||||||
|
|
||||||
|
|
||||||
|
@marketing_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
return render_template("tenant/feature_unavailable.html",
|
||||||
|
feature="Marketing")
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/pay_periods/routes.py
|
||||||
|
Pay period management: calculate, list, approve, mark paid.
|
||||||
|
Engine: hourly (rate × hours), salary (fixed), guarantee (max of guarantee vs commission).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, date, timedelta
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
|
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 Staff, StaffPayPeriod, StaffClocking, CommissionLog
|
||||||
|
from app.decorators import require_role, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
pay_periods_bp = Blueprint("pay_periods", __name__, url_prefix="/pay-periods")
|
||||||
|
|
||||||
|
|
||||||
|
def _period_bounds(period_type: str, ref_date: date):
|
||||||
|
"""Return (start, end) date for the pay period containing ref_date."""
|
||||||
|
if period_type == "weekly":
|
||||||
|
start = ref_date - timedelta(days=ref_date.weekday())
|
||||||
|
end = start + timedelta(days=6)
|
||||||
|
elif period_type == "biweekly":
|
||||||
|
# Anchor biweekly to 2025-01-06 (a Monday)
|
||||||
|
anchor = date(2025, 1, 6)
|
||||||
|
delta = (ref_date - anchor).days
|
||||||
|
week_num = delta // 7
|
||||||
|
period_num = week_num // 2
|
||||||
|
start = anchor + timedelta(weeks=period_num * 2)
|
||||||
|
end = start + timedelta(days=13)
|
||||||
|
else: # monthly
|
||||||
|
start = ref_date.replace(day=1)
|
||||||
|
if start.month == 12:
|
||||||
|
end = date(start.year + 1, 1, 1) - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
end = date(start.year, start.month + 1, 1) - timedelta(days=1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_pay_period(staff: Staff, period_start: date, period_end: date) -> dict:
|
||||||
|
"""
|
||||||
|
Calculate pay for a staff member over the given period.
|
||||||
|
Returns a dict with base_amount, commission_amount, guarantee_topup, total_amount.
|
||||||
|
"""
|
||||||
|
# Total minutes clocked in the period
|
||||||
|
start_dt = datetime.combine(period_start, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
end_dt = datetime.combine(period_end, datetime.max.time()).replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clockings = StaffClocking.query.filter_by(
|
||||||
|
tenant_id=staff.tenant_id, staff_id=staff.id
|
||||||
|
).filter(
|
||||||
|
StaffClocking.clocked_in_at >= start_dt,
|
||||||
|
StaffClocking.clocked_in_at <= end_dt,
|
||||||
|
StaffClocking.clocked_out_at.isnot(None),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total_minutes = sum(c.total_minutes or 0 for c in clockings)
|
||||||
|
total_hours = Decimal(total_minutes) / Decimal(60)
|
||||||
|
|
||||||
|
# Commission for the period
|
||||||
|
comm_logs = CommissionLog.query.filter_by(
|
||||||
|
tenant_id=staff.tenant_id, staff_id=staff.id
|
||||||
|
).filter(
|
||||||
|
CommissionLog.period.isnot(None),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Filter by date range using the transaction's created_at via join
|
||||||
|
from app.models.salon import Transaction
|
||||||
|
comm_rows = (
|
||||||
|
db.session.query(CommissionLog)
|
||||||
|
.join(Transaction, CommissionLog.transaction_id == Transaction.id)
|
||||||
|
.filter(
|
||||||
|
CommissionLog.tenant_id == staff.tenant_id,
|
||||||
|
CommissionLog.staff_id == staff.id,
|
||||||
|
Transaction.created_at >= start_dt,
|
||||||
|
Transaction.created_at <= end_dt,
|
||||||
|
Transaction.voided_at.is_(None),
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
commission_amount = sum(
|
||||||
|
Decimal(str(c.amount)) for c in comm_rows
|
||||||
|
)
|
||||||
|
|
||||||
|
pay_type = staff.pay_type
|
||||||
|
base_amount = Decimal("0")
|
||||||
|
guarantee_topup = Decimal("0")
|
||||||
|
|
||||||
|
if pay_type == "hourly":
|
||||||
|
rate = Decimal(str(staff.hourly_rate or 0))
|
||||||
|
base_amount = (rate * total_hours).quantize(
|
||||||
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
|
||||||
|
elif pay_type == "salary":
|
||||||
|
base_amount = Decimal(str(staff.salary_amount or 0))
|
||||||
|
|
||||||
|
elif pay_type == "guarantee":
|
||||||
|
guarantee = Decimal(str(staff.guarantee_amount or 0))
|
||||||
|
if commission_amount >= guarantee:
|
||||||
|
base_amount = commission_amount
|
||||||
|
commission_amount = Decimal("0") # rolled into base
|
||||||
|
else:
|
||||||
|
base_amount = guarantee
|
||||||
|
guarantee_topup = (guarantee - commission_amount).quantize(
|
||||||
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
commission_amount = Decimal("0") # topup covers the gap
|
||||||
|
|
||||||
|
# Commission on top of hourly/salary (if enabled)
|
||||||
|
if pay_type in ("hourly", "salary") and not staff.commission_enabled:
|
||||||
|
commission_amount = Decimal("0")
|
||||||
|
|
||||||
|
total_amount = (base_amount + commission_amount + guarantee_topup).quantize(
|
||||||
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pay_type": pay_type,
|
||||||
|
"base_amount": float(base_amount),
|
||||||
|
"commission_amount": float(commission_amount),
|
||||||
|
"guarantee_topup": float(guarantee_topup),
|
||||||
|
"total_amount": float(total_amount),
|
||||||
|
"total_hours": float(total_hours),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pay_periods_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
periods = (
|
||||||
|
StaffPayPeriod.query.filter_by(tenant_id=g.tenant.id)
|
||||||
|
.order_by(StaffPayPeriod.period_start.desc())
|
||||||
|
.limit(100)
|
||||||
|
.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()
|
||||||
|
return render_template(
|
||||||
|
"tenant/pay_periods/index.html",
|
||||||
|
periods=periods,
|
||||||
|
staff_list=staff_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pay_periods_bp.route("/calculate", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def calculate():
|
||||||
|
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()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
period_start = None
|
||||||
|
period_end = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
period_start_raw = request.form.get("period_start", "")
|
||||||
|
period_end_raw = request.form.get("period_end", "")
|
||||||
|
staff_ids = request.form.getlist("staff_ids")
|
||||||
|
|
||||||
|
try:
|
||||||
|
period_start = date.fromisoformat(period_start_raw)
|
||||||
|
period_end = date.fromisoformat(period_end_raw)
|
||||||
|
except ValueError:
|
||||||
|
flash("Invalid date range.", "danger")
|
||||||
|
return render_template(
|
||||||
|
"tenant/pay_periods/calculate.html",
|
||||||
|
staff_list=staff_list,
|
||||||
|
results=[], period_start=None, period_end=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if period_end < period_start:
|
||||||
|
flash("End date must be after start date.", "danger")
|
||||||
|
return render_template(
|
||||||
|
"tenant/pay_periods/calculate.html",
|
||||||
|
staff_list=staff_list,
|
||||||
|
results=[], period_start=period_start, period_end=period_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
target_staff = [s for s in staff_list
|
||||||
|
if not staff_ids or str(s.id) in staff_ids]
|
||||||
|
|
||||||
|
for member in target_staff:
|
||||||
|
calc = calculate_pay_period(member, period_start, period_end)
|
||||||
|
|
||||||
|
# Check if a period record already exists
|
||||||
|
existing = StaffPayPeriod.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
staff_id=member.id,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update draft; never overwrite approved/paid
|
||||||
|
if existing.status == "draft":
|
||||||
|
existing.pay_type = calc["pay_type"]
|
||||||
|
existing.base_amount = calc["base_amount"]
|
||||||
|
existing.commission_amount = calc["commission_amount"]
|
||||||
|
existing.guarantee_topup = calc["guarantee_topup"]
|
||||||
|
existing.total_amount = calc["total_amount"]
|
||||||
|
pp = existing
|
||||||
|
else:
|
||||||
|
pp = existing
|
||||||
|
else:
|
||||||
|
pp = StaffPayPeriod(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
staff_id=member.id,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
**{k: v for k, v in calc.items()},
|
||||||
|
)
|
||||||
|
db.session.add(pp)
|
||||||
|
|
||||||
|
results.append({"staff": member, "calc": calc, "pay_period": pp})
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
log_tenant_action(
|
||||||
|
"pay_period.calculate", "pay_period", None,
|
||||||
|
{"period": f"{period_start}–{period_end}",
|
||||||
|
"staff_count": len(results)},
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Pay periods calculated: tenant=%s period=%s–%s count=%d",
|
||||||
|
g.tenant.id, period_start, period_end, len(results),
|
||||||
|
)
|
||||||
|
flash(f"Calculated pay for {len(results)} staff member(s).", "success")
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"tenant/pay_periods/calculate.html",
|
||||||
|
staff_list=staff_list,
|
||||||
|
results=results,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pay_periods_bp.route("/<int:pp_id>/approve", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def approve(pp_id):
|
||||||
|
pp = StaffPayPeriod.query.filter_by(
|
||||||
|
id=pp_id, tenant_id=g.tenant.id
|
||||||
|
).first_or_404()
|
||||||
|
if pp.status != "draft":
|
||||||
|
flash("Only draft periods can be approved.", "warning")
|
||||||
|
return redirect(url_for("pay_periods.index"))
|
||||||
|
pp.status = "approved"
|
||||||
|
log_tenant_action("pay_period.approve", "pay_period", pp.id,
|
||||||
|
{"staff": pp.staff_id, "total": float(pp.total_amount)})
|
||||||
|
db.session.commit()
|
||||||
|
logger.info("Pay period approved: id=%s staff=%s", pp.id, pp.staff_id)
|
||||||
|
flash("Pay period approved.", "success")
|
||||||
|
return redirect(url_for("pay_periods.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@pay_periods_bp.route("/<int:pp_id>/mark-paid", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def mark_paid(pp_id):
|
||||||
|
pp = StaffPayPeriod.query.filter_by(
|
||||||
|
id=pp_id, tenant_id=g.tenant.id
|
||||||
|
).first_or_404()
|
||||||
|
if pp.status != "approved":
|
||||||
|
flash("Only approved periods can be marked as paid.", "warning")
|
||||||
|
return redirect(url_for("pay_periods.index"))
|
||||||
|
pp.status = "paid"
|
||||||
|
pp.notes = request.form.get("notes", pp.notes)
|
||||||
|
log_tenant_action("pay_period.mark_paid", "pay_period", pp.id,
|
||||||
|
{"staff": pp.staff_id, "total": float(pp.total_amount)})
|
||||||
|
db.session.commit()
|
||||||
|
logger.info("Pay period marked paid: id=%s staff=%s", pp.id, pp.staff_id)
|
||||||
|
flash("Pay period marked as paid.", "success")
|
||||||
|
return redirect(url_for("pay_periods.index"))
|
||||||
+401
-2
@@ -1,7 +1,406 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/pos/routes.py
|
app/tenant/pos/routes.py
|
||||||
Phase 3+ implementation.
|
POS / Checkout: new transaction, line item entry, promotion auto-apply,
|
||||||
|
tip, gift card redemption, void, receipt.
|
||||||
|
Rebook-at-checkout creates a new pending appointment.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g, jsonify
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import (
|
||||||
|
Transaction, TransactionItem, Appointment, Customer, Staff,
|
||||||
|
Service, Product, GiftCard, AppointmentReminder,
|
||||||
|
)
|
||||||
|
from app.decorators import require_role, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action, get_active_promotion, apply_promotion_to_price
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
pos_bp = Blueprint("pos", __name__, url_prefix="/pos")
|
pos_bp = Blueprint("pos", __name__, url_prefix="/pos")
|
||||||
|
|
||||||
|
PAYMENT_METHODS = ["cash", "zelle", "venmo", "cashapp", "gift_card", "other"]
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def checkout():
|
||||||
|
"""New POS transaction entry form."""
|
||||||
|
customers = Customer.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
|
||||||
|
staff_list = Staff.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
|
||||||
|
services = Service.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Service.deleted_at.is_(None)).order_by(Service.category, Service.name).all()
|
||||||
|
products = Product.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Product.deleted_at.is_(None)).order_by(Product.name).all()
|
||||||
|
# Pre-fill from appointment if provided
|
||||||
|
appt_id = request.args.get("appointment_id", type=int)
|
||||||
|
appointment = None
|
||||||
|
if appt_id:
|
||||||
|
appointment = Appointment.query.filter_by(
|
||||||
|
id=appt_id, tenant_id=g.tenant.id).first()
|
||||||
|
return render_template("tenant/pos/checkout.html",
|
||||||
|
customers=customers, staff_list=staff_list,
|
||||||
|
services=services, products=products,
|
||||||
|
appointment=appointment,
|
||||||
|
payment_methods=PAYMENT_METHODS)
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route("/submit", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def submit():
|
||||||
|
"""Process a completed checkout."""
|
||||||
|
customer_id = request.form.get("customer_id", type=int) or None
|
||||||
|
staff_id = request.form.get("staff_id", type=int) or None
|
||||||
|
appt_id = request.form.get("appointment_id", type=int) or None
|
||||||
|
payment_method = request.form.get("payment_method", "cash")
|
||||||
|
payment_reference = request.form.get("payment_reference", "").strip() or None
|
||||||
|
tip_raw = request.form.get("tip_amount", "0")
|
||||||
|
gc_code = request.form.get("gift_card_code", "").strip().upper() or None
|
||||||
|
|
||||||
|
if payment_method not in PAYMENT_METHODS:
|
||||||
|
flash("Invalid payment method.", "danger")
|
||||||
|
return redirect(url_for("pos.checkout"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
tip_amount = Decimal(tip_raw or "0").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
except Exception:
|
||||||
|
tip_amount = Decimal("0")
|
||||||
|
|
||||||
|
# Parse line items from form: service_ids[] and product_ids[]
|
||||||
|
service_ids = request.form.getlist("service_ids")
|
||||||
|
product_ids = request.form.getlist("product_ids")
|
||||||
|
|
||||||
|
if not service_ids and not product_ids:
|
||||||
|
flash("Please add at least one service or product.", "danger")
|
||||||
|
return redirect(url_for("pos.checkout"))
|
||||||
|
|
||||||
|
# Resolve gift card
|
||||||
|
gift_card = None
|
||||||
|
gc_applied = Decimal("0")
|
||||||
|
if gc_code:
|
||||||
|
gift_card = GiftCard.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, code=gc_code, is_active=True).first()
|
||||||
|
if not gift_card or gift_card.remaining_balance <= 0:
|
||||||
|
flash(f"Gift card {gc_code} is invalid or has no balance.", "danger")
|
||||||
|
return redirect(url_for("pos.checkout"))
|
||||||
|
|
||||||
|
# Build transaction
|
||||||
|
txn = Transaction(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
location_id=g.location.id,
|
||||||
|
appointment_id=appt_id,
|
||||||
|
customer_id=customer_id,
|
||||||
|
staff_id=staff_id,
|
||||||
|
payment_method=payment_method,
|
||||||
|
payment_reference=payment_reference,
|
||||||
|
tip_amount=float(tip_amount),
|
||||||
|
gift_card_id=gift_card.id if gift_card else None,
|
||||||
|
subtotal=0, discount=0, gift_card_amount=0, total=0,
|
||||||
|
)
|
||||||
|
db.session.add(txn)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
subtotal = Decimal("0")
|
||||||
|
total_discount = Decimal("0")
|
||||||
|
|
||||||
|
# Add service line items
|
||||||
|
for sid_str in service_ids:
|
||||||
|
try:
|
||||||
|
sid = int(sid_str)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
svc = Service.query.filter_by(
|
||||||
|
id=sid, tenant_id=g.tenant.id).first()
|
||||||
|
if not svc:
|
||||||
|
continue
|
||||||
|
original_price = Decimal(str(svc.price))
|
||||||
|
promo = get_active_promotion(g.tenant.id, sid, "service")
|
||||||
|
unit_price_f, disc_pct = apply_promotion_to_price(float(original_price), promo)
|
||||||
|
unit_price = Decimal(str(unit_price_f))
|
||||||
|
discount_amt = original_price - unit_price
|
||||||
|
item = TransactionItem(
|
||||||
|
transaction_id=txn.id,
|
||||||
|
service_id=sid, product_id=None,
|
||||||
|
qty=1, unit_price=float(unit_price),
|
||||||
|
original_price=float(original_price),
|
||||||
|
discount_percent=disc_pct,
|
||||||
|
promotion_id=promo.id if promo else None,
|
||||||
|
)
|
||||||
|
db.session.add(item)
|
||||||
|
subtotal += unit_price
|
||||||
|
total_discount += discount_amt
|
||||||
|
|
||||||
|
# Add product line items
|
||||||
|
for pid_str in product_ids:
|
||||||
|
try:
|
||||||
|
pid = int(pid_str)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
prod = Product.query.filter_by(
|
||||||
|
id=pid, tenant_id=g.tenant.id).first()
|
||||||
|
if not prod:
|
||||||
|
continue
|
||||||
|
original_price = Decimal(str(prod.sale_price))
|
||||||
|
promo = get_active_promotion(g.tenant.id, pid, "product")
|
||||||
|
unit_price_f, disc_pct = apply_promotion_to_price(float(original_price), promo)
|
||||||
|
unit_price = Decimal(str(unit_price_f))
|
||||||
|
discount_amt = original_price - unit_price
|
||||||
|
item = TransactionItem(
|
||||||
|
transaction_id=txn.id,
|
||||||
|
service_id=None, product_id=pid,
|
||||||
|
qty=1, unit_price=float(unit_price),
|
||||||
|
original_price=float(original_price),
|
||||||
|
discount_percent=disc_pct,
|
||||||
|
promotion_id=promo.id if promo else None,
|
||||||
|
)
|
||||||
|
db.session.add(item)
|
||||||
|
subtotal += unit_price
|
||||||
|
total_discount += discount_amt
|
||||||
|
|
||||||
|
# Apply gift card
|
||||||
|
if gift_card:
|
||||||
|
gc_available = Decimal(str(gift_card.remaining_balance))
|
||||||
|
gc_applied = min(gc_available, subtotal + tip_amount)
|
||||||
|
gift_card.remaining_balance = float(gc_available - gc_applied)
|
||||||
|
if gift_card.remaining_balance <= 0:
|
||||||
|
gift_card.is_active = False
|
||||||
|
|
||||||
|
total = subtotal + tip_amount - gc_applied
|
||||||
|
if total < 0:
|
||||||
|
total = Decimal("0")
|
||||||
|
|
||||||
|
txn.subtotal = float(subtotal)
|
||||||
|
txn.discount = float(total_discount)
|
||||||
|
txn.tip_amount = float(tip_amount)
|
||||||
|
txn.gift_card_amount = float(gc_applied)
|
||||||
|
txn.total = float(total)
|
||||||
|
|
||||||
|
# Mark appointment completed if linked
|
||||||
|
if appt_id:
|
||||||
|
Appointment.query.filter_by(
|
||||||
|
id=appt_id, tenant_id=g.tenant.id
|
||||||
|
).update({"status": "completed"})
|
||||||
|
|
||||||
|
# Commission log if staff has commission enabled
|
||||||
|
if staff_id:
|
||||||
|
staff = Staff.query.get(staff_id)
|
||||||
|
if staff and staff.commission_enabled and staff.commission_rate:
|
||||||
|
from app.models.salon import CommissionLog
|
||||||
|
commission_amount = float(
|
||||||
|
(Decimal(str(subtotal)) * Decimal(str(staff.commission_rate)) /
|
||||||
|
Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
)
|
||||||
|
from datetime import date
|
||||||
|
period = date.today().strftime("%Y-W%U")
|
||||||
|
comm_log = CommissionLog(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
location_id=g.location.id,
|
||||||
|
staff_id=staff_id,
|
||||||
|
transaction_id=txn.id,
|
||||||
|
amount=commission_amount,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
db.session.add(comm_log)
|
||||||
|
|
||||||
|
# ── Inventory auto-deduction for product line items ─────
|
||||||
|
for item in db.session.query(TransactionItem).filter_by(
|
||||||
|
transaction_id=txn.id
|
||||||
|
).filter(TransactionItem.product_id.isnot(None)).all():
|
||||||
|
from app.models.salon import Inventory, InventoryLog, Product as _Prod
|
||||||
|
prod = _Prod.query.get(item.product_id)
|
||||||
|
if prod and prod.sku:
|
||||||
|
inv = Inventory.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
location_id=g.location.id,
|
||||||
|
sku=prod.sku,
|
||||||
|
).first()
|
||||||
|
elif prod:
|
||||||
|
inv = Inventory.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
location_id=g.location.id,
|
||||||
|
name=prod.name,
|
||||||
|
).first()
|
||||||
|
else:
|
||||||
|
inv = None
|
||||||
|
|
||||||
|
if inv and inv.qty_on_hand > 0:
|
||||||
|
inv.qty_on_hand -= item.qty
|
||||||
|
db.session.add(InventoryLog(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
location_id=g.location.id,
|
||||||
|
inventory_id=inv.id,
|
||||||
|
delta=-item.qty,
|
||||||
|
reason=f"POS sale txn#{txn.id}",
|
||||||
|
))
|
||||||
|
|
||||||
|
log_tenant_action("transaction.create", "transaction", txn.id,
|
||||||
|
{"total": float(total), "payment": payment_method})
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f"Checkout complete — Total: ${float(total):.2f}", "success")
|
||||||
|
|
||||||
|
# Rebook at checkout
|
||||||
|
rebook = request.form.get("rebook") == "1"
|
||||||
|
if rebook:
|
||||||
|
return redirect(url_for("pos.rebook", transaction_id=txn.id))
|
||||||
|
|
||||||
|
return redirect(url_for("pos.receipt", transaction_id=txn.id))
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route("/receipt/<int:transaction_id>")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def receipt(transaction_id):
|
||||||
|
txn = Transaction.query.filter_by(
|
||||||
|
id=transaction_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
items = list(txn.items)
|
||||||
|
return render_template("tenant/pos/receipt.html",
|
||||||
|
transaction=txn, items=items, tenant=g.tenant,
|
||||||
|
location=g.location)
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route("/rebook/<int:transaction_id>", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def rebook(transaction_id):
|
||||||
|
"""Next-visit scheduling at checkout."""
|
||||||
|
txn = Transaction.query.filter_by(
|
||||||
|
id=transaction_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
staff_list = Staff.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
|
||||||
|
services = Service.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Service.deleted_at.is_(None)).order_by(Service.name).all()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
start_raw = request.form.get("start_time", "")
|
||||||
|
service_id = request.form.get("service_id", type=int)
|
||||||
|
staff_id = request.form.get("staff_id", type=int) or None
|
||||||
|
try:
|
||||||
|
start_time = datetime.fromisoformat(start_raw)
|
||||||
|
if start_time.tzinfo is None:
|
||||||
|
start_time = start_time.replace(tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/pos/rebook.html",
|
||||||
|
transaction=txn, staff_list=staff_list,
|
||||||
|
services=services,
|
||||||
|
error="Invalid date/time.")
|
||||||
|
svc = Service.query.get(service_id) if service_id else None
|
||||||
|
duration = svc.duration_min if svc else 30
|
||||||
|
end_time = start_time + timedelta(minutes=duration)
|
||||||
|
|
||||||
|
appt = Appointment(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id,
|
||||||
|
customer_id=txn.customer_id,
|
||||||
|
staff_id=staff_id, service_id=service_id,
|
||||||
|
start_time=start_time, end_time=end_time,
|
||||||
|
is_walk_in=False, status="pending",
|
||||||
|
rebook_source="checkout",
|
||||||
|
rebooked_from_transaction_id=txn.id,
|
||||||
|
created_by=_user_db_id(),
|
||||||
|
)
|
||||||
|
db.session.add(appt)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Schedule 24h reminder
|
||||||
|
reminder_time = start_time - timedelta(hours=24)
|
||||||
|
if reminder_time > datetime.now(timezone.utc):
|
||||||
|
reminder = AppointmentReminder(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id,
|
||||||
|
appointment_id=appt.id, reminder_type="24h",
|
||||||
|
scheduled_for=reminder_time, channel="email", status="pending",
|
||||||
|
)
|
||||||
|
db.session.add(reminder)
|
||||||
|
|
||||||
|
log_tenant_action("appointment.rebook", "appointment", appt.id,
|
||||||
|
{"from_transaction": txn.id, "start": start_raw})
|
||||||
|
db.session.commit()
|
||||||
|
flash("Next visit scheduled.", "success")
|
||||||
|
return redirect(url_for("pos.receipt", transaction_id=txn.id))
|
||||||
|
|
||||||
|
return render_template("tenant/pos/rebook.html",
|
||||||
|
transaction=txn, staff_list=staff_list,
|
||||||
|
services=services)
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route("/void/<int:transaction_id>", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def void(transaction_id):
|
||||||
|
txn = Transaction.query.filter_by(
|
||||||
|
id=transaction_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
if txn.voided_at:
|
||||||
|
flash("This transaction is already voided.", "warning")
|
||||||
|
return redirect(url_for("pos.receipt", transaction_id=transaction_id))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
reason = request.form.get("reason", "").strip()
|
||||||
|
if not reason:
|
||||||
|
return render_template("tenant/pos/void.html",
|
||||||
|
transaction=txn, error="Reason is required.")
|
||||||
|
txn.voided_at = datetime.now(timezone.utc)
|
||||||
|
txn.voided_by = _user_db_id()
|
||||||
|
txn.void_reason = reason
|
||||||
|
|
||||||
|
# Reverse gift card balance if applicable
|
||||||
|
if txn.gift_card_id and txn.gift_card_amount > 0:
|
||||||
|
gc = GiftCard.query.get(txn.gift_card_id)
|
||||||
|
if gc:
|
||||||
|
gc.remaining_balance = float(
|
||||||
|
Decimal(str(gc.remaining_balance)) +
|
||||||
|
Decimal(str(txn.gift_card_amount))
|
||||||
|
)
|
||||||
|
gc.is_active = True
|
||||||
|
|
||||||
|
log_tenant_action("transaction.void", "transaction", txn.id,
|
||||||
|
{"reason": reason, "total": txn.total})
|
||||||
|
db.session.commit()
|
||||||
|
flash("Transaction voided.", "success")
|
||||||
|
return redirect(url_for("pos.receipt", transaction_id=transaction_id))
|
||||||
|
|
||||||
|
return render_template("tenant/pos/void.html", transaction=txn)
|
||||||
|
|
||||||
|
|
||||||
|
@pos_bp.route("/transactions")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def transactions():
|
||||||
|
from datetime import date
|
||||||
|
date_str = request.args.get("date", date.today().isoformat())
|
||||||
|
try:
|
||||||
|
view_date = date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
view_date = date.today()
|
||||||
|
day_start = datetime.combine(view_date, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
txns = Transaction.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id
|
||||||
|
).filter(
|
||||||
|
Transaction.created_at >= day_start,
|
||||||
|
Transaction.created_at < day_end,
|
||||||
|
Transaction.voided_at.is_(None),
|
||||||
|
).order_by(Transaction.created_at.desc()).all()
|
||||||
|
return render_template("tenant/pos/transactions.html",
|
||||||
|
transactions=txns, view_date=view_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_db_id():
|
||||||
|
from flask_login import current_user
|
||||||
|
try:
|
||||||
|
uid_str = current_user.get_id()
|
||||||
|
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,7 +1,127 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/reconciliation/routes.py
|
app/tenant/reconciliation/routes.py
|
||||||
Phase 3+ implementation.
|
End-of-day reconciliation: close day, cash count, variance calculation.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta, date
|
||||||
|
from decimal import Decimal
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import DailyReconciliation, Transaction
|
||||||
|
from app.decorators import require_role, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
|
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
|
||||||
|
|
||||||
|
|
||||||
|
@reconciliation_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
records = DailyReconciliation.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id
|
||||||
|
).order_by(DailyReconciliation.date.desc()).limit(30).all()
|
||||||
|
return render_template("tenant/reconciliation/index.html", records=records)
|
||||||
|
|
||||||
|
|
||||||
|
@reconciliation_bp.route("/close", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def close_day():
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
# Check if already closed for today
|
||||||
|
existing = DailyReconciliation.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id, date=today
|
||||||
|
).first()
|
||||||
|
if existing and existing.closed_at:
|
||||||
|
flash("Today has already been reconciled.", "info")
|
||||||
|
return redirect(url_for("reconciliation.index"))
|
||||||
|
|
||||||
|
# Compute expected totals from today's transactions
|
||||||
|
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
|
||||||
|
txns = Transaction.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id
|
||||||
|
).filter(
|
||||||
|
Transaction.created_at >= day_start,
|
||||||
|
Transaction.created_at < day_end,
|
||||||
|
Transaction.voided_at.is_(None),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total_cash = sum(
|
||||||
|
Decimal(str(t.total)) for t in txns if t.payment_method == "cash"
|
||||||
|
)
|
||||||
|
total_app = sum(
|
||||||
|
Decimal(str(t.total)) for t in txns
|
||||||
|
if t.payment_method in ("zelle", "venmo", "cashapp", "other")
|
||||||
|
)
|
||||||
|
total_tips = sum(Decimal(str(t.tip_amount)) for t in txns)
|
||||||
|
total_gc = sum(Decimal(str(t.gift_card_amount)) for t in txns)
|
||||||
|
expected_cash = total_cash # Starting float not tracked in MVP
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
actual_cash = Decimal(request.form.get("actual_cash", "0"))
|
||||||
|
except Exception:
|
||||||
|
return render_template("tenant/reconciliation/close.html",
|
||||||
|
today=today, total_cash=float(total_cash),
|
||||||
|
total_app=float(total_app),
|
||||||
|
total_tips=float(total_tips),
|
||||||
|
total_gc=float(total_gc),
|
||||||
|
expected_cash=float(expected_cash),
|
||||||
|
error="Invalid cash amount.")
|
||||||
|
variance = actual_cash - expected_cash
|
||||||
|
notes = request.form.get("notes", "").strip() or None
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.actual_cash_counted = float(actual_cash)
|
||||||
|
existing.variance = float(variance)
|
||||||
|
existing.closed_by = _user_db_id()
|
||||||
|
existing.closed_at = datetime.now(timezone.utc)
|
||||||
|
existing.notes = notes
|
||||||
|
rec = existing
|
||||||
|
else:
|
||||||
|
rec = DailyReconciliation(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
location_id=g.location.id,
|
||||||
|
date=today,
|
||||||
|
total_cash=float(total_cash),
|
||||||
|
total_app_payments=float(total_app),
|
||||||
|
total_tips=float(total_tips),
|
||||||
|
total_gift_card_redemptions=float(total_gc),
|
||||||
|
expected_cash_in_drawer=float(expected_cash),
|
||||||
|
actual_cash_counted=float(actual_cash),
|
||||||
|
variance=float(variance),
|
||||||
|
closed_by=_user_db_id(),
|
||||||
|
closed_at=datetime.now(timezone.utc),
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
db.session.add(rec)
|
||||||
|
|
||||||
|
log_tenant_action("reconciliation.close", "reconciliation", None,
|
||||||
|
{"date": str(today), "variance": float(variance)})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Day closed. Variance: ${float(variance):.2f}", "success")
|
||||||
|
return redirect(url_for("reconciliation.index"))
|
||||||
|
|
||||||
|
return render_template("tenant/reconciliation/close.html",
|
||||||
|
today=today,
|
||||||
|
total_cash=float(total_cash),
|
||||||
|
total_app=float(total_app),
|
||||||
|
total_tips=float(total_tips),
|
||||||
|
total_gc=float(total_gc),
|
||||||
|
expected_cash=float(expected_cash))
|
||||||
|
|
||||||
|
|
||||||
|
def _user_db_id():
|
||||||
|
from flask_login import current_user
|
||||||
|
try:
|
||||||
|
uid_str = current_user.get_id()
|
||||||
|
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/reports/routes.py
|
app/tenant/reports/routes.py
|
||||||
Phase 3+ implementation.
|
Phase 4 stub — implemented in Phase 4.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
from flask import Blueprint, render_template, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.decorators import require_role
|
||||||
|
|
||||||
reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
|
reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
|
||||||
|
|
||||||
|
|
||||||
|
@reports_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
return render_template("tenant/feature_unavailable.html",
|
||||||
|
feature="Reports")
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/reviews/routes.py
|
app/tenant/reviews/routes.py
|
||||||
Phase 3+ implementation.
|
Owner view of customer reviews submitted at checkout.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from flask import Blueprint, render_template, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.models.salon import CheckoutReview
|
||||||
|
from app.decorators import require_role
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
|
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
|
||||||
|
|
||||||
|
|
||||||
|
@reviews_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
reviews = CheckoutReview.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id
|
||||||
|
).order_by(CheckoutReview.created_at.desc()).limit(100).all()
|
||||||
|
|
||||||
|
avg_row = db.session.query(
|
||||||
|
func.avg(CheckoutReview.rating)
|
||||||
|
).filter_by(tenant_id=g.tenant.id).scalar()
|
||||||
|
avg_rating = round(float(avg_row or 0), 1)
|
||||||
|
|
||||||
|
dist = dict(
|
||||||
|
db.session.query(CheckoutReview.rating, func.count(CheckoutReview.id))
|
||||||
|
.filter_by(tenant_id=g.tenant.id)
|
||||||
|
.group_by(CheckoutReview.rating).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template("tenant/reviews/index.html",
|
||||||
|
reviews=reviews, avg_rating=avg_rating,
|
||||||
|
rating_dist=dist)
|
||||||
|
|||||||
@@ -1,7 +1,282 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/services/routes.py
|
app/tenant/services/routes.py
|
||||||
Phase 3+ implementation.
|
Services, products, and promotions catalogue.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import Service, Product, Promotion
|
||||||
|
from app.decorators import require_role, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
services_bp = Blueprint("services", __name__, url_prefix="/services")
|
services_bp = Blueprint("services", __name__, url_prefix="/services")
|
||||||
|
|
||||||
|
# ── Services ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@services_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
services = Service.query.filter_by(tenant_id=g.tenant.id).filter(
|
||||||
|
Service.deleted_at.is_(None)).order_by(
|
||||||
|
Service.category, Service.name).all()
|
||||||
|
products = Product.query.filter_by(tenant_id=g.tenant.id).filter(
|
||||||
|
Product.deleted_at.is_(None)).order_by(
|
||||||
|
Product.category, Product.name).all()
|
||||||
|
promotions = Promotion.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).order_by(
|
||||||
|
Promotion.ends_at).all()
|
||||||
|
return render_template("tenant/services/index.html",
|
||||||
|
services=services, products=products,
|
||||||
|
promotions=promotions)
|
||||||
|
|
||||||
|
|
||||||
|
@services_bp.route("/services/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def create_service():
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/services/service_form.html",
|
||||||
|
mode="create", error="Name is required.")
|
||||||
|
try:
|
||||||
|
price = float(request.form.get("price", 0))
|
||||||
|
duration = int(request.form.get("duration_min", 30))
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/services/service_form.html",
|
||||||
|
mode="create", error="Invalid price or duration.")
|
||||||
|
svc = Service(
|
||||||
|
tenant_id=g.tenant.id, name=name,
|
||||||
|
category=request.form.get("category", "").strip() or None,
|
||||||
|
price=price, duration_min=duration, is_active=True,
|
||||||
|
)
|
||||||
|
db.session.add(svc)
|
||||||
|
db.session.flush()
|
||||||
|
log_tenant_action("service.create", "service", svc.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Service \'{name}\' created.", "success")
|
||||||
|
return redirect(url_for("services.index"))
|
||||||
|
return render_template("tenant/services/service_form.html", mode="create")
|
||||||
|
|
||||||
|
|
||||||
|
@services_bp.route("/services/<int:service_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def edit_service(service_id):
|
||||||
|
svc = Service.query.filter_by(
|
||||||
|
id=service_id, tenant_id=g.tenant.id).filter(
|
||||||
|
Service.deleted_at.is_(None)).first_or_404()
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/services/service_form.html",
|
||||||
|
mode="edit", service=svc, error="Name is required.")
|
||||||
|
try:
|
||||||
|
price = float(request.form.get("price", 0))
|
||||||
|
duration = int(request.form.get("duration_min", 30))
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/services/service_form.html",
|
||||||
|
mode="edit", service=svc, error="Invalid price or duration.")
|
||||||
|
svc.name = name
|
||||||
|
svc.category = request.form.get("category", "").strip() or None
|
||||||
|
svc.price = price
|
||||||
|
svc.duration_min = duration
|
||||||
|
svc.is_active = request.form.get("is_active") == "1"
|
||||||
|
log_tenant_action("service.edit", "service", svc.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Service \'{name}\' updated.", "success")
|
||||||
|
return redirect(url_for("services.index"))
|
||||||
|
return render_template("tenant/services/service_form.html",
|
||||||
|
mode="edit", service=svc)
|
||||||
|
|
||||||
|
|
||||||
|
@services_bp.route("/services/<int:service_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def delete_service(service_id):
|
||||||
|
svc = Service.query.filter_by(
|
||||||
|
id=service_id, tenant_id=g.tenant.id).filter(
|
||||||
|
Service.deleted_at.is_(None)).first_or_404()
|
||||||
|
svc.deleted_at = datetime.now(timezone.utc)
|
||||||
|
log_tenant_action("service.delete", "service", svc.id, {"name": svc.name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Service \'{svc.name}\' deleted.", "success")
|
||||||
|
return redirect(url_for("services.index"))
|
||||||
|
|
||||||
|
# ── Products ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@services_bp.route("/products/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def create_product():
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/services/product_form.html",
|
||||||
|
mode="create", error="Name is required.")
|
||||||
|
try:
|
||||||
|
price = float(request.form.get("sale_price", 0))
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/services/product_form.html",
|
||||||
|
mode="create", error="Invalid price.")
|
||||||
|
prod = Product(
|
||||||
|
tenant_id=g.tenant.id, name=name,
|
||||||
|
sku=request.form.get("sku", "").strip() or None,
|
||||||
|
category=request.form.get("category", "").strip() or None,
|
||||||
|
sale_price=price, is_active=True,
|
||||||
|
)
|
||||||
|
db.session.add(prod)
|
||||||
|
db.session.flush()
|
||||||
|
log_tenant_action("product.create", "product", prod.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Product \'{name}\' created.", "success")
|
||||||
|
return redirect(url_for("services.index"))
|
||||||
|
return render_template("tenant/services/product_form.html", mode="create")
|
||||||
|
|
||||||
|
|
||||||
|
@services_bp.route("/products/<int:product_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def edit_product(product_id):
|
||||||
|
prod = Product.query.filter_by(
|
||||||
|
id=product_id, tenant_id=g.tenant.id).filter(
|
||||||
|
Product.deleted_at.is_(None)).first_or_404()
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/services/product_form.html",
|
||||||
|
mode="edit", product=prod, error="Name is required.")
|
||||||
|
try:
|
||||||
|
price = float(request.form.get("sale_price", 0))
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/services/product_form.html",
|
||||||
|
mode="edit", product=prod, error="Invalid price.")
|
||||||
|
prod.name = name
|
||||||
|
prod.sku = request.form.get("sku", "").strip() or None
|
||||||
|
prod.category = request.form.get("category", "").strip() or None
|
||||||
|
prod.sale_price = price
|
||||||
|
prod.is_active = request.form.get("is_active") == "1"
|
||||||
|
log_tenant_action("product.edit", "product", prod.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Product \'{name}\' updated.", "success")
|
||||||
|
return redirect(url_for("services.index"))
|
||||||
|
return render_template("tenant/services/product_form.html",
|
||||||
|
mode="edit", product=prod)
|
||||||
|
|
||||||
|
|
||||||
|
@services_bp.route("/products/<int:product_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def delete_product(product_id):
|
||||||
|
prod = Product.query.filter_by(
|
||||||
|
id=product_id, tenant_id=g.tenant.id).filter(
|
||||||
|
Product.deleted_at.is_(None)).first_or_404()
|
||||||
|
prod.deleted_at = datetime.now(timezone.utc)
|
||||||
|
log_tenant_action("product.delete", "product", prod.id, {"name": prod.name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Product \'{prod.name}\' deleted.", "success")
|
||||||
|
return redirect(url_for("services.index"))
|
||||||
|
|
||||||
|
# ── Promotions ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@services_bp.route("/promotions/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def create_promotion():
|
||||||
|
services = Service.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Service.deleted_at.is_(None)).order_by(Service.name).all()
|
||||||
|
products = Product.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Product.deleted_at.is_(None)).order_by(Product.name).all()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
applies_to = request.form.get("applies_to", "all_services")
|
||||||
|
try:
|
||||||
|
pct = int(request.form.get("discount_percent", 0))
|
||||||
|
assert 1 <= pct <= 100
|
||||||
|
except (ValueError, AssertionError):
|
||||||
|
return render_template("tenant/services/promotion_form.html",
|
||||||
|
mode="create", services=services, products=products,
|
||||||
|
error="Discount must be 1–100%.")
|
||||||
|
|
||||||
|
starts_raw = request.form.get("starts_at", "")
|
||||||
|
ends_raw = request.form.get("ends_at", "")
|
||||||
|
if not starts_raw or not ends_raw:
|
||||||
|
return render_template("tenant/services/promotion_form.html",
|
||||||
|
mode="create", services=services, products=products,
|
||||||
|
error="Start and end dates are required.")
|
||||||
|
try:
|
||||||
|
starts_at = datetime.fromisoformat(starts_raw)
|
||||||
|
ends_at = datetime.fromisoformat(ends_raw)
|
||||||
|
except ValueError:
|
||||||
|
return render_template("tenant/services/promotion_form.html",
|
||||||
|
mode="create", services=services, products=products,
|
||||||
|
error="Invalid date format.")
|
||||||
|
|
||||||
|
# Build target_ids_json for specific targets
|
||||||
|
target_ids = None
|
||||||
|
if applies_to in ("service", "product"):
|
||||||
|
raw_ids = request.form.getlist("target_ids")
|
||||||
|
target_ids = [int(i) for i in raw_ids if i.isdigit()]
|
||||||
|
if not target_ids:
|
||||||
|
return render_template("tenant/services/promotion_form.html",
|
||||||
|
mode="create", services=services, products=products,
|
||||||
|
error="Please select at least one target.")
|
||||||
|
|
||||||
|
from flask_login import current_user
|
||||||
|
promo = Promotion(
|
||||||
|
tenant_id=g.tenant.id, name=name,
|
||||||
|
discount_percent=pct, applies_to=applies_to,
|
||||||
|
target_ids_json=target_ids,
|
||||||
|
starts_at=starts_at, ends_at=ends_at,
|
||||||
|
is_active=True,
|
||||||
|
created_by=_user_id(),
|
||||||
|
)
|
||||||
|
db.session.add(promo)
|
||||||
|
db.session.flush()
|
||||||
|
log_tenant_action("promotion.create", "promotion", promo.id,
|
||||||
|
{"name": name, "discount": pct})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Promotion \'{name}\' created.", "success")
|
||||||
|
return redirect(url_for("services.index"))
|
||||||
|
|
||||||
|
return render_template("tenant/services/promotion_form.html",
|
||||||
|
mode="create", services=services, products=products)
|
||||||
|
|
||||||
|
|
||||||
|
@services_bp.route("/promotions/<int:promo_id>/toggle", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def toggle_promotion(promo_id):
|
||||||
|
promo = Promotion.query.filter_by(
|
||||||
|
id=promo_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
promo.is_active = not promo.is_active
|
||||||
|
action = "promotion.activate" if promo.is_active else "promotion.deactivate"
|
||||||
|
log_tenant_action(action, "promotion", promo.id, {"name": promo.name})
|
||||||
|
db.session.commit()
|
||||||
|
status = "activated" if promo.is_active else "deactivated"
|
||||||
|
flash(f"Promotion \'{promo.name}\' {status}.", "success")
|
||||||
|
return redirect(url_for("services.index"))
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id():
|
||||||
|
from flask_login import current_user
|
||||||
|
try:
|
||||||
|
uid_str = current_user.get_id()
|
||||||
|
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,7 +1,59 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/settings/routes.py
|
app/tenant/settings/routes.py
|
||||||
Phase 3+ implementation.
|
Tenant settings management.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import TenantSetting
|
||||||
|
from app.decorators import require_role, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
|
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
|
||||||
|
|
||||||
|
# Keys managed by the UI — any key not in this list is hidden
|
||||||
|
MANAGED_KEYS = [
|
||||||
|
"business_hours",
|
||||||
|
"booking_advance_days",
|
||||||
|
"auto_confirm_bookings",
|
||||||
|
"review_request_delay_minutes",
|
||||||
|
"receipt_footer_note",
|
||||||
|
"google_review_url",
|
||||||
|
"facebook_review_url",
|
||||||
|
"yelp_review_url",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
def index():
|
||||||
|
settings = {s.setting_key: s.setting_value for s in TenantSetting.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id).all()}
|
||||||
|
return render_template("tenant/settings/index.html",
|
||||||
|
settings=settings, managed_keys=MANAGED_KEYS)
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route("/save", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def save():
|
||||||
|
for key in MANAGED_KEYS:
|
||||||
|
value = request.form.get(key, "").strip()
|
||||||
|
existing = TenantSetting.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, setting_key=key).first()
|
||||||
|
if existing:
|
||||||
|
existing.setting_value = value or None
|
||||||
|
else:
|
||||||
|
setting = TenantSetting(
|
||||||
|
tenant_id=g.tenant.id, setting_key=key,
|
||||||
|
setting_value=value or None,
|
||||||
|
)
|
||||||
|
db.session.add(setting)
|
||||||
|
log_tenant_action("settings.save", "tenant", g.tenant.id)
|
||||||
|
db.session.commit()
|
||||||
|
flash("Settings saved.", "success")
|
||||||
|
return redirect(url_for("settings.index"))
|
||||||
|
|||||||
+198
-2
@@ -1,7 +1,203 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/staff/routes.py
|
app/tenant/staff/routes.py
|
||||||
Phase 3+ implementation.
|
Staff profiles, passcode management, location assignment.
|
||||||
|
Phase 4 adds pay structure, schedules, and commission config.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.extensions import db, bcrypt
|
||||||
|
from app.models.salon import Staff, StaffLocation, Location
|
||||||
|
from app.decorators import require_role, demo_readonly
|
||||||
|
from app.tenant.utils import log_tenant_action, plan_limit_check
|
||||||
|
from app.security import validate_passcode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
staff_bp = Blueprint("staff", __name__, url_prefix="/staff")
|
staff_bp = Blueprint("staff", __name__, url_prefix="/staff")
|
||||||
|
|
||||||
|
|
||||||
|
@staff_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
def index():
|
||||||
|
staff_list = Staff.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id).filter(
|
||||||
|
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
|
||||||
|
return render_template("tenant/staff/index.html", staff_list=staff_list)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_bp.route("/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def create():
|
||||||
|
allowed, err = plan_limit_check("staff")
|
||||||
|
if not allowed:
|
||||||
|
flash(err, "warning")
|
||||||
|
return redirect(url_for("staff.index"))
|
||||||
|
|
||||||
|
locations = Location.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).order_by(Location.name).all()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
phone = request.form.get("phone", "").strip()
|
||||||
|
passcode = request.form.get("passcode", "").strip()
|
||||||
|
|
||||||
|
min_len = 4
|
||||||
|
max_len = 6
|
||||||
|
if not name or not phone:
|
||||||
|
return render_template("tenant/staff/form.html", mode="create",
|
||||||
|
locations=locations, assigned_ids=set(), 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, assigned_ids=set(),
|
||||||
|
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, assigned_ids=set(),
|
||||||
|
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, assigned_ids=set(), 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, assigned_ids=set())
|
||||||
|
|
||||||
|
|
||||||
|
@staff_bp.route("/<int:staff_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin")
|
||||||
|
@demo_readonly
|
||||||
|
def edit(staff_id):
|
||||||
|
member = Staff.query.filter_by(
|
||||||
|
id=staff_id, tenant_id=g.tenant.id).filter(
|
||||||
|
Staff.deleted_at.is_(None)).first_or_404()
|
||||||
|
locations = Location.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).order_by(Location.name).all()
|
||||||
|
assigned_ids = {a.location_id for a in StaffLocation.query.filter_by(
|
||||||
|
staff_id=staff_id, tenant_id=g.tenant.id).all()}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
phone = request.form.get("phone", "").strip()
|
||||||
|
if not name or not phone:
|
||||||
|
return render_template("tenant/staff/form.html", mode="edit",
|
||||||
|
staff=member, locations=locations,
|
||||||
|
assigned_ids=assigned_ids,
|
||||||
|
error="Name and phone are required.")
|
||||||
|
|
||||||
|
# Check phone uniqueness excluding self
|
||||||
|
conflict = Staff.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, phone=phone).filter(
|
||||||
|
Staff.id != staff_id,
|
||||||
|
Staff.deleted_at.is_(None)).first()
|
||||||
|
if conflict:
|
||||||
|
return render_template("tenant/staff/form.html", mode="edit",
|
||||||
|
staff=member, locations=locations,
|
||||||
|
assigned_ids=assigned_ids,
|
||||||
|
error="Another staff member has that phone number.")
|
||||||
|
|
||||||
|
member.name = name
|
||||||
|
member.phone = phone
|
||||||
|
member.staff_type = request.form.get("staff_type", member.staff_type)
|
||||||
|
member.is_active = request.form.get("is_active") == "1"
|
||||||
|
# Pay structure fields
|
||||||
|
member.pay_type = request.form.get("pay_type", member.pay_type)
|
||||||
|
member.pay_period = request.form.get("pay_period", member.pay_period)
|
||||||
|
member.commission_enabled = request.form.get("commission_enabled") == "1"
|
||||||
|
member.hourly_rate = _parse_decimal(request.form.get("hourly_rate", ""))
|
||||||
|
member.salary_amount = _parse_decimal(request.form.get("salary_amount", ""))
|
||||||
|
member.guarantee_amount = _parse_decimal(request.form.get("guarantee_amount", ""))
|
||||||
|
member.commission_rate = _parse_decimal(request.form.get("commission_rate", ""))
|
||||||
|
|
||||||
|
# Update location assignments
|
||||||
|
StaffLocation.query.filter_by(
|
||||||
|
staff_id=staff_id, tenant_id=g.tenant.id).delete()
|
||||||
|
loc_ids = request.form.getlist("location_ids")
|
||||||
|
for lid_str in loc_ids:
|
||||||
|
try:
|
||||||
|
lid = int(lid_str)
|
||||||
|
loc = Location.query.filter_by(
|
||||||
|
id=lid, tenant_id=g.tenant.id).first()
|
||||||
|
if loc:
|
||||||
|
db.session.add(StaffLocation(
|
||||||
|
tenant_id=g.tenant.id,
|
||||||
|
staff_id=staff_id,
|
||||||
|
location_id=lid,
|
||||||
|
))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log_tenant_action("staff.edit", "staff", member.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"\'{name}\' updated.", "success")
|
||||||
|
return redirect(url_for("staff.index"))
|
||||||
|
|
||||||
|
return render_template("tenant/staff/form.html", mode="edit",
|
||||||
|
staff=member, locations=locations,
|
||||||
|
assigned_ids=assigned_ids)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_bp.route("/<int:staff_id>/reset-passcode", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
def reset_passcode(staff_id):
|
||||||
|
member = Staff.query.filter_by(
|
||||||
|
id=staff_id, tenant_id=g.tenant.id).filter(
|
||||||
|
Staff.deleted_at.is_(None)).first_or_404()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
new_passcode = request.form.get("passcode", "").strip()
|
||||||
|
min_len = 4
|
||||||
|
max_len = 6
|
||||||
|
if not validate_passcode(new_passcode, min_len, max_len):
|
||||||
|
return render_template("tenant/staff/reset_passcode.html",
|
||||||
|
staff=member,
|
||||||
|
error=f"Passcode must be {min_len}–{max_len} digits.")
|
||||||
|
member.passcode_hash = bcrypt.generate_password_hash(new_passcode).decode("utf-8")
|
||||||
|
member.passcode_failed_attempts = 0
|
||||||
|
member.passcode_locked_until = None
|
||||||
|
log_tenant_action("staff.reset_passcode", "staff", member.id,
|
||||||
|
{"name": member.name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Passcode for \'{member.name}\' reset. Show it to them once, then discard it.",
|
||||||
|
"success")
|
||||||
|
return redirect(url_for("staff.index"))
|
||||||
|
|
||||||
|
return render_template("tenant/staff/reset_passcode.html", staff=member)
|
||||||
|
|||||||
@@ -1,7 +1,188 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/staff_portal/routes.py
|
app/tenant/staff_portal/routes.py
|
||||||
Phase 3+ implementation.
|
Staff Portal — accessible by tenant_staff role (phone + passcode login).
|
||||||
|
Routes: personal schedule, upcoming appointments, clock in/out,
|
||||||
|
commission summary, payment history, read-only profile.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta, date
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.salon import (
|
||||||
|
Staff, Appointment, StaffClocking, CommissionLog,
|
||||||
|
StaffPayPeriod, Transaction,
|
||||||
|
)
|
||||||
|
from app.decorators import require_role
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
|
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_staff():
|
||||||
|
"""Resolve the Staff record from the current user session."""
|
||||||
|
uid_str = current_user.get_id()
|
||||||
|
if not uid_str or not uid_str.startswith("staff:"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
sid = int(uid_str.split(":")[1])
|
||||||
|
return Staff.query.filter_by(id=sid, is_active=True).filter(
|
||||||
|
Staff.deleted_at.is_(None)).first()
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@staff_portal_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_staff")
|
||||||
|
def index():
|
||||||
|
staff = _get_current_staff()
|
||||||
|
if not staff:
|
||||||
|
flash("Staff profile not found.", "danger")
|
||||||
|
return redirect(url_for("staff_auth.staff_login"))
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
|
||||||
|
# Today's appointments
|
||||||
|
todays_appts = Appointment.query.filter_by(
|
||||||
|
tenant_id=staff.tenant_id,
|
||||||
|
staff_id=staff.id,
|
||||||
|
).filter(
|
||||||
|
Appointment.start_time >= day_start,
|
||||||
|
Appointment.start_time < day_end,
|
||||||
|
Appointment.status.notin_(["cancelled", "no_show"]),
|
||||||
|
).order_by(Appointment.start_time).all()
|
||||||
|
|
||||||
|
# Current clocking state
|
||||||
|
current_clocking = StaffClocking.query.filter_by(
|
||||||
|
staff_id=staff.id, tenant_id=staff.tenant_id
|
||||||
|
).filter(StaffClocking.clocked_out_at.is_(None)).first()
|
||||||
|
|
||||||
|
return render_template("tenant/staff_portal/index.html",
|
||||||
|
staff=staff,
|
||||||
|
todays_appointments=todays_appts,
|
||||||
|
current_clocking=current_clocking,
|
||||||
|
today=today)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_portal_bp.route("/clock-in", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_staff")
|
||||||
|
def clock_in():
|
||||||
|
staff = _get_current_staff()
|
||||||
|
if not staff:
|
||||||
|
return redirect(url_for("staff_auth.staff_login"))
|
||||||
|
|
||||||
|
# Check not already clocked in
|
||||||
|
existing = StaffClocking.query.filter_by(
|
||||||
|
staff_id=staff.id, tenant_id=staff.tenant_id
|
||||||
|
).filter(StaffClocking.clocked_out_at.is_(None)).first()
|
||||||
|
if existing:
|
||||||
|
flash("You are already clocked in.", "warning")
|
||||||
|
return redirect(url_for("staff_portal.index"))
|
||||||
|
|
||||||
|
location_id = g.location.id if g.location else None
|
||||||
|
clocking = StaffClocking(
|
||||||
|
tenant_id=staff.tenant_id,
|
||||||
|
location_id=location_id,
|
||||||
|
staff_id=staff.id,
|
||||||
|
clocked_in_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.session.add(clocking)
|
||||||
|
log_tenant_action("staff.clock_in", "staff", staff.id,
|
||||||
|
{"location": location_id})
|
||||||
|
db.session.commit()
|
||||||
|
flash("Clocked in successfully.", "success")
|
||||||
|
return redirect(url_for("staff_portal.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@staff_portal_bp.route("/clock-out", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_staff")
|
||||||
|
def clock_out():
|
||||||
|
staff = _get_current_staff()
|
||||||
|
if not staff:
|
||||||
|
return redirect(url_for("staff_auth.staff_login"))
|
||||||
|
|
||||||
|
clocking = StaffClocking.query.filter_by(
|
||||||
|
staff_id=staff.id, tenant_id=staff.tenant_id
|
||||||
|
).filter(StaffClocking.clocked_out_at.is_(None)).first()
|
||||||
|
|
||||||
|
if not clocking:
|
||||||
|
flash("You are not currently clocked in.", "warning")
|
||||||
|
return redirect(url_for("staff_portal.index"))
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
clocking.clocked_out_at = now
|
||||||
|
total_minutes = int((now - clocking.clocked_in_at.replace(
|
||||||
|
tzinfo=timezone.utc if clocking.clocked_in_at.tzinfo is None else clocking.clocked_in_at.tzinfo
|
||||||
|
)).total_seconds() / 60)
|
||||||
|
clocking.total_minutes = total_minutes
|
||||||
|
clocking.notes = request.form.get("notes", "").strip() or None
|
||||||
|
|
||||||
|
log_tenant_action("staff.clock_out", "staff", staff.id,
|
||||||
|
{"minutes": total_minutes})
|
||||||
|
db.session.commit()
|
||||||
|
hours = total_minutes // 60
|
||||||
|
mins = total_minutes % 60
|
||||||
|
flash(f"Clocked out. Shift duration: {hours}h {mins}m.", "success")
|
||||||
|
return redirect(url_for("staff_portal.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@staff_portal_bp.route("/schedule")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_staff")
|
||||||
|
def schedule():
|
||||||
|
staff = _get_current_staff()
|
||||||
|
if not staff:
|
||||||
|
return redirect(url_for("staff_auth.staff_login"))
|
||||||
|
|
||||||
|
# Next 7 days of appointments
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
week_ahead = now + timedelta(days=7)
|
||||||
|
appts = Appointment.query.filter_by(
|
||||||
|
tenant_id=staff.tenant_id, staff_id=staff.id,
|
||||||
|
).filter(
|
||||||
|
Appointment.start_time >= now,
|
||||||
|
Appointment.start_time < week_ahead,
|
||||||
|
Appointment.status.notin_(["cancelled", "no_show"]),
|
||||||
|
).order_by(Appointment.start_time).all()
|
||||||
|
return render_template("tenant/staff_portal/schedule.html",
|
||||||
|
staff=staff, appointments=appts)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_portal_bp.route("/commission")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_staff")
|
||||||
|
def commission():
|
||||||
|
staff = _get_current_staff()
|
||||||
|
if not staff:
|
||||||
|
return redirect(url_for("staff_auth.staff_login"))
|
||||||
|
|
||||||
|
# Current and previous pay period summary
|
||||||
|
from datetime import date
|
||||||
|
today = date.today()
|
||||||
|
# Current month period label
|
||||||
|
period = today.strftime("%Y-W%U")
|
||||||
|
logs = CommissionLog.query.filter_by(
|
||||||
|
tenant_id=staff.tenant_id, staff_id=staff.id
|
||||||
|
).order_by(CommissionLog.id.desc()).limit(50).all()
|
||||||
|
pay_periods = StaffPayPeriod.query.filter_by(
|
||||||
|
tenant_id=staff.tenant_id, staff_id=staff.id
|
||||||
|
).order_by(StaffPayPeriod.period_start.desc()).limit(12).all()
|
||||||
|
return render_template("tenant/staff_portal/commission.html",
|
||||||
|
staff=staff, commission_logs=logs,
|
||||||
|
pay_periods=pay_periods)
|
||||||
|
|
||||||
|
|
||||||
|
@staff_portal_bp.route("/profile")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_staff")
|
||||||
|
def profile():
|
||||||
|
staff = _get_current_staff()
|
||||||
|
if not staff:
|
||||||
|
return redirect(url_for("staff_auth.staff_login"))
|
||||||
|
return render_template("tenant/staff_portal/profile.html", staff=staff)
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
app/tenant/utils.py
|
||||||
|
Shared helpers used across all tenant portal blueprints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import g, flash, redirect, url_for, request, jsonify
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def log_tenant_action(action, target_type=None, target_id=None, details=None):
|
||||||
|
"""
|
||||||
|
Write a structured log entry for any tenant create/edit/delete action.
|
||||||
|
Not persisted to audit_log (tenant-level actions use application logs).
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"TENANT_ACTION action=%s target_type=%s target_id=%s tenant=%s user=%s details=%s",
|
||||||
|
action,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
getattr(g, 'tenant', None) and g.tenant.id,
|
||||||
|
current_user.get_id() if current_user.is_authenticated else None,
|
||||||
|
details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_limit_check(resource: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Check whether the current tenant has headroom to add one more of `resource`.
|
||||||
|
resource: 'staff' | 'location'
|
||||||
|
Returns (allowed: bool, error_message: str | None)
|
||||||
|
"""
|
||||||
|
from app.models.salon import Staff, Location
|
||||||
|
|
||||||
|
tenant = getattr(g, 'tenant', None)
|
||||||
|
if not tenant or not tenant.plan:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
plan = tenant.plan
|
||||||
|
|
||||||
|
if resource == 'staff':
|
||||||
|
if plan.max_staff is None:
|
||||||
|
return True, None
|
||||||
|
count = Staff.query.filter_by(
|
||||||
|
tenant_id=tenant.id, is_active=True
|
||||||
|
).filter(Staff.deleted_at.is_(None)).count()
|
||||||
|
if count >= plan.max_staff:
|
||||||
|
return False, (
|
||||||
|
f"Your plan allows a maximum of {plan.max_staff} active staff members. "
|
||||||
|
"Please upgrade your plan to add more."
|
||||||
|
)
|
||||||
|
|
||||||
|
elif resource == 'location':
|
||||||
|
if plan.max_locations is None:
|
||||||
|
return True, None
|
||||||
|
count = Location.query.filter_by(
|
||||||
|
tenant_id=tenant.id, is_active=True
|
||||||
|
).count()
|
||||||
|
if count >= plan.max_locations:
|
||||||
|
return False, (
|
||||||
|
f"Your plan allows a maximum of {plan.max_locations} locations. "
|
||||||
|
"Please upgrade your plan to add more."
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_promotion(tenant_id: int, item_id: int, item_type: str):
|
||||||
|
"""
|
||||||
|
Promotion engine — resolves the single best active promotion for a
|
||||||
|
service or product line item at checkout time.
|
||||||
|
|
||||||
|
item_type: 'service' | 'product'
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. Specific promotion targeting this exact item ID
|
||||||
|
2. 'all_services' / 'all_products' promotion
|
||||||
|
3. 'all' promotion (covers both services and products)
|
||||||
|
|
||||||
|
If multiple promotions match at the same priority level, the highest
|
||||||
|
discount_percent wins. Returns None if no active promotion applies.
|
||||||
|
"""
|
||||||
|
from app.models.salon import Promotion
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
active = Promotion.query.filter_by(
|
||||||
|
tenant_id=tenant_id, is_active=True
|
||||||
|
).filter(
|
||||||
|
Promotion.starts_at <= now,
|
||||||
|
Promotion.ends_at >= now,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Collect all candidates
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
for promo in active:
|
||||||
|
at = promo.applies_to
|
||||||
|
|
||||||
|
if at == 'all':
|
||||||
|
candidates.append((0, promo))
|
||||||
|
|
||||||
|
elif at == 'all_services' and item_type == 'service':
|
||||||
|
candidates.append((1, promo))
|
||||||
|
|
||||||
|
elif at == 'all_products' and item_type == 'product':
|
||||||
|
candidates.append((1, promo))
|
||||||
|
|
||||||
|
elif at == item_type:
|
||||||
|
# Specific IDs — check membership
|
||||||
|
target_ids = promo.target_ids_json or []
|
||||||
|
if item_id in target_ids:
|
||||||
|
candidates.append((2, promo))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sort: highest specificity first (2 > 1 > 0), then highest discount
|
||||||
|
candidates.sort(key=lambda x: (x[0], x[1].discount_percent), reverse=True)
|
||||||
|
return candidates[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_promotion_to_price(price, promotion):
|
||||||
|
"""
|
||||||
|
Apply a promotion to a unit price.
|
||||||
|
Returns (discounted_price, discount_percent).
|
||||||
|
If promotion is None, returns original price and 0.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
|
if promotion is None:
|
||||||
|
return price, 0
|
||||||
|
|
||||||
|
pct = promotion.discount_percent
|
||||||
|
price = Decimal(str(price))
|
||||||
|
discount = (price * Decimal(pct) / Decimal(100)).quantize(
|
||||||
|
Decimal('0.01'), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
return float(price - discount), pct
|
||||||
|
|
||||||
|
|
||||||
|
def is_api_request():
|
||||||
|
return request.path.startswith('/api/') or \
|
||||||
|
request.accept_mimetypes.best == 'application/json'
|
||||||
@@ -1,7 +1,131 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/waitlist/routes.py
|
app/tenant/waitlist/routes.py
|
||||||
Phase 3+ implementation.
|
Waitlist management: list, add, notify, mark booked, expire.
|
||||||
"""
|
"""
|
||||||
from flask import Blueprint
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.extensions import db, mail
|
||||||
|
from app.models.salon import Waitlist, Staff, Service
|
||||||
|
from app.decorators import require_role, demo_readonly, tenant_feature_required
|
||||||
|
from app.tenant.utils import log_tenant_action
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")
|
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")
|
||||||
|
|
||||||
|
|
||||||
|
@waitlist_bp.route("/")
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@tenant_feature_required("waitlist")
|
||||||
|
def index():
|
||||||
|
entries = Waitlist.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id
|
||||||
|
).filter(
|
||||||
|
Waitlist.status.in_(["waiting", "notified"])
|
||||||
|
).order_by(Waitlist.created_at).all()
|
||||||
|
return render_template("tenant/waitlist/index.html", entries=entries)
|
||||||
|
|
||||||
|
|
||||||
|
@waitlist_bp.route("/add", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
@tenant_feature_required("waitlist")
|
||||||
|
def add():
|
||||||
|
staff_list = Staff.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
|
||||||
|
services = Service.query.filter_by(
|
||||||
|
tenant_id=g.tenant.id, is_active=True).filter(
|
||||||
|
Service.deleted_at.is_(None)).order_by(Service.name).all()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("customer_name", "").strip()
|
||||||
|
phone = request.form.get("customer_phone", "").strip() or None
|
||||||
|
email_addr = request.form.get("customer_email", "").strip() or None
|
||||||
|
if not name:
|
||||||
|
return render_template("tenant/waitlist/form.html",
|
||||||
|
staff_list=staff_list, services=services,
|
||||||
|
error="Customer name is required.")
|
||||||
|
entry = Waitlist(
|
||||||
|
tenant_id=g.tenant.id, location_id=g.location.id,
|
||||||
|
customer_name=name, customer_phone=phone,
|
||||||
|
customer_email=email_addr,
|
||||||
|
staff_id=request.form.get("staff_id", type=int) or None,
|
||||||
|
service_id=request.form.get("service_id", type=int) or None,
|
||||||
|
requested_date=_parse_date(request.form.get("requested_date", "")),
|
||||||
|
status="waiting",
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.flush()
|
||||||
|
log_tenant_action("waitlist.add", "waitlist", entry.id, {"name": name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"'{name}' added to waitlist.", "success")
|
||||||
|
return redirect(url_for("waitlist.index"))
|
||||||
|
return render_template("tenant/waitlist/form.html",
|
||||||
|
staff_list=staff_list, services=services)
|
||||||
|
|
||||||
|
|
||||||
|
@waitlist_bp.route("/<int:entry_id>/notify", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
@tenant_feature_required("waitlist")
|
||||||
|
def notify(entry_id):
|
||||||
|
entry = Waitlist.query.filter_by(
|
||||||
|
id=entry_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
entry.status = "notified"
|
||||||
|
entry.notified_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Send email notification if available
|
||||||
|
if entry.customer_email:
|
||||||
|
try:
|
||||||
|
from flask_mail import Message
|
||||||
|
msg = Message(
|
||||||
|
subject=f"A slot is available — {g.tenant.name}",
|
||||||
|
recipients=[entry.customer_email],
|
||||||
|
body=(
|
||||||
|
f"Hi {entry.customer_name},\n\n"
|
||||||
|
"A slot has opened up for you. Please call or book online to confirm.\n\n"
|
||||||
|
f"{g.tenant.name}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
mail.send(msg)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Waitlist notify email failed: %s", exc)
|
||||||
|
|
||||||
|
log_tenant_action("waitlist.notify", "waitlist", entry.id,
|
||||||
|
{"name": entry.customer_name})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"'{entry.customer_name}' notified.", "success")
|
||||||
|
return redirect(url_for("waitlist.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@waitlist_bp.route("/<int:entry_id>/set-status", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@demo_readonly
|
||||||
|
@tenant_feature_required("waitlist")
|
||||||
|
def set_status(entry_id):
|
||||||
|
entry = Waitlist.query.filter_by(
|
||||||
|
id=entry_id, tenant_id=g.tenant.id).first_or_404()
|
||||||
|
new_status = request.form.get("status", "")
|
||||||
|
if new_status in ("booked", "expired"):
|
||||||
|
entry.status = new_status
|
||||||
|
log_tenant_action("waitlist.set_status", "waitlist", entry.id,
|
||||||
|
{"status": new_status})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Entry updated to '{new_status}'.", "success")
|
||||||
|
return redirect(url_for("waitlist.index"))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from datetime import date
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user