mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 20:29:44 -05:00
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:
+15
-3
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user