From bf9b54f6b515dd816508e720659b3498305e6c0b Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 8 May 2026 10:15:40 -0400 Subject: [PATCH] 05/08 Phase 4 --- CLAUDE.md | 26 +- app/scheduler_jobs.py | 184 ++++++++++++ app/templates/tenant/appointments/index.html | 30 +- app/templates/tenant/inventory/adjust.html | 32 ++ app/templates/tenant/inventory/form.html | 61 ++++ app/templates/tenant/inventory/index.html | 52 ++++ app/templates/tenant/inventory/log.html | 31 ++ app/templates/tenant/layouts/base.html | 8 +- .../tenant/pay_periods/calculate.html | 76 +++++ app/templates/tenant/pay_periods/index.html | 53 ++++ app/templates/tenant/staff/form.html | 65 +++- app/tenant/__init__.py | 26 ++ app/tenant/appointments/routes.py | 16 + app/tenant/inventory/routes.py | 167 +++++++++- app/tenant/pay_periods/__init__.py | 0 app/tenant/pay_periods/routes.py | 284 ++++++++++++++++++ app/tenant/pos/routes.py | 31 ++ app/tenant/staff/routes.py | 10 +- templates/tenant/inventory/adjust.html | 32 ++ templates/tenant/inventory/form.html | 61 ++++ templates/tenant/inventory/index.html | 52 ++++ templates/tenant/inventory/log.html | 31 ++ templates/tenant/layouts/base.html | 8 +- templates/tenant/pay_periods/calculate.html | 76 +++++ templates/tenant/pay_periods/index.html | 53 ++++ templates/tenant/staff/form.html | 86 +++++- 26 files changed, 1509 insertions(+), 42 deletions(-) create mode 100644 app/scheduler_jobs.py create mode 100644 app/templates/tenant/inventory/adjust.html create mode 100644 app/templates/tenant/inventory/form.html create mode 100644 app/templates/tenant/inventory/index.html create mode 100644 app/templates/tenant/inventory/log.html create mode 100644 app/templates/tenant/pay_periods/calculate.html create mode 100644 app/templates/tenant/pay_periods/index.html create mode 100644 app/tenant/pay_periods/__init__.py create mode 100644 app/tenant/pay_periods/routes.py create mode 100644 templates/tenant/inventory/adjust.html create mode 100644 templates/tenant/inventory/form.html create mode 100644 templates/tenant/inventory/index.html create mode 100644 templates/tenant/inventory/log.html create mode 100644 templates/tenant/pay_periods/calculate.html create mode 100644 templates/tenant/pay_periods/index.html diff --git a/CLAUDE.md b/CLAUDE.md index 4960b61..b83885b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -731,8 +731,23 @@ WantedBy=multi-user.target - [ ] 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) -### Phase 4 — Tenant Operations Modules -- [ ] Staff management (profiles, job type, system role, location assignments, schedules) +### Phase 4 — Tenant Operations Modules ✅ COMPLETE +- [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) - [ ] 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) @@ -949,6 +964,13 @@ Every fix must be validated by a programmatic test (import test, render test, or **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 diff --git a/app/scheduler_jobs.py b/app/scheduler_jobs.py new file mode 100644 index 0000000..3979e37 --- /dev/null +++ b/app/scheduler_jobs.py @@ -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) diff --git a/app/templates/tenant/appointments/index.html b/app/templates/tenant/appointments/index.html index b16a947..d98a586 100644 --- a/app/templates/tenant/appointments/index.html +++ b/app/templates/tenant/appointments/index.html @@ -5,11 +5,12 @@

Appointments

