feat(absences): team filter + multi-reason filter + dashboard quick action

Three UX wins from checkpoint feedback:
- Team selector on /absences/log/ now hides non-team workers (matches /attendance/log/ behavior).
- /absences/ reason filter accepts multiple values (?reason=sick&reason=annual). Multi-checkbox dropdown UI.
- Dashboard Quick Actions: added Log Absence card with fa-user-clock icon.
1 new regression test for multi-reason filtering.
This commit is contained in:
Konrad du Plessis 2026-05-14 21:44:47 +02:00
parent ea94c46cb6
commit 2ae9f34058
5 changed files with 115 additions and 25 deletions

View File

@ -67,13 +67,29 @@ has an inline delete form. CSV export button only shows for admin.
</select>
</div>
<div>
<label class="form-label small mb-0">Reason</label>
<select name="reason" class="form-select form-select-sm">
<option value="">All</option>
{% for key, label in reason_choices %}
<option value="{{ key }}" {% if filter_reason == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{# === Reason filter — multi-checkbox dropdown (Fix A2, May 2026) === #}
{# All checkboxes share name="reason" so Django gets multiple values #}
{# in request.GET.getlist('reason') when the form submits. The wrapping #}
{# <ul onclick="event.stopPropagation();"> keeps the dropdown open while #}
{# the user is picking multiple boxes; submitting via the Apply button #}
{# closes it normally. #}
<label class="form-label small mb-0">Reasons</label>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle form-select-sm" type="button" data-bs-toggle="dropdown" style="min-width: 140px; text-align: left;">
{% if filter_reasons %}{{ filter_reasons|length }} selected{% else %}All{% endif %}
</button>
<ul class="dropdown-menu p-2" style="min-width: 220px;" onclick="event.stopPropagation();">
{% for key, label in reason_choices %}
<li>
<label class="form-check d-block mb-0">
<input type="checkbox" class="form-check-input" name="reason" value="{{ key }}"
{% if key in filter_reasons %}checked{% endif %}>
<span class="form-check-label ms-1">{{ label }}</span>
</label>
</li>
{% endfor %}
</ul>
</div>
</div>
<div>
<label class="form-label small mb-0">From</label>

View File

@ -103,7 +103,7 @@ them from the WorkLog).
<input type="text" class="form-control mb-2" id="workerSearch" placeholder="Search workers...">
<div class="border rounded p-2" style="max-height: 300px; overflow-y: auto;">
{% for worker in form.workers %}
<div class="form-check worker-row" data-name="{{ worker.choice_label|lower }}">
<div class="form-check worker-row" data-name="{{ worker.choice_label|lower }}" data-worker-id="{{ worker.choice_value }}">
{{ worker.tag }}
<label class="form-check-label">{{ worker.choice_label }}</label>
</div>
@ -132,20 +132,43 @@ them from the WorkLog).
</div>
<script>
// === Worker search filter ===
// Quick client-side filter for the worker checkbox list — types into the
// search box → only matching rows stay visible. Match is case-insensitive
// against the worker's display name (stored in data-name on the row).
// === Worker filter — search box + team dropdown COMPOSE ===
// Two independent filters operate against the same worker checkbox list:
// 1. The search input narrows by name substring (case-insensitive).
// 2. The team dropdown narrows by team membership (hides workers who
// aren't on the selected team).
// A row is visible only when BOTH filters allow it. Switching either
// re-runs the combined visibility pass via applyWorkerFilters().
//
// Empty selection in either filter = "no restriction" for that filter.
(function() {
var searchInput = document.getElementById('workerSearch');
if (!searchInput) return;
searchInput.addEventListener('input', function() {
var q = this.value.toLowerCase();
var teamSelect = document.querySelector('[name="team"]');
// teamWorkersMap: { team_id_string: [worker_id_int, ...] }
// Parse once at load — re-used by every filter pass.
var teamWorkersMap = JSON.parse('{{ team_workers_json|escapejs }}');
function applyWorkerFilters() {
var q = searchInput ? searchInput.value.toLowerCase() : '';
var teamId = teamSelect ? teamSelect.value : '';
// Workers allowed by the team filter — null means "no team filter".
var allowedWorkerIds = null;
if (teamId && teamWorkersMap[teamId]) {
// Stringify so we can compare against data-worker-id (which is a string).
allowedWorkerIds = teamWorkersMap[teamId].map(function(id) { return String(id); });
}
document.querySelectorAll('.worker-row').forEach(function(row) {
var name = row.dataset.name || '';
row.style.display = name.indexOf(q) > -1 ? '' : 'none';
var wid = row.dataset.workerId || '';
// Visible only if BOTH conditions pass.
var matchesSearch = name.indexOf(q) > -1;
var matchesTeam = (allowedWorkerIds === null) || (allowedWorkerIds.indexOf(wid) > -1);
row.style.display = (matchesSearch && matchesTeam) ? '' : 'none';
});
});
}
if (searchInput) searchInput.addEventListener('input', applyWorkerFilters);
if (teamSelect) teamSelect.addEventListener('change', applyWorkerFilters);
})();
</script>
{% endblock %}

View File

@ -172,6 +172,13 @@
<i class="fas fa-clipboard-list"></i>
<span>Log Work</span>
</a>
{# === LOG ABSENCE — quick path to /absences/log/ (Fix A3, May 2026) === #}
{# Same icon as the Resources menu entry so users have one mental model #}
{# for "absences = fa-user-clock". #}
<a href="{% url 'absence_log' %}" class="quick-action">
<i class="fas fa-user-clock"></i>
<span>Log Absence</span>
</a>
<a href="{% url 'payroll_dashboard' %}" class="quick-action">
<i class="fas fa-money-check-alt"></i>
<span>Run Payroll</span>

View File

@ -2285,6 +2285,20 @@ class AbsenceListViewTests(TestCase):
self.assertContains(resp, '<td>WA</td>', html=False)
self.assertNotContains(resp, '<td>WB</td>', html=False)
def test_filter_by_multiple_reasons(self):
"""?reason=sick&reason=annual → table shows BOTH workers.
Regression test for Fix A2 (May 2026): the reason filter accepts
multiple values via request.GET.getlist('reason') and OR-unions
them with filter(reason__in=...). Without this, the second
?reason= param overrides the first and only one worker would
appear.
"""
self.client.force_login(self.admin)
resp = self.client.get('/absences/?reason=sick&reason=annual')
self.assertContains(resp, '<td>WA</td>', html=False)
self.assertContains(resp, '<td>WB</td>', html=False)
def test_malformed_date_param_does_not_crash(self):
"""SECURITY: garbage in URL params must not 500. parse_date()
returns None on invalid input those filters get skipped.

View File

@ -5258,7 +5258,25 @@ def absence_log(request):
else:
form = AbsenceLogForm(user=request.user)
return render(request, 'core/absences/log.html', {'form': form})
# === TEAM → WORKERS MAP for the in-page team filter (Fix A1, May 2026) ===
# Mirrors the pattern in attendance_log(): build a dict of team_id →
# [worker_ids] and pass it as JSON so the template's JS can hide
# worker rows whose worker_id is not in the selected team's list.
# Supervisors only see their own teams; admins see every active team.
team_workers_map = {}
teams_qs = Team.objects.filter(active=True).prefetch_related('workers')
if not is_admin(request.user):
teams_qs = teams_qs.filter(supervisor=request.user)
for team in teams_qs:
active_worker_ids = list(
team.workers.filter(active=True).values_list('id', flat=True)
)
team_workers_map[team.id] = active_worker_ids
return render(request, 'core/absences/log.html', {
'form': form,
'team_workers_json': json.dumps(team_workers_map),
})
@login_required
@ -5408,7 +5426,14 @@ def absence_list(request):
worker_id = request.GET.get('worker')
team_id = request.GET.get('team')
project_id = request.GET.get('project')
reason = request.GET.get('reason')
# Multi-value reason filter (Fix A2, May 2026): the template renders
# the reasons as a checkbox list, all sharing name="reason", so the
# querystring carries ?reason=sick&reason=annual on multi-select.
# getlist() pulls them ALL; we then whitelist against REASON_CHOICES
# so an attacker can't sneak an arbitrary string into the SQL filter.
reasons = request.GET.getlist('reason')
valid_reason_keys = dict(Absence.REASON_CHOICES)
reasons = [r for r in reasons if r in valid_reason_keys]
date_from = request.GET.get('date_from')
date_to = request.GET.get('date_to')
paid = request.GET.get('paid')
@ -5419,8 +5444,8 @@ def absence_list(request):
qs = qs.filter(worker__teams__id=team_id).distinct()
if project_id and project_id.isdigit():
qs = qs.filter(worker__work_logs__project_id=project_id).distinct()
if reason and reason in dict(Absence.REASON_CHOICES):
qs = qs.filter(reason=reason)
if reasons:
qs = qs.filter(reason__in=reasons)
# parse_date() returns None for malformed input (e.g. "not-a-date")
# so the filter is simply skipped. Without this guard, Django's
# date coercion raises ValidationError (NOT ValueError/TypeError)
@ -5474,7 +5499,9 @@ def absence_list(request):
'filter_worker': worker_id or '',
'filter_team': team_id or '',
'filter_project': project_id or '',
'filter_reason': reason or '',
# Note: filter_reasons is a LIST (post-Fix-A2). Templates iterating
# this need {% if x in filter_reasons %}, not {% if filter_reasons == x %}.
'filter_reasons': reasons,
'filter_date_from': date_from or '',
'filter_date_to': date_to or '',
'filter_paid': paid or '',
@ -5588,7 +5615,10 @@ def absence_export_csv(request):
worker_id = request.GET.get('worker')
team_id = request.GET.get('team')
project_id = request.GET.get('project')
reason = request.GET.get('reason')
# Multi-value reason filter — kept in parity with absence_list (Fix A2).
reasons = request.GET.getlist('reason')
valid_reason_keys = dict(Absence.REASON_CHOICES)
reasons = [r for r in reasons if r in valid_reason_keys]
date_from = request.GET.get('date_from')
date_to = request.GET.get('date_to')
paid = request.GET.get('paid')
@ -5599,8 +5629,8 @@ def absence_export_csv(request):
qs = qs.filter(worker__teams__id=team_id).distinct()
if project_id and project_id.isdigit():
qs = qs.filter(worker__work_logs__project_id=project_id).distinct()
if reason and reason in dict(Absence.REASON_CHOICES):
qs = qs.filter(reason=reason)
if reasons:
qs = qs.filter(reason__in=reasons)
if date_from:
parsed = parse_date(date_from)
if parsed: