mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 21:30:12 -05:00
daf3236c37
- 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
353 lines
22 KiB
HTML
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 %}
|