+ class="btn btn-outline-secondary btn-sm">‹ + onchange="window.location='{{ url_for('appointments.index') }}?date='+this.value" + style="width:150px;"> + class="btn btn-outline-secondary btn-sm">› + New
@@ -18,15 +19,7 @@
- - - - - - - - - + {% for appt in appointments %} @@ -36,25 +29,20 @@ {% else %} - - - + {% endfor %}
TimeCustomerServiceStaffStatusType
TimeCustomerServiceStaffStatusType
{{ appt.service.name if appt.service else '—' }} {{ appt.staff.name if appt.staff else '—' }} - + {{ appt.status }} {{ 'Walk-in' if appt.is_walk_in else 'Booked' }} - View + View {% if appt.status in ['pending','confirmed','in_progress'] %} - Checkout + Checkout {% endif %}
No appointments for this day.
No appointments for this day.
diff --git a/app/templates/tenant/inventory/adjust.html b/app/templates/tenant/inventory/adjust.html new file mode 100644 index 0000000..0ce67a5 --- /dev/null +++ b/app/templates/tenant/inventory/adjust.html @@ -0,0 +1,32 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Adjust Stock — {{ item.name }}{% endblock %} +{% block content %} +
+
+
+
Adjust Stock — {{ item.name }}
+
+

Current quantity: {{ item.qty_on_hand }}

+ {% if error %}
{{ error }}
{% endif %} +
+ +
+ + +
+
+ + +
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/inventory/form.html b/app/templates/tenant/inventory/form.html new file mode 100644 index 0000000..b39247d --- /dev/null +++ b/app/templates/tenant/inventory/form.html @@ -0,0 +1,61 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Inventory Item{% endblock %} +{% block content %} +
+
+
+
{{ 'Edit' if mode == 'edit' else 'Add' }} Inventory Item
+
+ {% if error %}
{{ error }}
{% endif %} +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ {% if mode == 'create' %} +
+ + +
+ {% endif %} +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/inventory/index.html b/app/templates/tenant/inventory/index.html new file mode 100644 index 0000000..e5ed825 --- /dev/null +++ b/app/templates/tenant/inventory/index.html @@ -0,0 +1,52 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Inventory{% endblock %} +{% block content %} +
+

Inventory

+ + Add Item +
+ +{% if low_stock %} +
+ +
+ {{ low_stock|length }} item(s) at or below reorder level: + {{ low_stock|map(attribute='name')|join(', ') }} +
+
+{% endif %} + +
+
+ + + + + + {% for item in items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
NameSKUCategoryOn HandReorder AtCostSale
{{ item.name }}{{ item.sku or '—' }}{{ item.category or '—' }} + + {{ item.qty_on_hand }} + + {{ item.reorder_level }}{{ '$' ~ "%.2f"|format(item.cost_price) if item.cost_price else '—' }}{{ '$' ~ "%.2f"|format(item.sale_price) if item.sale_price else '—' }} + Adjust + Edit + Log +
No inventory items yet.
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/inventory/log.html b/app/templates/tenant/inventory/log.html new file mode 100644 index 0000000..5a2884a --- /dev/null +++ b/app/templates/tenant/inventory/log.html @@ -0,0 +1,31 @@ +{% extends "tenant/layouts/base.html" %} +{% block title %}Stock Log — {{ item.name }}{% endblock %} +{% block content %} +
+

Stock Log — {{ item.name }}

+ Current: {{ item.qty_on_hand }} units +
+
+
+ + + + {% for e in entries %} + + + + + + {% else %} + + {% endfor %} + +
DateChangeReason
{{ e.created_at.strftime('%Y-%m-%d %H:%M') }} + {{ '+' if e.delta > 0 else '' }}{{ e.delta }} + {{ e.reason or '—' }}
No log entries.
+
+
+ + Back to Inventory + +{% endblock %} \ No newline at end of file diff --git a/app/templates/tenant/layouts/base.html b/app/templates/tenant/layouts/base.html index 7815faa..1437712 100644 --- a/app/templates/tenant/layouts/base.html +++ b/app/templates/tenant/layouts/base.html @@ -116,7 +116,7 @@ @@ -132,6 +132,12 @@ Reconciliation + @@ -132,6 +132,12 @@ Reconciliation +