189 lines
6.5 KiB
Python
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)
|