Update batch pay modal: 3-option loan filter + radio button fix

- Replace "Exclude workers with loans" checkbox with dropdown
  (All Workers / With loans only / Without loans) in batch pay modal,
  matching the pending payments table filter style
- Fix radio button visual state when switching between
  "Until Last Paydate" and "Pay All" modes (set checked after DOM append)
- Update CLAUDE.md with pending table filter and overdue badge docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-03-25 09:23:01 +02:00
parent 3bb75c5615
commit 72d40971f1
2 changed files with 31 additions and 21 deletions

View File

@ -103,9 +103,10 @@ python manage.py check # System check
- Advance Payment auto-processing: `add_adjustment` immediately creates PayrollRecord + sends payslip when an advance is created. Also auto-creates an "Advance Repayment" adjustment for the next salary cycle. Uses `_send_payslip_email()` helper (shared with `process_payment`)
- Advance-to-loan conversion: When an Advance Repayment is only partially paid, `process_payment` changes the Loan's `loan_type` from 'advance' to 'loan' so the remainder is tracked as a regular loan
- Split Payslip: Preview modal has checkboxes on work logs and adjustments (all checked by default). `process_payment()` accepts optional `selected_log_ids` / `selected_adj_ids` POST params to pay only selected items. Falls back to "pay all" if no IDs provided (backward compatible with the quick Pay button).
- Team Pay Schedules: Teams have optional `pay_frequency` + `pay_start_date` fields. `get_pay_period(team)` calculates current period boundaries by stepping forward from the anchor date. The preview modal shows a "Split at Pay Date" button that auto-unchecks items outside the current pay period. `get_worker_active_team(worker)` returns the worker's first active team.
- Team Pay Schedules: Teams have optional `pay_frequency` + `pay_start_date` fields. `get_pay_period(team)` calculates current period boundaries by stepping forward from the anchor date. The preview modal shows a "Split at Pay Date" button that auto-unchecks items after the `cutoff_date` (end of last completed period — includes ALL overdue work, not just one period). `get_worker_active_team(worker)` returns the worker's first active team.
- Pay period calculation: `pay_start_date` is an anchor (never needs updating). Weekly=7 days, Fortnightly=14 days, Monthly=calendar month stepping. Uses `calendar.monthrange()` for month-length edge cases (no `dateutil` dependency).
- Batch Pay: "Batch Pay" button on payroll dashboard opens a modal with two radio modes — **"Until Last Paydate"** (default, splits at last completed pay period per team schedule) and **"Pay All"** (includes all unpaid items regardless of date). Preview fetches from `batch_pay_preview` with `?mode=schedule|all`. Workers without team pay schedules are skipped in schedule mode but included in Pay All mode. `batch_pay` POST endpoint processes each worker in independent atomic transactions; emails are sent after all payments complete. Uses `_process_single_payment()` shared helper (same logic as individual `process_payment`).
- Batch Pay: "Batch Pay" button on payroll dashboard opens a modal with two radio modes — **"Until Last Paydate"** (default, splits at last completed pay period per team schedule) and **"Pay All"** (includes all unpaid items regardless of date). Preview fetches from `batch_pay_preview` with `?mode=schedule|all`. Workers without team pay schedules are skipped in schedule mode but included in Pay All mode. `batch_pay` POST endpoint processes each worker in independent atomic transactions; emails are sent after all payments complete. Uses `_process_single_payment()` shared helper (same logic as individual `process_payment`). Modal includes team filter dropdown and 3-option loan filter (All / With loans only / Without loans).
- Pending Payments Table: Shows overdue badges (red) for workers with unpaid work from completed pay periods, and loan badges (yellow) for workers with active loans/advances. Filter bar has: team dropdown, "Overdue only" checkbox, and loan dropdown (All Workers / With loans only / Without loans). Overdue detection uses `get_pay_period()` cutoff logic.
## URL Routes
| Path | View | Purpose |

View File

