feat(adjustments): sortable column headers with URL state

4 sortable columns: Date, Worker, Amount, Status. Click cycles
desc -> asc -> desc. Click a different column -> resets to desc.
Keyboard Enter / Space also works (role=button + tabindex=0).

The sort/order state lives in hidden inputs inside the adjustments
filter form, so the JS just mutates those and .submit()s — the sort
then piggy-backs on the same GET the filter bar uses, and the URL
retains it across pagination. Backend sort_map (Task 3) already
whitelists the allowed columns, so no SQL-injection surface.

Arrow icons reflect state: fa-sort (inactive), fa-sort-down (desc),
fa-sort-up (asc). Active column gets .sorted class for stronger
arrow opacity (CSS already shipped in Task 2).

No backend changes, no new tests — the existing 65 tests cover the
sort contract from the URL.
This commit is contained in:
Konrad du Plessis 2026-04-23 19:20:49 +02:00
parent c851b49dea
commit 7b71048376

View File

@ -803,14 +803,34 @@
aria-label="Select all unpaid adjustments on this page"
title="Select all unpaid on this page">
</th>
<th class="sortable" data-sort="date">Date <i class="fas fa-sort sort-arrow"></i></th>
<th class="sortable" data-sort="worker">Worker <i class="fas fa-sort sort-arrow"></i></th>
{# === Sortable column headers — click toggles sort via filter form === #}
{# Each sortable <th> reflects the current sort/order from adj_filter_values. #}
{# The arrow icon is fa-sort (inactive), fa-sort-down (desc) or fa-sort-up (asc). #}
{# JS click handler is wired up in the DOMContentLoaded block further down. #}
<th class="sortable{% if adj_filter_values.sort == 'date' %} sorted{% endif %}"
data-sort="date" role="button" tabindex="0">
Date
<i class="fas {% if adj_filter_values.sort == 'date' %}{% if adj_filter_values.order == 'asc' %}fa-sort-up{% else %}fa-sort-down{% endif %}{% else %}fa-sort{% endif %} sort-arrow"></i>
</th>
<th class="sortable{% if adj_filter_values.sort == 'worker' %} sorted{% endif %}"
data-sort="worker" role="button" tabindex="0">
Worker
<i class="fas {% if adj_filter_values.sort == 'worker' %}{% if adj_filter_values.order == 'asc' %}fa-sort-up{% else %}fa-sort-down{% endif %}{% else %}fa-sort{% endif %} sort-arrow"></i>
</th>
<th>Type</th>
<th class="text-end sortable" data-sort="amount">Amount <i class="fas fa-sort sort-arrow"></i></th>
<th class="text-end sortable{% if adj_filter_values.sort == 'amount' %} sorted{% endif %}"
data-sort="amount" role="button" tabindex="0">
Amount
<i class="fas {% if adj_filter_values.sort == 'amount' %}{% if adj_filter_values.order == 'asc' %}fa-sort-up{% else %}fa-sort-down{% endif %}{% else %}fa-sort{% endif %} sort-arrow"></i>
</th>
<th>Project</th>
<th>Team</th>
<th>Description</th>
<th class="sortable" data-sort="status">Status <i class="fas fa-sort sort-arrow"></i></th>
<th class="sortable{% if adj_filter_values.sort == 'status' %} sorted{% endif %}"
data-sort="status" role="button" tabindex="0">
Status
<i class="fas {% if adj_filter_values.sort == 'status' %}{% if adj_filter_values.order == 'asc' %}fa-sort-up{% else %}fa-sort-down{% endif %}{% else %}fa-sort{% endif %} sort-arrow"></i>
</th>
<th class="text-end">Actions</th>
</tr>
</thead>
@ -3752,6 +3772,51 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// === ADJUSTMENTS TAB — Sortable column headers ===
// Click a <th class="sortable"> to sort by that column. Same column
// twice flips asc/desc. A different column resets to desc. Submits
// the filter form so the sort persists in the URL and survives
// pagination. Keyboard Enter/Space also works (role="button").
// Backend whitelists allowed columns in sort_map (see views.py),
// so the data-sort values here must match: date / worker / amount / status.
var adjFilterForm = document.getElementById('adjFilterForm');
if (adjFilterForm) {
// Scope the query to the adjustments tab. The table sits after the
// filter bar as a sibling, so we walk up to the tab container and
// collect every sortable header inside it.
var adjFilterBar = document.getElementById('adjustmentsFilters');
var adjTabRoot = adjFilterBar ? adjFilterBar.parentElement : document;
var sortInput = adjFilterForm.querySelector('input[name="sort"]');
var orderInput = adjFilterForm.querySelector('input[name="order"]');
adjTabRoot.querySelectorAll('th.sortable').forEach(function(th) {
// Clicking a sortable <th> mutates the hidden sort/order inputs
// and submits the form so the URL carries the new state.
function triggerSort() {
if (!sortInput || !orderInput) return;
var col = th.getAttribute('data-sort');
if (sortInput.value === col) {
// Same column clicked again — flip direction.
orderInput.value = orderInput.value === 'asc' ? 'desc' : 'asc';
} else {
// Different column — switch to it, start at desc (default).
sortInput.value = col;
orderInput.value = 'desc';
}
adjFilterForm.submit();
}
th.addEventListener('click', triggerSort);
// Keyboard access: Enter or Space triggers the same sort.
// preventDefault stops Space from scrolling the page.
th.addEventListener('keydown', function(ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
triggerSort();
}
});
});
}
// --- Direct delete buttons on each unpaid row ---
// Short-circuits the edit modal's usual 2-step delete flow by opening
// #deleteConfirmModal directly with the correct form action + labels.