05/07 Phase 3: initial codes
This commit is contained in:
@@ -1,7 +1,188 @@
|
||||
"""
|
||||
app/tenant/staff_portal/routes.py
|
||||
Phase 3+ implementation.
|
||||
Staff Portal — accessible by tenant_staff role (phone + passcode login).
|
||||
Routes: personal schedule, upcoming appointments, clock in/out,
|
||||
commission summary, payment history, read-only profile.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, g
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models.salon import (
|
||||
Staff, Appointment, StaffClocking, CommissionLog,
|
||||
StaffPayPeriod, Transaction,
|
||||
)
|
||||
from app.decorators import require_role
|
||||
from app.tenant.utils import log_tenant_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
staff_portal_bp = Blueprint("staff_portal", __name__, url_prefix="/staff/portal")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user