@ -2254,12 +2254,12 @@ document.addEventListener('DOMContentLoaded', function() {
// Re-fetch preview when radio changes
radioSchedule.addEventListener('change', function() { loadBatchPreview('schedule'); });
radioAll.addEventListener('change', function() { loadBatchPreview('all'); });
} else {
// Update checked state using the saved reference (not getElementById — element is detached)
_batchRadioGroup.querySelector('#batchModeSchedule').checked = (mode === 'schedule');
_batchRadioGroup.querySelector('#batchModeAll').checked = (mode === 'all');
}
// Re-append radio group to DOM first, THEN set checked state.
// Radio buttons only properly toggle siblings when attached to the DOM.
body.appendChild(_batchRadioGroup);
_batchRadioGroup.querySelector('#batchModeSchedule').checked = (mode === 'schedule');
_batchRadioGroup.querySelector('#batchModeAll').checked = (mode === 'all');
// Show loading spinner below radio buttons
var loadDiv = document.createElement('div');
@ -2339,22 +2339,29 @@ document.addEventListener('DOMContentLoaded', function() {
});
filterRow.appendChild(filterSelect);
// Only show the "Exclude loans" checkbox if any worker has a loan
// Loan filter dropdown (All / With loans only / Without loans)
var batchLoanFilter = null;
var anyHasLoan = data.eligible.some(function(w) { return w.has_loan; });
var excludeLoansCheck = null;
if (anyHasLoan) {
var loanDiv = document.createElement('div');
loanDiv.className = 'form-check ms-3 mb-0';
excludeLoansCheck = document.createElement('input');
excludeLoansCheck.type = 'checkbox';
excludeLoansCheck.className = 'form-check-input';
excludeLoansCheck.id = 'batchExcludeLoans';
loanDiv.appendChild(excludeLoansCheck);
loanDiv.className = 'd-flex align-items-center gap-2 ms-3';
var loanLabel = document.createElement('label');
loanLabel.className = 'form-check-label text-muted small';
loanLabel.setAttribute('for', 'batchExcludeLoans');
loanLabel.textContent = 'Exclude workers with loans';
loanLabel.className = 'text-muted small mb-0';
loanLabel.textContent = 'Loans:';
loanLabel.setAttribute('for', 'batchLoanFilter');
loanDiv.appendChild(loanLabel);
batchLoanFilter = document.createElement('select');
batchLoanFilter.id = 'batchLoanFilter';
batchLoanFilter.className = 'form-select form-select-sm';
batchLoanFilter.style.width = 'auto';
var opts = [['', 'All Workers'], ['with', 'With loans only'], ['without', 'Without loans']];
opts.forEach(function(o) {
var opt = document.createElement('option');
opt.value = o[0];
opt.textContent = o[1];
batchLoanFilter.appendChild(opt);
});
loanDiv.appendChild(batchLoanFilter);
filterRow.appendChild(loanDiv);
}
@ -2447,12 +2454,14 @@ document.addEventListener('DOMContentLoaded', function() {
// --- Shared filter function (team + loan filters combined) ---
function applyBatchFilters() {
var selectedTeam = filterSelect.value;
var excludeLoans = excludeLoansCheck ? excludeLoansCheck.checked : false;
var loanMode = batchLoanFilter ? batchLoanFilter.value : '';
var rows = tbody.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var teamMatch = !selectedTeam || row.dataset.team === selectedTeam;
var loanMatch = !excludeLoans || row.dataset.hasLoan !== 'true';
var loanMatch = !loanMode
|| (loanMode === 'with' && row.dataset.hasLoan === 'true')
|| (loanMode === 'without' && row.dataset.hasLoan !== 'true');
if (teamMatch && loanMatch) {
row.style.display = '';
row.querySelector('.batch-worker-cb').checked = true;
@ -2466,8 +2475,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
filterSelect.addEventListener('change', applyBatchFilters);
if (excludeLoansCheck) {
excludeLoansCheck.addEventListener('change', applyBatchFilters);
if (batchLoanFilter) {
batchLoanFilter.addEventListener('change', applyBatchFilters);
}
// --- Skipped workers (collapsible) ---