Add batch pay feature and fix pay period cutoff logic
Batch Pay: new button on payroll dashboard lets admins pay multiple workers at once using team pay schedules. Shows preview modal with eligible workers, then processes all payments in one click. Fix: "Split at Pay Date" now uses cutoff_date (end of last completed period) instead of current period end. This includes ALL overdue work across completed periods, not just one period. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
79b6345cb9
commit
2e6881b7a4
@ -13,6 +13,9 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Payroll Dashboard</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary shadow-sm" id="batchPayBtn" title="Pay all workers with a configured pay schedule for their current pay period">
|
||||
<i class="fas fa-users fa-sm me-1"></i> Batch Pay
|
||||
</button>
|
||||
<button type="button" class="btn btn-accent shadow-sm" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
|
||||
<i class="fas fa-plus fa-sm me-1"></i> Add Adjustment
|
||||
</button>
|
||||
@ -678,6 +681,33 @@
|
||||
</div>
|
||||
|
||||
{# --- PREVIEW PAYSLIP MODAL --- #}
|
||||
{# === BATCH PAY MODAL === #}
|
||||
{# Shows a preview of which workers will be paid (based on team pay schedules), #}
|
||||
{# then lets the admin confirm to process all payments at once. #}
|
||||
<div class="modal fade" id="batchPayModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-users me-2"></i>Batch Pay by Schedule</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="batchPayModalBody">
|
||||
{# Content loaded via JavaScript #}
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="text-muted mt-2 small">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" id="batchPayModalFooter" style="display:none;">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-accent" id="confirmBatchPayBtn">
|
||||
<i class="fas fa-money-bill-wave me-1"></i> Confirm & Pay All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="previewPayslipModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
@ -1462,10 +1492,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
var infoIcon = document.createElement('i');
|
||||
infoIcon.className = 'fas fa-calendar-alt me-2';
|
||||
periodInfo.appendChild(infoIcon);
|
||||
// Show current period info + cutoff date
|
||||
// cutoff_date = end of the last COMPLETED period (everything due for payment)
|
||||
periodInfo.appendChild(document.createTextNode(
|
||||
data.pay_period.team_name + ' \u2014 ' +
|
||||
data.pay_period.frequency + ' pay period: ' +
|
||||
data.pay_period.start + ' to ' + data.pay_period.end
|
||||
data.pay_period.frequency + ' | Current period: ' +
|
||||
data.pay_period.start + ' to ' + data.pay_period.end +
|
||||
' | Pay through: ' + data.pay_period.cutoff_date
|
||||
));
|
||||
modalBody.appendChild(periodInfo);
|
||||
|
||||
@ -1476,17 +1509,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
splitIcon.className = 'fas fa-cut me-1';
|
||||
splitBtn.appendChild(splitIcon);
|
||||
splitBtn.appendChild(document.createTextNode(
|
||||
'Split at Pay Date (' + data.pay_period.end + ')'
|
||||
'Split at Pay Date (up to ' + data.pay_period.cutoff_date + ')'
|
||||
));
|
||||
splitBtn.addEventListener('click', function() {
|
||||
var periodEnd = data.pay_period.end;
|
||||
// Uncheck logs after the pay period end date
|
||||
// Use cutoff_date (end of last completed period) — includes ALL
|
||||
// overdue work, not just one period. Leaves current in-progress
|
||||
// period for the next pay run.
|
||||
var cutoff = data.pay_period.cutoff_date;
|
||||
// Uncheck logs after the cutoff date
|
||||
modalBody.querySelectorAll('.log-checkbox').forEach(function(cb) {
|
||||
cb.checked = (cb.dataset.logDate <= periodEnd);
|
||||
cb.checked = (cb.dataset.logDate <= cutoff);
|
||||
});
|
||||
// Uncheck adjustments after the pay period end date
|
||||
// Uncheck adjustments after the cutoff date
|
||||
modalBody.querySelectorAll('.adj-checkbox').forEach(function(cb) {
|
||||
cb.checked = (cb.dataset.adjDate <= periodEnd);
|
||||
cb.checked = (cb.dataset.adjDate <= cutoff);
|
||||
});
|
||||
recalcNetPay();
|
||||
});
|
||||
@ -1966,6 +2002,337 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
|
||||
// =================================================================
|
||||
// === BATCH PAY FLOW ===
|
||||
// Step 1: Click "Batch Pay" → fetch preview → show confirmation modal
|
||||
// Step 2: Click "Confirm & Pay All" → POST to batch_pay → redirect
|
||||
// =================================================================
|
||||
|
||||
// --- Helper: Format number as money (e.g., 45320 → "45,320.00") ---
|
||||
function formatMoney(num) {
|
||||
return Number(num).toLocaleString('en-ZA', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
// --- Helper: Build the "Skipped workers" collapsible section ---
|
||||
function buildSkippedSection(skipped) {
|
||||
var div = document.createElement('div');
|
||||
div.className = 'mt-2';
|
||||
|
||||
var toggle = document.createElement('a');
|
||||
toggle.href = '#';
|
||||
toggle.className = 'text-muted small';
|
||||
var warnIcon = document.createElement('i');
|
||||
warnIcon.className = 'fas fa-exclamation-triangle me-1';
|
||||
toggle.appendChild(warnIcon);
|
||||
toggle.appendChild(document.createTextNode(skipped.length + ' worker(s) skipped '));
|
||||
var chevron = document.createElement('i');
|
||||
chevron.className = 'fas fa-chevron-down ms-1';
|
||||
toggle.appendChild(chevron);
|
||||
div.appendChild(toggle);
|
||||
|
||||
var list = document.createElement('div');
|
||||
list.style.display = 'none';
|
||||
list.className = 'mt-2';
|
||||
|
||||
var ul = document.createElement('ul');
|
||||
ul.className = 'list-unstyled small text-muted mb-0';
|
||||
skipped.forEach(function(s) {
|
||||
var li = document.createElement('li');
|
||||
li.textContent = s.worker_name + ' — ' + s.reason;
|
||||
ul.appendChild(li);
|
||||
});
|
||||
list.appendChild(ul);
|
||||
div.appendChild(list);
|
||||
|
||||
toggle.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
list.style.display = list.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
// --- Helper: Update batch summary when checkboxes change ---
|
||||
function updateBatchSummary(data, summaryEl) {
|
||||
var checked = document.querySelectorAll('.batch-worker-cb:checked');
|
||||
var count = checked.length;
|
||||
var total = 0;
|
||||
checked.forEach(function(cb) {
|
||||
var idx = parseInt(cb.dataset.index);
|
||||
total += data.eligible[idx].net_pay;
|
||||
});
|
||||
// Clear and rebuild summary safely
|
||||
while (summaryEl.firstChild) summaryEl.removeChild(summaryEl.firstChild);
|
||||
var leftSpan = document.createElement('span');
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = count + ' worker(s)';
|
||||
leftSpan.appendChild(strong);
|
||||
leftSpan.appendChild(document.createTextNode(' selected for payment'));
|
||||
summaryEl.appendChild(leftSpan);
|
||||
var rightSpan = document.createElement('span');
|
||||
rightSpan.className = 'fw-bold';
|
||||
rightSpan.textContent = 'Total: R ' + formatMoney(total);
|
||||
summaryEl.appendChild(rightSpan);
|
||||
}
|
||||
|
||||
// --- Helper: Build a table row for an eligible worker ---
|
||||
function buildWorkerRow(w, idx) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
// Checkbox cell
|
||||
var tdCb = document.createElement('td');
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'batch-worker-cb';
|
||||
cb.dataset.index = idx;
|
||||
cb.checked = true;
|
||||
tdCb.appendChild(cb);
|
||||
tr.appendChild(tdCb);
|
||||
|
||||
// Worker name
|
||||
var tdName = document.createElement('td');
|
||||
tdName.textContent = w.worker_name;
|
||||
tr.appendChild(tdName);
|
||||
|
||||
// Team badge
|
||||
var tdTeam = document.createElement('td');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge bg-secondary';
|
||||
badge.textContent = w.team_name;
|
||||
tdTeam.appendChild(badge);
|
||||
tr.appendChild(tdTeam);
|
||||
|
||||
// Period
|
||||
var tdPeriod = document.createElement('td');
|
||||
var small = document.createElement('small');
|
||||
small.textContent = w.period;
|
||||
tdPeriod.appendChild(small);
|
||||
tr.appendChild(tdPeriod);
|
||||
|
||||
// Days
|
||||
var tdDays = document.createElement('td');
|
||||
tdDays.className = 'text-end';
|
||||
tdDays.textContent = w.days;
|
||||
tr.appendChild(tdDays);
|
||||
|
||||
// Net pay
|
||||
var tdNet = document.createElement('td');
|
||||
tdNet.className = 'text-end fw-bold';
|
||||
tdNet.textContent = 'R ' + formatMoney(w.net_pay);
|
||||
tr.appendChild(tdNet);
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
var batchPayBtn = document.getElementById('batchPayBtn');
|
||||
if (batchPayBtn) {
|
||||
batchPayBtn.addEventListener('click', function() {
|
||||
var modal = new bootstrap.Modal(document.getElementById('batchPayModal'));
|
||||
var body = document.getElementById('batchPayModalBody');
|
||||
var footer = document.getElementById('batchPayModalFooter');
|
||||
footer.style.display = 'none';
|
||||
|
||||
// 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();
|
||||
|
||||
// Fetch batch pay preview (dry run — no payments made)
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Confirm & Pay All button ---
|
||||
var confirmBatchBtn = document.getElementById('confirmBatchPayBtn');
|
||||
if (confirmBatchBtn) {
|
||||
confirmBatchBtn.addEventListener('click', function() {
|
||||
var btn = this;
|
||||
|
||||
// Gather checked workers
|
||||
var workers = [];
|
||||
document.querySelectorAll('.batch-worker-cb:checked').forEach(function(cb) {
|
||||
var idx = parseInt(cb.dataset.index);
|
||||
var w = window._batchPayData[idx];
|
||||
workers.push({
|
||||
worker_id: w.worker_id,
|
||||
log_ids: w.log_ids,
|
||||
adj_ids: w.adj_ids,
|
||||
});
|
||||
});
|
||||
|
||||
if (workers.length === 0) {
|
||||
alert('No workers selected. Check at least one worker to proceed.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable button and show processing state
|
||||
btn.disabled = true;
|
||||
while (btn.firstChild) btn.removeChild(btn.firstChild);
|
||||
var sp = document.createElement('span');
|
||||
sp.className = 'spinner-border spinner-border-sm me-2';
|
||||
sp.setAttribute('role', 'status');
|
||||
btn.appendChild(sp);
|
||||
btn.appendChild(document.createTextNode('Processing ' + workers.length + ' payment(s)...'));
|
||||
|
||||
// POST to batch pay endpoint
|
||||
fetch('/payroll/batch-pay/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}',
|
||||
},
|
||||
body: JSON.stringify({ workers: workers }),
|
||||
}).then(function() {
|
||||
// Redirect to refresh page and show Django success messages
|
||||
window.location.href = '/payroll/';
|
||||
}).catch(function() {
|
||||
btn.disabled = false;
|
||||
while (btn.firstChild) btn.removeChild(btn.firstChild);
|
||||
var retryIcon = document.createElement('i');
|
||||
retryIcon.className = 'fas fa-money-bill-wave me-1';
|
||||
btn.appendChild(retryIcon);
|
||||
btn.appendChild(document.createTextNode('Confirm & Pay All'));
|
||||
alert('Batch payment failed. Please try again.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}); // end DOMContentLoaded
|
||||
</script>
|
||||
|
||||
|
||||
@ -31,6 +31,10 @@ urlpatterns = [
|
||||
# Process payment — pays a worker and links their unpaid logs + adjustments
|
||||
path('payroll/pay/<int:worker_id>/', views.process_payment, name='process_payment'),
|
||||
|
||||
# Batch pay — preview which workers would be paid, then process all at once
|
||||
path('payroll/batch-pay/preview/', views.batch_pay_preview, name='batch_pay_preview'),
|
||||
path('payroll/batch-pay/', views.batch_pay, name='batch_pay'),
|
||||
|
||||
# Price overtime — creates Overtime adjustments from unpriced OT entries
|
||||
path('payroll/price-overtime/', views.price_overtime, name='price_overtime'),
|
||||
|
||||
|
||||
344
core/views.py
344
core/views.py
@ -1167,46 +1167,28 @@ def payroll_dashboard(request):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === PROCESS PAYMENT ===
|
||||
# Creates a PayrollRecord for a worker, linking all their unpaid work logs
|
||||
# and applying any pending adjustments. Handles loan repayment deductions.
|
||||
# === SINGLE PAYMENT HELPER ===
|
||||
# Core payment logic used by both individual payments and batch payments.
|
||||
# Locks the worker row, creates a PayrollRecord, links logs/adjustments,
|
||||
# and handles loan repayment deductions — all inside an atomic transaction.
|
||||
# =============================================================================
|
||||
|
||||
@login_required
|
||||
def process_payment(request, worker_id):
|
||||
if request.method != 'POST':
|
||||
return redirect('payroll_dashboard')
|
||||
if not is_admin(request.user):
|
||||
return HttpResponseForbidden("Not authorized.")
|
||||
def _process_single_payment(worker_id, selected_log_ids=None, selected_adj_ids=None):
|
||||
"""
|
||||
Process payment for one worker inside an atomic transaction.
|
||||
Returns (payroll_record, log_count, logs_amount) on success, or None if nothing to pay.
|
||||
|
||||
# Validate the worker exists first (returns 404 if not found)
|
||||
worker = get_object_or_404(Worker, id=worker_id)
|
||||
|
||||
# --- DUPLICATE PAYMENT PREVENTION ---
|
||||
# All queries and the PayrollRecord creation happen inside a single
|
||||
# database transaction. select_for_update() locks the Worker row,
|
||||
# which forces concurrent requests (e.g. double-click) to wait.
|
||||
# The second request will see the logs as already paid and bail out.
|
||||
- worker_id: the Worker's PK
|
||||
- selected_log_ids: list of WorkLog IDs to include (None = all unpaid)
|
||||
- selected_adj_ids: list of PayrollAdjustment IDs to include (None = all pending)
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Lock this worker's row — any other request for the same worker
|
||||
# will wait here until this transaction commits.
|
||||
# Lock this worker's row — any concurrent request for the same
|
||||
# worker will wait here until this transaction commits.
|
||||
worker = Worker.objects.select_for_update().get(id=worker_id)
|
||||
|
||||
# === SPLIT PAYSLIP SUPPORT ===
|
||||
# If the POST includes specific log/adjustment IDs (from the preview
|
||||
# modal's checkboxes), only pay those selected items.
|
||||
# If no IDs provided (e.g., the quick "Pay" button on the table),
|
||||
# fall back to paying everything — backward compatible.
|
||||
selected_log_ids = [int(x) for x in request.POST.getlist('selected_log_ids') if x.isdigit()]
|
||||
selected_adj_ids = [int(x) for x in request.POST.getlist('selected_adj_ids') if x.isdigit()]
|
||||
|
||||
# Find unpaid logs for this worker (inside the lock, so this
|
||||
# result is guaranteed to be up-to-date)
|
||||
all_unpaid_logs = worker.work_logs.exclude(
|
||||
payroll_records__worker=worker
|
||||
)
|
||||
|
||||
# If specific logs were selected, only pay those
|
||||
# Get unpaid logs, filter to selected if IDs provided
|
||||
all_unpaid_logs = worker.work_logs.exclude(payroll_records__worker=worker)
|
||||
if selected_log_ids:
|
||||
unpaid_logs = all_unpaid_logs.filter(id__in=selected_log_ids)
|
||||
else:
|
||||
@ -1215,7 +1197,7 @@ def process_payment(request, worker_id):
|
||||
log_count = unpaid_logs.count()
|
||||
logs_amount = log_count * worker.daily_rate
|
||||
|
||||
# Find pending adjustments — filter to selected if IDs provided
|
||||
# Get pending adjustments, filter to selected if IDs provided
|
||||
all_pending_adjs = list(worker.adjustments.filter(payroll_record__isnull=True))
|
||||
if selected_adj_ids:
|
||||
selected_adj_set = set(selected_adj_ids)
|
||||
@ -1223,11 +1205,9 @@ def process_payment(request, worker_id):
|
||||
else:
|
||||
pending_adjs = all_pending_adjs
|
||||
|
||||
# Nothing to pay — already paid or nothing owed
|
||||
if log_count == 0 and not pending_adjs:
|
||||
# Nothing to pay — either everything is already paid (duplicate
|
||||
# request), or there genuinely are no pending items.
|
||||
messages.warning(request, f'No pending payments for {worker.name} — already paid or nothing owed.')
|
||||
return redirect('payroll_dashboard')
|
||||
return None
|
||||
|
||||
# Calculate net adjustment
|
||||
adj_amount = Decimal('0.00')
|
||||
@ -1246,10 +1226,10 @@ def process_payment(request, worker_id):
|
||||
date=timezone.now().date(),
|
||||
)
|
||||
|
||||
# Link all unpaid work logs to this payment
|
||||
# Link work logs to this payment
|
||||
payroll_record.work_logs.set(unpaid_logs)
|
||||
|
||||
# Link all pending adjustments to this payment
|
||||
# Link adjustments + handle loan repayments
|
||||
for adj in pending_adjs:
|
||||
adj.payroll_record = payroll_record
|
||||
adj.save()
|
||||
@ -1268,6 +1248,45 @@ def process_payment(request, worker_id):
|
||||
adj.loan.loan_type = 'loan'
|
||||
adj.loan.save()
|
||||
|
||||
return (payroll_record, log_count, logs_amount)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === PROCESS PAYMENT ===
|
||||
# HTTP endpoint for paying a single worker. Reads selected IDs from the POST
|
||||
# form (split payslip), delegates to _process_single_payment, then emails.
|
||||
# =============================================================================
|
||||
|
||||
@login_required
|
||||
def process_payment(request, worker_id):
|
||||
if request.method != 'POST':
|
||||
return redirect('payroll_dashboard')
|
||||
if not is_admin(request.user):
|
||||
return HttpResponseForbidden("Not authorized.")
|
||||
|
||||
# Validate the worker exists (returns 404 if not found)
|
||||
worker = get_object_or_404(Worker, id=worker_id)
|
||||
|
||||
# === SPLIT PAYSLIP SUPPORT ===
|
||||
# If the POST includes specific log/adjustment IDs (from the preview
|
||||
# modal's checkboxes), only pay those selected items.
|
||||
# If no IDs provided (e.g., the quick "Pay" button on the table),
|
||||
# fall back to paying everything — backward compatible.
|
||||
selected_log_ids = [int(x) for x in request.POST.getlist('selected_log_ids') if x.isdigit()]
|
||||
selected_adj_ids = [int(x) for x in request.POST.getlist('selected_adj_ids') if x.isdigit()]
|
||||
|
||||
result = _process_single_payment(
|
||||
worker_id,
|
||||
selected_log_ids=selected_log_ids or None,
|
||||
selected_adj_ids=selected_adj_ids or None,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
messages.warning(request, f'No pending payments for {worker.name} — already paid or nothing owed.')
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
payroll_record, log_count, logs_amount = result
|
||||
|
||||
# =========================================================================
|
||||
# EMAIL PAYSLIP (outside the transaction — if email fails, payment is
|
||||
# still saved. We don't want a network error to roll back a real payment.)
|
||||
@ -1283,7 +1302,7 @@ def process_payment(request, worker_id):
|
||||
# Used by both process_payment (regular salary) and add_adjustment (advances).
|
||||
# =============================================================================
|
||||
|
||||
def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount):
|
||||
def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount, suppress_messages=False):
|
||||
"""
|
||||
Generate and email a payslip for a completed payment.
|
||||
Called after a PayrollRecord has been created and adjustments linked.
|
||||
@ -1293,6 +1312,7 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount)
|
||||
- payroll_record: the PayrollRecord just created
|
||||
- log_count: number of work logs in this payment (0 for advance-only)
|
||||
- logs_amount: total earnings from work logs (Decimal('0.00') for advance-only)
|
||||
- suppress_messages: if True, skip Django messages (used by batch pay)
|
||||
"""
|
||||
# Lazy import — avoids crashing the app if xhtml2pdf isn't installed
|
||||
from .utils import render_to_pdf
|
||||
@ -1351,26 +1371,230 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount)
|
||||
)
|
||||
|
||||
email.send()
|
||||
if not suppress_messages:
|
||||
messages.success(
|
||||
request,
|
||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
||||
f'Payslip emailed successfully.'
|
||||
)
|
||||
except Exception as e:
|
||||
# Payment is saved — just warn that email failed
|
||||
if not suppress_messages:
|
||||
messages.warning(
|
||||
request,
|
||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}, '
|
||||
f'but email delivery failed: {str(e)}'
|
||||
)
|
||||
raise # Re-raise so batch_pay can count failures
|
||||
else:
|
||||
# No SPARK_RECEIPT_EMAIL configured — just show success
|
||||
if not suppress_messages:
|
||||
messages.success(
|
||||
request,
|
||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
||||
f'Payslip emailed successfully.'
|
||||
f'{log_count} work log(s) marked as paid.'
|
||||
)
|
||||
except Exception as e:
|
||||
# Payment is saved — just warn that email failed
|
||||
messages.warning(
|
||||
request,
|
||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}, '
|
||||
f'but email delivery failed: {str(e)}'
|
||||
)
|
||||
else:
|
||||
# No SPARK_RECEIPT_EMAIL configured — just show success
|
||||
messages.success(
|
||||
request,
|
||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
||||
f'{log_count} work log(s) marked as paid.'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === BATCH PAY PREVIEW ===
|
||||
# AJAX GET endpoint — dry run showing which workers would be paid and how
|
||||
# much, based on their team's pay schedule. No payments are made here.
|
||||
# =============================================================================
|
||||
|
||||
@login_required
|
||||
def batch_pay_preview(request):
|
||||
"""Return JSON preview of batch payment — who gets paid and how much."""
|
||||
if not is_admin(request.user):
|
||||
return JsonResponse({'error': 'Not authorized'}, status=403)
|
||||
|
||||
eligible = []
|
||||
skipped = []
|
||||
total_amount = Decimal('0.00')
|
||||
|
||||
# Get all active workers with their work logs and pending adjustments
|
||||
active_workers = Worker.objects.filter(active=True).prefetch_related(
|
||||
Prefetch(
|
||||
'work_logs',
|
||||
queryset=WorkLog.objects.prefetch_related('payroll_records').select_related('project')
|
||||
),
|
||||
Prefetch(
|
||||
'adjustments',
|
||||
queryset=PayrollAdjustment.objects.filter(payroll_record__isnull=True)
|
||||
),
|
||||
).order_by('name')
|
||||
|
||||
for worker in active_workers:
|
||||
# --- Check if worker has a team with a pay schedule ---
|
||||
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:
|
||||
skipped.append({
|
||||
'worker_name': worker.name,
|
||||
'reason': 'No pay schedule configured',
|
||||
})
|
||||
continue
|
||||
|
||||
# --- Get the current pay period and calculate the cutoff date ---
|
||||
# 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 up to the cutoff date ---
|
||||
unpaid_log_ids = []
|
||||
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 and log.date <= cutoff_date:
|
||||
unpaid_log_ids.append(log.id)
|
||||
|
||||
# --- Find pending adjustments up to the cutoff date ---
|
||||
unpaid_adj_ids = []
|
||||
adj_amount = Decimal('0.00')
|
||||
for adj in worker.adjustments.all():
|
||||
if adj.date and adj.date <= cutoff_date:
|
||||
unpaid_adj_ids.append(adj.id)
|
||||
if adj.type in ADDITIVE_TYPES:
|
||||
adj_amount += adj.amount
|
||||
elif adj.type in DEDUCTIVE_TYPES:
|
||||
adj_amount -= adj.amount
|
||||
|
||||
# Nothing due for this worker (all work is in the current, incomplete period)
|
||||
if not unpaid_log_ids and not unpaid_adj_ids:
|
||||
continue
|
||||
|
||||
log_count = len(unpaid_log_ids)
|
||||
logs_amount = log_count * worker.daily_rate
|
||||
net = logs_amount + adj_amount
|
||||
|
||||
# Skip workers with zero or negative net pay
|
||||
if net <= 0:
|
||||
skipped.append({
|
||||
'worker_name': worker.name,
|
||||
'reason': f'Net pay is R {net:,.2f} (zero or negative)',
|
||||
})
|
||||
continue
|
||||
|
||||
# Format: show "up to [cutoff date]" since we're paying all overdue work
|
||||
# Use day integer to avoid platform-specific strftime issues (%-d on Linux, %#d on Windows)
|
||||
period_display = f"Up to {cutoff_date.day} {cutoff_date.strftime('%b %Y')}"
|
||||
|
||||
eligible.append({
|
||||
'worker_id': worker.id,
|
||||
'worker_name': worker.name,
|
||||
'team_name': team.name,
|
||||
'period': period_display,
|
||||
'days': log_count,
|
||||
'logs_amount': float(logs_amount),
|
||||
'adj_amount': float(adj_amount),
|
||||
'net_pay': float(net),
|
||||
'log_ids': unpaid_log_ids,
|
||||
'adj_ids': unpaid_adj_ids,
|
||||
})
|
||||
total_amount += net
|
||||
|
||||
return JsonResponse({
|
||||
'eligible': eligible,
|
||||
'skipped': skipped,
|
||||
'total_amount': float(total_amount),
|
||||
'worker_count': len(eligible),
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === BATCH PAY (PROCESS) ===
|
||||
# POST endpoint — processes payments for multiple workers at once.
|
||||
# Each worker gets their own atomic transaction and payslip email.
|
||||
# =============================================================================
|
||||
|
||||
@login_required
|
||||
def batch_pay(request):
|
||||
"""Process batch payments for multiple workers using their team pay schedules."""
|
||||
if request.method != 'POST':
|
||||
return redirect('payroll_dashboard')
|
||||
if not is_admin(request.user):
|
||||
return HttpResponseForbidden("Not authorized.")
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
messages.error(request, 'Invalid request data.')
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
workers_to_pay = body.get('workers', [])
|
||||
if not workers_to_pay:
|
||||
messages.warning(request, 'No workers selected for batch payment.')
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
# === PROCESS EACH WORKER ===
|
||||
# Each worker gets their own atomic transaction (independent row locks).
|
||||
# This means if one worker fails, others still succeed.
|
||||
paid_count = 0
|
||||
paid_total = Decimal('0.00')
|
||||
errors = []
|
||||
email_queue = [] # Collect payslip data for emails (sent after all payments)
|
||||
|
||||
for entry in workers_to_pay:
|
||||
worker_id = entry.get('worker_id')
|
||||
log_ids = entry.get('log_ids', [])
|
||||
adj_ids = entry.get('adj_ids', [])
|
||||
|
||||
try:
|
||||
worker = Worker.objects.get(id=worker_id, active=True)
|
||||
except Worker.DoesNotExist:
|
||||
errors.append(f'Worker ID {worker_id} not found or inactive.')
|
||||
continue
|
||||
|
||||
result = _process_single_payment(
|
||||
worker_id,
|
||||
selected_log_ids=log_ids or None,
|
||||
selected_adj_ids=adj_ids or None,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
continue # Nothing to pay — silently skip
|
||||
|
||||
payroll_record, log_count, logs_amount = result
|
||||
paid_count += 1
|
||||
paid_total += payroll_record.amount_paid
|
||||
email_queue.append((worker, payroll_record, log_count, logs_amount))
|
||||
|
||||
# === SEND PAYSLIP EMAILS (outside all transactions) ===
|
||||
# If an email fails, the payment is still saved — same pattern as individual pay.
|
||||
email_failures = 0
|
||||
for worker, pr, lc, la in email_queue:
|
||||
try:
|
||||
_send_payslip_email(request, worker, pr, lc, la, suppress_messages=True)
|
||||
except Exception:
|
||||
email_failures += 1
|
||||
|
||||
# === SUMMARY MESSAGE ===
|
||||
if paid_count > 0:
|
||||
msg = f'Batch payment complete: {paid_count} worker(s) paid, total R {paid_total:,.2f}.'
|
||||
if email_failures:
|
||||
msg += f' ({email_failures} email(s) failed to send.)'
|
||||
messages.success(request, msg)
|
||||
|
||||
for err in errors:
|
||||
messages.warning(request, err)
|
||||
|
||||
if paid_count == 0 and not errors:
|
||||
messages.info(request, 'No payments were processed — all workers already paid or had zero/negative net pay.')
|
||||
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === PRICE OVERTIME ===
|
||||
@ -1810,10 +2034,18 @@ def preview_payslip(request, worker_id):
|
||||
# current period boundaries so the "Split at Pay Date" button can work.
|
||||
team = get_worker_active_team(worker)
|
||||
period_start, period_end = get_pay_period(team)
|
||||
|
||||
# cutoff_date = last day of the most recently COMPLETED pay period.
|
||||
# All unpaid logs on or before this date are "due" for payment.
|
||||
# E.g., fortnightly periods ending Mar 14, Mar 28, Apr 11...
|
||||
# If today is Mar 20, cutoff_date = Mar 14 (pay everything through last completed period).
|
||||
cutoff_date = (period_start - datetime.timedelta(days=1)) if period_start else None
|
||||
|
||||
pay_period = {
|
||||
'has_schedule': period_start is not None,
|
||||
'start': period_start.strftime('%Y-%m-%d') if period_start else None,
|
||||
'end': period_end.strftime('%Y-%m-%d') if period_end else None,
|
||||
'cutoff_date': cutoff_date.strftime('%Y-%m-%d') if cutoff_date else None,
|
||||
'frequency': team.pay_frequency if team else None,
|
||||
'team_name': team.name if team else None,
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user