From 251d41bc33469a6d6786adf921ffed1b1d98d2ab Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 11 Mar 2026 17:39:20 +0100 Subject: [PATCH] feat(workforce): overtime overview and take as paid leave (Issue #560) - Workforce dashboard: show Accumulated overtime (YTD) next to Leave Balances - Add get_overtime_leave_type() and validate requested_hours <= YTD for overtime leave - Time-off form: 'Take as paid leave' link, overtime type preset, available hours hint - create_leave_request rejects overtime requests exceeding YTD with clear error --- app/routes/workforce.py | 18 ++++- app/services/workforce_governance_service.py | 22 +++++- app/templates/workforce/dashboard.html | 72 ++++++++++++++++++-- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/app/routes/workforce.py b/app/routes/workforce.py index 14fbf662..3772c892 100644 --- a/app/routes/workforce.py +++ b/app/routes/workforce.py @@ -1,4 +1,4 @@ -from datetime import datetime, date, timedelta +from datetime import datetime, date, timedelta from flask import Blueprint, render_template, request, redirect, url_for, flash, Response from flask_login import login_required, current_user @@ -71,12 +71,22 @@ def dashboard(): balances = service.get_leave_balance(selected_user_id) + from app.models import User + from app.utils.overtime import get_overtime_ytd + users = [] if current_user.is_admin: - from app.models import User - users = User.query.order_by(User.username.asc()).all() + # Accumulated overtime (YTD) for selected user and overtime leave type for "Take as paid leave" + selected_user = User.query.get(selected_user_id) + overtime_ytd_hours = 0.0 + overtime_leave_type = service.get_overtime_leave_type() + overtime_leave_type_id = overtime_leave_type.id if overtime_leave_type else None + if selected_user: + overtime_ytd = get_overtime_ytd(selected_user) + overtime_ytd_hours = float(overtime_ytd.get("overtime_hours", 0) or 0) + return render_template( "workforce/dashboard.html", periods=periods, @@ -91,6 +101,8 @@ def dashboard(): capacity=capacity, cap_start=cap_start, cap_end=cap_end, + overtime_ytd_hours=overtime_ytd_hours, + overtime_leave_type_id=overtime_leave_type_id, ) diff --git a/app/services/workforce_governance_service.py b/app/services/workforce_governance_service.py index 2d0e3f9d..cbff7244 100644 --- a/app/services/workforce_governance_service.py +++ b/app/services/workforce_governance_service.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations from datetime import date, datetime, timedelta from decimal import Decimal @@ -179,6 +179,10 @@ class WorkforceGovernanceService: q = q.filter(LeaveType.enabled.is_(True)) return q.order_by(LeaveType.name.asc()).all() + def get_overtime_leave_type(self) -> Optional[LeaveType]: + """Return the leave type used for overtime-as-paid-leave (code 'overtime'), if present.""" + return LeaveType.query.filter_by(code="overtime", enabled=True).first() + def create_leave_request( self, *, @@ -196,6 +200,22 @@ class WorkforceGovernanceService: if end_date < start_date: return {"success": False, "message": "end_date must be after start_date"} + # When requesting overtime-as-leave, cap requested_hours at accumulated YTD overtime + if leave_type.code == "overtime" and requested_hours is not None and requested_hours > 0: + from app.utils.overtime import get_overtime_ytd + + user = User.query.get(user_id) + if user: + ytd = get_overtime_ytd(user) + ytd_overtime = float(ytd.get("overtime_hours", 0) or 0) + if float(requested_hours) > ytd_overtime: + return { + "success": False, + "message": f"Requested hours ({requested_hours}) exceed your accumulated overtime (YTD: {ytd_overtime:.2f}h). Please request at most {ytd_overtime:.2f} hours.", + } + else: + return {"success": False, "message": "User not found"} + status = TimeOffRequestStatus.SUBMITTED if submit_now else TimeOffRequestStatus.DRAFT req = TimeOffRequest( user_id=user_id, diff --git a/app/templates/workforce/dashboard.html b/app/templates/workforce/dashboard.html index a1e367c8..ac369d6c 100644 --- a/app/templates/workforce/dashboard.html +++ b/app/templates/workforce/dashboard.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "base.html" %} {% block title %}{{ _('Workforce Governance') }}{% endblock %} {% block content %} @@ -98,6 +98,14 @@

{{ _('Leave Balances') }}

+ {% if overtime_ytd_hours is defined %} +

+ {{ _('Accumulated overtime (YTD)') }}: {{ "%.2f"|format(overtime_ytd_hours) }}h + {% if overtime_leave_type_id and overtime_ytd_hours > 0 %} + — {{ _('Take as paid leave') }} + {% endif %} +

+ {% endif %}
@@ -128,17 +136,22 @@

{{ _('Time-Off Requests') }}

-
+ - {% for lt in leave_types %} {% if lt.enabled %} - + {% endif %} {% endfor %} - +
+ 0 %}data-overtime-max="{{ "%.2f"|format(overtime_ytd_hours) }}"{% endif %}> + {% if overtime_ytd_hours is defined and overtime_ytd_hours > 0 %} + + {% endif %} +
@@ -255,4 +268,53 @@
{% endif %} +{% block scripts_extra %} + +{% endblock %} {% endblock %}