Compare commits

..
10 Commits
162 changed files with 9459 additions and 295 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ MAIL_USE_TLS=true
MAIL_USERNAME=noreply@mydomain.com MAIL_USERNAME=noreply@mydomain.com
MAIL_PASSWORD= MAIL_PASSWORD=
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24 ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
ADMIN_DOMAIN=admin.mydomain.com ADMIN_DOMAIN=posadmin.ngodanguyen.tech
TENANT_DOMAIN=mydomain.com TENANT_DOMAIN=pos.ngodanguyen.tech
DEMO_TENANT_SLUG=demo DEMO_TENANT_SLUG=demo
BACKUP_DIR=/var/backups/salon_pos BACKUP_DIR=/var/backups/salon_pos
BACKUP_RETAIN_DAYS=30 BACKUP_RETAIN_DAYS=30
+104 -4
View File
@@ -682,8 +682,37 @@ WantedBy=multi-user.target
- [x] `tests/test_admin_phase2.py` — full test suite for all 7 modules - [x] `tests/test_admin_phase2.py` — full test suite for all 7 modules
- [x] Demo account creation deferred — available via `POST /tenants/new` with `is_demo=1` - [x] Demo account creation deferred — available via `POST /tenants/new` with `is_demo=1`
### Phase 3 — Multi-Location & Tenant Core Modules ### Phase 3 — Multi-Location & Tenant Core Modules ✅ COMPLETE
- [ ] Location management (CRUD, primary flag, per-location settings) - [x] Location management (CRUD, primary flag, switch, plan limit check)
- [x] Location switcher UI + tenant_staff location restriction enforcement
- [x] Staff management (profiles, job type, location assignment, passcode set/reset)
- [x] Staff passcode management (validate_passcode helper in security.py)
- [x] Dashboard (Phase 3 KPIs: revenue, appointments, staff on shift, queue count, upcoming)
- [x] Customers (search, CRUD, visit history, no-show count, soft-delete)
- [x] Services & products (tenant-wide catalogue, soft-delete)
- [x] Promotions management (create, activate/deactivate, applies_to, target_ids_json)
- [x] Promotion engine: get_active_promotion() + apply_promotion_to_price() in app/tenant/utils.py
- [x] Appointments (calendar by day, create, edit, status workflow, no-show counter, cancellation)
- [x] Customer check-in kiosk (/checkin/<slug>; auto-profile creation; queue entry; rate-limited; CSRF-exempt)
- [x] Queue polling API: GET /api/v1/checkin/queue + POST /api/v1/checkin/queue/<id>/acknowledge
- [x] Online booking (/book/<slug>; no auth; availability check; confirmation email; tenant_feature_required)
- [x] Waitlist (add, notify via email, set booked/expired; tenant_feature_required)
- [x] Staff Portal (clock in/out, today's appointments, schedule, commission, profile)
- [x] POS/Checkout (services + products, auto-apply promotions, tip, gift card redemption, payment method)
- [x] Rebook at checkout (creates pending appointment + 24h reminder; linked to transaction)
- [x] Gift cards (unique code gen, issuance, balance lookup, POS redemption, deactivate)
- [x] Transaction void (reason required; gift card balance reversed)
- [x] End-of-day reconciliation (close day, cash count, variance, history)
- [x] Reviews dashboard (avg rating, distribution chart, list)
- [x] Settings (managed key/value pairs for booking, reviews, receipt)
- [x] Phase 4 stubs (inventory, marketing, reports) registered with feature_unavailable page
- [x] app/tenant/utils.py — log_tenant_action, plan_limit_check, get_active_promotion, apply_promotion_to_price
- [x] All 17 Phase 3 blueprints registered in create_tenant_app()
- [x] All nav links in tenant/layouts/base.html wired to Phase 3 routes
- [x] Both template trees (templates/ and app/templates/) in sync: 67 files each
- [x] All 46 render_template references verified against disk
### Phase 3 Location management (CRUD, primary flag, per-location settings)
- [ ] Location switcher UI + `tenant_staff` location restriction enforcement - [ ] Location switcher UI + `tenant_staff` location restriction enforcement
- [ ] Staff ↔ location assignment (many-to-many) - [ ] Staff ↔ location assignment (many-to-many)
- [ ] Staff passcode management (set at creation, reset by admin/manager, never stored in plaintext) - [ ] Staff passcode management (set at creation, reset by admin/manager, never stored in plaintext)
@@ -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. |
+7 -2
View File
@@ -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
View File
@@ -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
+184
View File
@@ -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)
+9
View File
@@ -104,3 +104,12 @@ def validate_setting_key(key: str) -> bool:
"""Return True if key is a valid setting key (alphanumeric, underscores, dots).""" """Return True if key is a valid setting key (alphanumeric, underscores, dots)."""
import re import re
return bool(key and re.match(r'^[a-zA-Z0-9_.]+$', key) and len(key) <= 100) return bool(key and re.match(r'^[a-zA-Z0-9_.]+$', key) and len(key) <= 100)
def validate_passcode(passcode: str, min_len: int = 4, max_len: int = 6) -> bool:
"""Return True if passcode is digits only and within the allowed length range."""
return (
bool(passcode)
and passcode.isdigit()
and min_len <= len(passcode) <= max_len
)
+84
View File
@@ -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 %}
+88
View File
@@ -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 %}
+30 -12
View File
@@ -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">
<h4 class="card-title mb-4 text-center">Admin Portal</h4> <div class="text-center mb-4">
<i class="bi bi-shield-lock-fill fs-1 text-dark"></i>
<h4 class="mt-2 fw-bold">Admin Portal</h4>
<p class="text-muted small">Restricted access</p>
</div>
{% if error %} {% if error %}
<div class="alert alert-danger">{{ error }}</div> <div class="alert alert-danger py-2">{{ error }}</div>
{% endif %} {% endif %}
<form method="POST" action="{{ url_for('admin_auth.login') }}">
<form method="POST" action="{{ url_for('admin_auth.login') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Email</label> <label for="email" class="form-label">Email address</label>
<input type="email" name="email" class="form-control" required autofocus> <input type="email" class="form-control" id="email" name="email"
autocomplete="username" required autofocus>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label for="password" class="form-label">Password</label>
<input type="password" name="password" class="form-control" required> <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> </div>
<button type="submit" class="btn btn-dark w-100">Sign In</button>
</form> </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 %}
+37
View File
@@ -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 %}
+32
View File
@@ -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 %}
+35
View File
@@ -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 %}
+96
View File
@@ -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>
+65
View File
@@ -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 %}
+48
View File
@@ -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' }} &bull;
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 %}
+128
View File
@@ -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 %}
+77
View File
@@ -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 %}
+58
View File
@@ -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 -1
View File
@@ -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">
+36 -18
View File
@@ -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">
<h4 class="card-title mb-4 text-center">Salon Login</h4> <div class="text-center mb-4">
<i class="bi bi-scissors fs-1 text-primary"></i>
<h4 class="mt-2 fw-bold">Salon POS</h4>
<p class="text-muted small">Sign in to your account</p>
</div>
{% if error %} {% if error %}
<div class="alert alert-danger">{{ error }}</div> <div class="alert alert-danger py-2">{{ error }}</div>
{% endif %} {% endif %}
<form method="POST" action="{{ url_for('tenant_auth.login') }}">
<form method="POST" action="{{ url_for('tenant_auth.login') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Email</label> <label for="email" class="form-label">Email address</label>
<input type="email" name="email" class="form-control" required autofocus> <input type="email" class="form-control" id="email" name="email"
autocomplete="username" required autofocus>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label for="password" class="form-label">Password</label>
<input type="password" name="password" class="form-control" required> <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> </div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form> </form>
<hr>
<div class="text-center small"> <hr class="my-3">
<a href="{{ url_for('tenant_auth.password_reset_request') }}">Forgot password?</a>
&nbsp;|&nbsp; <div class="text-center">
<a href="{{ url_for('staff_auth.staff_login') }}">Staff login</a> <a href="{{ url_for('staff_auth.staff_login') }}" class="btn btn-outline-secondary btn-sm w-100 mb-2">
</div> <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,29 +1,34 @@
{% 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="card-title mb-3">Set New Password</h5> <h5 class="fw-bold 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">
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">New Password</label> <label for="password" class="form-label">New password</label>
<input type="password" name="password" class="form-control" required minlength="10"> <input type="password" class="form-control" id="password" name="password"
<div class="form-text">Min 10 chars, must include uppercase, lowercase, and a digit.</div> autocomplete="new-password" required autofocus>
<div class="form-text">Min 10 characters. Uppercase, lowercase, and a digit required.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Confirm Password</label> <label for="confirm_password" class="form-label">Confirm password</label>
<input type="password" name="confirm_password" class="form-control" required> <input type="password" class="form-control" id="confirm_password"
name="confirm_password" autocomplete="new-password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Update Password</button>
</div> </div>
<button type="submit" class="btn btn-primary w-100">Update Password</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}
@@ -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="card-title mb-3">Reset Password</h5> <h5 class="fw-bold mb-3">Reset Password</h5>
{% if message %} <p class="text-muted small">Enter your email address and we'll send a reset link.</p>
<div class="alert alert-info">{{ message }}</div>
{% else %} <form method="POST" novalidate>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Email address</label> <label for="email" class="form-label">Email address</label>
<input type="email" name="email" class="form-control" required autofocus> <input type="email" class="form-control" id="email" name="email" required autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Send Reset Link</button>
</div> </div>
<button type="submit" class="btn btn-primary w-100">Send Reset Link</button>
</form> </form>
{% endif %}
<div class="text-center mt-3 small"> <div class="text-center mt-3">
<a href="{{ url_for('tenant_auth.login') }}">Back to login</a> <a href="{{ url_for('tenant_auth.login') }}" class="text-muted small">Back to login</a>
</div>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -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">
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Book an Appointment — {{ tenant.name }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light">
<div class="container py-5" style="max-width:540px;">
{% if confirmed %}
<div class="card shadow text-center p-5">
<div class="display-1 mb-3"></div>
<h3 class="fw-bold">Booking Requested!</h3>
<p class="text-muted">We'll confirm your appointment shortly. Check your email for details.</p>
<a href="/book/{{ tenant.slug }}" class="btn btn-outline-primary mt-3">Book Another</a>
</div>
{% else %}
<div class="card shadow p-4">
<h3 class="fw-bold mb-1">{{ tenant.name }}</h3>
<p class="text-muted mb-4">Book an appointment online</p>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Your Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" required autofocus>
</div>
<div class="col">
<label class="form-label">Phone <span class="text-danger">*</span></label>
<input type="tel" class="form-control" name="phone" required inputmode="numeric">
</div>
</div>
<div class="mb-3">
<label class="form-label">Email <small class="text-muted">(for confirmation)</small></label>
<input type="email" class="form-control" name="email">
</div>
<div class="mb-3">
<label class="form-label">Service <span class="text-danger">*</span></label>
<select class="form-select" name="service_id" required>
<option value="">— Select a service —</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }} — ${{ "%.2f"|format(s.price) }} ({{ s.duration_min }} min)</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Preferred Staff <small class="text-muted">(optional)</small></label>
<select class="form-select" name="staff_id">
<option value="">— No preference —</option>
{% for s in staff_list %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Date <span class="text-danger">*</span></label>
<input type="date" class="form-control" name="preferred_date" required>
</div>
<div class="col">
<label class="form-label">Time <span class="text-danger">*</span></label>
<input type="time" class="form-control" name="preferred_time" required>
</div>
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="2"></textarea>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">Request Appointment</button>
</div>
</form>
</div>
{% endif %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Not Found</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="text-center">
<h2 class="fw-bold">Booking Page Not Found</h2>
<p class="text-muted">This salon's booking page doesn't exist or is no longer available.</p>
</div>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Booking Unavailable</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="text-center">
<h2 class="fw-bold">Online Booking Unavailable</h2>
<p class="text-muted">Online booking is not currently available for this salon. Please call to book.</p>
</div>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Check-In Not Found</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body class="bg-light d-flex align-items-center justify-content-center" style="min-height:100vh;">
<div class="text-center">
<h2 class="fw-bold">Check-In Unavailable</h2>
<p class="text-muted">This check-in kiosk is not available. Please see a staff member.</p>
</div>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Customer{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Customer</div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ customer.name if customer else '' }}" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" name="phone"
value="{{ customer.phone if customer else '' }}">
</div>
<div class="col">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email"
value="{{ customer.email if customer else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Date of Birth</label>
<input type="date" class="form-control" name="date_of_birth"
value="{{ customer.date_of_birth.isoformat() if customer and customer.date_of_birth else '' }}">
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="3">{{ customer.notes if customer else '' }}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Customers{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-people me-2"></i>Customers</h4>
<a href="{{ url_for('customers.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Customer
</a>
</div>
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<form method="GET" class="d-flex gap-2">
<input type="search" class="form-control form-control-sm" name="q"
value="{{ q }}" placeholder="Search by name, phone, or email…" autofocus>
<button class="btn btn-outline-secondary btn-sm">Search</button>
{% if q %}<a href="{{ url_for('customers.index') }}" class="btn btn-outline-danger btn-sm">Clear</a>{% endif %}
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>Phone</th><th>Email</th><th>Loyalty</th><th>No-Shows</th><th></th></tr></thead>
<tbody>
{% for c in customers %}
<tr>
<td><a href="{{ url_for('customers.view', customer_id=c.id) }}" class="text-decoration-none fw-semibold">{{ c.name }}</a></td>
<td class="text-muted small">{{ c.phone or '—' }}</td>
<td class="text-muted small">{{ c.email or '—' }}</td>
<td><span class="badge bg-warning text-dark">{{ c.loyalty_points }} pts</span></td>
<td>{% if c.no_show_count > 0 %}<span class="badge bg-danger">{{ c.no_show_count }}</span>{% else %}0{% endif %}</td>
<td><a href="{{ url_for('customers.edit', customer_id=c.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No customers found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ customer.name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0">{{ customer.name }}</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<a href="{{ url_for('appointments.create') }}?customer_id={{ customer.id }}" class="btn btn-primary btn-sm">New Appointment</a>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<p class="mb-1"><i class="bi bi-telephone me-2 text-muted"></i>{{ customer.phone or '—' }}</p>
<p class="mb-1"><i class="bi bi-envelope me-2 text-muted"></i>{{ customer.email or '—' }}</p>
{% if customer.date_of_birth %}
<p class="mb-1"><i class="bi bi-cake me-2 text-muted"></i>{{ customer.date_of_birth.strftime('%B %d') }}</p>
{% endif %}
<p class="mb-1"><i class="bi bi-star me-2 text-warning"></i>{{ customer.loyalty_points }} loyalty points</p>
{% if customer.no_show_count > 0 %}
<p class="mb-0"><i class="bi bi-x-circle me-2 text-danger"></i>{{ customer.no_show_count }} no-show(s)</p>
{% endif %}
{% if customer.notes %}
<hr><p class="text-muted small mb-0">{{ customer.notes }}</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Recent Appointments</div>
{% if appointments %}
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Date</th><th>Service</th><th>Staff</th><th>Status</th></tr></thead>
<tbody>
{% for a in appointments %}
<tr>
<td class="small">{{ a.start_time.strftime('%Y-%m-%d %I:%M %p') }}</td>
<td class="small">{{ a.service.name if a.service else '—' }}</td>
<td class="small">{{ a.staff.name if a.staff else '—' }}</td>
<td><span class="badge bg-{{ {'completed':'success','cancelled':'danger','no_show':'warning'}.get(a.status,'secondary') }} small">{{ a.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-muted small">No appointments yet.</div>
{% endif %}
</div>
</div>
</div>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
{% endblock %}
+61 -5
View File
@@ -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">
<i class="bi bi-speedometer2 me-2"></i>Dashboard
{% if location %}<small class="text-muted fs-6 ms-2">— {{ location.name }}</small>{% endif %}
</h4> </h4>
<div class="alert alert-info"> <span class="text-muted small">{{ today.strftime('%A, %B %d, %Y') }}</span>
Phase 3 KPI widgets will appear here (daily revenue, appointments, staff on-shift, low-stock alerts). </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 %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Issue Gift Card{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Issue New Gift Card</div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Value ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" min="1" class="form-control"
name="value" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Customer <small class="text-muted">(optional)</small></label>
<select class="form-select" name="customer_id">
<option value="">— None —</option>
{% for c in customers %}
<option value="{{ c.id }}">{{ c.name }}{% if c.phone %} — {{ c.phone }}{% endif %}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Expiry Date <small class="text-muted">(optional)</small></label>
<input type="datetime-local" class="form-control" name="expires_at">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Issue Card</button>
<a href="{{ url_for('gift_cards.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,49 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Gift Cards{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-gift me-2"></i>Gift Cards</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('gift_cards.lookup') }}" class="btn btn-outline-secondary btn-sm">Lookup Code</a>
<a href="{{ url_for('gift_cards.issue') }}" class="btn btn-primary btn-sm">+ Issue Gift Card</a>
</div>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Code</th><th>Original</th><th>Balance</th><th>Customer</th><th>Expires</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for card in cards %}
<tr class="{{ '' if card.is_active else 'text-muted' }}">
<td class="font-monospace fw-semibold">{{ card.code }}</td>
<td>${{ "%.2f"|format(card.original_value) }}</td>
<td class="fw-semibold {% if card.remaining_balance <= 0 %}text-danger{% endif %}">
${{ "%.2f"|format(card.remaining_balance) }}
</td>
<td class="small">{{ card.customer.name if card.issued_to_customer_id and card.customer else '—' }}</td>
<td class="small text-muted">{{ card.expires_at.strftime('%Y-%m-%d') if card.expires_at else 'No expiry' }}</td>
<td>
<span class="badge {{ 'bg-success' if card.is_active else 'bg-secondary' }}">
{{ 'Active' if card.is_active else 'Inactive' }}
</span>
</td>
<td>
{% if card.is_active %}
<form method="POST" action="{{ url_for('gift_cards.deactivate', card_id=card.id) }}"
class="d-inline" onsubmit="return confirm('Deactivate this gift card?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Deactivate</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No gift cards issued yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Gift Card Lookup{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Gift Card Lookup</div>
<div class="card-body">
<form method="GET" class="mb-3">
<div class="input-group">
<input type="text" class="form-control font-monospace" name="code"
value="{{ code }}" placeholder="XXXX-XXXX-XXXX" autofocus>
<button class="btn btn-primary">Look Up</button>
</div>
</form>
{% if code and card %}
<div class="alert {{ 'alert-success' if card.is_active else 'alert-warning' }}">
<dl class="row mb-0">
<dt class="col-sm-5">Code</dt><dd class="col-sm-7 font-monospace">{{ card.code }}</dd>
<dt class="col-sm-5">Original Value</dt><dd class="col-sm-7">${{ "%.2f"|format(card.original_value) }}</dd>
<dt class="col-sm-5">Remaining Balance</dt>
<dd class="col-sm-7 fw-bold {% if card.remaining_balance <= 0 %}text-danger{% endif %}">
${{ "%.2f"|format(card.remaining_balance) }}
</dd>
<dt class="col-sm-5">Status</dt>
<dd class="col-sm-7">{{ 'Active' if card.is_active else 'Inactive / Depleted' }}</dd>
{% if card.expires_at %}
<dt class="col-sm-5">Expires</dt><dd class="col-sm-7">{{ card.expires_at.strftime('%Y-%m-%d') }}</dd>
{% endif %}
</dl>
</div>
{% elif code %}
<div class="alert alert-danger">No gift card found with code <strong>{{ code }}</strong>.</div>
{% endif %}
<a href="{{ url_for('gift_cards.index') }}" class="btn btn-outline-secondary btn-sm">Back</a>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -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 %}
+61
View File
@@ -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 %}
+52
View File
@@ -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 %}
+31
View File
@@ -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 %}
+195
View File
@@ -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>
+62
View File
@@ -0,0 +1,62 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Location{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Location</div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ location.name if location else '' }}" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Address</label>
<input type="text" class="form-control" name="address"
value="{{ location.address if location else '' }}">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" name="phone"
value="{{ location.phone if location else '' }}">
</div>
<div class="col">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="email"
value="{{ location.email if location else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Timezone</label>
<input type="text" class="form-control" name="timezone"
value="{{ location.timezone if location else 'America/New_York' }}">
</div>
{% if mode == 'edit' %}
<div class="mb-3 d-flex gap-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if location and location.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_primary" value="1"
id="is_primary" {{ 'checked' if location and location.is_primary }}>
<label class="form-check-label" for="is_primary">Set as Primary</label>
</div>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('locations.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Locations{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-geo-alt me-2"></i>Locations</h4>
<a href="{{ url_for('locations.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Location
</a>
</div>
<div class="row g-3">
{% for loc in locations %}
<div class="col-md-6 col-lg-4">
<div class="card shadow-sm h-100 {{ '' if loc.is_active else 'opacity-50' }}">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>{{ loc.name }}</strong>
<div>
{% if loc.is_primary %}<span class="badge bg-primary me-1">Primary</span>{% endif %}
<span class="badge {{ 'bg-success' if loc.is_active else 'bg-secondary' }}">{{ 'Active' if loc.is_active else 'Inactive' }}</span>
</div>
</div>
<div class="card-body small text-muted">
{% if loc.address %}<p class="mb-1"><i class="bi bi-map me-1"></i>{{ loc.address }}</p>{% endif %}
{% if loc.phone %}<p class="mb-1"><i class="bi bi-telephone me-1"></i>{{ loc.phone }}</p>{% endif %}
{% if loc.email %}<p class="mb-1"><i class="bi bi-envelope me-1"></i>{{ loc.email }}</p>{% endif %}
<p class="mb-0"><i class="bi bi-clock me-1"></i>{{ loc.timezone }}</p>
</div>
<div class="card-footer d-flex gap-2">
<a href="{{ url_for('locations.edit', location_id=loc.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
{% if not loc.is_primary %}
<form method="POST" action="{{ url_for('locations.set_primary', location_id=loc.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-primary btn-sm">Set Primary</button>
</form>
{% endif %}
<a href="{{ url_for('locations.switch', location_id=loc.id) }}" class="btn btn-outline-success btn-sm ms-auto">Switch</a>
</div>
</div>
</div>
{% else %}
<div class="col"><p class="text-muted">No locations found.</p></div>
{% endfor %}
</div>
{% endblock %}
@@ -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 %}
+115
View File
@@ -0,0 +1,115 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}POS Checkout{% endblock %}
{% block content %}
<h4 class="fw-bold mb-4"><i class="bi bi-cash-register me-2"></i>Checkout</h4>
<form method="POST" action="{{ url_for('pos.submit') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if appointment %}
<input type="hidden" name="appointment_id" value="{{ appointment.id }}">
{% endif %}
<div class="row g-3">
<!-- Left: items -->
<div class="col-lg-7">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Services</div>
<div class="card-body">
{% for s in services %}
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" name="service_ids"
value="{{ s.id }}" id="svc_{{ s.id }}"
{{ 'checked' if appointment and appointment.service_id == s.id }}>
<label class="form-check-label" for="svc_{{ s.id }}">
{{ s.name }} <span class="text-muted small">({{ s.duration_min }}min)</span>
<span class="fw-semibold">${{ "%.2f"|format(s.price) }}</span>
</label>
</div>
{% endfor %}
</div>
</div>
{% if services %}
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Products</div>
<div class="card-body">
{% for p in products %}
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" name="product_ids"
value="{{ p.id }}" id="prod_{{ p.id }}">
<label class="form-check-label" for="prod_{{ p.id }}">
{{ p.name }} <span class="fw-semibold">${{ "%.2f"|format(p.sale_price) }}</span>
</label>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Right: payment details -->
<div class="col-lg-5">
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold">Payment Details</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Customer</label>
<select class="form-select" name="customer_id">
<option value="">— Walk-in / No Customer —</option>
{% for c in customers %}
<option value="{{ c.id }}"
{{ 'selected' if appointment and appointment.customer_id == c.id }}>
{{ c.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Not assigned —</option>
{% for s in staff_list %}
<option value="{{ s.id }}"
{{ 'selected' if appointment and appointment.staff_id == s.id }}>
{{ s.name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Payment Method</label>
<select class="form-select" name="payment_method">
{% for m in payment_methods %}
<option value="{{ m }}">{{ m.title() }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Payment Reference <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control" name="payment_reference"
placeholder="Transaction ID, note…">
</div>
<div class="mb-3">
<label class="form-label">Tip ($)</label>
<input type="number" step="0.01" class="form-control" name="tip_amount"
value="0" min="0">
</div>
<div class="mb-3">
<label class="form-label">Gift Card Code</label>
<input type="text" class="form-control font-monospace" name="gift_card_code"
placeholder="XXXX-XXXX-XXXX">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="rebook" value="1" id="rebook">
<label class="form-check-label" for="rebook">Schedule next visit after checkout</label>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success btn-lg">
<i class="bi bi-check-circle me-2"></i>Complete Checkout
</button>
</div>
</div>
</div>
</div>
</div>
</form>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Schedule Next Visit{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-calendar-plus me-2"></i>Schedule Next Visit</div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Date & Time <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="start_time" required>
</div>
<div class="mb-3">
<label class="form-label">Service</label>
<select class="form-select" name="service_id">
<option value="">— Same as today —</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Any —</option>
{% for s in staff_list %}
<option value="{{ s.id }}" {{ 'selected' if transaction.staff_id == s.id }}>{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Schedule</button>
<a href="{{ url_for('pos.receipt', transaction_id=transaction.id) }}" class="btn btn-outline-secondary">Skip</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Receipt #{{ transaction.id }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="text-center mb-4">
<h5 class="fw-bold">{{ tenant.name }}</h5>
{% if location %}<p class="text-muted small mb-0">{{ location.name }}</p>{% endif %}
<p class="text-muted small">{{ transaction.created_at.strftime('%B %d, %Y %I:%M %p') }}</p>
<p class="text-muted small">Receipt #{{ transaction.id }}</p>
{% if transaction.voided_at %}
<div class="alert alert-danger py-1 mt-2">VOIDED — {{ transaction.void_reason }}</div>
{% endif %}
</div>
<table class="table table-sm mb-3">
<tbody>
{% for item in items %}
<tr>
<td>
{% if item.service %}{{ item.service.name }}
{% elif item.product %}{{ item.product.name }}
{% endif %}
{% if item.discount_percent > 0 %}
<span class="badge bg-success ms-1">{{ item.discount_percent }}% off</span>
{% endif %}
</td>
<td class="text-end">
{% if item.discount_percent > 0 %}
<del class="text-muted small">${{ "%.2f"|format(item.original_price) }}</del>
{% endif %}
${{ "%.2f"|format(item.unit_price) }}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="fw-semibold">
{% if transaction.discount > 0 %}
<tr class="text-success"><td>Savings</td><td class="text-end">-${{ "%.2f"|format(transaction.discount) }}</td></tr>
{% endif %}
{% if transaction.tip_amount > 0 %}
<tr><td>Tip</td><td class="text-end">${{ "%.2f"|format(transaction.tip_amount) }}</td></tr>
{% endif %}
{% if transaction.gift_card_amount > 0 %}
<tr class="text-info"><td>Gift Card</td><td class="text-end">-${{ "%.2f"|format(transaction.gift_card_amount) }}</td></tr>
{% endif %}
<tr class="table-dark fs-5"><td>Total</td><td class="text-end">${{ "%.2f"|format(transaction.total) }}</td></tr>
<tr><td>Payment</td><td class="text-end">{{ transaction.payment_method.title() }}</td></tr>
</tfoot>
</table>
<div class="d-flex gap-2 justify-content-center flex-wrap">
<a href="{{ url_for('pos.checkout') }}" class="btn btn-primary">New Transaction</a>
<a href="{{ url_for('pos.rebook', transaction_id=transaction.id) }}" class="btn btn-outline-primary">Rebook</a>
{% if not transaction.voided_at %}
<a href="{{ url_for('pos.void', transaction_id=transaction.id) }}" class="btn btn-outline-danger">Void</a>
{% endif %}
<a href="{{ url_for('pos.transactions') }}" class="btn btn-outline-secondary">Transactions</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Transactions{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="fw-bold mb-0">Transactions</h4>
<input type="date" class="form-control form-control-sm" value="{{ view_date.isoformat() }}"
onchange="window.location='{{ url_for('pos.transactions') }}?date='+this.value"
style="width:150px;">
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Time</th><th>Customer</th><th>Staff</th><th>Total</th><th>Method</th><th>Tip</th><th></th></tr></thead>
<tbody>
{% for t in transactions %}
<tr>
<td class="small">{{ t.created_at.strftime('%I:%M %p') }}</td>
<td class="small">{{ t.customer.name if t.customer else '—' }}</td>
<td class="small">{{ t.staff.name if t.staff else '—' }}</td>
<td class="fw-semibold">${{ "%.2f"|format(t.total) }}</td>
<td class="small">{{ t.payment_method.title() }}</td>
<td class="small">{% if t.tip_amount > 0 %}${{ "%.2f"|format(t.tip_amount) }}{% else %}—{% endif %}</td>
<td><a href="{{ url_for('pos.receipt', transaction_id=t.id) }}" class="btn btn-outline-secondary btn-sm">View</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No transactions for this day.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Void Transaction{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm border-danger">
<div class="card-header bg-danger text-white fw-semibold">Void Transaction #{{ transaction.id }}</div>
<div class="card-body">
<p class="text-muted">Total: <strong>${{ "%.2f"|format(transaction.total) }}</strong></p>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate onsubmit="return confirm('Are you sure you want to void this transaction?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Reason <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="reason" required autofocus>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">Confirm Void</button>
<a href="{{ url_for('pos.receipt', transaction_id=transaction.id) }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,42 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Close Day{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">
<i class="bi bi-calculator me-2"></i>Close Day — {{ today.strftime('%B %d, %Y') }}
</div>
<div class="card-body">
<!-- Summary from transactions -->
<table class="table table-sm mb-4">
<tbody>
<tr><td>Cash Sales</td><td class="text-end fw-semibold">${{ "%.2f"|format(total_cash) }}</td></tr>
<tr><td>App Payments (Zelle / Venmo / etc.)</td><td class="text-end">${{ "%.2f"|format(total_app) }}</td></tr>
<tr><td>Tips Collected</td><td class="text-end">${{ "%.2f"|format(total_tips) }}</td></tr>
<tr><td>Gift Card Redemptions</td><td class="text-end">${{ "%.2f"|format(total_gc) }}</td></tr>
<tr class="table-light fw-bold"><td>Expected Cash in Drawer</td><td class="text-end">${{ "%.2f"|format(expected_cash) }}</td></tr>
</tbody>
</table>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Actual Cash Counted ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" min="0" class="form-control form-control-lg"
name="actual_cash" required autofocus placeholder="0.00">
</div>
<div class="mb-3">
<label class="form-label">Notes <small class="text-muted">(optional)</small></label>
<textarea class="form-control" name="notes" rows="2"></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Close Day</button>
<a href="{{ url_for('reconciliation.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Reconciliation{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-calculator me-2"></i>End-of-Day Reconciliation</h4>
<a href="{{ url_for('reconciliation.close_day') }}" class="btn btn-primary btn-sm">Close Today</a>
</div>
{% if records %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Date</th><th>Cash</th><th>App Payments</th><th>Tips</th><th>Gift Cards</th><th>Expected</th><th>Actual</th><th>Variance</th><th>Closed By</th></tr>
</thead>
<tbody>
{% for r in records %}
<tr>
<td class="fw-semibold">{{ r.date.strftime('%Y-%m-%d') }}</td>
<td>${{ "%.2f"|format(r.total_cash) }}</td>
<td>${{ "%.2f"|format(r.total_app_payments) }}</td>
<td>${{ "%.2f"|format(r.total_tips) }}</td>
<td>${{ "%.2f"|format(r.total_gift_card_redemptions) }}</td>
<td>${{ "%.2f"|format(r.expected_cash_in_drawer) }}</td>
<td>${{ "%.2f"|format(r.actual_cash_counted) }}</td>
<td class="fw-semibold {{ 'text-danger' if r.variance < 0 else 'text-success' if r.variance > 0 else '' }}">
{{ '+' if r.variance > 0 else '' }}${{ "%.2f"|format(r.variance) }}
</td>
<td class="small text-muted">{{ r.closed_at.strftime('%I:%M %p') if r.closed_at else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No reconciliation records yet.</div>
{% endif %}
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Reviews{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-star me-2"></i>Customer Reviews</h4>
<div class="text-end">
<div class="fs-3 fw-bold text-warning">{{ avg_rating }} ★</div>
<div class="text-muted small">Average rating</div>
</div>
</div>
<!-- Rating distribution -->
<div class="card shadow-sm mb-4" style="max-width:400px;">
<div class="card-body py-2">
{% for star in [5,4,3,2,1] %}
{% set count = rating_dist.get(star, 0) %}
{% set total = reviews|length %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="text-muted small" style="width:20px;">{{ star }}★</span>
<div class="progress flex-grow-1" style="height:8px;">
<div class="progress-bar bg-warning"
style="width:{{ ((count / total * 100) | int) if total else 0 }}%"></div>
</div>
<span class="text-muted small" style="width:30px;">{{ count }}</span>
</div>
{% endfor %}
</div>
</div>
{% if reviews %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Date</th><th>Rating</th><th>Customer</th><th>Staff</th><th>Comment</th></tr></thead>
<tbody>
{% for r in reviews %}
<tr>
<td class="small text-muted">{{ r.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<span class="text-warning fw-bold">{{ r.rating }}★</span>
</td>
<td class="small">{{ r.customer.name if r.customer else '—' }}</td>
<td class="small">{{ r.staff.name if r.staff else '—' }}</td>
<td class="small">{{ r.comment or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No reviews yet.</div>
{% endif %}
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Services & Products{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-card-list me-2"></i>Services & Products</h4>
<div class="d-flex gap-2">
<a href="{{ url_for('services.create_service') }}" class="btn btn-primary btn-sm">+ Service</a>
<a href="{{ url_for('services.create_product') }}" class="btn btn-outline-primary btn-sm">+ Product</a>
<a href="{{ url_for('services.create_promotion') }}" class="btn btn-outline-warning btn-sm">+ Promotion</a>
</div>
</div>
<div class="row g-3">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Services</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>Category</th><th>Duration</th><th>Price</th><th></th></tr></thead>
<tbody>
{% for s in services %}
<tr class="{{ '' if s.is_active else 'text-muted' }}">
<td class="fw-semibold">{{ s.name }}</td>
<td class="small">{{ s.category or '—' }}</td>
<td class="small">{{ s.duration_min }} min</td>
<td>${{ "%.2f"|format(s.price) }}</td>
<td>
<a href="{{ url_for('services.edit_service', service_id=s.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<form method="POST" action="{{ url_for('services.delete_service', service_id=s.id) }}" class="d-inline"
onsubmit="return confirm('Delete this service?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Del</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No services yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Products</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>SKU</th><th>Price</th><th></th></tr></thead>
<tbody>
{% for p in products %}
<tr class="{{ '' if p.is_active else 'text-muted' }}">
<td class="fw-semibold">{{ p.name }}</td>
<td class="small text-muted">{{ p.sku or '—' }}</td>
<td>${{ "%.2f"|format(p.sale_price) }}</td>
<td>
<a href="{{ url_for('services.edit_product', product_id=p.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<form method="POST" action="{{ url_for('services.delete_product', product_id=p.id) }}" class="d-inline"
onsubmit="return confirm('Delete this product?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-danger btn-sm">Del</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-center text-muted py-3">No products yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if promotions %}
<div class="card shadow-sm mt-3 border-warning">
<div class="card-header fw-semibold bg-warning-subtle">Active Promotions</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Name</th><th>Discount</th><th>Applies To</th><th>Ends</th><th></th></tr></thead>
<tbody>
{% for promo in promotions %}
<tr>
<td class="fw-semibold">{{ promo.name }}</td>
<td>{{ promo.discount_percent }}%</td>
<td class="small">{{ promo.applies_to }}</td>
<td class="small text-muted">{{ promo.ends_at.strftime('%Y-%m-%d') }}</td>
<td>
<form method="POST" action="{{ url_for('services.toggle_promotion', promo_id=promo.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-{{ 'danger' if promo.is_active else 'success' }} btn-sm">
{{ 'Deactivate' if promo.is_active else 'Activate' }}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Product{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Product</div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ product.name if product else '' }}" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">SKU</label>
<input type="text" class="form-control" name="sku"
value="{{ product.sku if product else '' }}">
</div>
<div class="col">
<label class="form-label">Category</label>
<input type="text" class="form-control" name="category"
value="{{ product.category if product else '' }}">
</div>
</div>
<div class="mb-3">
<label class="form-label">Sale Price ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" class="form-control" name="sale_price" min="0"
value="{{ "%.2f"|format(product.sale_price) if product else '' }}" required>
</div>
{% if mode == 'edit' %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if product and product.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,84 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}New Promotion{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header fw-semibold">New Promotion</div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Promotion Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Discount % <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="discount_percent" min="1" max="100" required>
</div>
<div class="col">
<label class="form-label">Applies To <span class="text-danger">*</span></label>
<select class="form-select" name="applies_to" id="applies_to"
onchange="toggleTargets(this.value)">
<option value="all_services">All Services</option>
<option value="all_products">All Products</option>
<option value="all">All Services & Products</option>
<option value="service">Specific Services</option>
<option value="product">Specific Products</option>
</select>
</div>
</div>
<div id="target_services" class="mb-3 d-none">
<label class="form-label">Select Services</label>
{% for svc in services %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="target_ids"
value="{{ svc.id }}" id="svc_{{ svc.id }}">
<label class="form-check-label" for="svc_{{ svc.id }}">
{{ svc.name }} — ${{ "%.2f"|format(svc.price) }}
</label>
</div>
{% endfor %}
</div>
<div id="target_products" class="mb-3 d-none">
<label class="form-label">Select Products</label>
{% for p in products %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="target_ids"
value="{{ p.id }}" id="prod_{{ p.id }}">
<label class="form-check-label" for="prod_{{ p.id }}">
{{ p.name }} — ${{ "%.2f"|format(p.sale_price) }}
</label>
</div>
{% endfor %}
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Starts At <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="starts_at" required>
</div>
<div class="col">
<label class="form-label">Ends At <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" name="ends_at" required>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning">Create Promotion</button>
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleTargets(val) {
document.getElementById("target_services").classList.toggle("d-none", val !== "service");
document.getElementById("target_products").classList.toggle("d-none", val !== "product");
}
</script>
{% endblock %}
@@ -0,0 +1,50 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Service{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'New' }} Service</div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="name"
value="{{ service.name if service else '' }}" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<input type="text" class="form-control" name="category"
value="{{ service.category if service else '' }}" placeholder="e.g. Nails, Spa, Waxing">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Duration (min) <span class="text-danger">*</span></label>
<input type="number" class="form-control" name="duration_min" min="5" max="480"
value="{{ service.duration_min if service else 30 }}" required>
</div>
<div class="col">
<label class="form-label">Price ($) <span class="text-danger">*</span></label>
<input type="number" step="0.01" class="form-control" name="price" min="0"
value="{{ "%.2f"|format(service.price) if service else '' }}" required>
</div>
</div>
{% if mode == 'edit' %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="is_active" value="1"
id="is_active" {{ 'checked' if service and service.is_active }}>
<label class="form-check-label" for="is_active">Active</label>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('services.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-7">
<h4 class="fw-bold mb-4"><i class="bi bi-gear me-2"></i>Settings</h4>
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="{{ url_for('settings.save') }}" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h6 class="fw-semibold text-muted mb-3">Booking</h6>
<div class="mb-3">
<label class="form-label">Advance Booking Days</label>
<input type="number" class="form-control" name="booking_advance_days"
value="{{ settings.get('booking_advance_days', '30') }}" min="1" max="365">
<div class="form-text">How many days ahead customers can book online.</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="auto_confirm_bookings"
value="true" id="auto_confirm"
{{ 'checked' if settings.get('auto_confirm_bookings') == 'true' }}>
<label class="form-check-label" for="auto_confirm">Auto-confirm online bookings</label>
</div>
</div>
<h6 class="fw-semibold text-muted mb-3 mt-4">Reviews</h6>
<div class="mb-3">
<label class="form-label">Review Request Delay (minutes after checkout)</label>
<input type="number" class="form-control" name="review_request_delay_minutes"
value="{{ settings.get('review_request_delay_minutes', '60') }}" min="0">
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Google Review URL</label>
<input type="url" class="form-control" name="google_review_url"
value="{{ settings.get('google_review_url', '') }}" placeholder="https://g.page/…">
</div>
<div class="col">
<label class="form-label">Yelp Review URL</label>
<input type="url" class="form-control" name="yelp_review_url"
value="{{ settings.get('yelp_review_url', '') }}" placeholder="https://yelp.com/…">
</div>
</div>
<h6 class="fw-semibold text-muted mb-3 mt-4">Receipt</h6>
<div class="mb-3">
<label class="form-label">Receipt Footer Note</label>
<textarea class="form-control" name="receipt_footer_note" rows="2">{{ settings.get('receipt_footer_note', '') }}</textarea>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+140
View File
@@ -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 (46 digits) <span class="text-danger">*</span></label>
<input type="password" class="form-control" name="passcode"
inputmode="numeric" maxlength="6" required>
<div class="form-text">Staff will use this PIN to log in. Show it to them once, then discard it.</div>
</div>
{% endif %}
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Job Type</label>
<select class="form-select" name="staff_type">
{% for t in ['full_time','part_time','seasonal','receptionist','salon_manager'] %}
<option value="{{ t }}" {{ 'selected' if staff and staff.staff_type == t }}>
{{ t.replace('_',' ').title() }}
</option>
{% endfor %}
</select>
</div>
<div class="col">
<label class="form-label">Pay Type</label>
<select class="form-select" name="pay_type">
{% for t in ['hourly','salary','guarantee'] %}
<option value="{{ t }}" {{ 'selected' if staff and staff.pay_type == t }}>{{ t.title() }}</option>
{% endfor %}
</select>
</div>
</div>
<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 %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Staff{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-person-badge me-2"></i>Staff</h4>
<a href="{{ url_for('staff.create') }}" class="btn btn-primary btn-sm">+ New Staff Member</a>
</div>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Name</th><th>Phone</th><th>Type</th><th>Pay Type</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{% for s in staff_list %}
<tr class="{{ '' if s.is_active else 'text-muted' }}">
<td class="fw-semibold">{{ s.name }}</td>
<td class="small">{{ s.phone }}</td>
<td class="small">{{ s.staff_type.replace('_',' ').title() }}</td>
<td class="small">{{ s.pay_type.title() }}</td>
<td><span class="badge {{ 'bg-success' if s.is_active else 'bg-secondary' }}">{{ 'Active' if s.is_active else 'Inactive' }}</span></td>
<td>
<a href="{{ url_for('staff.edit', staff_id=s.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
<a href="{{ url_for('staff.reset_passcode', staff_id=s.id) }}" class="btn btn-outline-warning btn-sm">Reset PIN</a>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">No staff members yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Reset Passcode — {{ staff.name }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm border-warning">
<div class="card-header bg-warning-subtle fw-semibold">Reset Passcode — {{ staff.name }}</div>
<div class="card-body">
<p class="text-muted small">Enter a new 46 digit PIN for this staff member. Show it to them once, then discard it.</p>
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">New Passcode</label>
<input type="text" class="form-control form-control-lg text-center font-monospace"
name="passcode" inputmode="numeric" maxlength="6"
placeholder="••••" autofocus required>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning">Set New Passcode</button>
<a href="{{ url_for('staff.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -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">46 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 %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Add to Waitlist{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header fw-semibold">Add to Waitlist</div>
<div class="card-body">
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Customer Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="customer_name" required autofocus>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Phone</label>
<input type="tel" class="form-control" name="customer_phone">
</div>
<div class="col">
<label class="form-label">Email</label>
<input type="email" class="form-control" name="customer_email">
</div>
</div>
<div class="row g-2 mb-3">
<div class="col">
<label class="form-label">Preferred Service</label>
<select class="form-select" name="service_id">
<option value="">— Any —</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
<div class="col">
<label class="form-label">Preferred Staff</label>
<select class="form-select" name="staff_id">
<option value="">— Any —</option>
{% for s in staff_list %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Requested Date</label>
<input type="date" class="form-control" name="requested_date">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Add to Waitlist</button>
<a href="{{ url_for('waitlist.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends "tenant/layouts/base.html" %}
{% block title %}Waitlist{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="fw-bold mb-0"><i class="bi bi-clock-history me-2"></i>Waitlist</h4>
<a href="{{ url_for('waitlist.add') }}" class="btn btn-primary btn-sm">+ Add to Waitlist</a>
</div>
{% if entries %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Name</th><th>Phone</th><th>Service</th><th>Staff</th><th>Requested</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td class="fw-semibold">{{ e.customer_name }}</td>
<td class="small">{{ e.customer_phone or '—' }}</td>
<td class="small">{{ e.service.name if e.service else '—' }}</td>
<td class="small">{{ e.staff.name if e.staff else '—' }}</td>
<td class="small text-muted">{{ e.requested_date.strftime('%b %d') if e.requested_date else '—' }}</td>
<td>
<span class="badge {{ 'bg-warning text-dark' if e.status == 'waiting' else 'bg-info' }}">
{{ e.status }}
</span>
</td>
<td class="d-flex gap-1">
{% if e.status == 'waiting' %}
<form method="POST" action="{{ url_for('waitlist.notify', entry_id=e.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-primary btn-sm">Notify</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('waitlist.set_status', entry_id=e.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" value="booked">
<button class="btn btn-outline-success btn-sm">Booked</button>
</form>
<form method="POST" action="{{ url_for('waitlist.set_status', entry_id=e.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" value="expired">
<button class="btn btn-outline-secondary btn-sm">Expire</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-light">No active waitlist entries.</div>
{% endif %}
{% endblock %}
+61 -10
View File
@@ -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 36: # Phase 4+ stubs (inventory, marketing, reports)
# locations, customers, appointments, services, pos, staff, from app.tenant.inventory.routes import inventory_bp
# staff_portal, booking, waitlist, gift_cards, reviews, from app.tenant.marketing.routes import marketing_bp
# reconciliation, inventory, marketing, reports, settings from app.tenant.reports.routes import reports_bp
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
+242 -2
View File
@@ -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
+124 -3
View File
@@ -1,7 +1,128 @@
""" """
app/tenant/booking/routes.py app/tenant/booking/routes.py
Phase 3+ implementation. Public online customer booking — /book/<tenant_slug>
No authentication required.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone, timedelta, date
from flask import Blueprint, render_template, request, g
from app.extensions import db, limiter, csrf, mail
from app.models.platform import Tenant
from app.models.salon import Appointment, Customer, Staff, Service, Location
from app.decorators import tenant_feature_required
booking_bp = Blueprint("booking", __name__, url_prefix="") logger = logging.getLogger(__name__)
booking_bp = Blueprint("booking", __name__)
@booking_bp.route("/book/<tenant_slug>", methods=["GET", "POST"])
@limiter.limit("15 per minute")
@csrf.exempt
def public_booking(tenant_slug):
tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
Tenant.status.in_(["active", "trial"])
).first()
if not tenant:
return render_template("tenant/booking/not_found.html"), 404
if not tenant.has_feature("online_booking"):
return render_template("tenant/booking/unavailable.html", tenant=tenant), 403
location = Location.query.filter_by(
tenant_id=tenant.id, is_primary=True, is_active=True
).first() or Location.query.filter_by(
tenant_id=tenant.id, is_active=True).first()
if not location:
return render_template("tenant/booking/unavailable.html", tenant=tenant), 403
services = Service.query.filter_by(
tenant_id=tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
staff_list = Staff.query.filter_by(
tenant_id=tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
confirmed = False
error = None
if request.method == "POST":
name = request.form.get("name", "").strip()[:255]
phone = request.form.get("phone", "").strip()[:30]
email = request.form.get("email", "").strip()[:255] or None
service_id = request.form.get("service_id", type=int)
staff_id = request.form.get("staff_id", type=int) or None
date_raw = request.form.get("preferred_date", "")
time_raw = request.form.get("preferred_time", "")
notes = request.form.get("notes", "").strip()[:500] or None
if not name or not phone or not service_id or not date_raw or not time_raw:
error = "Please complete all required fields."
else:
try:
start_time = datetime.fromisoformat(f"{date_raw}T{time_raw}")
start_time = start_time.replace(tzinfo=timezone.utc)
except ValueError:
error = "Invalid date or time."
start_time = None
if start_time and start_time < datetime.now(timezone.utc):
error = "Please choose a future date and time."
if not error:
svc = Service.query.filter_by(
id=service_id, tenant_id=tenant.id).first()
duration = svc.duration_min if svc else 30
end_time = start_time + timedelta(minutes=duration)
# Look up or create customer
customer = Customer.query.filter_by(
tenant_id=tenant.id, phone=phone).filter(
Customer.deleted_at.is_(None)).first()
if not customer:
customer = Customer(
tenant_id=tenant.id, name=name, phone=phone,
email=email, is_active=True, loyalty_points=0,
no_show_count=0,
)
db.session.add(customer)
db.session.flush()
appt = Appointment(
tenant_id=tenant.id, location_id=location.id,
customer_id=customer.id,
staff_id=staff_id, service_id=service_id,
start_time=start_time, end_time=end_time,
is_walk_in=False, status="pending",
notes=notes, rebook_source="online",
)
db.session.add(appt)
db.session.commit()
logger.info("Online booking: tenant=%s appt=%s customer=%s",
tenant.id, appt.id, customer.id)
# Send confirmation email
if email:
try:
from flask_mail import Message
msg = Message(
subject=f"Booking Confirmed — {tenant.name}",
recipients=[email],
body=(
f"Hi {name},\n\nYour appointment has been requested.\n"
f"Service: {svc.name if svc else 'N/A'}\n"
f"Date: {start_time.strftime('%B %d, %Y at %I:%M %p')}\n\n"
f"We'll confirm your booking shortly. Thank you!"
),
)
mail.send(msg)
except Exception as exc:
logger.error("Booking confirmation email failed: %s", exc)
confirmed = True
return render_template(
"tenant/booking/form.html",
tenant=tenant, services=services, staff_list=staff_list,
confirmed=confirmed, error=error,
)
+58 -91
View File
@@ -1,135 +1,102 @@
""" """
app/tenant/checkin/routes.py — Customer self check-in kiosk. app/tenant/checkin/routes.py
Route: GET/POST /checkin/<tenant_slug> Customer self check-in kiosk — /checkin/<tenant_slug>
No authentication required. CSRF-exempt. Rate-limited. No authentication required. CSRF-exempt. Rate-limited.
Alert delivery: 5-second polling via GET /api/v1/checkin/queue?status=waiting. Receptionist alert delivered via 5-second polling: GET /api/v1/checkin/queue?status=waiting
""" """
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import ( from flask import Blueprint, render_template, request, redirect, url_for, g
Blueprint, render_template, request, jsonify,
current_app, abort,
)
from app.extensions import db, limiter, csrf from app.extensions import db, limiter, csrf
from app.models.platform import Tenant from app.models.platform import Tenant
from app.models.salon import Customer, CheckinQueue, Location from app.models.salon import CheckinQueue, Customer, Service, Location
from app.security import sanitise_string, validate_slug
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
checkin_bp = Blueprint("checkin", __name__) checkin_bp = Blueprint("checkin", __name__)
@checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"]) @checkin_bp.route("/checkin/<tenant_slug>", methods=["GET", "POST"])
@csrf.exempt
@limiter.limit("20 per minute") @limiter.limit("20 per minute")
def kiosk(tenant_slug: str): @csrf.exempt
""" def kiosk(tenant_slug):
Public kiosk page. No auth required. # Validate slug against known tenants — prevents enumeration
Tenant slug validated against known slugs (not guessable). tenant = Tenant.query.filter_by(slug=tenant_slug).filter(
Accepts: name, phone, service_requested — all other fields ignored. Tenant.status.in_(["active", "trial"])
Phone is sanitised and validated before profile lookup. ).first()
Page auto-resets after 10 seconds (configurable per tenant) via JS. if not tenant:
""" return render_template("tenant/checkin/not_found.html"), 404
# Validate slug format before hitting the DB
if not validate_slug(tenant_slug):
logger.warning("Kiosk: invalid slug format: %s", tenant_slug)
abort(404)
tenant = Tenant.query.filter_by(slug=tenant_slug, is_demo=False).first() # Resolve location: session-stored or primary
if not tenant or not tenant.is_active_status():
logger.warning("Kiosk: tenant not found or inactive: %s", tenant_slug)
abort(404)
# Resolve the primary location for this tenant
location = Location.query.filter_by( location = Location.query.filter_by(
tenant_id=tenant.id, tenant_id=tenant.id, is_primary=True, is_active=True
is_primary=True, ).first()
is_active=True, if not location:
).filter(Location.deleted_at.is_(None)).first() location = Location.query.filter_by(
tenant_id=tenant.id, is_active=True
).first()
if not location:
return render_template("tenant/checkin/not_found.html"), 404
if location is None: services = Service.query.filter_by(
abort(404) tenant_id=tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
confirmed = False confirmed = False
error = None
if request.method == "POST": if request.method == "POST":
raw_name = request.form.get("customer_name", "") # Accept ONLY these three fields — all others ignored
raw_phone = request.form.get("customer_phone", "") customer_name = request.form.get("customer_name", "").strip()[:255]
raw_service = request.form.get("service_requested", "") customer_phone = request.form.get("customer_phone", "").strip()[:30]
service_requested = request.form.get("service_requested", "").strip()[:255] or None
name = sanitise_string(raw_name, max_length=150)
phone = sanitise_string(raw_phone, max_length=30)
service = sanitise_string(raw_service, max_length=150)
# Validate required fields
if not name or not phone:
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
error="Name and phone number are required.",
confirmed=False,
)
# Strip non-digit characters from phone for lookup consistency
phone_digits = "".join(filter(str.isdigit, phone))
if len(phone_digits) < 7:
return render_template(
"tenant/checkin/kiosk.html",
tenant=tenant,
error="Please enter a valid phone number.",
confirmed=False,
)
if not customer_name or not customer_phone:
error = "Please enter your name and phone number."
else:
# Look up or create customer profile # Look up or create customer profile
customer = Customer.query.filter_by( customer = Customer.query.filter_by(
tenant_id=tenant.id, tenant_id=tenant.id, phone=customer_phone
phone=phone_digits,
).filter(Customer.deleted_at.is_(None)).first() ).filter(Customer.deleted_at.is_(None)).first()
customer_id = None
if customer is None: if customer:
customer = Customer( customer_id = customer.id
else:
# Auto-create profile
new_cust = Customer(
tenant_id=tenant.id, tenant_id=tenant.id,
name=name, name=customer_name,
phone=phone_digits, phone=customer_phone,
) is_active=True,
db.session.add(customer) loyalty_points=0,
db.session.flush() # get customer.id before committing no_show_count=0,
logger.info(
"Kiosk: new customer profile created for tenant %s", tenant.slug
) )
db.session.add(new_cust)
db.session.flush()
customer_id = new_cust.id
logger.info("Kiosk: new customer profile created tenant=%s phone=%s",
tenant.id, customer_phone)
# Queue the walk-in entry
entry = CheckinQueue( entry = CheckinQueue(
tenant_id=tenant.id, tenant_id=tenant.id,
location_id=location.id, location_id=location.id,
customer_id=customer.id, customer_id=customer_id,
customer_name=name, customer_name=customer_name,
customer_phone=phone_digits, customer_phone=customer_phone,
service_requested=service or None, service_requested=service_requested,
checked_in_at=datetime.now(timezone.utc),
status="waiting", status="waiting",
) )
db.session.add(entry) db.session.add(entry)
db.session.commit() db.session.commit()
logger.info( logger.info("Kiosk check-in: tenant=%s location=%s customer=%s service=%s",
"Kiosk: check-in queued for tenant=%s location=%s customer=%s", tenant.id, location.id, customer_id, service_requested)
tenant.slug, location.id, customer.id,
)
confirmed = True confirmed = True
# Fetch services for the kiosk selector (if configured)
from app.models.salon import Service
services = Service.query.filter_by(
tenant_id=tenant.id,
is_active=True,
).filter(Service.deleted_at.is_(None)).order_by(Service.name).all()
return render_template( return render_template(
"tenant/checkin/kiosk.html", "tenant/checkin/kiosk.html",
tenant=tenant, tenant=tenant,
services=services, services=services,
confirmed=confirmed, confirmed=confirmed,
error=None, error=error,
) )
+130 -2
View File
@@ -1,7 +1,135 @@
""" """
app/tenant/customers/routes.py app/tenant/customers/routes.py
Phase 3+ implementation. Customer management: list (search), create, view, edit, soft-delete, restore.
""" """
from flask import Blueprint import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import Customer, Appointment, Transaction
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
customers_bp = Blueprint("customers", __name__, url_prefix="/customers") customers_bp = Blueprint("customers", __name__, url_prefix="/customers")
@customers_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
q = request.args.get("q", "").strip()
query = Customer.query.filter_by(tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None))
if q:
query = query.filter(
db.or_(
Customer.name.ilike(f"%{q}%"),
Customer.phone.ilike(f"%{q}%"),
Customer.email.ilike(f"%{q}%"),
)
)
customers = query.order_by(Customer.name).limit(200).all()
return render_template("tenant/customers/index.html", customers=customers, q=q)
@customers_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create():
if request.method == "POST":
name = request.form.get("name", "").strip()
phone = request.form.get("phone", "").strip() or None
if not name:
return render_template("tenant/customers/form.html",
mode="create", error="Name is required.")
customer = Customer(
tenant_id=g.tenant.id,
name=name, phone=phone,
email=request.form.get("email", "").strip() or None,
date_of_birth=_parse_date(request.form.get("date_of_birth", "")),
notes=request.form.get("notes", "").strip() or None,
is_active=True, loyalty_points=0, no_show_count=0,
)
db.session.add(customer)
db.session.flush()
log_tenant_action("customer.create", "customer", customer.id, {"name": name})
db.session.commit()
flash(f"Customer \'{name}\' created.", "success")
return redirect(url_for("customers.view", customer_id=customer.id))
return render_template("tenant/customers/form.html", mode="create")
@customers_bp.route("/<int:customer_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def view(customer_id):
customer = Customer.query.filter_by(
id=customer_id, tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None)).first_or_404()
appointments = Appointment.query.filter_by(
tenant_id=g.tenant.id, customer_id=customer_id
).order_by(Appointment.start_time.desc()).limit(20).all()
transactions = Transaction.query.filter_by(
tenant_id=g.tenant.id, customer_id=customer_id
).filter(Transaction.voided_at.is_(None)).order_by(
Transaction.created_at.desc()).limit(20).all()
return render_template("tenant/customers/view.html",
customer=customer,
appointments=appointments,
transactions=transactions)
@customers_bp.route("/<int:customer_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit(customer_id):
customer = Customer.query.filter_by(
id=customer_id, tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/customers/form.html",
mode="edit", customer=customer,
error="Name is required.")
customer.name = name
customer.phone = request.form.get("phone", "").strip() or None
customer.email = request.form.get("email", "").strip() or None
customer.date_of_birth = _parse_date(request.form.get("date_of_birth", ""))
customer.notes = request.form.get("notes", "").strip() or None
log_tenant_action("customer.edit", "customer", customer.id, {"name": name})
db.session.commit()
flash(f"Customer \'{name}\' updated.", "success")
return redirect(url_for("customers.view", customer_id=customer.id))
return render_template("tenant/customers/form.html",
mode="edit", customer=customer)
@customers_bp.route("/<int:customer_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def soft_delete(customer_id):
customer = Customer.query.filter_by(
id=customer_id, tenant_id=g.tenant.id).filter(
Customer.deleted_at.is_(None)).first_or_404()
from datetime import datetime, timezone
customer.deleted_at = datetime.now(timezone.utc)
log_tenant_action("customer.delete", "customer", customer.id,
{"name": customer.name})
db.session.commit()
flash(f"Customer \'{customer.name}\' deleted.", "success")
return redirect(url_for("customers.index"))
def _parse_date(value):
if not value:
return None
try:
from datetime import date
return date.fromisoformat(value)
except ValueError:
return None
+73 -3
View File
@@ -1,14 +1,20 @@
""" """
app/tenant/dashboard/routes.py — Tenant dashboard (placeholder for Phase 3 KPIs). app/tenant/dashboard/routes.py
Dashboard with Phase 3 KPI widgets scoped to active location.
""" """
import logging import logging
from datetime import datetime, timezone, timedelta, date
from decimal import Decimal
from flask import Blueprint, render_template, g from flask import Blueprint, render_template, g
from flask_login import login_required from flask_login import login_required
from app.extensions import db
from app.models.salon import (
Appointment, Transaction, Staff, CheckinQueue, StaffClocking,
)
from app.decorators import require_role from app.decorators import require_role
from sqlalchemy import func
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
dashboard_bp = Blueprint("dashboard", __name__) dashboard_bp = Blueprint("dashboard", __name__)
@@ -16,8 +22,72 @@ dashboard_bp = Blueprint("dashboard", __name__)
@login_required @login_required
@require_role("tenant_admin", "tenant_manager") @require_role("tenant_admin", "tenant_manager")
def index(): def index():
today = date.today()
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
location_id = g.location.id if g.location else None
# ── Today's revenue ──────────────────────────────────────
revenue_row = db.session.query(
func.sum(Transaction.total)
).filter_by(
tenant_id=g.tenant.id, location_id=location_id
).filter(
Transaction.created_at >= day_start,
Transaction.created_at < day_end,
Transaction.voided_at.is_(None),
).scalar()
today_revenue = float(revenue_row or 0)
# ── Today's appointments ─────────────────────────────────
appt_counts = dict(
db.session.query(Appointment.status, func.count(Appointment.id))
.filter_by(tenant_id=g.tenant.id, location_id=location_id)
.filter(
Appointment.start_time >= day_start,
Appointment.start_time < day_end,
)
.group_by(Appointment.status)
.all()
)
total_appts = sum(appt_counts.values())
completed_appts = appt_counts.get("completed", 0)
pending_appts = appt_counts.get("pending", 0) + appt_counts.get("confirmed", 0)
# ── Staff on shift ────────────────────────────────────────
staff_on_shift = StaffClocking.query.filter_by(
tenant_id=g.tenant.id, location_id=location_id
).filter(StaffClocking.clocked_out_at.is_(None)).count()
# ── Check-in queue ─────────────────────────────────────────
queue_count = CheckinQueue.query.filter_by(
tenant_id=g.tenant.id, location_id=location_id, status="waiting"
).count()
# ── Upcoming appointments today (next 3) ──────────────────
now = datetime.now(timezone.utc)
upcoming = Appointment.query.filter_by(
tenant_id=g.tenant.id, location_id=location_id
).filter(
Appointment.start_time >= now,
Appointment.start_time < day_end,
Appointment.status.in_(["pending", "confirmed"]),
).order_by(Appointment.start_time).limit(5).all()
kpis = {
"today_revenue": today_revenue,
"total_appointments": total_appts,
"completed_appointments": completed_appts,
"pending_appointments": pending_appts,
"staff_on_shift": staff_on_shift,
"queue_count": queue_count,
}
return render_template( return render_template(
"tenant/dashboard/index.html", "tenant/dashboard/index.html",
tenant=g.tenant, tenant=g.tenant,
location=g.location, location=g.location,
kpis=kpis,
upcoming_appointments=upcoming,
today=today,
) )
+114 -2
View File
@@ -1,7 +1,119 @@
""" """
app/tenant/gift_cards/routes.py app/tenant/gift_cards/routes.py
Phase 3+ implementation. Gift card issuance, management, and balance enquiry.
""" """
from flask import Blueprint import logging
import secrets
import string
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import GiftCard, Customer
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards") gift_cards_bp = Blueprint("gift_cards", __name__, url_prefix="/gift-cards")
def _generate_code(tenant_id: int) -> str:
"""Generate a unique gift card code for this tenant."""
alphabet = string.ascii_uppercase + string.digits
for _ in range(20):
code = "".join(secrets.choice(alphabet) for _ in range(12))
code = f"{code[:4]}-{code[4:8]}-{code[8:12]}"
if not GiftCard.query.filter_by(tenant_id=tenant_id, code=code).first():
return code
raise RuntimeError("Failed to generate unique gift card code")
@gift_cards_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
cards = GiftCard.query.filter_by(
tenant_id=g.tenant.id
).order_by(GiftCard.created_at.desc()).limit(200).all()
return render_template("tenant/gift_cards/index.html", cards=cards)
@gift_cards_bp.route("/issue", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def issue():
customers = Customer.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Customer.deleted_at.is_(None)).order_by(Customer.name).limit(500).all()
if request.method == "POST":
try:
value = float(request.form.get("value", 0))
assert value > 0
except (ValueError, AssertionError):
return render_template("tenant/gift_cards/form.html",
customers=customers, error="Value must be greater than 0.")
customer_id = request.form.get("customer_id", type=int) or None
expires_raw = request.form.get("expires_at", "").strip()
expires_at = None
if expires_raw:
try:
expires_at = datetime.fromisoformat(expires_raw)
except ValueError:
pass
code = _generate_code(g.tenant.id)
card = GiftCard(
tenant_id=g.tenant.id, code=code,
original_value=value, remaining_balance=value,
issued_by=_user_db_id(),
issued_to_customer_id=customer_id,
expires_at=expires_at, is_active=True,
)
db.session.add(card)
db.session.flush()
log_tenant_action("gift_card.issue", "gift_card", card.id,
{"code": code, "value": value})
db.session.commit()
flash(f"Gift card issued: {code} (${value:.2f})", "success")
return redirect(url_for("gift_cards.index"))
return render_template("tenant/gift_cards/form.html", customers=customers)
@gift_cards_bp.route("/lookup")
@login_required
@require_role("tenant_admin", "tenant_manager")
def lookup():
code = request.args.get("code", "").strip().upper()
card = None
if code:
card = GiftCard.query.filter_by(
tenant_id=g.tenant.id, code=code).first()
return render_template("tenant/gift_cards/lookup.html",
card=card, code=code)
@gift_cards_bp.route("/<int:card_id>/deactivate", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def deactivate(card_id):
card = GiftCard.query.filter_by(
id=card_id, tenant_id=g.tenant.id).first_or_404()
card.is_active = False
log_tenant_action("gift_card.deactivate", "gift_card", card.id,
{"code": card.code})
db.session.commit()
flash(f"Gift card {card.code} deactivated.", "success")
return redirect(url_for("gift_cards.index"))
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+169 -2
View File
@@ -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
+112 -2
View File
@@ -1,7 +1,117 @@
""" """
app/tenant/locations/routes.py app/tenant/locations/routes.py
Phase 3+ implementation. Location management: list, create, edit, set primary, switch.
""" """
from flask import Blueprint import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, session, g
from flask_login import login_required, current_user
from app.extensions import db
from app.models.salon import Location
from app.decorators import require_role, tenant_feature_required, demo_readonly
from app.tenant.utils import log_tenant_action, plan_limit_check
logger = logging.getLogger(__name__)
locations_bp = Blueprint("locations", __name__, url_prefix="/locations") locations_bp = Blueprint("locations", __name__, url_prefix="/locations")
@locations_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
locs = Location.query.filter_by(tenant_id=g.tenant.id).order_by(
Location.is_primary.desc(), Location.name).all()
return render_template("tenant/locations/index.html", locations=locs)
@locations_bp.route("/switch/<int:location_id>")
@login_required
@require_role("tenant_admin", "tenant_manager")
def switch(location_id):
loc = Location.query.filter_by(
id=location_id, tenant_id=g.tenant.id, is_active=True).first_or_404()
session["active_location_id"] = loc.id
logger.info("Location switched: location=%s tenant=%s user=%s",
loc.id, g.tenant.id, current_user.get_id())
flash(f"Switched to {loc.name}.", "info")
return redirect(request.referrer or url_for("dashboard.index"))
@locations_bp.route("/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def create():
allowed, err = plan_limit_check("location")
if not allowed:
flash(err, "warning")
return redirect(url_for("locations.index"))
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/locations/form.html",
mode="create", error="Name is required.")
allowed, err = plan_limit_check("location")
if not allowed:
return render_template("tenant/locations/form.html",
mode="create", error=err)
loc = Location(
tenant_id=g.tenant.id, name=name,
address=request.form.get("address", "").strip() or None,
phone=request.form.get("phone", "").strip() or None,
email=request.form.get("email", "").strip() or None,
timezone=request.form.get("timezone", "America/New_York"),
is_active=True, is_primary=False,
)
db.session.add(loc)
db.session.flush()
log_tenant_action("location.create", "location", loc.id, {"name": name})
db.session.commit()
flash(f"Location \'{name}\' created.", "success")
return redirect(url_for("locations.index"))
return render_template("tenant/locations/form.html", mode="create")
@locations_bp.route("/<int:location_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def edit(location_id):
loc = Location.query.filter_by(
id=location_id, tenant_id=g.tenant.id).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/locations/form.html",
mode="edit", location=loc, error="Name is required.")
old_name = loc.name
loc.name = name
loc.address = request.form.get("address", "").strip() or None
loc.phone = request.form.get("phone", "").strip() or None
loc.email = request.form.get("email", "").strip() or None
loc.timezone = request.form.get("timezone", loc.timezone)
loc.is_active = request.form.get("is_active") == "1"
if request.form.get("is_primary") == "1" and not loc.is_primary:
Location.query.filter_by(tenant_id=g.tenant.id, is_primary=True).update({"is_primary": False})
loc.is_primary = True
log_tenant_action("location.edit", "location", loc.id,
{"old_name": old_name, "new_name": name})
db.session.commit()
flash(f"Location \'{name}\' updated.", "success")
return redirect(url_for("locations.index"))
return render_template("tenant/locations/form.html", mode="edit", location=loc)
@locations_bp.route("/<int:location_id>/set-primary", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def set_primary(location_id):
loc = Location.query.filter_by(
id=location_id, tenant_id=g.tenant.id, is_active=True).first_or_404()
Location.query.filter_by(tenant_id=g.tenant.id, is_primary=True).update({"is_primary": False})
loc.is_primary = True
log_tenant_action("location.set_primary", "location", loc.id)
db.session.commit()
flash(f"\'{loc.name}\' set as primary.", "success")
return redirect(url_for("locations.index"))
+12 -2
View File
@@ -1,7 +1,17 @@
""" """
app/tenant/marketing/routes.py app/tenant/marketing/routes.py
Phase 3+ implementation. Phase 4 stub — implemented in Phase 4.
""" """
from flask import Blueprint from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing") marketing_bp = Blueprint("marketing", __name__, url_prefix="/marketing")
@marketing_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template("tenant/feature_unavailable.html",
feature="Marketing")
View File
+284
View File
@@ -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
View File
@@ -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
+122 -2
View File
@@ -1,7 +1,127 @@
""" """
app/tenant/reconciliation/routes.py app/tenant/reconciliation/routes.py
Phase 3+ implementation. End-of-day reconciliation: close day, cash count, variance calculation.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone, timedelta, date
from decimal import Decimal
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import DailyReconciliation, Transaction
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation") reconciliation_bp = Blueprint("reconciliation", __name__, url_prefix="/reconciliation")
@reconciliation_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
records = DailyReconciliation.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).order_by(DailyReconciliation.date.desc()).limit(30).all()
return render_template("tenant/reconciliation/index.html", records=records)
@reconciliation_bp.route("/close", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def close_day():
today = date.today()
# Check if already closed for today
existing = DailyReconciliation.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id, date=today
).first()
if existing and existing.closed_at:
flash("Today has already been reconciled.", "info")
return redirect(url_for("reconciliation.index"))
# Compute expected totals from today's transactions
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
txns = Transaction.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Transaction.created_at >= day_start,
Transaction.created_at < day_end,
Transaction.voided_at.is_(None),
).all()
total_cash = sum(
Decimal(str(t.total)) for t in txns if t.payment_method == "cash"
)
total_app = sum(
Decimal(str(t.total)) for t in txns
if t.payment_method in ("zelle", "venmo", "cashapp", "other")
)
total_tips = sum(Decimal(str(t.tip_amount)) for t in txns)
total_gc = sum(Decimal(str(t.gift_card_amount)) for t in txns)
expected_cash = total_cash # Starting float not tracked in MVP
if request.method == "POST":
try:
actual_cash = Decimal(request.form.get("actual_cash", "0"))
except Exception:
return render_template("tenant/reconciliation/close.html",
today=today, total_cash=float(total_cash),
total_app=float(total_app),
total_tips=float(total_tips),
total_gc=float(total_gc),
expected_cash=float(expected_cash),
error="Invalid cash amount.")
variance = actual_cash - expected_cash
notes = request.form.get("notes", "").strip() or None
if existing:
existing.actual_cash_counted = float(actual_cash)
existing.variance = float(variance)
existing.closed_by = _user_db_id()
existing.closed_at = datetime.now(timezone.utc)
existing.notes = notes
rec = existing
else:
rec = DailyReconciliation(
tenant_id=g.tenant.id,
location_id=g.location.id,
date=today,
total_cash=float(total_cash),
total_app_payments=float(total_app),
total_tips=float(total_tips),
total_gift_card_redemptions=float(total_gc),
expected_cash_in_drawer=float(expected_cash),
actual_cash_counted=float(actual_cash),
variance=float(variance),
closed_by=_user_db_id(),
closed_at=datetime.now(timezone.utc),
notes=notes,
)
db.session.add(rec)
log_tenant_action("reconciliation.close", "reconciliation", None,
{"date": str(today), "variance": float(variance)})
db.session.commit()
flash(f"Day closed. Variance: ${float(variance):.2f}", "success")
return redirect(url_for("reconciliation.index"))
return render_template("tenant/reconciliation/close.html",
today=today,
total_cash=float(total_cash),
total_app=float(total_app),
total_tips=float(total_tips),
total_gc=float(total_gc),
expected_cash=float(expected_cash))
def _user_db_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+12 -2
View File
@@ -1,7 +1,17 @@
""" """
app/tenant/reports/routes.py app/tenant/reports/routes.py
Phase 3+ implementation. Phase 4 stub — implemented in Phase 4.
""" """
from flask import Blueprint from flask import Blueprint, render_template, g
from flask_login import login_required
from app.decorators import require_role
reports_bp = Blueprint("reports", __name__, url_prefix="/reports") reports_bp = Blueprint("reports", __name__, url_prefix="/reports")
@reports_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
return render_template("tenant/feature_unavailable.html",
feature="Reports")
+33 -2
View File
@@ -1,7 +1,38 @@
""" """
app/tenant/reviews/routes.py app/tenant/reviews/routes.py
Phase 3+ implementation. Owner view of customer reviews submitted at checkout.
""" """
from flask import Blueprint import logging
from flask import Blueprint, render_template, g
from flask_login import login_required
from app.models.salon import CheckoutReview
from app.decorators import require_role
from sqlalchemy import func
from app.extensions import db
logger = logging.getLogger(__name__)
reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews") reviews_bp = Blueprint("reviews", __name__, url_prefix="/reviews")
@reviews_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
reviews = CheckoutReview.query.filter_by(
tenant_id=g.tenant.id
).order_by(CheckoutReview.created_at.desc()).limit(100).all()
avg_row = db.session.query(
func.avg(CheckoutReview.rating)
).filter_by(tenant_id=g.tenant.id).scalar()
avg_rating = round(float(avg_row or 0), 1)
dist = dict(
db.session.query(CheckoutReview.rating, func.count(CheckoutReview.id))
.filter_by(tenant_id=g.tenant.id)
.group_by(CheckoutReview.rating).all()
)
return render_template("tenant/reviews/index.html",
reviews=reviews, avg_rating=avg_rating,
rating_dist=dist)
+277 -2
View File
@@ -1,7 +1,282 @@
""" """
app/tenant/services/routes.py app/tenant/services/routes.py
Phase 3+ implementation. Services, products, and promotions catalogue.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import Service, Product, Promotion
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
services_bp = Blueprint("services", __name__, url_prefix="/services") services_bp = Blueprint("services", __name__, url_prefix="/services")
# ── Services ──────────────────────────────────────────────────────────────────
@services_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
def index():
services = Service.query.filter_by(tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).order_by(
Service.category, Service.name).all()
products = Product.query.filter_by(tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).order_by(
Product.category, Product.name).all()
promotions = Promotion.query.filter_by(
tenant_id=g.tenant.id, is_active=True).order_by(
Promotion.ends_at).all()
return render_template("tenant/services/index.html",
services=services, products=products,
promotions=promotions)
@services_bp.route("/services/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_service():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/service_form.html",
mode="create", error="Name is required.")
try:
price = float(request.form.get("price", 0))
duration = int(request.form.get("duration_min", 30))
except ValueError:
return render_template("tenant/services/service_form.html",
mode="create", error="Invalid price or duration.")
svc = Service(
tenant_id=g.tenant.id, name=name,
category=request.form.get("category", "").strip() or None,
price=price, duration_min=duration, is_active=True,
)
db.session.add(svc)
db.session.flush()
log_tenant_action("service.create", "service", svc.id, {"name": name})
db.session.commit()
flash(f"Service \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/service_form.html", mode="create")
@services_bp.route("/services/<int:service_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit_service(service_id):
svc = Service.query.filter_by(
id=service_id, tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/service_form.html",
mode="edit", service=svc, error="Name is required.")
try:
price = float(request.form.get("price", 0))
duration = int(request.form.get("duration_min", 30))
except ValueError:
return render_template("tenant/services/service_form.html",
mode="edit", service=svc, error="Invalid price or duration.")
svc.name = name
svc.category = request.form.get("category", "").strip() or None
svc.price = price
svc.duration_min = duration
svc.is_active = request.form.get("is_active") == "1"
log_tenant_action("service.edit", "service", svc.id, {"name": name})
db.session.commit()
flash(f"Service \'{name}\' updated.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/service_form.html",
mode="edit", service=svc)
@services_bp.route("/services/<int:service_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def delete_service(service_id):
svc = Service.query.filter_by(
id=service_id, tenant_id=g.tenant.id).filter(
Service.deleted_at.is_(None)).first_or_404()
svc.deleted_at = datetime.now(timezone.utc)
log_tenant_action("service.delete", "service", svc.id, {"name": svc.name})
db.session.commit()
flash(f"Service \'{svc.name}\' deleted.", "success")
return redirect(url_for("services.index"))
# ── Products ──────────────────────────────────────────────────────────────────
@services_bp.route("/products/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_product():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/product_form.html",
mode="create", error="Name is required.")
try:
price = float(request.form.get("sale_price", 0))
except ValueError:
return render_template("tenant/services/product_form.html",
mode="create", error="Invalid price.")
prod = Product(
tenant_id=g.tenant.id, name=name,
sku=request.form.get("sku", "").strip() or None,
category=request.form.get("category", "").strip() or None,
sale_price=price, is_active=True,
)
db.session.add(prod)
db.session.flush()
log_tenant_action("product.create", "product", prod.id, {"name": name})
db.session.commit()
flash(f"Product \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/product_form.html", mode="create")
@services_bp.route("/products/<int:product_id>/edit", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def edit_product(product_id):
prod = Product.query.filter_by(
id=product_id, tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).first_or_404()
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
return render_template("tenant/services/product_form.html",
mode="edit", product=prod, error="Name is required.")
try:
price = float(request.form.get("sale_price", 0))
except ValueError:
return render_template("tenant/services/product_form.html",
mode="edit", product=prod, error="Invalid price.")
prod.name = name
prod.sku = request.form.get("sku", "").strip() or None
prod.category = request.form.get("category", "").strip() or None
prod.sale_price = price
prod.is_active = request.form.get("is_active") == "1"
log_tenant_action("product.edit", "product", prod.id, {"name": name})
db.session.commit()
flash(f"Product \'{name}\' updated.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/product_form.html",
mode="edit", product=prod)
@services_bp.route("/products/<int:product_id>/delete", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def delete_product(product_id):
prod = Product.query.filter_by(
id=product_id, tenant_id=g.tenant.id).filter(
Product.deleted_at.is_(None)).first_or_404()
prod.deleted_at = datetime.now(timezone.utc)
log_tenant_action("product.delete", "product", prod.id, {"name": prod.name})
db.session.commit()
flash(f"Product \'{prod.name}\' deleted.", "success")
return redirect(url_for("services.index"))
# ── Promotions ─────────────────────────────────────────────────────────────────
@services_bp.route("/promotions/new", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def create_promotion():
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
products = Product.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Product.deleted_at.is_(None)).order_by(Product.name).all()
if request.method == "POST":
name = request.form.get("name", "").strip()
applies_to = request.form.get("applies_to", "all_services")
try:
pct = int(request.form.get("discount_percent", 0))
assert 1 <= pct <= 100
except (ValueError, AssertionError):
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Discount must be 1100%.")
starts_raw = request.form.get("starts_at", "")
ends_raw = request.form.get("ends_at", "")
if not starts_raw or not ends_raw:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Start and end dates are required.")
try:
starts_at = datetime.fromisoformat(starts_raw)
ends_at = datetime.fromisoformat(ends_raw)
except ValueError:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Invalid date format.")
# Build target_ids_json for specific targets
target_ids = None
if applies_to in ("service", "product"):
raw_ids = request.form.getlist("target_ids")
target_ids = [int(i) for i in raw_ids if i.isdigit()]
if not target_ids:
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products,
error="Please select at least one target.")
from flask_login import current_user
promo = Promotion(
tenant_id=g.tenant.id, name=name,
discount_percent=pct, applies_to=applies_to,
target_ids_json=target_ids,
starts_at=starts_at, ends_at=ends_at,
is_active=True,
created_by=_user_id(),
)
db.session.add(promo)
db.session.flush()
log_tenant_action("promotion.create", "promotion", promo.id,
{"name": name, "discount": pct})
db.session.commit()
flash(f"Promotion \'{name}\' created.", "success")
return redirect(url_for("services.index"))
return render_template("tenant/services/promotion_form.html",
mode="create", services=services, products=products)
@services_bp.route("/promotions/<int:promo_id>/toggle", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
def toggle_promotion(promo_id):
promo = Promotion.query.filter_by(
id=promo_id, tenant_id=g.tenant.id).first_or_404()
promo.is_active = not promo.is_active
action = "promotion.activate" if promo.is_active else "promotion.deactivate"
log_tenant_action(action, "promotion", promo.id, {"name": promo.name})
db.session.commit()
status = "activated" if promo.is_active else "deactivated"
flash(f"Promotion \'{promo.name}\' {status}.", "success")
return redirect(url_for("services.index"))
def _user_id():
from flask_login import current_user
try:
uid_str = current_user.get_id()
return int(uid_str.split(":")[1]) if uid_str and ":" in uid_str else None
except Exception:
return None
+54 -2
View File
@@ -1,7 +1,59 @@
""" """
app/tenant/settings/routes.py app/tenant/settings/routes.py
Phase 3+ implementation. Tenant settings management.
""" """
from flask import Blueprint import logging
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db
from app.models.salon import TenantSetting
from app.decorators import require_role, demo_readonly
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings") settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
# Keys managed by the UI — any key not in this list is hidden
MANAGED_KEYS = [
"business_hours",
"booking_advance_days",
"auto_confirm_bookings",
"review_request_delay_minutes",
"receipt_footer_note",
"google_review_url",
"facebook_review_url",
"yelp_review_url",
]
@settings_bp.route("/")
@login_required
@require_role("tenant_admin")
def index():
settings = {s.setting_key: s.setting_value for s in TenantSetting.query.filter_by(
tenant_id=g.tenant.id).all()}
return render_template("tenant/settings/index.html",
settings=settings, managed_keys=MANAGED_KEYS)
@settings_bp.route("/save", methods=["POST"])
@login_required
@require_role("tenant_admin")
@demo_readonly
def save():
for key in MANAGED_KEYS:
value = request.form.get(key, "").strip()
existing = TenantSetting.query.filter_by(
tenant_id=g.tenant.id, setting_key=key).first()
if existing:
existing.setting_value = value or None
else:
setting = TenantSetting(
tenant_id=g.tenant.id, setting_key=key,
setting_value=value or None,
)
db.session.add(setting)
log_tenant_action("settings.save", "tenant", g.tenant.id)
db.session.commit()
flash("Settings saved.", "success")
return redirect(url_for("settings.index"))
+198 -2
View File
@@ -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)
+183 -2
View File
@@ -1,7 +1,188 @@
""" """
app/tenant/staff_portal/routes.py app/tenant/staff_portal/routes.py
Phase 3+ implementation. Staff Portal — accessible by tenant_staff role (phone + passcode login).
Routes: personal schedule, upcoming appointments, clock in/out,
commission summary, payment history, read-only profile.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone, timedelta, date
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required, current_user
from app.extensions import db
from app.models.salon import (
Staff, Appointment, StaffClocking, CommissionLog,
StaffPayPeriod, Transaction,
)
from app.decorators import require_role
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal") staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
def _get_current_staff():
"""Resolve the Staff record from the current user session."""
uid_str = current_user.get_id()
if not uid_str or not uid_str.startswith("staff:"):
return None
try:
sid = int(uid_str.split(":")[1])
return Staff.query.filter_by(id=sid, is_active=True).filter(
Staff.deleted_at.is_(None)).first()
except (ValueError, IndexError):
return None
@staff_portal_bp.route("/")
@login_required
@require_role("tenant_staff")
def index():
staff = _get_current_staff()
if not staff:
flash("Staff profile not found.", "danger")
return redirect(url_for("staff_auth.staff_login"))
today = date.today()
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
# Today's appointments
todays_appts = Appointment.query.filter_by(
tenant_id=staff.tenant_id,
staff_id=staff.id,
).filter(
Appointment.start_time >= day_start,
Appointment.start_time < day_end,
Appointment.status.notin_(["cancelled", "no_show"]),
).order_by(Appointment.start_time).all()
# Current clocking state
current_clocking = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
return render_template("tenant/staff_portal/index.html",
staff=staff,
todays_appointments=todays_appts,
current_clocking=current_clocking,
today=today)
@staff_portal_bp.route("/clock-in", methods=["POST"])
@login_required
@require_role("tenant_staff")
def clock_in():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Check not already clocked in
existing = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
if existing:
flash("You are already clocked in.", "warning")
return redirect(url_for("staff_portal.index"))
location_id = g.location.id if g.location else None
clocking = StaffClocking(
tenant_id=staff.tenant_id,
location_id=location_id,
staff_id=staff.id,
clocked_in_at=datetime.now(timezone.utc),
)
db.session.add(clocking)
log_tenant_action("staff.clock_in", "staff", staff.id,
{"location": location_id})
db.session.commit()
flash("Clocked in successfully.", "success")
return redirect(url_for("staff_portal.index"))
@staff_portal_bp.route("/clock-out", methods=["POST"])
@login_required
@require_role("tenant_staff")
def clock_out():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
clocking = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
if not clocking:
flash("You are not currently clocked in.", "warning")
return redirect(url_for("staff_portal.index"))
now = datetime.now(timezone.utc)
clocking.clocked_out_at = now
total_minutes = int((now - clocking.clocked_in_at.replace(
tzinfo=timezone.utc if clocking.clocked_in_at.tzinfo is None else clocking.clocked_in_at.tzinfo
)).total_seconds() / 60)
clocking.total_minutes = total_minutes
clocking.notes = request.form.get("notes", "").strip() or None
log_tenant_action("staff.clock_out", "staff", staff.id,
{"minutes": total_minutes})
db.session.commit()
hours = total_minutes // 60
mins = total_minutes % 60
flash(f"Clocked out. Shift duration: {hours}h {mins}m.", "success")
return redirect(url_for("staff_portal.index"))
@staff_portal_bp.route("/schedule")
@login_required
@require_role("tenant_staff")
def schedule():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Next 7 days of appointments
now = datetime.now(timezone.utc)
week_ahead = now + timedelta(days=7)
appts = Appointment.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id,
).filter(
Appointment.start_time >= now,
Appointment.start_time < week_ahead,
Appointment.status.notin_(["cancelled", "no_show"]),
).order_by(Appointment.start_time).all()
return render_template("tenant/staff_portal/schedule.html",
staff=staff, appointments=appts)
@staff_portal_bp.route("/commission")
@login_required
@require_role("tenant_staff")
def commission():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Current and previous pay period summary
from datetime import date
today = date.today()
# Current month period label
period = today.strftime("%Y-W%U")
logs = CommissionLog.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id
).order_by(CommissionLog.id.desc()).limit(50).all()
pay_periods = StaffPayPeriod.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id
).order_by(StaffPayPeriod.period_start.desc()).limit(12).all()
return render_template("tenant/staff_portal/commission.html",
staff=staff, commission_logs=logs,
pay_periods=pay_periods)
@staff_portal_bp.route("/profile")
@login_required
@require_role("tenant_staff")
def profile():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
return render_template("tenant/staff_portal/profile.html", staff=staff)
+152
View File
@@ -0,0 +1,152 @@
"""
app/tenant/utils.py
Shared helpers used across all tenant portal blueprints.
"""
import logging
from datetime import datetime, timezone
from functools import wraps
from flask import g, flash, redirect, url_for, request, jsonify
from flask_login import current_user
logger = logging.getLogger(__name__)
def log_tenant_action(action, target_type=None, target_id=None, details=None):
"""
Write a structured log entry for any tenant create/edit/delete action.
Not persisted to audit_log (tenant-level actions use application logs).
"""
logger.info(
"TENANT_ACTION action=%s target_type=%s target_id=%s tenant=%s user=%s details=%s",
action,
target_type,
target_id,
getattr(g, 'tenant', None) and g.tenant.id,
current_user.get_id() if current_user.is_authenticated else None,
details,
)
def plan_limit_check(resource: str) -> tuple[bool, str]:
"""
Check whether the current tenant has headroom to add one more of `resource`.
resource: 'staff' | 'location'
Returns (allowed: bool, error_message: str | None)
"""
from app.models.salon import Staff, Location
tenant = getattr(g, 'tenant', None)
if not tenant or not tenant.plan:
return True, None
plan = tenant.plan
if resource == 'staff':
if plan.max_staff is None:
return True, None
count = Staff.query.filter_by(
tenant_id=tenant.id, is_active=True
).filter(Staff.deleted_at.is_(None)).count()
if count >= plan.max_staff:
return False, (
f"Your plan allows a maximum of {plan.max_staff} active staff members. "
"Please upgrade your plan to add more."
)
elif resource == 'location':
if plan.max_locations is None:
return True, None
count = Location.query.filter_by(
tenant_id=tenant.id, is_active=True
).count()
if count >= plan.max_locations:
return False, (
f"Your plan allows a maximum of {plan.max_locations} locations. "
"Please upgrade your plan to add more."
)
return True, None
def get_active_promotion(tenant_id: int, item_id: int, item_type: str):
"""
Promotion engine — resolves the single best active promotion for a
service or product line item at checkout time.
item_type: 'service' | 'product'
Priority:
1. Specific promotion targeting this exact item ID
2. 'all_services' / 'all_products' promotion
3. 'all' promotion (covers both services and products)
If multiple promotions match at the same priority level, the highest
discount_percent wins. Returns None if no active promotion applies.
"""
from app.models.salon import Promotion
now = datetime.now(timezone.utc)
active = Promotion.query.filter_by(
tenant_id=tenant_id, is_active=True
).filter(
Promotion.starts_at <= now,
Promotion.ends_at >= now,
).all()
if not active:
return None
# Collect all candidates
candidates = []
for promo in active:
at = promo.applies_to
if at == 'all':
candidates.append((0, promo))
elif at == 'all_services' and item_type == 'service':
candidates.append((1, promo))
elif at == 'all_products' and item_type == 'product':
candidates.append((1, promo))
elif at == item_type:
# Specific IDs — check membership
target_ids = promo.target_ids_json or []
if item_id in target_ids:
candidates.append((2, promo))
if not candidates:
return None
# Sort: highest specificity first (2 > 1 > 0), then highest discount
candidates.sort(key=lambda x: (x[0], x[1].discount_percent), reverse=True)
return candidates[0][1]
def apply_promotion_to_price(price, promotion):
"""
Apply a promotion to a unit price.
Returns (discounted_price, discount_percent).
If promotion is None, returns original price and 0.
"""
from decimal import Decimal, ROUND_HALF_UP
if promotion is None:
return price, 0
pct = promotion.discount_percent
price = Decimal(str(price))
discount = (price * Decimal(pct) / Decimal(100)).quantize(
Decimal('0.01'), rounding=ROUND_HALF_UP
)
return float(price - discount), pct
def is_api_request():
return request.path.startswith('/api/') or \
request.accept_mimetypes.best == 'application/json'
+126 -2
View File
@@ -1,7 +1,131 @@
""" """
app/tenant/waitlist/routes.py app/tenant/waitlist/routes.py
Phase 3+ implementation. Waitlist management: list, add, notify, mark booked, expire.
""" """
from flask import Blueprint import logging
from datetime import datetime, timezone
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required
from app.extensions import db, mail
from app.models.salon import Waitlist, Staff, Service
from app.decorators import require_role, demo_readonly, tenant_feature_required
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist") waitlist_bp = Blueprint("waitlist", __name__, url_prefix="/waitlist")
@waitlist_bp.route("/")
@login_required
@require_role("tenant_admin", "tenant_manager")
@tenant_feature_required("waitlist")
def index():
entries = Waitlist.query.filter_by(
tenant_id=g.tenant.id, location_id=g.location.id
).filter(
Waitlist.status.in_(["waiting", "notified"])
).order_by(Waitlist.created_at).all()
return render_template("tenant/waitlist/index.html", entries=entries)
@waitlist_bp.route("/add", methods=["GET", "POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("waitlist")
def add():
staff_list = Staff.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Staff.deleted_at.is_(None)).order_by(Staff.name).all()
services = Service.query.filter_by(
tenant_id=g.tenant.id, is_active=True).filter(
Service.deleted_at.is_(None)).order_by(Service.name).all()
if request.method == "POST":
name = request.form.get("customer_name", "").strip()
phone = request.form.get("customer_phone", "").strip() or None
email_addr = request.form.get("customer_email", "").strip() or None
if not name:
return render_template("tenant/waitlist/form.html",
staff_list=staff_list, services=services,
error="Customer name is required.")
entry = Waitlist(
tenant_id=g.tenant.id, location_id=g.location.id,
customer_name=name, customer_phone=phone,
customer_email=email_addr,
staff_id=request.form.get("staff_id", type=int) or None,
service_id=request.form.get("service_id", type=int) or None,
requested_date=_parse_date(request.form.get("requested_date", "")),
status="waiting",
)
db.session.add(entry)
db.session.flush()
log_tenant_action("waitlist.add", "waitlist", entry.id, {"name": name})
db.session.commit()
flash(f"'{name}' added to waitlist.", "success")
return redirect(url_for("waitlist.index"))
return render_template("tenant/waitlist/form.html",
staff_list=staff_list, services=services)
@waitlist_bp.route("/<int:entry_id>/notify", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("waitlist")
def notify(entry_id):
entry = Waitlist.query.filter_by(
id=entry_id, tenant_id=g.tenant.id).first_or_404()
entry.status = "notified"
entry.notified_at = datetime.now(timezone.utc)
# Send email notification if available
if entry.customer_email:
try:
from flask_mail import Message
msg = Message(
subject=f"A slot is available — {g.tenant.name}",
recipients=[entry.customer_email],
body=(
f"Hi {entry.customer_name},\n\n"
"A slot has opened up for you. Please call or book online to confirm.\n\n"
f"{g.tenant.name}"
),
)
mail.send(msg)
except Exception as exc:
logger.error("Waitlist notify email failed: %s", exc)
log_tenant_action("waitlist.notify", "waitlist", entry.id,
{"name": entry.customer_name})
db.session.commit()
flash(f"'{entry.customer_name}' notified.", "success")
return redirect(url_for("waitlist.index"))
@waitlist_bp.route("/<int:entry_id>/set-status", methods=["POST"])
@login_required
@require_role("tenant_admin", "tenant_manager")
@demo_readonly
@tenant_feature_required("waitlist")
def set_status(entry_id):
entry = Waitlist.query.filter_by(
id=entry_id, tenant_id=g.tenant.id).first_or_404()
new_status = request.form.get("status", "")
if new_status in ("booked", "expired"):
entry.status = new_status
log_tenant_action("waitlist.set_status", "waitlist", entry.id,
{"status": new_status})
db.session.commit()
flash(f"Entry updated to '{new_status}'.", "success")
return redirect(url_for("waitlist.index"))
def _parse_date(value):
if not value:
return None
try:
from datetime import date
return date.fromisoformat(value)
except ValueError:
return None

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