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">
|
<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>
|
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Payroll Dashboard</h1>
|
||||||
<div class="d-flex gap-2">
|
<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">
|
<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
|
<i class="fas fa-plus fa-sm me-1"></i> Add Adjustment
|
||||||
</button>
|
</button>
|
||||||
@ -678,6 +681,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# --- PREVIEW PAYSLIP MODAL --- #}
|
{# --- 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 fade" id="previewPayslipModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@ -1462,10 +1492,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var infoIcon = document.createElement('i');
|
var infoIcon = document.createElement('i');
|
||||||
infoIcon.className = 'fas fa-calendar-alt me-2';
|
infoIcon.className = 'fas fa-calendar-alt me-2';
|
||||||
periodInfo.appendChild(infoIcon);
|
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(
|
periodInfo.appendChild(document.createTextNode(
|
||||||
data.pay_period.team_name + ' \u2014 ' +
|
data.pay_period.team_name + ' \u2014 ' +
|
||||||
data.pay_period.frequency + ' pay period: ' +
|
data.pay_period.frequency + ' | Current period: ' +
|
||||||
data.pay_period.start + ' to ' + data.pay_period.end
|
data.pay_period.start + ' to ' + data.pay_period.end +
|
||||||
|
' | Pay through: ' + data.pay_period.cutoff_date
|
||||||
));
|
));
|
||||||
modalBody.appendChild(periodInfo);
|
modalBody.appendChild(periodInfo);
|
||||||
|
|
||||||
@ -1476,17 +1509,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
splitIcon.className = 'fas fa-cut me-1';
|
splitIcon.className = 'fas fa-cut me-1';
|
||||||
splitBtn.appendChild(splitIcon);
|
splitBtn.appendChild(splitIcon);
|
||||||
splitBtn.appendChild(document.createTextNode(
|
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() {
|
splitBtn.addEventListener('click', function() {
|
||||||
var periodEnd = data.pay_period.end;
|
// Use cutoff_date (end of last completed period) — includes ALL
|
||||||
// Uncheck logs after the pay period end date
|
// 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) {
|
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) {
|
modalBody.querySelectorAll('.adj-checkbox').forEach(function(cb) {
|
||||||
cb.checked = (cb.dataset.adjDate <= periodEnd);
|
cb.checked = (cb.dataset.adjDate <= cutoff);
|
||||||
});
|
});
|
||||||
recalcNetPay();
|
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
|
}); // end DOMContentLoaded
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,10 @@ urlpatterns = [
|
|||||||
# Process payment — pays a worker and links their unpaid logs + adjustments
|
# Process payment — pays a worker and links their unpaid logs + adjustments
|
||||||
path('payroll/pay/<int:worker_id>/', views.process_payment, name='process_payment'),
|
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
|
# Price overtime — creates Overtime adjustments from unpriced OT entries
|
||||||
path('payroll/price-overtime/', views.price_overtime, name='price_overtime'),
|
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 ===
|
# === SINGLE PAYMENT HELPER ===
|
||||||
# Creates a PayrollRecord for a worker, linking all their unpaid work logs
|
# Core payment logic used by both individual payments and batch payments.
|
||||||
# and applying any pending adjustments. Handles loan repayment deductions.
|
# Locks the worker row, creates a PayrollRecord, links logs/adjustments,
|
||||||
|
# and handles loan repayment deductions — all inside an atomic transaction.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@login_required
|
def _process_single_payment(worker_id, selected_log_ids=None, selected_adj_ids=None):
|
||||||
def process_payment(request, worker_id):
|
"""
|
||||||
if request.method != 'POST':
|
Process payment for one worker inside an atomic transaction.
|
||||||
return redirect('payroll_dashboard')
|
Returns (payroll_record, log_count, logs_amount) on success, or None if nothing to pay.
|
||||||
if not is_admin(request.user):
|
|
||||||
return HttpResponseForbidden("Not authorized.")
|
|
||||||
|
|
||||||
# Validate the worker exists first (returns 404 if not found)
|
- worker_id: the Worker's PK
|
||||||
worker = get_object_or_404(Worker, id=worker_id)
|
- selected_log_ids: list of WorkLog IDs to include (None = all unpaid)
|
||||||
|
- selected_adj_ids: list of PayrollAdjustment IDs to include (None = all pending)
|
||||||
# --- 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.
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Lock this worker's row — any other request for the same worker
|
# Lock this worker's row — any concurrent request for the same
|
||||||
# will wait here until this transaction commits.
|
# worker will wait here until this transaction commits.
|
||||||
worker = Worker.objects.select_for_update().get(id=worker_id)
|
worker = Worker.objects.select_for_update().get(id=worker_id)
|
||||||
|
|
||||||
# === SPLIT PAYSLIP SUPPORT ===
|
# Get unpaid logs, filter to selected if IDs provided
|
||||||
# If the POST includes specific log/adjustment IDs (from the preview
|
all_unpaid_logs = worker.work_logs.exclude(payroll_records__worker=worker)
|
||||||
# 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
|
|
||||||
if selected_log_ids:
|
if selected_log_ids:
|
||||||
unpaid_logs = all_unpaid_logs.filter(id__in=selected_log_ids)
|
unpaid_logs = all_unpaid_logs.filter(id__in=selected_log_ids)
|
||||||
else:
|
else:
|
||||||
@ -1215,7 +1197,7 @@ def process_payment(request, worker_id):
|
|||||||
log_count = unpaid_logs.count()
|
log_count = unpaid_logs.count()
|
||||||
logs_amount = log_count * worker.daily_rate
|
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))
|
all_pending_adjs = list(worker.adjustments.filter(payroll_record__isnull=True))
|
||||||
if selected_adj_ids:
|
if selected_adj_ids:
|
||||||
selected_adj_set = set(selected_adj_ids)
|
selected_adj_set = set(selected_adj_ids)
|
||||||
@ -1223,11 +1205,9 @@ def process_payment(request, worker_id):
|
|||||||
else:
|
else:
|
||||||
pending_adjs = all_pending_adjs
|
pending_adjs = all_pending_adjs
|
||||||
|
|
||||||
|
# Nothing to pay — already paid or nothing owed
|
||||||
if log_count == 0 and not pending_adjs:
|
if log_count == 0 and not pending_adjs:
|
||||||
# Nothing to pay — either everything is already paid (duplicate
|
return None
|
||||||
# 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')
|
|
||||||
|
|
||||||
# Calculate net adjustment
|
# Calculate net adjustment
|
||||||
adj_amount = Decimal('0.00')
|
adj_amount = Decimal('0.00')
|
||||||
@ -1246,10 +1226,10 @@ def process_payment(request, worker_id):
|
|||||||
date=timezone.now().date(),
|
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)
|
payroll_record.work_logs.set(unpaid_logs)
|
||||||
|
|
||||||
# Link all pending adjustments to this payment
|
# Link adjustments + handle loan repayments
|
||||||
for adj in pending_adjs:
|
for adj in pending_adjs:
|
||||||
adj.payroll_record = payroll_record
|
adj.payroll_record = payroll_record
|
||||||
adj.save()
|
adj.save()
|
||||||
@ -1268,6 +1248,45 @@ def process_payment(request, worker_id):
|
|||||||
adj.loan.loan_type = 'loan'
|
adj.loan.loan_type = 'loan'
|
||||||
adj.loan.save()
|
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
|
# 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.)
|
# 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).
|
# 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.
|
Generate and email a payslip for a completed payment.
|
||||||
Called after a PayrollRecord has been created and adjustments linked.
|
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
|
- payroll_record: the PayrollRecord just created
|
||||||
- log_count: number of work logs in this payment (0 for advance-only)
|
- 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)
|
- 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
|
# Lazy import — avoids crashing the app if xhtml2pdf isn't installed
|
||||||
from .utils import render_to_pdf
|
from .utils import render_to_pdf
|
||||||
@ -1351,26 +1371,230 @@ def _send_payslip_email(request, worker, payroll_record, log_count, logs_amount)
|
|||||||
)
|
)
|
||||||
|
|
||||||
email.send()
|
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(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
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,
|
# === BATCH PAY PREVIEW ===
|
||||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}, '
|
# AJAX GET endpoint — dry run showing which workers would be paid and how
|
||||||
f'but email delivery failed: {str(e)}'
|
# much, based on their team's pay schedule. No payments are made here.
|
||||||
)
|
# =============================================================================
|
||||||
else:
|
|
||||||
# No SPARK_RECEIPT_EMAIL configured — just show success
|
@login_required
|
||||||
messages.success(
|
def batch_pay_preview(request):
|
||||||
request,
|
"""Return JSON preview of batch payment — who gets paid and how much."""
|
||||||
f'Payment of R {total_amount:,.2f} processed for {worker.name}. '
|
if not is_admin(request.user):
|
||||||
f'{log_count} work log(s) marked as paid.'
|
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 ===
|
# === PRICE OVERTIME ===
|
||||||
@ -1810,10 +2034,18 @@ def preview_payslip(request, worker_id):
|
|||||||
# current period boundaries so the "Split at Pay Date" button can work.
|
# current period boundaries so the "Split at Pay Date" button can work.
|
||||||
team = get_worker_active_team(worker)
|
team = get_worker_active_team(worker)
|
||||||
period_start, period_end = get_pay_period(team)
|
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 = {
|
pay_period = {
|
||||||
'has_schedule': period_start is not None,
|
'has_schedule': period_start is not None,
|
||||||
'start': period_start.strftime('%Y-%m-%d') if period_start else 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,
|
'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,
|
'frequency': team.pay_frequency if team else None,
|
||||||
'team_name': team.name if team else None,
|
'team_name': team.name if team else None,
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user