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:
parent
4c3e90f2a7
commit
6905703492
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user