feat(adjustments): Team -> Workers cross-filter in the popover JS

When Team(s) are selected via the Teams popover, the Workers popover
now only shows workers who belong to at least one of those teams.
URL-selected workers stay visible regardless (so the user can see
and untick them).

Backend adds one context key: team_worker_pairs_json — raw Python
list of {team_id, worker_id} dicts from Team.workers.through;
template renders via |json_script (safe, no double-encoding).

Frontend reads the JSON once, builds a team_id -> Set(worker_id)
index, and on every Workers-popover open (and on Teams-popover OK)
hides rows whose worker is out-of-team. display:none on the <label>
row is visually cleaner than disabling the checkbox alone.

Scope: entire roster (not date-range-scoped) — cross-filter is
about data possibility, not data in this period.

One new test locks in the pairs-context-key shape (asserts it's a
raw Python list of dicts, not a pre-serialised JSON string —
guards against the double-encoding regression from Feature 1).
65/65 tests pass.
This commit is contained in:
Konrad du Plessis 2026-04-23 19:13:10 +02:00
parent 4c3e90f2a7
commit 6905703492
3 changed files with 118 additions and 0 deletions

View File

@ -556,6 +556,9 @@
{# Row actions reuse the existing Edit / Delete / Preview modals so no new JS is needed. #}
{% if active_tab == 'adjustments' %}
{# --- Cross-filter data: (team_id, worker_id) pairs for the Workers popover --- #}
{{ team_worker_pairs_json|json_script:"adjTeamWorkerPairs" }}
{# --- Sticky filter bar (pill-popover checkbox filters for Type/Workers/Teams) --- #}
<div class="adjustments-filter-bar" id="adjustmentsFilters">
<form method="get" action="{% url 'payroll_dashboard' %}"
@ -3577,6 +3580,94 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// === ADJUSTMENTS TAB — Team -> Workers cross-filter ===
// When a Workers popover opens, disable worker checkboxes that
// aren't in any currently-URL-selected team. Also re-disable on
// Teams popover OK so a fresh team choice immediately narrows
// the visible workers.
// Scope: entire active roster (not date-range-scoped) — cross-filter
// is about data possibility, not data in this period.
var pairsEl = document.getElementById('adjTeamWorkerPairs');
if (pairsEl) {
var pairs = JSON.parse(pairsEl.textContent);
// team_id -> Set(worker_id)
var workersByTeam = {};
pairs.forEach(function(p) {
if (!workersByTeam[p.team_id]) workersByTeam[p.team_id] = new Set();
workersByTeam[p.team_id].add(p.worker_id);
});
function currentTeamIds() {
// Read COMMITTED state (hidden inputs), not the popover's
// pending state — the user hasn't clicked OK yet.
return Array.from(document.querySelectorAll(
'.adj-hidden-inputs[data-adj-filter="team"] input[type=hidden]'
)).map(function(i) { return parseInt(i.value, 10); });
}
function urlSelectedWorkerIds() {
// Ditto — the committed Workers state (to preserve already-selected
// workers even if they're now out-of-team).
return Array.from(document.querySelectorAll(
'.adj-hidden-inputs[data-adj-filter="worker"] input[type=hidden]'
)).map(function(i) { return parseInt(i.value, 10); });
}
function applyWorkerCrossFilter() {
var teamIds = currentTeamIds();
var keepWorkerIds = new Set(urlSelectedWorkerIds());
if (teamIds.length === 0) {
// No team filter -> show all workers
document.querySelectorAll(
'.adj-filter-cb[data-adj-filter="worker"]'
).forEach(function(cb) {
cb.disabled = false;
cb.closest('.adj-cb-row').style.display = '';
});
return;
}
// Build the set of valid worker IDs for the selected teams
var validIds = new Set();
teamIds.forEach(function(tid) {
if (workersByTeam[tid]) {
workersByTeam[tid].forEach(function(wid) { validIds.add(wid); });
}
});
// Hide any row whose worker isn't valid AND isn't already
// selected (already-selected stays visible so user can untick).
document.querySelectorAll(
'.adj-filter-cb[data-adj-filter="worker"]'
).forEach(function(cb) {
var wid = parseInt(cb.value, 10);
var ok = validIds.has(wid) || keepWorkerIds.has(wid);
cb.disabled = !ok;
// Visually hide disabled rows entirely — less clutter
cb.closest('.adj-cb-row').style.display = ok ? '' : 'none';
});
}
// Run cross-filter whenever the Workers popover opens
var workersPill = document.querySelector('.adj-filter-pill[data-adj-filter="worker"]');
if (workersPill) {
workersPill.addEventListener('click', function() {
// Defer to next tick so the popover is open and DOM queries
// see the right elements.
setTimeout(applyWorkerCrossFilter, 0);
});
}
// Re-apply cross-filter when Teams popover OK fires (the committed
// hidden inputs have been rewritten by then).
var teamsPopover = document.getElementById('adjTeamPopover');
if (teamsPopover) {
teamsPopover.querySelector('.popover-ok').addEventListener('click', function() {
// Same tick is fine — commitCheckboxes ran synchronously above.
applyWorkerCrossFilter();
});
}
// Initial apply on page load (URL may already have team= selected)
applyWorkerCrossFilter();
}
// --- 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.

View File

@ -1218,3 +1218,22 @@ class AdjustmentsTabTests(TestCase):
self.assertTrue(PayrollAdjustment.objects.filter(id=new_loan_adj.id).exists())
self.assertTrue(Loan.objects.filter(id=loan.id).exists())
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a2.id).exists())
def test_team_worker_pairs_json_context_key(self):
"""Cross-filter map is a raw Python list of {team_id, worker_id}
dicts. Django's |json_script filter handles serialisation at
template render time (no double-encoding see the 2026-04-23
inline-filters regression test)."""
self._login_admin()
resp = self.client.get(self.url)
pairs = resp.context['team_worker_pairs_json']
self.assertIsInstance(pairs, list)
for entry in pairs:
self.assertIn('team_id', entry)
self.assertIn('worker_id', entry)
# Our fixture: two teams (Alpha + Beta) with both workers in each
pair_set = {(p['team_id'], p['worker_id']) for p in pairs}
self.assertIn((self.team.id, self.w1.id), pair_set)
self.assertIn((self.team.id, self.w2.id), pair_set)
self.assertIn((self.team2.id, self.w1.id), pair_set)
self.assertIn((self.team2.id, self.w2.id), pair_set)

View File

@ -3084,6 +3084,14 @@ def payroll_dashboard(request):
'all_teams_for_filter': Team.objects.filter(active=True).order_by('name'),
# Task 4 will use this to decide +/- signs on each row.
'additive_types': list(ADDITIVE_TYPES),
# === CROSS-FILTER SOURCE: (team_id, worker_id) PAIRS ===
# Consumed by the popover JS to disable Workers checkboxes that
# aren't in any currently-URL-selected team. Raw Python list
# — |json_script in the template handles safe serialisation
# (NOT json.dumps — see the 2026-04-23 inline-filters regression).
'team_worker_pairs_json': list(
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
),
})
return render(request, 'core/payroll_dashboard.html', context)