Add batch pay mode toggle: Until Last Paydate / Pay All
Radio buttons in the Batch Pay modal let admin choose between: - "Until Last Paydate" (default): splits at last completed pay period - "Pay All": includes all unpaid work regardless of pay schedule Preview re-fetches when mode changes. Workers without teams are included in Pay All mode (skipped in schedule mode as before). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2e6881b7a4
commit
8d13c552aa
@ -2124,157 +2124,232 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === BATCH PAY MODAL: Radio buttons control split mode ===
|
||||||
|
// 'schedule' = split at last paydate (default), 'all' = pay everything unpaid
|
||||||
|
|
||||||
|
// --- Helper: Load batch preview for the given mode ---
|
||||||
|
function loadBatchPreview(mode) {
|
||||||
|
var body = document.getElementById('batchPayModalBody');
|
||||||
|
var footer = document.getElementById('batchPayModalFooter');
|
||||||
|
footer.style.display = 'none';
|
||||||
|
|
||||||
|
// Keep radio buttons if they exist, clear everything else
|
||||||
|
var radioGroup = document.getElementById('batchModeRadioGroup');
|
||||||
|
while (body.firstChild) body.removeChild(body.firstChild);
|
||||||
|
|
||||||
|
// === Radio button group (always at top) ===
|
||||||
|
if (!radioGroup) {
|
||||||
|
radioGroup = document.createElement('div');
|
||||||
|
radioGroup.id = 'batchModeRadioGroup';
|
||||||
|
radioGroup.className = 'btn-group w-100 mb-3';
|
||||||
|
radioGroup.setAttribute('role', 'group');
|
||||||
|
|
||||||
|
// "Until Last Paydate" radio
|
||||||
|
var radioSchedule = document.createElement('input');
|
||||||
|
radioSchedule.type = 'radio';
|
||||||
|
radioSchedule.className = 'btn-check';
|
||||||
|
radioSchedule.name = 'batchMode';
|
||||||
|
radioSchedule.id = 'batchModeSchedule';
|
||||||
|
radioSchedule.value = 'schedule';
|
||||||
|
radioSchedule.checked = (mode === 'schedule');
|
||||||
|
radioGroup.appendChild(radioSchedule);
|
||||||
|
|
||||||
|
var labelSchedule = document.createElement('label');
|
||||||
|
labelSchedule.className = 'btn btn-outline-primary';
|
||||||
|
labelSchedule.setAttribute('for', 'batchModeSchedule');
|
||||||
|
var iconSchedule = document.createElement('i');
|
||||||
|
iconSchedule.className = 'fas fa-calendar-check me-1';
|
||||||
|
labelSchedule.appendChild(iconSchedule);
|
||||||
|
labelSchedule.appendChild(document.createTextNode('Until Last Paydate'));
|
||||||
|
radioGroup.appendChild(labelSchedule);
|
||||||
|
|
||||||
|
// "Pay All" radio
|
||||||
|
var radioAll = document.createElement('input');
|
||||||
|
radioAll.type = 'radio';
|
||||||
|
radioAll.className = 'btn-check';
|
||||||
|
radioAll.name = 'batchMode';
|
||||||
|
radioAll.id = 'batchModeAll';
|
||||||
|
radioAll.value = 'all';
|
||||||
|
radioAll.checked = (mode === 'all');
|
||||||
|
radioGroup.appendChild(radioAll);
|
||||||
|
|
||||||
|
var labelAll = document.createElement('label');
|
||||||
|
labelAll.className = 'btn btn-outline-primary';
|
||||||
|
labelAll.setAttribute('for', 'batchModeAll');
|
||||||
|
var iconAll = document.createElement('i');
|
||||||
|
iconAll.className = 'fas fa-list me-1';
|
||||||
|
labelAll.appendChild(iconAll);
|
||||||
|
labelAll.appendChild(document.createTextNode('Pay All'));
|
||||||
|
radioGroup.appendChild(labelAll);
|
||||||
|
|
||||||
|
// Re-fetch preview when radio changes
|
||||||
|
radioSchedule.addEventListener('change', function() { loadBatchPreview('schedule'); });
|
||||||
|
radioAll.addEventListener('change', function() { loadBatchPreview('all'); });
|
||||||
|
} else {
|
||||||
|
// Update checked state on existing radios
|
||||||
|
document.getElementById('batchModeSchedule').checked = (mode === 'schedule');
|
||||||
|
document.getElementById('batchModeAll').checked = (mode === 'all');
|
||||||
|
}
|
||||||
|
body.appendChild(radioGroup);
|
||||||
|
|
||||||
|
// Show loading spinner below radio buttons
|
||||||
|
var loadDiv = document.createElement('div');
|
||||||
|
loadDiv.className = 'text-center py-4';
|
||||||
|
var spinner = document.createElement('div');
|
||||||
|
spinner.className = 'spinner-border text-primary';
|
||||||
|
spinner.setAttribute('role', 'status');
|
||||||
|
loadDiv.appendChild(spinner);
|
||||||
|
var loadText = document.createElement('p');
|
||||||
|
loadText.className = 'text-muted mt-2 small';
|
||||||
|
loadText.textContent = 'Calculating pay periods...';
|
||||||
|
loadDiv.appendChild(loadText);
|
||||||
|
body.appendChild(loadDiv);
|
||||||
|
|
||||||
|
// Fetch batch pay preview with mode parameter
|
||||||
|
fetch('/payroll/batch-pay/preview/?mode=' + mode)
|
||||||
|
.then(function(resp) { return resp.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
// Clear everything except the radio group
|
||||||
|
while (body.children.length > 1) body.removeChild(body.lastChild);
|
||||||
|
|
||||||
|
// --- No eligible workers ---
|
||||||
|
if (data.eligible.length === 0) {
|
||||||
|
var noData = document.createElement('div');
|
||||||
|
noData.className = 'text-center py-4';
|
||||||
|
var icon = document.createElement('i');
|
||||||
|
icon.className = 'fas fa-check-circle fa-3x text-muted mb-3 d-block';
|
||||||
|
noData.appendChild(icon);
|
||||||
|
var msg = document.createElement('p');
|
||||||
|
msg.className = 'text-muted';
|
||||||
|
if (mode === 'schedule') {
|
||||||
|
msg.textContent = 'No workers eligible for batch payment — no completed pay periods with unpaid work.';
|
||||||
|
} else {
|
||||||
|
msg.textContent = 'No workers with unpaid work found.';
|
||||||
|
}
|
||||||
|
noData.appendChild(msg);
|
||||||
|
body.appendChild(noData);
|
||||||
|
|
||||||
|
if (data.skipped.length > 0) {
|
||||||
|
body.appendChild(buildSkippedSection(data.skipped));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary header ---
|
||||||
|
var summary = document.createElement('div');
|
||||||
|
summary.className = 'alert alert-info d-flex justify-content-between align-items-center mb-3';
|
||||||
|
var leftSpan = document.createElement('span');
|
||||||
|
var strong = document.createElement('strong');
|
||||||
|
strong.textContent = data.worker_count + ' worker(s)';
|
||||||
|
leftSpan.appendChild(strong);
|
||||||
|
leftSpan.appendChild(document.createTextNode(' eligible for payment'));
|
||||||
|
summary.appendChild(leftSpan);
|
||||||
|
var rightSpan = document.createElement('span');
|
||||||
|
rightSpan.className = 'fw-bold';
|
||||||
|
rightSpan.textContent = 'Total: R ' + formatMoney(data.total_amount);
|
||||||
|
summary.appendChild(rightSpan);
|
||||||
|
body.appendChild(summary);
|
||||||
|
|
||||||
|
// --- Eligible workers table ---
|
||||||
|
var table = document.createElement('table');
|
||||||
|
table.className = 'table table-sm table-hover mb-3';
|
||||||
|
|
||||||
|
// Table header
|
||||||
|
var thead = document.createElement('thead');
|
||||||
|
var headerRow = document.createElement('tr');
|
||||||
|
|
||||||
|
var thCb = document.createElement('th');
|
||||||
|
thCb.style.width = '30px';
|
||||||
|
var selectAllCb = document.createElement('input');
|
||||||
|
selectAllCb.type = 'checkbox';
|
||||||
|
selectAllCb.id = 'batchSelectAll';
|
||||||
|
selectAllCb.checked = true;
|
||||||
|
thCb.appendChild(selectAllCb);
|
||||||
|
headerRow.appendChild(thCb);
|
||||||
|
|
||||||
|
['Worker', 'Team', 'Period'].forEach(function(text) {
|
||||||
|
var th = document.createElement('th');
|
||||||
|
th.textContent = text;
|
||||||
|
headerRow.appendChild(th);
|
||||||
|
});
|
||||||
|
['Days', 'Net Pay'].forEach(function(text) {
|
||||||
|
var th = document.createElement('th');
|
||||||
|
th.className = 'text-end';
|
||||||
|
th.textContent = text;
|
||||||
|
headerRow.appendChild(th);
|
||||||
|
});
|
||||||
|
thead.appendChild(headerRow);
|
||||||
|
table.appendChild(thead);
|
||||||
|
|
||||||
|
// Table body
|
||||||
|
var tbody = document.createElement('tbody');
|
||||||
|
data.eligible.forEach(function(w, idx) {
|
||||||
|
tbody.appendChild(buildWorkerRow(w, idx));
|
||||||
|
});
|
||||||
|
table.appendChild(tbody);
|
||||||
|
body.appendChild(table);
|
||||||
|
|
||||||
|
// --- Select All checkbox behavior ---
|
||||||
|
selectAllCb.addEventListener('change', function() {
|
||||||
|
var cbs = body.querySelectorAll('.batch-worker-cb');
|
||||||
|
for (var i = 0; i < cbs.length; i++) {
|
||||||
|
cbs[i].checked = selectAllCb.checked;
|
||||||
|
}
|
||||||
|
updateBatchSummary(data, summary);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update summary when individual checkboxes change
|
||||||
|
body.addEventListener('change', function(e) {
|
||||||
|
if (e.target.classList.contains('batch-worker-cb')) {
|
||||||
|
updateBatchSummary(data, summary);
|
||||||
|
var allCbs = body.querySelectorAll('.batch-worker-cb');
|
||||||
|
var allChecked = true;
|
||||||
|
for (var i = 0; i < allCbs.length; i++) {
|
||||||
|
if (!allCbs[i].checked) { allChecked = false; break; }
|
||||||
|
}
|
||||||
|
selectAllCb.checked = allChecked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Skipped workers (collapsible) ---
|
||||||
|
if (data.skipped.length > 0) {
|
||||||
|
body.appendChild(buildSkippedSection(data.skipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store data for the confirm button
|
||||||
|
window._batchPayData = data.eligible;
|
||||||
|
|
||||||
|
// Show footer with Confirm button
|
||||||
|
footer.style.display = '';
|
||||||
|
|
||||||
|
// Reset confirm button state
|
||||||
|
var cBtn = document.getElementById('confirmBatchPayBtn');
|
||||||
|
cBtn.disabled = false;
|
||||||
|
while (cBtn.firstChild) cBtn.removeChild(cBtn.firstChild);
|
||||||
|
var btnIcon = document.createElement('i');
|
||||||
|
btnIcon.className = 'fas fa-money-bill-wave me-1';
|
||||||
|
cBtn.appendChild(btnIcon);
|
||||||
|
cBtn.appendChild(document.createTextNode('Confirm & Pay All'));
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
while (body.children.length > 1) body.removeChild(body.lastChild);
|
||||||
|
var errDiv = document.createElement('div');
|
||||||
|
errDiv.className = 'alert alert-danger';
|
||||||
|
errDiv.textContent = 'Failed to load batch preview. Please try again.';
|
||||||
|
body.appendChild(errDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var batchPayBtn = document.getElementById('batchPayBtn');
|
var batchPayBtn = document.getElementById('batchPayBtn');
|
||||||
if (batchPayBtn) {
|
if (batchPayBtn) {
|
||||||
batchPayBtn.addEventListener('click', function() {
|
batchPayBtn.addEventListener('click', function() {
|
||||||
var modal = new bootstrap.Modal(document.getElementById('batchPayModal'));
|
var modal = new bootstrap.Modal(document.getElementById('batchPayModal'));
|
||||||
var body = document.getElementById('batchPayModalBody');
|
// Reset radio group so it gets re-created fresh
|
||||||
var footer = document.getElementById('batchPayModalFooter');
|
var oldRadio = document.getElementById('batchModeRadioGroup');
|
||||||
footer.style.display = 'none';
|
if (oldRadio) oldRadio.remove();
|
||||||
|
|
||||||
// Show loading spinner
|
|
||||||
while (body.firstChild) body.removeChild(body.firstChild);
|
|
||||||
var loadDiv = document.createElement('div');
|
|
||||||
loadDiv.className = 'text-center py-4';
|
|
||||||
var spinner = document.createElement('div');
|
|
||||||
spinner.className = 'spinner-border text-primary';
|
|
||||||
spinner.setAttribute('role', 'status');
|
|
||||||
loadDiv.appendChild(spinner);
|
|
||||||
var loadText = document.createElement('p');
|
|
||||||
loadText.className = 'text-muted mt-2 small';
|
|
||||||
loadText.textContent = 'Calculating pay periods...';
|
|
||||||
loadDiv.appendChild(loadText);
|
|
||||||
body.appendChild(loadDiv);
|
|
||||||
modal.show();
|
modal.show();
|
||||||
|
// Default mode: split at last paydate
|
||||||
// Fetch batch pay preview (dry run — no payments made)
|
loadBatchPreview('schedule');
|
||||||
fetch('/payroll/batch-pay/preview/')
|
|
||||||
.then(function(resp) { return resp.json(); })
|
|
||||||
.then(function(data) {
|
|
||||||
while (body.firstChild) body.removeChild(body.firstChild);
|
|
||||||
|
|
||||||
// --- No eligible workers ---
|
|
||||||
if (data.eligible.length === 0) {
|
|
||||||
var noData = document.createElement('div');
|
|
||||||
noData.className = 'text-center py-4';
|
|
||||||
var icon = document.createElement('i');
|
|
||||||
icon.className = 'fas fa-check-circle fa-3x text-muted mb-3 d-block';
|
|
||||||
noData.appendChild(icon);
|
|
||||||
var msg = document.createElement('p');
|
|
||||||
msg.className = 'text-muted';
|
|
||||||
msg.textContent = 'No workers eligible for batch payment — no completed pay periods with unpaid work.';
|
|
||||||
noData.appendChild(msg);
|
|
||||||
body.appendChild(noData);
|
|
||||||
|
|
||||||
if (data.skipped.length > 0) {
|
|
||||||
body.appendChild(buildSkippedSection(data.skipped));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Summary header ---
|
|
||||||
var summary = document.createElement('div');
|
|
||||||
summary.className = 'alert alert-info d-flex justify-content-between align-items-center mb-3';
|
|
||||||
var leftSpan = document.createElement('span');
|
|
||||||
var strong = document.createElement('strong');
|
|
||||||
strong.textContent = data.worker_count + ' worker(s)';
|
|
||||||
leftSpan.appendChild(strong);
|
|
||||||
leftSpan.appendChild(document.createTextNode(' eligible for payment'));
|
|
||||||
summary.appendChild(leftSpan);
|
|
||||||
var rightSpan = document.createElement('span');
|
|
||||||
rightSpan.className = 'fw-bold';
|
|
||||||
rightSpan.textContent = 'Total: R ' + formatMoney(data.total_amount);
|
|
||||||
summary.appendChild(rightSpan);
|
|
||||||
body.appendChild(summary);
|
|
||||||
|
|
||||||
// --- Eligible workers table ---
|
|
||||||
var table = document.createElement('table');
|
|
||||||
table.className = 'table table-sm table-hover mb-3';
|
|
||||||
|
|
||||||
// Table header
|
|
||||||
var thead = document.createElement('thead');
|
|
||||||
var headerRow = document.createElement('tr');
|
|
||||||
|
|
||||||
var thCb = document.createElement('th');
|
|
||||||
thCb.style.width = '30px';
|
|
||||||
var selectAllCb = document.createElement('input');
|
|
||||||
selectAllCb.type = 'checkbox';
|
|
||||||
selectAllCb.id = 'batchSelectAll';
|
|
||||||
selectAllCb.checked = true;
|
|
||||||
thCb.appendChild(selectAllCb);
|
|
||||||
headerRow.appendChild(thCb);
|
|
||||||
|
|
||||||
['Worker', 'Team', 'Period'].forEach(function(text) {
|
|
||||||
var th = document.createElement('th');
|
|
||||||
th.textContent = text;
|
|
||||||
headerRow.appendChild(th);
|
|
||||||
});
|
|
||||||
['Days', 'Net Pay'].forEach(function(text) {
|
|
||||||
var th = document.createElement('th');
|
|
||||||
th.className = 'text-end';
|
|
||||||
th.textContent = text;
|
|
||||||
headerRow.appendChild(th);
|
|
||||||
});
|
|
||||||
thead.appendChild(headerRow);
|
|
||||||
table.appendChild(thead);
|
|
||||||
|
|
||||||
// Table body
|
|
||||||
var tbody = document.createElement('tbody');
|
|
||||||
data.eligible.forEach(function(w, idx) {
|
|
||||||
tbody.appendChild(buildWorkerRow(w, idx));
|
|
||||||
});
|
|
||||||
table.appendChild(tbody);
|
|
||||||
body.appendChild(table);
|
|
||||||
|
|
||||||
// --- Select All checkbox behavior ---
|
|
||||||
selectAllCb.addEventListener('change', function() {
|
|
||||||
var cbs = body.querySelectorAll('.batch-worker-cb');
|
|
||||||
for (var i = 0; i < cbs.length; i++) {
|
|
||||||
cbs[i].checked = selectAllCb.checked;
|
|
||||||
}
|
|
||||||
updateBatchSummary(data, summary);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update summary when individual checkboxes change
|
|
||||||
body.addEventListener('change', function(e) {
|
|
||||||
if (e.target.classList.contains('batch-worker-cb')) {
|
|
||||||
updateBatchSummary(data, summary);
|
|
||||||
var allCbs = body.querySelectorAll('.batch-worker-cb');
|
|
||||||
var allChecked = true;
|
|
||||||
for (var i = 0; i < allCbs.length; i++) {
|
|
||||||
if (!allCbs[i].checked) { allChecked = false; break; }
|
|
||||||
}
|
|
||||||
selectAllCb.checked = allChecked;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Skipped workers (collapsible) ---
|
|
||||||
if (data.skipped.length > 0) {
|
|
||||||
body.appendChild(buildSkippedSection(data.skipped));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store data for the confirm button
|
|
||||||
window._batchPayData = data.eligible;
|
|
||||||
|
|
||||||
// Show footer with Confirm button
|
|
||||||
footer.style.display = '';
|
|
||||||
|
|
||||||
// Reset confirm button state
|
|
||||||
var cBtn = document.getElementById('confirmBatchPayBtn');
|
|
||||||
cBtn.disabled = false;
|
|
||||||
while (cBtn.firstChild) cBtn.removeChild(cBtn.firstChild);
|
|
||||||
var btnIcon = document.createElement('i');
|
|
||||||
btnIcon.className = 'fas fa-money-bill-wave me-1';
|
|
||||||
cBtn.appendChild(btnIcon);
|
|
||||||
cBtn.appendChild(document.createTextNode('Confirm & Pay All'));
|
|
||||||
})
|
|
||||||
.catch(function() {
|
|
||||||
while (body.firstChild) body.removeChild(body.firstChild);
|
|
||||||
var errDiv = document.createElement('div');
|
|
||||||
errDiv.className = 'alert alert-danger';
|
|
||||||
errDiv.textContent = 'Failed to load batch preview. Please try again.';
|
|
||||||
body.appendChild(errDiv);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1404,10 +1404,15 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount,
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def batch_pay_preview(request):
|
def batch_pay_preview(request):
|
||||||
"""Return JSON preview of batch payment — who gets paid and how much."""
|
"""Return JSON preview of batch payment — who gets paid and how much.
|
||||||
|
Accepts ?mode=all to skip pay-period cutoff and include ALL unpaid items.
|
||||||
|
Default mode is 'schedule' which splits at last completed pay period."""
|
||||||
if not is_admin(request.user):
|
if not is_admin(request.user):
|
||||||
return JsonResponse({'error': 'Not authorized'}, status=403)
|
return JsonResponse({'error': 'Not authorized'}, status=403)
|
||||||
|
|
||||||
|
# === MODE: 'schedule' (default) = split at last paydate, 'all' = pay everything ===
|
||||||
|
mode = request.GET.get('mode', 'schedule')
|
||||||
|
|
||||||
eligible = []
|
eligible = []
|
||||||
skipped = []
|
skipped = []
|
||||||
total_amount = Decimal('0.00')
|
total_amount = Decimal('0.00')
|
||||||
@ -1425,53 +1430,59 @@ def batch_pay_preview(request):
|
|||||||
).order_by('name')
|
).order_by('name')
|
||||||
|
|
||||||
for worker in active_workers:
|
for worker in active_workers:
|
||||||
# --- Check if worker has a team with a pay schedule ---
|
|
||||||
team = get_worker_active_team(worker)
|
team = get_worker_active_team(worker)
|
||||||
if not team or not team.pay_frequency or not team.pay_start_date:
|
|
||||||
# Check if worker has ANY unpaid items before listing as skipped
|
|
||||||
has_unpaid = False
|
|
||||||
for log in worker.work_logs.all():
|
|
||||||
paid_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
|
||||||
if worker.id not in paid_ids:
|
|
||||||
has_unpaid = True
|
|
||||||
break
|
|
||||||
if not has_unpaid:
|
|
||||||
has_unpaid = worker.adjustments.filter(payroll_record__isnull=True).exists()
|
|
||||||
|
|
||||||
if has_unpaid:
|
# --- In 'schedule' mode, skip workers without a pay schedule ---
|
||||||
skipped.append({
|
if mode == 'schedule':
|
||||||
'worker_name': worker.name,
|
if not team or not team.pay_frequency or not team.pay_start_date:
|
||||||
'reason': 'No pay schedule configured',
|
# Check if worker has ANY unpaid items before listing as skipped
|
||||||
})
|
has_unpaid = False
|
||||||
continue
|
for log in worker.work_logs.all():
|
||||||
|
paid_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
||||||
|
if worker.id not in paid_ids:
|
||||||
|
has_unpaid = True
|
||||||
|
break
|
||||||
|
if not has_unpaid:
|
||||||
|
has_unpaid = worker.adjustments.filter(payroll_record__isnull=True).exists()
|
||||||
|
|
||||||
# --- Get the current pay period and calculate the cutoff date ---
|
if has_unpaid:
|
||||||
# cutoff_date = end of the last COMPLETED period.
|
skipped.append({
|
||||||
# We pay ALL overdue work (across all past periods), not just one period.
|
'worker_name': worker.name,
|
||||||
period_start, period_end = get_pay_period(team)
|
'reason': 'No pay schedule configured',
|
||||||
if not period_start:
|
})
|
||||||
continue
|
continue
|
||||||
cutoff_date = period_start - datetime.timedelta(days=1)
|
|
||||||
|
|
||||||
# --- Find unpaid logs up to the cutoff date ---
|
# --- Determine cutoff date (if applicable) ---
|
||||||
|
cutoff_date = None
|
||||||
|
if mode == 'schedule':
|
||||||
|
# cutoff_date = end of the last COMPLETED period.
|
||||||
|
# We pay ALL overdue work (across all past periods), not just one period.
|
||||||
|
period_start, period_end = get_pay_period(team)
|
||||||
|
if not period_start:
|
||||||
|
continue
|
||||||
|
cutoff_date = period_start - datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
# --- Find unpaid logs (with or without cutoff) ---
|
||||||
unpaid_log_ids = []
|
unpaid_log_ids = []
|
||||||
for log in worker.work_logs.all():
|
for log in worker.work_logs.all():
|
||||||
paid_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
paid_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
||||||
if worker.id not in paid_ids and log.date <= cutoff_date:
|
if worker.id not in paid_ids:
|
||||||
unpaid_log_ids.append(log.id)
|
# In 'all' mode: no date filter. In 'schedule' mode: only up to cutoff.
|
||||||
|
if cutoff_date is None or log.date <= cutoff_date:
|
||||||
|
unpaid_log_ids.append(log.id)
|
||||||
|
|
||||||
# --- Find pending adjustments up to the cutoff date ---
|
# --- Find pending adjustments (with or without cutoff) ---
|
||||||
unpaid_adj_ids = []
|
unpaid_adj_ids = []
|
||||||
adj_amount = Decimal('0.00')
|
adj_amount = Decimal('0.00')
|
||||||
for adj in worker.adjustments.all():
|
for adj in worker.adjustments.all():
|
||||||
if adj.date and adj.date <= cutoff_date:
|
if cutoff_date is None or (adj.date and adj.date <= cutoff_date):
|
||||||
unpaid_adj_ids.append(adj.id)
|
unpaid_adj_ids.append(adj.id)
|
||||||
if adj.type in ADDITIVE_TYPES:
|
if adj.type in ADDITIVE_TYPES:
|
||||||
adj_amount += adj.amount
|
adj_amount += adj.amount
|
||||||
elif adj.type in DEDUCTIVE_TYPES:
|
elif adj.type in DEDUCTIVE_TYPES:
|
||||||
adj_amount -= adj.amount
|
adj_amount -= adj.amount
|
||||||
|
|
||||||
# Nothing due for this worker (all work is in the current, incomplete period)
|
# Nothing due for this worker
|
||||||
if not unpaid_log_ids and not unpaid_adj_ids:
|
if not unpaid_log_ids and not unpaid_adj_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -1487,14 +1498,17 @@ def batch_pay_preview(request):
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Format: show "up to [cutoff date]" since we're paying all overdue work
|
# --- Period display text ---
|
||||||
# Use day integer to avoid platform-specific strftime issues (%-d on Linux, %#d on Windows)
|
if cutoff_date:
|
||||||
period_display = f"Up to {cutoff_date.day} {cutoff_date.strftime('%b %Y')}"
|
# Use day integer to avoid platform-specific strftime issues
|
||||||
|
period_display = f"Up to {cutoff_date.day} {cutoff_date.strftime('%b %Y')}"
|
||||||
|
else:
|
||||||
|
period_display = "All unpaid"
|
||||||
|
|
||||||
eligible.append({
|
eligible.append({
|
||||||
'worker_id': worker.id,
|
'worker_id': worker.id,
|
||||||
'worker_name': worker.name,
|
'worker_name': worker.name,
|
||||||
'team_name': team.name,
|
'team_name': team.name if team else '—',
|
||||||
'period': period_display,
|
'period': period_display,
|
||||||
'days': log_count,
|
'days': log_count,
|
||||||
'logs_amount': float(logs_amount),
|
'logs_amount': float(logs_amount),
|
||||||
@ -1510,6 +1524,7 @@ def batch_pay_preview(request):
|
|||||||
'skipped': skipped,
|
'skipped': skipped,
|
||||||
'total_amount': float(total_amount),
|
'total_amount': float(total_amount),
|
||||||
'worker_count': len(eligible),
|
'worker_count': len(eligible),
|
||||||
|
'mode': mode,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user