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:
parent
ea94c46cb6
commit
2ae9f34058
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user