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
This commit is contained in:
Dries Peeters
2026-03-11 17:39:20 +01:00
parent bd31609ea1
commit 251d41bc33
3 changed files with 103 additions and 9 deletions
+15 -3
View File
@@ -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,
)
+21 -1
View File
@@ -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,
+67 -5
View File
@@ -1,4 +1,4 @@
{% extends "base.html" %}
{% extends "base.html" %}
{% block title %}{{ _('Workforce Governance') }}{% endblock %}
{% block content %}
@@ -98,6 +98,14 @@
<section class="card p-4">
<h2 class="text-lg font-semibold mb-3">{{ _('Leave Balances') }}</h2>
{% if overtime_ytd_hours is defined %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">
{{ _('Accumulated overtime (YTD)') }}: <strong>{{ "%.2f"|format(overtime_ytd_hours) }}h</strong>
{% if overtime_leave_type_id and overtime_ytd_hours > 0 %}
<a href="#time-off-request-form" class="text-primary hover:underline js-take-overtime-as-leave">{{ _('Take as paid leave') }}</a>
{% endif %}
</p>
{% endif %}
<div class="overflow-auto">
<table class="table w-full">
<thead>
@@ -128,17 +136,22 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section class="card p-4 space-y-3">
<h2 class="text-lg font-semibold">{{ _('Time-Off Requests') }}</h2>
<form method="post" action="{{ url_for('workforce.create_time_off_request') }}" class="grid grid-cols-1 md:grid-cols-2 gap-2">
<form id="time-off-request-form" method="post" action="{{ url_for('workforce.create_time_off_request') }}" class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select name="leave_type_id" class="form-select" required>
<select name="leave_type_id" class="form-select js-leave-type-select" required>
<option value="">{{ _('Select leave type') }}</option>
{% for lt in leave_types %}
{% if lt.enabled %}
<option value="{{ lt.id }}">{{ lt.name }} ({{ lt.code }})</option>
<option value="{{ lt.id }}" {% if overtime_leave_type_id is defined and lt.id == overtime_leave_type_id %}data-overtime="1"{% endif %}>{{ lt.name }} ({{ lt.code }})</option>
{% endif %}
{% endfor %}
</select>
<input type="number" step="0.25" name="requested_hours" class="form-input" placeholder="{{ _('Requested hours') }}">
<div class="md:col-span-2 flex flex-col gap-1">
<input type="number" step="0.25" name="requested_hours" class="form-input js-requested-hours" placeholder="{{ _('Requested hours') }}" min="0" {% if overtime_ytd_hours is defined and overtime_ytd_hours > 0 %}data-overtime-max="{{ "%.2f"|format(overtime_ytd_hours) }}"{% endif %}>
{% if overtime_ytd_hours is defined and overtime_ytd_hours > 0 %}
<span class="text-xs text-text-muted-light dark:text-text-muted-dark js-overtime-hint" style="display: none;">{{ _('Available overtime (YTD)') }}: {{ "%.2f"|format(overtime_ytd_hours) }}h</span>
{% endif %}
</div>
<input type="date" name="start_date" class="form-input" required>
<input type="date" name="end_date" class="form-input" required>
<input type="text" name="comment" class="form-input md:col-span-2" placeholder="{{ _('Comment') }}">
@@ -255,4 +268,53 @@
</div>
{% endif %}
</div>
{% block scripts_extra %}
<script>
(function() {
var form = document.getElementById('time-off-request-form');
if (!form) return;
var leaveTypeSelect = form.querySelector('.js-leave-type-select');
var requestedHoursInput = form.querySelector('.js-requested-hours');
var overtimeHint = form.querySelector('.js-overtime-hint');
var overtimeMax = requestedHoursInput && requestedHoursInput.getAttribute('data-overtime-max');
function isOvertimeSelected() {
var opt = leaveTypeSelect && leaveTypeSelect.options[leaveTypeSelect.selectedIndex];
return opt && opt.getAttribute('data-overtime') === '1';
}
function updateOvertimeUI() {
if (!overtimeHint || !overtimeMax) return;
if (isOvertimeSelected()) {
overtimeHint.style.display = '';
if (requestedHoursInput) {
requestedHoursInput.placeholder = overtimeMax + ' {{ _("hours (max from overtime)") }}';
requestedHoursInput.setAttribute('max', overtimeMax);
}
} else {
overtimeHint.style.display = 'none';
if (requestedHoursInput) {
requestedHoursInput.placeholder = '{{ _("Requested hours") }}';
requestedHoursInput.removeAttribute('max');
}
}
}
if (leaveTypeSelect) leaveTypeSelect.addEventListener('change', updateOvertimeUI);
document.querySelectorAll('.js-take-overtime-as-leave').forEach(function(a) {
a.addEventListener('click', function(e) {
e.preventDefault();
form.scrollIntoView({ behavior: 'smooth' });
if (leaveTypeSelect && overtimeMax) {
for (var i = 0; i < leaveTypeSelect.options.length; i++) {
if (leaveTypeSelect.options[i].getAttribute('data-overtime') === '1') {
leaveTypeSelect.selectedIndex = i;
break;
}
}
updateOvertimeUI();
}
});
});
updateOvertimeUI();
})();
</script>
{% endblock %}
{% endblock %}