05/08 Phase 4
This commit is contained in:
@@ -731,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)
|
||||||
@@ -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.**
|
**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.
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
<a href="{{ url_for('appointments.index', date=prev_date.isoformat()) }}"
|
<a href="{{ url_for('appointments.index', date=prev_date.isoformat()) }}"
|
||||||
class="btn btn-outline-secondary btn-sm">‹</a>
|
class="btn btn-outline-secondary btn-sm">‹</a>
|
||||||
<input type="date" class="form-control form-control-sm" value="{{ view_date.isoformat() }}"
|
<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;">
|
onchange="window.location='{{ url_for('appointments.index') }}?date='+this.value"
|
||||||
|
style="width:150px;">
|
||||||
<a href="{{ url_for('appointments.index', date=next_date.isoformat()) }}"
|
<a href="{{ url_for('appointments.index', date=next_date.isoformat()) }}"
|
||||||
class="btn btn-outline-secondary btn-sm">›</a>
|
class="btn btn-outline-secondary btn-sm">›</a>
|
||||||
<a href="{{ url_for('appointments.create') }}" class="btn btn-primary btn-sm ms-2">+ New</a>
|
<a href="{{ url_for('appointments.create') }}" class="btn btn-primary btn-sm ms-2">+ New</a>
|
||||||
@@ -18,15 +19,7 @@
|
|||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr><th>Time</th><th>Customer</th><th>Service</th><th>Staff</th><th>Status</th><th>Type</th><th></th></tr>
|
||||||
<th>Time</th>
|
|
||||||
<th>Customer</th>
|
|
||||||
<th>Service</th>
|
|
||||||
<th>Staff</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for appt in appointments %}
|
{% for appt in appointments %}
|
||||||
@@ -36,25 +29,20 @@
|
|||||||
<td class="small">{{ appt.service.name if appt.service else '—' }}</td>
|
<td class="small">{{ appt.service.name if appt.service else '—' }}</td>
|
||||||
<td class="small">{{ appt.staff.name if appt.staff else '—' }}</td>
|
<td class="small">{{ appt.staff.name if appt.staff else '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span class="badge bg-{{ {'pending':'warning','confirmed':'primary','in_progress':'info','completed':'success','cancelled':'danger','no_show':'dark'}.get(appt.status,'secondary') }}">
|
||||||
class="badge bg-{{ {'pending':'warning','confirmed':'primary','in_progress':'info','completed':'success','cancelled':'danger','no_show':'dark'}.get(appt.status,'secondary') }}">
|
|
||||||
{{ appt.status }}
|
{{ appt.status }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="small">{{ 'Walk-in' if appt.is_walk_in else 'Booked' }}</td>
|
<td class="small">{{ 'Walk-in' if appt.is_walk_in else 'Booked' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('appointments.view', appt_id=appt.id) }}"
|
<a href="{{ url_for('appointments.view', appt_id=appt.id) }}" class="btn btn-outline-secondary btn-sm">View</a>
|
||||||
class="btn btn-outline-secondary btn-sm">View</a>
|
|
||||||
{% if appt.status in ['pending','confirmed','in_progress'] %}
|
{% if appt.status in ['pending','confirmed','in_progress'] %}
|
||||||
<a href="{{ url_for('pos.checkout') }}?appointment_id={{ appt.id }}"
|
<a href="{{ url_for('pos.checkout') }}?appointment_id={{ appt.id }}" class="btn btn-success btn-sm">Checkout</a>
|
||||||
class="btn btn-success btn-sm">Checkout</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr><td colspan="7" class="text-center text-muted py-4">No appointments for this day.</td></tr>
|
||||||
<td colspan="7" class="text-center text-muted py-4">No appointments for this day.</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Adjust Stock — {{ item.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Adjust Stock — {{ item.name }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Current quantity: <strong>{{ item.qty_on_hand }}</strong></p>
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Adjustment <small class="text-muted">(positive = add, negative = remove)</small></label>
|
||||||
|
<input type="number" class="form-control form-control-lg text-center" name="delta"
|
||||||
|
placeholder="e.g. +10 or -3" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Reason</label>
|
||||||
|
<input type="text" class="form-control" name="reason"
|
||||||
|
placeholder="e.g. Stock count, Supplier delivery, Damage">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Apply Adjustment</button>
|
||||||
|
<a href="{{ url_for('inventory.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Inventory Item{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'Add' }} Inventory Item</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ item.name if item else '' }}" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">SKU</label>
|
||||||
|
<input type="text" class="form-control font-monospace" name="sku"
|
||||||
|
value="{{ item.sku if item else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input type="text" class="form-control" name="category"
|
||||||
|
value="{{ item.category if item else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if mode == 'create' %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Initial Quantity</label>
|
||||||
|
<input type="number" class="form-control" name="qty_on_hand" min="0" value="0">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Reorder Level <small class="text-muted">(alert when stock reaches this)</small></label>
|
||||||
|
<input type="number" class="form-control" name="reorder_level" min="0"
|
||||||
|
value="{{ item.reorder_level if item else 5 }}">
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Cost Price ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="cost_price" min="0"
|
||||||
|
value="{{ "%.2f"|format(item.cost_price) if item and item.cost_price else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Sale Price ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="sale_price" min="0"
|
||||||
|
value="{{ "%.2f"|format(item.sale_price) if item and item.sale_price else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('inventory.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Inventory{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-box-seam me-2"></i>Inventory</h4>
|
||||||
|
<a href="{{ url_for('inventory.create') }}" class="btn btn-primary btn-sm">+ Add Item</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if low_stock %}
|
||||||
|
<div class="alert alert-warning d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<div>
|
||||||
|
<strong>{{ low_stock|length }} item(s) at or below reorder level:</strong>
|
||||||
|
{{ low_stock|map(attribute='name')|join(', ') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Name</th><th>SKU</th><th>Category</th><th>On Hand</th><th>Reorder At</th><th>Cost</th><th>Sale</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr class="{{ 'table-warning' if item.qty_on_hand <= item.reorder_level else '' }}">
|
||||||
|
<td class="fw-semibold">{{ item.name }}</td>
|
||||||
|
<td class="small text-muted font-monospace">{{ item.sku or '—' }}</td>
|
||||||
|
<td class="small">{{ item.category or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="fw-bold {{ 'text-danger' if item.qty_on_hand <= item.reorder_level else '' }}">
|
||||||
|
{{ item.qty_on_hand }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ item.reorder_level }}</td>
|
||||||
|
<td class="small">{{ '$' ~ "%.2f"|format(item.cost_price) if item.cost_price else '—' }}</td>
|
||||||
|
<td class="small">{{ '$' ~ "%.2f"|format(item.sale_price) if item.sale_price else '—' }}</td>
|
||||||
|
<td class="d-flex gap-1">
|
||||||
|
<a href="{{ url_for('inventory.adjust', item_id=item.id) }}" class="btn btn-outline-primary btn-sm">Adjust</a>
|
||||||
|
<a href="{{ url_for('inventory.edit', item_id=item.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<a href="{{ url_for('inventory.log', item_id=item.id) }}" class="btn btn-outline-secondary btn-sm">Log</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4">No inventory items yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Stock Log — {{ item.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0">Stock Log — {{ item.name }}</h4>
|
||||||
|
<span class="badge bg-primary fs-6">Current: {{ item.qty_on_hand }} units</span>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Date</th><th>Change</th><th>Reason</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in entries %}
|
||||||
|
<tr>
|
||||||
|
<td class="small text-muted">{{ e.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td class="fw-bold {{ 'text-success' if e.delta > 0 else 'text-danger' }}">
|
||||||
|
{{ '+' if e.delta > 0 else '' }}{{ e.delta }}
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ e.reason or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="text-center text-muted py-4">No log entries.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('inventory.index') }}" class="btn btn-outline-secondary btn-sm mt-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Inventory
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint and 'inventory' in request.endpoint %}active fw-bold{% endif %}"
|
<a class="nav-link {% if request.endpoint and 'inventory' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
href="{{ '#' }}">
|
href="{{ url_for('inventory.index') }}">
|
||||||
<i class="bi bi-box-seam me-2"></i>Inventory
|
<i class="bi bi-box-seam me-2"></i>Inventory
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -132,6 +132,12 @@
|
|||||||
<i class="bi bi-calculator me-2"></i>Reconciliation
|
<i class="bi bi-calculator me-2"></i>Reconciliation
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint and 'reports' in request.endpoint %}active fw-bold{% endif %}"
|
<a class="nav-link {% if request.endpoint and 'reports' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
href="{{ '#' }}">
|
href="{{ '#' }}">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
{% extends "tenant/layouts/base.html" %}
|
{% extends "tenant/layouts/base.html" %}
|
||||||
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -47,13 +60,63 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="mb-3">
|
||||||
<label class="form-label">Location Assignments</label>
|
<label class="form-label">Location Assignments</label>
|
||||||
{% for loc in locations %}
|
{% for loc in locations %}
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" name="location_ids"
|
<input class="form-check-input" type="checkbox" name="location_ids"
|
||||||
value="{{ loc.id }}" id="loc_{{ loc.id }}"
|
value="{{ loc.id }}" id="loc_{{ loc.id }}"
|
||||||
{{ 'checked' if loc.id in (assigned_ids or set()) }}>
|
{{ 'checked' if assigned_ids and loc.id in assigned_ids }}>
|
||||||
<label class="form-check-label" for="loc_{{ loc.id }}">{{ loc.name }}</label>
|
<label class="form-check-label" for="loc_{{ loc.id }}">{{ loc.name }}</label>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -119,9 +119,35 @@ def create_tenant_app(config_override=None):
|
|||||||
from app.tenant.inventory.routes import inventory_bp
|
from app.tenant.inventory.routes import inventory_bp
|
||||||
from app.tenant.marketing.routes import marketing_bp
|
from app.tenant.marketing.routes import marketing_bp
|
||||||
from app.tenant.reports.routes import reports_bp
|
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(inventory_bp)
|
||||||
flask_app.register_blueprint(marketing_bp)
|
flask_app.register_blueprint(marketing_bp)
|
||||||
flask_app.register_blueprint(reports_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
|
||||||
|
|||||||
@@ -115,6 +115,22 @@ def create():
|
|||||||
log_tenant_action("appointment.create", "appointment", appt.id,
|
log_tenant_action("appointment.create", "appointment", appt.id,
|
||||||
{"customer": customer_id, "staff": staff_id,
|
{"customer": customer_id, "staff": staff_id,
|
||||||
"start": start_raw})
|
"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()
|
db.session.commit()
|
||||||
flash("Appointment created.", "success")
|
flash("Appointment created.", "success")
|
||||||
return redirect(url_for("appointments.index",
|
return redirect(url_for("appointments.index",
|
||||||
|
|||||||
@@ -1,17 +1,174 @@
|
|||||||
"""
|
"""
|
||||||
app/tenant/inventory/routes.py
|
app/tenant/inventory/routes.py
|
||||||
Phase 4 stub — implemented in Phase 4.
|
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, render_template, g
|
import logging
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from app.decorators import require_role
|
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("/")
|
@inventory_bp.route("/")
|
||||||
@login_required
|
@login_required
|
||||||
@require_role("tenant_admin", "tenant_manager")
|
@require_role("tenant_admin", "tenant_manager")
|
||||||
|
@tenant_feature_required("inventory")
|
||||||
def index():
|
def index():
|
||||||
return render_template("tenant/feature_unavailable.html",
|
items = Inventory.query.filter_by(
|
||||||
feature="Inventory")
|
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
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -212,6 +212,37 @@ def submit():
|
|||||||
)
|
)
|
||||||
db.session.add(comm_log)
|
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,
|
log_tenant_action("transaction.create", "transaction", txn.id,
|
||||||
{"total": float(total), "payment": payment_method})
|
{"total": float(total), "payment": payment_method})
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ def edit(staff_id):
|
|||||||
member.phone = phone
|
member.phone = phone
|
||||||
member.staff_type = request.form.get("staff_type", member.staff_type)
|
member.staff_type = request.form.get("staff_type", member.staff_type)
|
||||||
member.is_active = request.form.get("is_active") == "1"
|
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
|
# Update location assignments
|
||||||
StaffLocation.query.filter_by(
|
StaffLocation.query.filter_by(
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Adjust Stock — {{ item.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">Adjust Stock — {{ item.name }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Current quantity: <strong>{{ item.qty_on_hand }}</strong></p>
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Adjustment <small class="text-muted">(positive = add, negative = remove)</small></label>
|
||||||
|
<input type="number" class="form-control form-control-lg text-center" name="delta"
|
||||||
|
placeholder="e.g. +10 or -3" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Reason</label>
|
||||||
|
<input type="text" class="form-control" name="reason"
|
||||||
|
placeholder="e.g. Stock count, Supplier delivery, Damage">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Apply Adjustment</button>
|
||||||
|
<a href="{{ url_for('inventory.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Inventory Item{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header fw-semibold">{{ 'Edit' if mode == 'edit' else 'Add' }} Inventory Item</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||||
|
<form method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" class="form-control" name="name"
|
||||||
|
value="{{ item.name if item else '' }}" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">SKU</label>
|
||||||
|
<input type="text" class="form-control font-monospace" name="sku"
|
||||||
|
value="{{ item.sku if item else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input type="text" class="form-control" name="category"
|
||||||
|
value="{{ item.category if item else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if mode == 'create' %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Initial Quantity</label>
|
||||||
|
<input type="number" class="form-control" name="qty_on_hand" min="0" value="0">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Reorder Level <small class="text-muted">(alert when stock reaches this)</small></label>
|
||||||
|
<input type="number" class="form-control" name="reorder_level" min="0"
|
||||||
|
value="{{ item.reorder_level if item else 5 }}">
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Cost Price ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="cost_price" min="0"
|
||||||
|
value="{{ "%.2f"|format(item.cost_price) if item and item.cost_price else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label">Sale Price ($)</label>
|
||||||
|
<input type="number" step="0.01" class="form-control" name="sale_price" min="0"
|
||||||
|
value="{{ "%.2f"|format(item.sale_price) if item and item.sale_price else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('inventory.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Inventory{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0"><i class="bi bi-box-seam me-2"></i>Inventory</h4>
|
||||||
|
<a href="{{ url_for('inventory.create') }}" class="btn btn-primary btn-sm">+ Add Item</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if low_stock %}
|
||||||
|
<div class="alert alert-warning d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<div>
|
||||||
|
<strong>{{ low_stock|length }} item(s) at or below reorder level:</strong>
|
||||||
|
{{ low_stock|map(attribute='name')|join(', ') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Name</th><th>SKU</th><th>Category</th><th>On Hand</th><th>Reorder At</th><th>Cost</th><th>Sale</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr class="{{ 'table-warning' if item.qty_on_hand <= item.reorder_level else '' }}">
|
||||||
|
<td class="fw-semibold">{{ item.name }}</td>
|
||||||
|
<td class="small text-muted font-monospace">{{ item.sku or '—' }}</td>
|
||||||
|
<td class="small">{{ item.category or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="fw-bold {{ 'text-danger' if item.qty_on_hand <= item.reorder_level else '' }}">
|
||||||
|
{{ item.qty_on_hand }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ item.reorder_level }}</td>
|
||||||
|
<td class="small">{{ '$' ~ "%.2f"|format(item.cost_price) if item.cost_price else '—' }}</td>
|
||||||
|
<td class="small">{{ '$' ~ "%.2f"|format(item.sale_price) if item.sale_price else '—' }}</td>
|
||||||
|
<td class="d-flex gap-1">
|
||||||
|
<a href="{{ url_for('inventory.adjust', item_id=item.id) }}" class="btn btn-outline-primary btn-sm">Adjust</a>
|
||||||
|
<a href="{{ url_for('inventory.edit', item_id=item.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<a href="{{ url_for('inventory.log', item_id=item.id) }}" class="btn btn-outline-secondary btn-sm">Log</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4">No inventory items yet.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "tenant/layouts/base.html" %}
|
||||||
|
{% block title %}Stock Log — {{ item.name }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="fw-bold mb-0">Stock Log — {{ item.name }}</h4>
|
||||||
|
<span class="badge bg-primary fs-6">Current: {{ item.qty_on_hand }} units</span>
|
||||||
|
</div>
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light"><tr><th>Date</th><th>Change</th><th>Reason</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in entries %}
|
||||||
|
<tr>
|
||||||
|
<td class="small text-muted">{{ e.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td class="fw-bold {{ 'text-success' if e.delta > 0 else 'text-danger' }}">
|
||||||
|
{{ '+' if e.delta > 0 else '' }}{{ e.delta }}
|
||||||
|
</td>
|
||||||
|
<td class="small">{{ e.reason or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3" class="text-center text-muted py-4">No log entries.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('inventory.index') }}" class="btn btn-outline-secondary btn-sm mt-3">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Back to Inventory
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint and 'inventory' in request.endpoint %}active fw-bold{% endif %}"
|
<a class="nav-link {% if request.endpoint and 'inventory' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
href="{{ '#' }}">
|
href="{{ url_for('inventory.index') }}">
|
||||||
<i class="bi bi-box-seam me-2"></i>Inventory
|
<i class="bi bi-box-seam me-2"></i>Inventory
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -132,6 +132,12 @@
|
|||||||
<i class="bi bi-calculator me-2"></i>Reconciliation
|
<i class="bi bi-calculator me-2"></i>Reconciliation
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint and 'reports' in request.endpoint %}active fw-bold{% endif %}"
|
<a class="nav-link {% if request.endpoint and 'reports' in request.endpoint %}active fw-bold{% endif %}"
|
||||||
href="{{ '#' }}">
|
href="{{ '#' }}">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
{% extends "tenant/layouts/base.html" %}
|
{% extends "tenant/layouts/base.html" %}
|
||||||
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Staff Member{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -11,17 +24,19 @@
|
|||||||
<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">Name <span class="text-danger">*</span></label>
|
<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
|
<input type="text" class="form-control" name="name"
|
||||||
autofocus>
|
value="{{ staff.name if staff else '' }}" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Phone <span class="text-danger">*</span></label>
|
<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>
|
<input type="tel" class="form-control" name="phone"
|
||||||
|
value="{{ staff.phone if staff else '' }}" required>
|
||||||
</div>
|
</div>
|
||||||
{% if mode == 'create' %}
|
{% if mode == 'create' %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Passcode (4–6 digits) <span class="text-danger">*</span></label>
|
<label class="form-label">Passcode (4–6 digits) <span class="text-danger">*</span></label>
|
||||||
<input type="password" class="form-control" name="passcode" inputmode="numeric" maxlength="6" required>
|
<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 class="form-text">Staff will use this PIN to log in. Show it to them once, then discard it.</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -30,7 +45,7 @@
|
|||||||
<label class="form-label">Job Type</label>
|
<label class="form-label">Job Type</label>
|
||||||
<select class="form-select" name="staff_type">
|
<select class="form-select" name="staff_type">
|
||||||
{% for t in ['full_time','part_time','seasonal','receptionist','salon_manager'] %}
|
{% for t in ['full_time','part_time','seasonal','receptionist','salon_manager'] %}
|
||||||
<option value="{{ t }}" {{ 'selected' if staff and staff.staff_type==t }}>
|
<option value="{{ t }}" {{ 'selected' if staff and staff.staff_type == t }}>
|
||||||
{{ t.replace('_',' ').title() }}
|
{{ t.replace('_',' ').title() }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -40,25 +55,76 @@
|
|||||||
<label class="form-label">Pay Type</label>
|
<label class="form-label">Pay Type</label>
|
||||||
<select class="form-select" name="pay_type">
|
<select class="form-select" name="pay_type">
|
||||||
{% for t in ['hourly','salary','guarantee'] %}
|
{% for t in ['hourly','salary','guarantee'] %}
|
||||||
<option value="{{ t }}" {{ 'selected' if staff and staff.pay_type==t }}>{{ t.title() }}</option>
|
<option value="{{ t }}" {{ 'selected' if staff and staff.pay_type == t }}>{{ t.title() }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="mb-3">
|
||||||
<label class="form-label">Location Assignments</label>
|
<label class="form-label">Location Assignments</label>
|
||||||
{% for loc in locations %}
|
{% for loc in locations %}
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" name="location_ids" value="{{ loc.id }}"
|
<input class="form-check-input" type="checkbox" name="location_ids"
|
||||||
id="loc_{{ loc.id }}" {{ 'checked' if assigned_ids and loc.id in assigned_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>
|
<label class="form-check-label" for="loc_{{ loc.id }}">{{ loc.name }}</label>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% if mode == 'edit' %}
|
{% if mode == 'edit' %}
|
||||||
<div class="form-check form-switch mb-3">
|
<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
|
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||||
staff and staff.is_active }}>
|
id="is_active" {{ 'checked' if staff and staff.is_active }}>
|
||||||
<label class="form-check-label" for="is_active">Active</label>
|
<label class="form-check-label" for="is_active">Active</label>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user