""" 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)