Files
2026-05-07 12:17:19 -04:00

189 lines
6.5 KiB
Python

"""
app/tenant/staff_portal/routes.py
Staff Portal — accessible by tenant_staff role (phone + passcode login).
Routes: personal schedule, upcoming appointments, clock in/out,
commission summary, payment history, read-only profile.
"""
import logging
from datetime import datetime, timezone, timedelta, date
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
from flask_login import login_required, current_user
from app.extensions import db
from app.models.salon import (
Staff, Appointment, StaffClocking, CommissionLog,
StaffPayPeriod, Transaction,
)
from app.decorators import require_role
from app.tenant.utils import log_tenant_action
logger = logging.getLogger(__name__)
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
def _get_current_staff():
"""Resolve the Staff record from the current user session."""
uid_str = current_user.get_id()
if not uid_str or not uid_str.startswith("staff:"):
return None
try:
sid = int(uid_str.split(":")[1])
return Staff.query.filter_by(id=sid, is_active=True).filter(
Staff.deleted_at.is_(None)).first()
except (ValueError, IndexError):
return None
@staff_portal_bp.route("/")
@login_required
@require_role("tenant_staff")
def index():
staff = _get_current_staff()
if not staff:
flash("Staff profile not found.", "danger")
return redirect(url_for("staff_auth.staff_login"))
today = date.today()
day_start = datetime.combine(today, datetime.min.time()).replace(tzinfo=timezone.utc)
day_end = day_start + timedelta(days=1)
# Today's appointments
todays_appts = Appointment.query.filter_by(
tenant_id=staff.tenant_id,
staff_id=staff.id,
).filter(
Appointment.start_time >= day_start,
Appointment.start_time < day_end,
Appointment.status.notin_(["cancelled", "no_show"]),
).order_by(Appointment.start_time).all()
# Current clocking state
current_clocking = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
return render_template("tenant/staff_portal/index.html",
staff=staff,
todays_appointments=todays_appts,
current_clocking=current_clocking,
today=today)
@staff_portal_bp.route("/clock-in", methods=["POST"])
@login_required
@require_role("tenant_staff")
def clock_in():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Check not already clocked in
existing = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
if existing:
flash("You are already clocked in.", "warning")
return redirect(url_for("staff_portal.index"))
location_id = g.location.id if g.location else None
clocking = StaffClocking(
tenant_id=staff.tenant_id,
location_id=location_id,
staff_id=staff.id,
clocked_in_at=datetime.now(timezone.utc),
)
db.session.add(clocking)
log_tenant_action("staff.clock_in", "staff", staff.id,
{"location": location_id})
db.session.commit()
flash("Clocked in successfully.", "success")
return redirect(url_for("staff_portal.index"))
@staff_portal_bp.route("/clock-out", methods=["POST"])
@login_required
@require_role("tenant_staff")
def clock_out():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
clocking = StaffClocking.query.filter_by(
staff_id=staff.id, tenant_id=staff.tenant_id
).filter(StaffClocking.clocked_out_at.is_(None)).first()
if not clocking:
flash("You are not currently clocked in.", "warning")
return redirect(url_for("staff_portal.index"))
now = datetime.now(timezone.utc)
clocking.clocked_out_at = now
total_minutes = int((now - clocking.clocked_in_at.replace(
tzinfo=timezone.utc if clocking.clocked_in_at.tzinfo is None else clocking.clocked_in_at.tzinfo
)).total_seconds() / 60)
clocking.total_minutes = total_minutes
clocking.notes = request.form.get("notes", "").strip() or None
log_tenant_action("staff.clock_out", "staff", staff.id,
{"minutes": total_minutes})
db.session.commit()
hours = total_minutes // 60
mins = total_minutes % 60
flash(f"Clocked out. Shift duration: {hours}h {mins}m.", "success")
return redirect(url_for("staff_portal.index"))
@staff_portal_bp.route("/schedule")
@login_required
@require_role("tenant_staff")
def schedule():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Next 7 days of appointments
now = datetime.now(timezone.utc)
week_ahead = now + timedelta(days=7)
appts = Appointment.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id,
).filter(
Appointment.start_time >= now,
Appointment.start_time < week_ahead,
Appointment.status.notin_(["cancelled", "no_show"]),
).order_by(Appointment.start_time).all()
return render_template("tenant/staff_portal/schedule.html",
staff=staff, appointments=appts)
@staff_portal_bp.route("/commission")
@login_required
@require_role("tenant_staff")
def commission():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
# Current and previous pay period summary
from datetime import date
today = date.today()
# Current month period label
period = today.strftime("%Y-W%U")
logs = CommissionLog.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id
).order_by(CommissionLog.id.desc()).limit(50).all()
pay_periods = StaffPayPeriod.query.filter_by(
tenant_id=staff.tenant_id, staff_id=staff.id
).order_by(StaffPayPeriod.period_start.desc()).limit(12).all()
return render_template("tenant/staff_portal/commission.html",
staff=staff, commission_logs=logs,
pay_periods=pay_periods)
@staff_portal_bp.route("/profile")
@login_required
@require_role("tenant_staff")
def profile():
staff = _get_current_staff()
if not staff:
return redirect(url_for("staff_auth.staff_login"))
return render_template("tenant/staff_portal/profile.html", staff=staff)