diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index de30c76..2fd00ab 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -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) --- #}
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. diff --git a/core/tests.py b/core/tests.py index fdb87a6..c08afa0 100644 --- a/core/tests.py +++ b/core/tests.py @@ -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) diff --git a/core/views.py b/core/views.py index 65c447c..1d176e8 100644 --- a/core/views.py +++ b/core/views.py @@ -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)