Files
TimeTracker/app/templates/workforce/dashboard.html
T
Dries Peeters daf3236c37 feat(workforce): add delete for periods, time-off, leave types, and holidays (fixes #562)
- Backend: WorkforceGovernanceService.delete_period, delete_leave_request,
  delete_leave_type, delete_holiday with permission and state checks
- Web: POST delete routes in workforce blueprint; delete buttons in dashboard
  for periods (draft/rejected), time-off (draft/submitted/cancelled), leave
  types list, and company holidays (admin only)
- API v1: DELETE endpoints for timesheet-periods, time-off/requests,
  time-off/leave-types, time-off/holidays (scopes and admin where required)
- Desktop: deleteTimesheetPeriod/deleteTimeOffRequest in API client; Delete
  buttons and handlers in workforce view with confirmation and refresh
- Mobile: deleteTimesheetPeriod/deleteTimeOffRequest in API client; Delete
  in popup menus for periods and time-off requests
- Docs: WORKFORCE_DELETE.md, PROJECT_STRUCTURE and API_TOKEN_SCOPES updates
2026-03-11 18:44:53 +01:00

353 lines
22 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('Workforce Governance') }}{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto py-6 space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-text-light dark:text-text-dark">{{ _('Workforce Governance') }}</h1>
<form method="post" action="{{ url_for('workforce.create_period') }}" class="flex items-end gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Reference date') }}</label>
<input type="date" name="reference_date" class="form-input" />
</div>
<button class="btn btn-primary" type="submit">{{ _('Create/Get Period') }}</button>
</form>
</div>
{% if users %}
<form method="get" class="card p-4 flex gap-2 items-end">
<div>
<label class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('User') }}</label>
<select name="user_id" class="form-select">
{% for u in users %}
<option value="{{ u.id }}" {% if u.id == selected_user_id %}selected{% endif %}>{{ u.username }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Start date') }}</label>
<input type="date" name="start_date" class="form-input" />
</div>
<div>
<label class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('End date') }}</label>
<input type="date" name="end_date" class="form-input" />
</div>
<button class="btn btn-secondary" type="submit">{{ _('Filter') }}</button>
</form>
{% endif %}
<div class="card p-4 flex flex-wrap gap-2 items-center justify-between">
<div>
<h2 class="text-lg font-semibold">{{ _('Exports') }}</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Download payroll, capacity, and compliance exports') }}</p>
</div>
<div class="flex flex-wrap gap-2">
<a class="btn btn-secondary" href="{{ url_for('workforce.payroll_export_csv', start_date=cap_start.isoformat(), end_date=cap_end.isoformat(), user_id=selected_user_id) }}">{{ _('Payroll CSV') }}</a>
<a class="btn btn-secondary" href="{{ url_for('workforce.capacity_export_csv', start_date=cap_start.isoformat(), end_date=cap_end.isoformat()) }}">{{ _('Capacity CSV') }}</a>
{% if can_approve %}
<a class="btn btn-secondary" href="{{ url_for('workforce.locked_periods_export_csv', start_date=cap_start.isoformat(), end_date=cap_end.isoformat()) }}">{{ _('Locked Periods CSV') }}</a>
<a class="btn btn-secondary" href="{{ url_for('workforce.audit_events_export_csv', start_date=cap_start.isoformat(), end_date=cap_end.isoformat(), user_id=selected_user_id if current_user.is_admin else current_user.id) }}">{{ _('Audit Events CSV') }}</a>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section class="card p-4">
<h2 class="text-lg font-semibold mb-3">{{ _('Timesheet Periods') }}</h2>
<div class="space-y-3">
{% for p in periods %}
<div class="border border-border-light dark:border-border-dark rounded p-3">
<div class="flex justify-between items-center">
<div>
<div class="font-medium">{{ p.period_start }} - {{ p.period_end }}</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Status') }}: {{ p.status }}</div>
</div>
<div class="flex gap-2 flex-wrap justify-end">
{% if p.status != 'closed' and p.user_id == current_user.id %}
<form method="post" action="{{ url_for('workforce.submit_period', period_id=p.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-secondary" type="submit">{{ _('Submit') }}</button>
</form>
{% endif %}
{% if can_approve and p.status in ['submitted', 'rejected'] %}
<form method="post" action="{{ url_for('workforce.approve_period', period_id=p.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-primary" type="submit">{{ _('Approve') }}</button>
</form>
<form method="post" action="{{ url_for('workforce.reject_period', period_id=p.id) }}" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text" name="reason" class="form-input form-input-sm" placeholder="{{ _('Reason') }}" required>
<button class="btn btn-sm btn-danger" type="submit">{{ _('Reject') }}</button>
</form>
{% endif %}
{% if current_user.is_admin and p.status != 'closed' %}
<form method="post" action="{{ url_for('workforce.close_period', period_id=p.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-danger" type="submit">{{ _('Close') }}</button>
</form>
{% endif %}
{% set p_status_val = p.status.value if p.status is defined and p.status.value is defined else (p.status|string) %}
{% if p_status_val in ['draft', 'rejected'] and (p.user_id == current_user.id or current_user.is_admin) %}
<form method="post" action="{{ url_for('workforce.delete_period', period_id=p.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-danger" type="submit">{{ _('Delete') }}</button>
</form>
{% endif %}
</div>
</div>
</div>
{% else %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No timesheet periods yet.') }}</p>
{% endfor %}
</div>
</section>
<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>
<tr>
<th>{{ _('Type') }}</th>
<th>{{ _('Allowance') }}</th>
<th>{{ _('Used') }}</th>
<th>{{ _('Remaining') }}</th>
</tr>
</thead>
<tbody>
{% for b in balances %}
<tr>
<td>{{ b.leave_type_name }}</td>
<td>{{ b.allowance_hours if b.allowance_hours is not none else '-' }}</td>
<td>{{ b.used_hours }}</td>
<td>{{ b.remaining_hours if b.remaining_hours is not none else '-' }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No leave types configured.') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<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 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 js-leave-type-select" required>
<option value="">{{ _('Select leave type') }}</option>
{% for lt in leave_types %}
{% if lt.enabled %}
<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>
<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') }}">
<button class="btn btn-primary md:col-span-2" type="submit">{{ _('Submit time-off request') }}</button>
</form>
<div class="space-y-2">
{% for r in leave_requests %}
{% set r_status = r.status.value if r.status.value is defined else r.status %}
<div class="border border-border-light dark:border-border-dark rounded p-3">
<div class="flex items-center justify-between gap-2">
<div>
<div class="font-medium">{{ r.leave_type.name if r.leave_type else r.leave_type_id }}: {{ r.start_date }} - {{ r.end_date }}</div>
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Status') }}: {{ r_status }}</div>
</div>
{% if can_approve and r_status in ['submitted', 'draft'] %}
<div class="flex gap-2">
<form method="post" action="{{ url_for('workforce.approve_time_off_request', request_id=r.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-primary" type="submit">{{ _('Approve') }}</button>
</form>
<form method="post" action="{{ url_for('workforce.reject_time_off_request', request_id=r.id) }}" class="flex gap-1">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text" name="comment" class="form-input form-input-sm" placeholder="{{ _('Reason') }}">
<button class="btn btn-sm btn-danger" type="submit">{{ _('Reject') }}</button>
</form>
</div>
{% endif %}
{% if r_status in ['draft', 'submitted', 'cancelled'] and (r.user_id == current_user.id or can_approve) %}
<form method="post" action="{{ url_for('workforce.delete_time_off_request', request_id=r.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-danger" type="submit">{{ _('Delete') }}</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
<section class="card p-4 space-y-3">
<h2 class="text-lg font-semibold">{{ _('Capacity') }} ({{ cap_start.isoformat() }} - {{ cap_end.isoformat() }})</h2>
<div class="overflow-auto">
<table class="table w-full">
<thead>
<tr>
<th>{{ _('User') }}</th>
<th>{{ _('Expected') }}</th>
<th>{{ _('Allocated') }}</th>
<th>{{ _('Time Off') }}</th>
<th>{{ _('Available') }}</th>
<th>{{ _('Utilization %') }}</th>
</tr>
</thead>
<tbody>
{% for row in capacity %}
<tr>
<td>{{ row.username }}</td>
<td>{{ row.expected_hours }}</td>
<td>{{ row.allocated_hours }}</td>
<td>{{ row.time_off_hours }}</td>
<td>{{ row.available_hours }}</td>
<td>{{ row.utilization_pct }}</td>
</tr>
{% else %}
<tr><td colspan="6">{{ _('No capacity data available.') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
{% if current_user.is_admin %}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<section class="card p-4 space-y-2">
<h3 class="font-semibold">{{ _('Timesheet Policy') }}</h3>
<form method="post" action="{{ url_for('workforce.update_policy') }}" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="number" name="auto_lock_days" class="form-input" value="{{ policy.auto_lock_days if policy else '' }}" placeholder="{{ _('Auto lock days') }}">
<input type="text" name="approver_user_ids" class="form-input" value="{{ policy.approver_user_ids if policy else '' }}" placeholder="{{ _('Approver user IDs (comma separated)') }}">
<label class="flex items-center gap-2"><input type="checkbox" name="enable_multi_level_approval" {% if policy and policy.enable_multi_level_approval %}checked{% endif %}> {{ _('Enable multi-level approval') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" name="require_rejection_comment" {% if not policy or policy.require_rejection_comment %}checked{% endif %}> {{ _('Require rejection comment') }}</label>
<label class="flex items-center gap-2"><input type="checkbox" name="enable_admin_override" {% if not policy or policy.enable_admin_override %}checked{% endif %}> {{ _('Enable admin override') }}</label>
<button class="btn btn-primary" type="submit">{{ _('Save policy') }}</button>
</form>
</section>
<section class="card p-4 space-y-2">
<h3 class="font-semibold">{{ _('Leave Types') }}</h3>
<form method="post" action="{{ url_for('workforce.create_leave_type') }}" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text" name="name" class="form-input" placeholder="{{ _('Name') }}" required>
<input type="text" name="code" class="form-input" placeholder="{{ _('Code') }}" required>
<input type="number" step="0.25" name="annual_allowance_hours" class="form-input" placeholder="{{ _('Annual allowance (hours)') }}">
<input type="number" step="0.25" name="accrual_hours_per_month" class="form-input" placeholder="{{ _('Accrual hours per month') }}">
<label class="flex items-center gap-2"><input type="checkbox" name="is_paid" checked> {{ _('Paid leave') }}</label>
<button class="btn btn-secondary" type="submit">{{ _('Add leave type') }}</button>
</form>
<div class="mt-3 space-y-1">
{% for lt in leave_types %}
<div class="flex justify-between items-center gap-2 text-sm">
<span>{{ lt.name }} ({{ lt.code }}){% if not lt.enabled %} <span class="text-text-muted-light dark:text-text-muted-dark">— {{ _('disabled') }}</span>{% endif %}</span>
<form method="post" action="{{ url_for('workforce.delete_leave_type', leave_type_id=lt.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-danger" type="submit">{{ _('Delete') }}</button>
</form>
</div>
{% else %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No leave types configured.') }}</p>
{% endfor %}
</div>
</section>
<section class="card p-4 space-y-2">
<h3 class="font-semibold">{{ _('Company Holidays') }}</h3>
<form method="post" action="{{ url_for('workforce.create_holiday') }}" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text" name="name" class="form-input" placeholder="{{ _('Holiday name') }}" required>
<input type="date" name="start_date" class="form-input" required>
<input type="date" name="end_date" class="form-input" required>
<input type="text" name="region" class="form-input" placeholder="{{ _('Region (optional)') }}">
<button class="btn btn-secondary" type="submit">{{ _('Add holiday') }}</button>
</form>
<div class="max-h-40 overflow-auto text-sm space-y-1">
{% for h in holidays %}
<div class="flex justify-between items-center gap-2">
<span>{{ h.start_date }} - {{ h.end_date }}: {{ h.name }}{% if h.region %} ({{ h.region }}){% endif %}</span>
<form method="post" action="{{ url_for('workforce.delete_holiday', holiday_id=h.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-danger" type="submit">{{ _('Delete') }}</button>
</form>
</div>
{% else %}
<div>{{ _('No holidays configured.') }}</div>
{% endfor %}
</div>
</section>
</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 %}