@@ -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
diff --git a/core/urls.py b/core/urls.py
index 658d909..864164d 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -31,6 +31,10 @@ urlpatterns = [
# Process payment — pays a worker and links their unpaid logs + adjustments
path('payroll/pay//', 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'),
diff --git a/core/views.py b/core/views.py
index 60fa514..b25ee0b 100644
--- a/core/views.py
+++ b/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,
}