Add split payslip feature with team pay schedules
Enable selective payment of work logs and adjustments instead of all-or-nothing. The preview modal now shows checkboxes on every item (all checked by default) with dynamic net pay recalculation. Teams can be configured with a pay frequency (weekly/fortnightly/monthly) and anchor start date. When set, a "Split at Pay Date" button appears that auto-unchecks items outside the current pay period. Key changes: - Team model: add pay_frequency and pay_start_date fields - preview_payslip: return IDs, dates, and pay period info in JSON - process_payment: accept optional selected_log_ids/selected_adj_ids - Preview modal JS: checkboxes, recalcNetPay(), Split button, Pay Selected - Backward compatible: existing Pay button still processes everything Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
44a0030c46
commit
409e7bfd57
@ -25,8 +25,9 @@ class WorkerAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Team)
|
@admin.register(Team)
|
||||||
class TeamAdmin(admin.ModelAdmin):
|
class TeamAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'supervisor', 'active')
|
list_display = ('name', 'supervisor', 'pay_frequency', 'pay_start_date', 'active')
|
||||||
list_filter = ('active', 'supervisor')
|
list_editable = ('pay_frequency', 'pay_start_date')
|
||||||
|
list_filter = ('active', 'supervisor', 'pay_frequency')
|
||||||
search_fields = ('name',)
|
search_fields = ('name',)
|
||||||
filter_horizontal = ('workers',)
|
filter_horizontal = ('workers',)
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-03-24 18:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0004_add_loan_type_and_advance_repayment'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='pay_frequency',
|
||||||
|
field=models.CharField(blank=True, choices=[('weekly', 'Weekly'), ('fortnightly', 'Fortnightly'), ('monthly', 'Monthly')], default='', max_length=15),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='pay_start_date',
|
||||||
|
field=models.DateField(blank=True, help_text='Anchor date for first pay period', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -53,11 +53,27 @@ class Worker(models.Model):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class Team(models.Model):
|
class Team(models.Model):
|
||||||
|
# === PAY FREQUENCY CHOICES ===
|
||||||
|
# Used for the team's recurring pay schedule (weekly, fortnightly, or monthly)
|
||||||
|
PAY_FREQUENCY_CHOICES = [
|
||||||
|
('weekly', 'Weekly'),
|
||||||
|
('fortnightly', 'Fortnightly'),
|
||||||
|
('monthly', 'Monthly'),
|
||||||
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
workers = models.ManyToManyField(Worker, related_name='teams')
|
workers = models.ManyToManyField(Worker, related_name='teams')
|
||||||
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='supervised_teams')
|
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='supervised_teams')
|
||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
# === PAY SCHEDULE ===
|
||||||
|
# These two fields define when the team gets paid.
|
||||||
|
# pay_start_date is the anchor — the first day of the very first pay period.
|
||||||
|
# pay_frequency determines the length of each recurring period.
|
||||||
|
# Both are optional — teams without a schedule work as before.
|
||||||
|
pay_frequency = models.CharField(max_length=15, choices=PAY_FREQUENCY_CHOICES, blank=True, default='')
|
||||||
|
pay_start_date = models.DateField(blank=True, null=True, help_text='Anchor date for first pay period')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|||||||
@ -273,7 +273,8 @@
|
|||||||
<form method="POST" action="{% url 'process_payment' wd.worker.id %}"
|
<form method="POST" action="{% url 'process_payment' wd.worker.id %}"
|
||||||
class="d-inline pay-form">
|
class="d-inline pay-form">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn btn-sm btn-accent">
|
<button type="submit" class="btn btn-sm btn-accent"
|
||||||
|
title="Pay all pending items. Use Preview (eye icon) for selective payment.">
|
||||||
<i class="fas fa-money-bill-wave me-1"></i> Pay
|
<i class="fas fa-money-bill-wave me-1"></i> Pay
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@ -1355,6 +1356,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// PREVIEW PAYSLIP — Fetch JSON and build DOM (no innerHTML for data)
|
// PREVIEW PAYSLIP — Fetch JSON and build DOM (no innerHTML for data)
|
||||||
// Refactored into refreshPreview() so we can re-fetch after adding
|
// Refactored into refreshPreview() so we can re-fetch after adding
|
||||||
// a repayment from the inline form inside the modal.
|
// a repayment from the inline form inside the modal.
|
||||||
|
// Now also supports SPLIT PAYSLIP — checkboxes on logs and adjustments,
|
||||||
|
// a "Split at Pay Date" button, and a "Pay Selected" submit button.
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
// Helper: fetch preview data and build the modal content
|
// Helper: fetch preview data and build the modal content
|
||||||
@ -1385,6 +1388,49 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// === Build payslip preview using DOM methods ===
|
// === Build payslip preview using DOM methods ===
|
||||||
|
|
||||||
|
// -------------------------------------------------------
|
||||||
|
// We'll store references to elements that recalcNetPay()
|
||||||
|
// needs to update when checkboxes change.
|
||||||
|
// -------------------------------------------------------
|
||||||
|
var earningsLabel, earningsVal, adjTotalVal, netVal, negWarnDiv;
|
||||||
|
|
||||||
|
// === RECALCULATE NET PAY ===
|
||||||
|
// Called whenever a log or adjustment checkbox changes.
|
||||||
|
// Counts checked items and recalculates the displayed totals.
|
||||||
|
function recalcNetPay() {
|
||||||
|
var checkedLogs = modalBody.querySelectorAll('.log-checkbox:checked');
|
||||||
|
var logTotal = checkedLogs.length * data.day_rate;
|
||||||
|
|
||||||
|
var adjSum = 0;
|
||||||
|
modalBody.querySelectorAll('.adj-checkbox:checked').forEach(function(cb) {
|
||||||
|
var amt = parseFloat(cb.dataset.adjAmount);
|
||||||
|
adjSum += (cb.dataset.adjSign === '+') ? amt : -amt;
|
||||||
|
});
|
||||||
|
|
||||||
|
var net = logTotal + adjSum;
|
||||||
|
|
||||||
|
// Update the earnings line
|
||||||
|
if (earningsLabel) earningsLabel.textContent = checkedLogs.length + ' day(s) \u00d7 ' + fmt(data.day_rate);
|
||||||
|
if (earningsVal) earningsVal.textContent = fmt(logTotal);
|
||||||
|
|
||||||
|
// Update adjustment total
|
||||||
|
if (adjTotalVal) {
|
||||||
|
adjTotalVal.textContent = (adjSum >= 0 ? '+' : '') + fmt(adjSum);
|
||||||
|
adjTotalVal.className = adjSum >= 0 ? 'text-success' : 'text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update net pay
|
||||||
|
if (netVal) {
|
||||||
|
netVal.textContent = fmt(net);
|
||||||
|
netVal.className = 'fw-bold ' + (net >= 0 ? 'text-success' : 'text-danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show or hide the negative pay warning
|
||||||
|
if (negWarnDiv) {
|
||||||
|
negWarnDiv.style.display = net < 0 ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Worker header
|
// Worker header
|
||||||
var header = document.createElement('div');
|
var header = document.createElement('div');
|
||||||
header.className = 'border-bottom pb-3 mb-3';
|
header.className = 'border-bottom pb-3 mb-3';
|
||||||
@ -1400,7 +1446,116 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
modalBody.appendChild(header);
|
modalBody.appendChild(header);
|
||||||
|
|
||||||
// Earnings section
|
// =============================================================
|
||||||
|
// WORK LOGS WITH CHECKBOXES — select which days to pay
|
||||||
|
// =============================================================
|
||||||
|
if (data.logs && data.logs.length > 0) {
|
||||||
|
|
||||||
|
// --- "Split at Pay Date" button (only if team has a schedule) ---
|
||||||
|
if (data.pay_period && data.pay_period.has_schedule) {
|
||||||
|
var periodInfo = document.createElement('div');
|
||||||
|
periodInfo.className = 'alert alert-info py-2 px-3 small mb-2';
|
||||||
|
periodInfo.style.backgroundColor = '#e0f2fe';
|
||||||
|
periodInfo.style.borderColor = '#7dd3fc';
|
||||||
|
periodInfo.style.color = '#0c4a6e';
|
||||||
|
|
||||||
|
var infoIcon = document.createElement('i');
|
||||||
|
infoIcon.className = 'fas fa-calendar-alt me-2';
|
||||||
|
periodInfo.appendChild(infoIcon);
|
||||||
|
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
|
||||||
|
));
|
||||||
|
modalBody.appendChild(periodInfo);
|
||||||
|
|
||||||
|
var splitBtn = document.createElement('button');
|
||||||
|
splitBtn.type = 'button';
|
||||||
|
splitBtn.className = 'btn btn-sm btn-outline-warning mb-3';
|
||||||
|
var splitIcon = document.createElement('i');
|
||||||
|
splitIcon.className = 'fas fa-cut me-1';
|
||||||
|
splitBtn.appendChild(splitIcon);
|
||||||
|
splitBtn.appendChild(document.createTextNode(
|
||||||
|
'Split at Pay Date (' + data.pay_period.end + ')'
|
||||||
|
));
|
||||||
|
splitBtn.addEventListener('click', function() {
|
||||||
|
var periodEnd = data.pay_period.end;
|
||||||
|
// Uncheck logs after the pay period end date
|
||||||
|
modalBody.querySelectorAll('.log-checkbox').forEach(function(cb) {
|
||||||
|
cb.checked = (cb.dataset.logDate <= periodEnd);
|
||||||
|
});
|
||||||
|
// Uncheck adjustments after the pay period end date
|
||||||
|
modalBody.querySelectorAll('.adj-checkbox').forEach(function(cb) {
|
||||||
|
cb.checked = (cb.dataset.adjDate <= periodEnd);
|
||||||
|
});
|
||||||
|
recalcNetPay();
|
||||||
|
});
|
||||||
|
modalBody.appendChild(splitBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
var logsH6 = document.createElement('h6');
|
||||||
|
logsH6.className = 'fw-bold mb-2';
|
||||||
|
logsH6.textContent = 'Work Logs';
|
||||||
|
modalBody.appendChild(logsH6);
|
||||||
|
|
||||||
|
var table = document.createElement('table');
|
||||||
|
table.className = 'table table-sm mb-2';
|
||||||
|
var thead = document.createElement('thead');
|
||||||
|
var headRow = document.createElement('tr');
|
||||||
|
|
||||||
|
// "Select All" checkbox in header
|
||||||
|
var thCb = document.createElement('th');
|
||||||
|
thCb.style.width = '35px';
|
||||||
|
var selectAllLog = document.createElement('input');
|
||||||
|
selectAllLog.type = 'checkbox';
|
||||||
|
selectAllLog.className = 'form-check-input';
|
||||||
|
selectAllLog.checked = true;
|
||||||
|
selectAllLog.title = 'Select All / None';
|
||||||
|
selectAllLog.addEventListener('change', function() {
|
||||||
|
var checked = selectAllLog.checked;
|
||||||
|
modalBody.querySelectorAll('.log-checkbox').forEach(function(cb) {
|
||||||
|
cb.checked = checked;
|
||||||
|
});
|
||||||
|
recalcNetPay();
|
||||||
|
});
|
||||||
|
thCb.appendChild(selectAllLog);
|
||||||
|
headRow.appendChild(thCb);
|
||||||
|
|
||||||
|
['Date', 'Project'].forEach(function(h) {
|
||||||
|
var th = document.createElement('th');
|
||||||
|
th.textContent = h;
|
||||||
|
headRow.appendChild(th);
|
||||||
|
});
|
||||||
|
thead.appendChild(headRow);
|
||||||
|
table.appendChild(thead);
|
||||||
|
|
||||||
|
var tbody = document.createElement('tbody');
|
||||||
|
data.logs.forEach(function(log) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
|
||||||
|
// Checkbox cell
|
||||||
|
var cbTd = document.createElement('td');
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.className = 'form-check-input log-checkbox';
|
||||||
|
cb.checked = true;
|
||||||
|
cb.dataset.logId = log.id;
|
||||||
|
cb.dataset.logDate = log.date;
|
||||||
|
cb.addEventListener('change', recalcNetPay);
|
||||||
|
cbTd.appendChild(cb);
|
||||||
|
tr.appendChild(cbTd);
|
||||||
|
|
||||||
|
tr.appendChild(createTd(log.date));
|
||||||
|
tr.appendChild(createTd(log.project));
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
table.appendChild(tbody);
|
||||||
|
modalBody.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// EARNINGS SUMMARY — updates dynamically when checkboxes change
|
||||||
|
// =============================================================
|
||||||
var earningsH6 = document.createElement('h6');
|
var earningsH6 = document.createElement('h6');
|
||||||
earningsH6.className = 'fw-bold mb-2';
|
earningsH6.className = 'fw-bold mb-2';
|
||||||
earningsH6.textContent = 'Earnings';
|
earningsH6.textContent = 'Earnings';
|
||||||
@ -1408,27 +1563,69 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
var earningsRow = document.createElement('div');
|
var earningsRow = document.createElement('div');
|
||||||
earningsRow.className = 'd-flex justify-content-between mb-1';
|
earningsRow.className = 'd-flex justify-content-between mb-1';
|
||||||
var earningsLabel = document.createElement('span');
|
earningsLabel = document.createElement('span');
|
||||||
earningsLabel.textContent = data.days_worked + ' day(s) \u00d7 ' + fmt(data.day_rate);
|
earningsLabel.textContent = data.days_worked + ' day(s) \u00d7 ' + fmt(data.day_rate);
|
||||||
earningsRow.appendChild(earningsLabel);
|
earningsRow.appendChild(earningsLabel);
|
||||||
var earningsVal = document.createElement('strong');
|
earningsVal = document.createElement('strong');
|
||||||
earningsVal.textContent = fmt(data.log_amount);
|
earningsVal.textContent = fmt(data.log_amount);
|
||||||
earningsRow.appendChild(earningsVal);
|
earningsRow.appendChild(earningsVal);
|
||||||
modalBody.appendChild(earningsRow);
|
modalBody.appendChild(earningsRow);
|
||||||
|
|
||||||
// Adjustments section
|
// =============================================================
|
||||||
|
// ADJUSTMENTS WITH CHECKBOXES — select which to include
|
||||||
|
// =============================================================
|
||||||
if (data.adjustments && data.adjustments.length > 0) {
|
if (data.adjustments && data.adjustments.length > 0) {
|
||||||
var adjHr = document.createElement('hr');
|
var adjHr = document.createElement('hr');
|
||||||
modalBody.appendChild(adjHr);
|
modalBody.appendChild(adjHr);
|
||||||
|
|
||||||
var adjH6 = document.createElement('h6');
|
var adjH6 = document.createElement('h6');
|
||||||
adjH6.className = 'fw-bold mb-2';
|
adjH6.className = 'fw-bold mb-2 d-flex justify-content-between align-items-center';
|
||||||
adjH6.textContent = 'Adjustments';
|
|
||||||
|
var adjTitle = document.createElement('span');
|
||||||
|
adjTitle.textContent = 'Adjustments';
|
||||||
|
adjH6.appendChild(adjTitle);
|
||||||
|
|
||||||
|
// "Select All" checkbox for adjustments
|
||||||
|
var adjSelectAllWrap = document.createElement('span');
|
||||||
|
adjSelectAllWrap.className = 'small fw-normal text-muted';
|
||||||
|
var selectAllAdj = document.createElement('input');
|
||||||
|
selectAllAdj.type = 'checkbox';
|
||||||
|
selectAllAdj.className = 'form-check-input me-1';
|
||||||
|
selectAllAdj.checked = true;
|
||||||
|
selectAllAdj.title = 'Select All / None';
|
||||||
|
selectAllAdj.addEventListener('change', function() {
|
||||||
|
var checked = selectAllAdj.checked;
|
||||||
|
modalBody.querySelectorAll('.adj-checkbox').forEach(function(cb) {
|
||||||
|
cb.checked = checked;
|
||||||
|
});
|
||||||
|
recalcNetPay();
|
||||||
|
});
|
||||||
|
adjSelectAllWrap.appendChild(selectAllAdj);
|
||||||
|
adjSelectAllWrap.appendChild(document.createTextNode('All'));
|
||||||
|
adjH6.appendChild(adjSelectAllWrap);
|
||||||
|
|
||||||
modalBody.appendChild(adjH6);
|
modalBody.appendChild(adjH6);
|
||||||
|
|
||||||
data.adjustments.forEach(function(adj) {
|
data.adjustments.forEach(function(adj) {
|
||||||
var row = document.createElement('div');
|
var row = document.createElement('div');
|
||||||
row.className = 'd-flex justify-content-between mb-1';
|
row.className = 'd-flex align-items-center mb-1';
|
||||||
|
|
||||||
|
// Checkbox
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.className = 'form-check-input adj-checkbox me-2';
|
||||||
|
cb.checked = true;
|
||||||
|
cb.dataset.adjId = adj.id;
|
||||||
|
cb.dataset.adjDate = adj.date;
|
||||||
|
cb.dataset.adjAmount = adj.amount;
|
||||||
|
cb.dataset.adjSign = adj.sign;
|
||||||
|
cb.addEventListener('change', recalcNetPay);
|
||||||
|
row.appendChild(cb);
|
||||||
|
|
||||||
|
// Label + value wrapper (fills remaining space)
|
||||||
|
var labelWrap = document.createElement('div');
|
||||||
|
labelWrap.className = 'd-flex justify-content-between flex-grow-1';
|
||||||
|
|
||||||
var label = document.createElement('span');
|
var label = document.createElement('span');
|
||||||
label.textContent = adj.type + (adj.project ? ' (' + adj.project + ')' : '');
|
label.textContent = adj.type + (adj.project ? ' (' + adj.project + ')' : '');
|
||||||
if (adj.description) {
|
if (adj.description) {
|
||||||
@ -1437,16 +1634,34 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
descSmall.textContent = '\u2014 ' + adj.description;
|
descSmall.textContent = '\u2014 ' + adj.description;
|
||||||
label.appendChild(descSmall);
|
label.appendChild(descSmall);
|
||||||
}
|
}
|
||||||
row.appendChild(label);
|
labelWrap.appendChild(label);
|
||||||
|
|
||||||
var val = document.createElement('span');
|
var val = document.createElement('span');
|
||||||
val.className = adj.sign === '+' ? 'text-success' : 'text-danger';
|
val.className = adj.sign === '+' ? 'text-success' : 'text-danger';
|
||||||
val.textContent = adj.sign + fmt(adj.amount);
|
val.textContent = adj.sign + fmt(adj.amount);
|
||||||
row.appendChild(val);
|
labelWrap.appendChild(val);
|
||||||
|
|
||||||
|
row.appendChild(labelWrap);
|
||||||
modalBody.appendChild(row);
|
modalBody.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Adjustment total line
|
||||||
|
var adjTotalRow = document.createElement('div');
|
||||||
|
adjTotalRow.className = 'd-flex justify-content-between mt-1 pt-1 border-top';
|
||||||
|
var adjTotalLabel = document.createElement('small');
|
||||||
|
adjTotalLabel.className = 'text-muted';
|
||||||
|
adjTotalLabel.textContent = 'Net adjustments';
|
||||||
|
adjTotalRow.appendChild(adjTotalLabel);
|
||||||
|
adjTotalVal = document.createElement('small');
|
||||||
|
adjTotalVal.className = data.adj_total >= 0 ? 'text-success' : 'text-danger';
|
||||||
|
adjTotalVal.textContent = (data.adj_total >= 0 ? '+' : '') + fmt(data.adj_total);
|
||||||
|
adjTotalRow.appendChild(adjTotalVal);
|
||||||
|
modalBody.appendChild(adjTotalRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Net pay
|
// =============================================================
|
||||||
|
// NET PAY — updates dynamically
|
||||||
|
// =============================================================
|
||||||
var netHr = document.createElement('hr');
|
var netHr = document.createElement('hr');
|
||||||
netHr.className = 'my-3';
|
netHr.className = 'my-3';
|
||||||
modalBody.appendChild(netHr);
|
modalBody.appendChild(netHr);
|
||||||
@ -1457,27 +1672,96 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
netLabel.className = 'fw-bold';
|
netLabel.className = 'fw-bold';
|
||||||
netLabel.textContent = 'Net Pay';
|
netLabel.textContent = 'Net Pay';
|
||||||
netRow.appendChild(netLabel);
|
netRow.appendChild(netLabel);
|
||||||
var netVal = document.createElement('h5');
|
netVal = document.createElement('h5');
|
||||||
netVal.className = 'fw-bold ' + (data.net_pay >= 0 ? 'text-success' : 'text-danger');
|
netVal.className = 'fw-bold ' + (data.net_pay >= 0 ? 'text-success' : 'text-danger');
|
||||||
netVal.textContent = fmt(data.net_pay);
|
netVal.textContent = fmt(data.net_pay);
|
||||||
netRow.appendChild(netVal);
|
netRow.appendChild(netVal);
|
||||||
modalBody.appendChild(netRow);
|
modalBody.appendChild(netRow);
|
||||||
|
|
||||||
// === NEGATIVE NET PAY WARNING ===
|
// === NEGATIVE NET PAY WARNING ===
|
||||||
// If net pay is negative (e.g., advance repayment exceeds earnings),
|
negWarnDiv = document.createElement('div');
|
||||||
// show a warning so the admin can edit the repayment before paying.
|
negWarnDiv.className = 'alert alert-warning py-2 px-3 small mt-2 mb-0';
|
||||||
if (data.net_pay < 0) {
|
negWarnDiv.style.display = data.net_pay < 0 ? '' : 'none';
|
||||||
var warnDiv = document.createElement('div');
|
var warnIcon = document.createElement('i');
|
||||||
warnDiv.className = 'alert alert-warning py-2 px-3 small mt-2 mb-0';
|
warnIcon.className = 'fas fa-exclamation-triangle me-2';
|
||||||
var warnIcon = document.createElement('i');
|
negWarnDiv.appendChild(warnIcon);
|
||||||
warnIcon.className = 'fas fa-exclamation-triangle me-2';
|
negWarnDiv.appendChild(document.createTextNode(
|
||||||
warnDiv.appendChild(warnIcon);
|
'Net pay is negative. Consider editing the Advance Repayment ' +
|
||||||
warnDiv.appendChild(document.createTextNode(
|
'amount on the Pending Payments table before processing.'
|
||||||
'Net pay is negative. Consider editing the Advance Repayment ' +
|
));
|
||||||
'amount on the Pending Payments table before processing.'
|
modalBody.appendChild(negWarnDiv);
|
||||||
));
|
|
||||||
modalBody.appendChild(warnDiv);
|
// =============================================================
|
||||||
}
|
// PAY SELECTED BUTTON — submits only checked logs/adjustments
|
||||||
|
// =============================================================
|
||||||
|
var payBtn = document.createElement('button');
|
||||||
|
payBtn.type = 'button';
|
||||||
|
payBtn.className = 'btn btn-accent w-100 mt-3';
|
||||||
|
var payIcon = document.createElement('i');
|
||||||
|
payIcon.className = 'fas fa-money-bill-wave me-2';
|
||||||
|
payBtn.appendChild(payIcon);
|
||||||
|
payBtn.appendChild(document.createTextNode('Pay Selected'));
|
||||||
|
|
||||||
|
payBtn.addEventListener('click', function() {
|
||||||
|
// Gather checked log IDs
|
||||||
|
var logIds = [];
|
||||||
|
modalBody.querySelectorAll('.log-checkbox:checked').forEach(function(cb) {
|
||||||
|
logIds.push(cb.dataset.logId);
|
||||||
|
});
|
||||||
|
// Gather checked adjustment IDs
|
||||||
|
var adjIds = [];
|
||||||
|
modalBody.querySelectorAll('.adj-checkbox:checked').forEach(function(cb) {
|
||||||
|
adjIds.push(cb.dataset.adjId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logIds.length === 0 && adjIds.length === 0) {
|
||||||
|
alert('Nothing selected to pay.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a hidden form and submit it
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '/payroll/pay/' + data.worker_id + '/';
|
||||||
|
|
||||||
|
// CSRF token
|
||||||
|
var csrf = document.createElement('input');
|
||||||
|
csrf.type = 'hidden';
|
||||||
|
csrf.name = 'csrfmiddlewaretoken';
|
||||||
|
csrf.value = '{{ csrf_token }}';
|
||||||
|
form.appendChild(csrf);
|
||||||
|
|
||||||
|
// Selected log IDs
|
||||||
|
logIds.forEach(function(lid) {
|
||||||
|
var inp = document.createElement('input');
|
||||||
|
inp.type = 'hidden';
|
||||||
|
inp.name = 'selected_log_ids';
|
||||||
|
inp.value = lid;
|
||||||
|
form.appendChild(inp);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Selected adjustment IDs
|
||||||
|
adjIds.forEach(function(aid) {
|
||||||
|
var inp = document.createElement('input');
|
||||||
|
inp.type = 'hidden';
|
||||||
|
inp.name = 'selected_adj_ids';
|
||||||
|
inp.value = aid;
|
||||||
|
form.appendChild(inp);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
|
||||||
|
// Prevent double-click
|
||||||
|
payBtn.disabled = true;
|
||||||
|
payBtn.textContent = '';
|
||||||
|
var procSpinner = document.createElement('span');
|
||||||
|
procSpinner.className = 'spinner-border spinner-border-sm me-2';
|
||||||
|
payBtn.appendChild(procSpinner);
|
||||||
|
payBtn.appendChild(document.createTextNode('Processing...'));
|
||||||
|
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
modalBody.appendChild(payBtn);
|
||||||
|
|
||||||
// =============================================================
|
// =============================================================
|
||||||
// OUTSTANDING LOANS & ADVANCES — shows active balances with
|
// OUTSTANDING LOANS & ADVANCES — shows active balances with
|
||||||
@ -1628,39 +1912,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
modalBody.appendChild(card);
|
modalBody.appendChild(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Work log details
|
|
||||||
if (data.logs && data.logs.length > 0) {
|
|
||||||
var wlHr = document.createElement('hr');
|
|
||||||
modalBody.appendChild(wlHr);
|
|
||||||
|
|
||||||
var logsH6b = document.createElement('h6');
|
|
||||||
logsH6b.className = 'fw-bold mb-2';
|
|
||||||
logsH6b.textContent = 'Work Log Details';
|
|
||||||
modalBody.appendChild(logsH6b);
|
|
||||||
|
|
||||||
var table = document.createElement('table');
|
|
||||||
table.className = 'table table-sm mb-0';
|
|
||||||
var thead = document.createElement('thead');
|
|
||||||
var headRow = document.createElement('tr');
|
|
||||||
['Date', 'Project'].forEach(function(h) {
|
|
||||||
var th = document.createElement('th');
|
|
||||||
th.textContent = h;
|
|
||||||
headRow.appendChild(th);
|
|
||||||
});
|
|
||||||
thead.appendChild(headRow);
|
|
||||||
table.appendChild(thead);
|
|
||||||
|
|
||||||
var tbody = document.createElement('tbody');
|
|
||||||
data.logs.forEach(function(log) {
|
|
||||||
var tr = document.createElement('tr');
|
|
||||||
tr.appendChild(createTd(log.date));
|
|
||||||
tr.appendChild(createTd(log.project));
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
|
||||||
table.appendChild(tbody);
|
|
||||||
modalBody.appendChild(table);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.catch(function() {
|
.catch(function() {
|
||||||
// Show error (safe hardcoded content)
|
// Show error (safe hardcoded content)
|
||||||
|
|||||||
123
core/views.py
123
core/views.py
@ -60,6 +60,77 @@ def is_staff_or_supervisor(user):
|
|||||||
return is_admin(user) or is_supervisor(user)
|
return is_admin(user) or is_supervisor(user)
|
||||||
|
|
||||||
|
|
||||||
|
# === PAY SCHEDULE HELPERS ===
|
||||||
|
# These help figure out a worker's pay period based on their team's schedule.
|
||||||
|
|
||||||
|
def get_worker_active_team(worker):
|
||||||
|
"""Return the worker's active team (first one found), or None."""
|
||||||
|
return worker.teams.filter(active=True).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_pay_period(team, reference_date=None):
|
||||||
|
"""
|
||||||
|
Calculate the current pay period's start and end dates for a team.
|
||||||
|
|
||||||
|
Returns (period_start, period_end) or (None, None) if the team has
|
||||||
|
no pay schedule configured.
|
||||||
|
|
||||||
|
How it works:
|
||||||
|
- pay_start_date is the "anchor" — the first day of the very first pay period.
|
||||||
|
- pay_frequency determines the length of each period (7, 14, or ~30 days).
|
||||||
|
- We step forward from the anchor in period-length increments until
|
||||||
|
we find the period that contains reference_date (today by default).
|
||||||
|
"""
|
||||||
|
if not team or not team.pay_frequency or not team.pay_start_date:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
if reference_date is None:
|
||||||
|
reference_date = timezone.now().date()
|
||||||
|
|
||||||
|
anchor = team.pay_start_date
|
||||||
|
|
||||||
|
# === WEEKLY / FORTNIGHTLY ===
|
||||||
|
# Simple fixed-length periods (7 or 14 days).
|
||||||
|
if team.pay_frequency in ('weekly', 'fortnightly'):
|
||||||
|
period_days = 7 if team.pay_frequency == 'weekly' else 14
|
||||||
|
|
||||||
|
# How many full periods have passed since the anchor?
|
||||||
|
days_since_anchor = (reference_date - anchor).days
|
||||||
|
if days_since_anchor < 0:
|
||||||
|
# reference_date is before the anchor — use the first period
|
||||||
|
return (anchor, anchor + datetime.timedelta(days=period_days - 1))
|
||||||
|
|
||||||
|
periods_passed = days_since_anchor // period_days
|
||||||
|
period_start = anchor + datetime.timedelta(days=periods_passed * period_days)
|
||||||
|
period_end = period_start + datetime.timedelta(days=period_days - 1)
|
||||||
|
return (period_start, period_end)
|
||||||
|
|
||||||
|
# === MONTHLY ===
|
||||||
|
# Step through calendar months from the anchor's day-of-month.
|
||||||
|
# E.g., anchor = Jan 15 means periods are: Jan 15–Feb 14, Feb 15–Mar 14, etc.
|
||||||
|
elif team.pay_frequency == 'monthly':
|
||||||
|
anchor_day = anchor.day
|
||||||
|
current_start = anchor
|
||||||
|
|
||||||
|
# Walk forward month by month until we find the period containing today
|
||||||
|
for _ in range(120): # Safety limit — 10 years of months
|
||||||
|
if current_start.month == 12:
|
||||||
|
next_month, next_year = 1, current_start.year + 1
|
||||||
|
else:
|
||||||
|
next_month, next_year = current_start.month + 1, current_start.year
|
||||||
|
|
||||||
|
# Clamp anchor day to the max days in that month (e.g., 31 → 28 for Feb)
|
||||||
|
max_day = cal_module.monthrange(next_year, next_month)[1]
|
||||||
|
next_start = datetime.date(next_year, next_month, min(anchor_day, max_day))
|
||||||
|
current_end = next_start - datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
if reference_date <= current_end:
|
||||||
|
return (current_start, current_end)
|
||||||
|
current_start = next_start
|
||||||
|
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
# === HOME DASHBOARD ===
|
# === HOME DASHBOARD ===
|
||||||
# The main page users see after logging in. Shows different content
|
# The main page users see after logging in. Shows different content
|
||||||
# depending on whether the user is an admin or supervisor.
|
# depending on whether the user is an admin or supervisor.
|
||||||
@ -1121,16 +1192,36 @@ def process_payment(request, worker_id):
|
|||||||
# will wait here until this transaction commits.
|
# 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 ===
|
||||||
|
# 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
|
# Find unpaid logs for this worker (inside the lock, so this
|
||||||
# result is guaranteed to be up-to-date)
|
# result is guaranteed to be up-to-date)
|
||||||
unpaid_logs = worker.work_logs.exclude(
|
all_unpaid_logs = worker.work_logs.exclude(
|
||||||
payroll_records__worker=worker
|
payroll_records__worker=worker
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If specific logs were selected, only pay those
|
||||||
|
if selected_log_ids:
|
||||||
|
unpaid_logs = all_unpaid_logs.filter(id__in=selected_log_ids)
|
||||||
|
else:
|
||||||
|
unpaid_logs = all_unpaid_logs
|
||||||
|
|
||||||
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
|
# Find pending adjustments — filter to selected if IDs provided
|
||||||
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:
|
||||||
|
selected_adj_set = set(selected_adj_ids)
|
||||||
|
pending_adjs = [a for a in all_pending_adjs if a.id in selected_adj_set]
|
||||||
|
else:
|
||||||
|
pending_adjs = all_pending_adjs
|
||||||
|
|
||||||
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
|
# Nothing to pay — either everything is already paid (duplicate
|
||||||
@ -1660,20 +1751,25 @@ def preview_payslip(request, worker_id):
|
|||||||
|
|
||||||
worker = get_object_or_404(Worker, id=worker_id)
|
worker = get_object_or_404(Worker, id=worker_id)
|
||||||
|
|
||||||
# Find unpaid logs
|
# Find unpaid logs — include the log ID so the frontend can send
|
||||||
|
# selected IDs back for split payslip (selective payment).
|
||||||
unpaid_logs = []
|
unpaid_logs = []
|
||||||
for log in worker.work_logs.select_related('project').prefetch_related('payroll_records').all():
|
for log in worker.work_logs.select_related('project').prefetch_related('payroll_records').all():
|
||||||
paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
paid_worker_ids = {pr.worker_id for pr in log.payroll_records.all()}
|
||||||
if worker.id not in paid_worker_ids:
|
if worker.id not in paid_worker_ids:
|
||||||
unpaid_logs.append({
|
unpaid_logs.append({
|
||||||
|
'id': log.id,
|
||||||
'date': log.date.strftime('%Y-%m-%d'),
|
'date': log.date.strftime('%Y-%m-%d'),
|
||||||
'project': log.project.name,
|
'project': log.project.name,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Sort logs by date so the split makes visual sense (oldest first)
|
||||||
|
unpaid_logs.sort(key=lambda x: x['date'])
|
||||||
|
|
||||||
log_count = len(unpaid_logs)
|
log_count = len(unpaid_logs)
|
||||||
log_amount = float(log_count * worker.daily_rate)
|
log_amount = float(log_count * worker.daily_rate)
|
||||||
|
|
||||||
# Find pending adjustments
|
# Find pending adjustments — include ID and date for split payslip
|
||||||
pending_adjs = worker.adjustments.filter(
|
pending_adjs = worker.adjustments.filter(
|
||||||
payroll_record__isnull=True
|
payroll_record__isnull=True
|
||||||
).select_related('project')
|
).select_related('project')
|
||||||
@ -1684,11 +1780,13 @@ def preview_payslip(request, worker_id):
|
|||||||
sign = '+' if adj.type in ADDITIVE_TYPES else '-'
|
sign = '+' if adj.type in ADDITIVE_TYPES else '-'
|
||||||
adj_total += float(adj.amount) if adj.type in ADDITIVE_TYPES else -float(adj.amount)
|
adj_total += float(adj.amount) if adj.type in ADDITIVE_TYPES else -float(adj.amount)
|
||||||
adjustments_list.append({
|
adjustments_list.append({
|
||||||
|
'id': adj.id,
|
||||||
'type': adj.type,
|
'type': adj.type,
|
||||||
'amount': float(adj.amount),
|
'amount': float(adj.amount),
|
||||||
'sign': sign,
|
'sign': sign,
|
||||||
'description': adj.description,
|
'description': adj.description,
|
||||||
'project': adj.project.name if adj.project else '',
|
'project': adj.project.name if adj.project else '',
|
||||||
|
'date': adj.date.strftime('%Y-%m-%d'),
|
||||||
})
|
})
|
||||||
|
|
||||||
# === ACTIVE LOANS & ADVANCES ===
|
# === ACTIVE LOANS & ADVANCES ===
|
||||||
@ -1707,7 +1805,21 @@ def preview_payslip(request, worker_id):
|
|||||||
'reason': loan.reason or '',
|
'reason': loan.reason or '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# === PAY PERIOD INFO ===
|
||||||
|
# If the worker belongs to a team with a pay schedule, include the
|
||||||
|
# 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)
|
||||||
|
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,
|
||||||
|
'frequency': team.pay_frequency if team else None,
|
||||||
|
'team_name': team.name if team else None,
|
||||||
|
}
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
|
'worker_id': worker.id,
|
||||||
'worker_name': worker.name,
|
'worker_name': worker.name,
|
||||||
'worker_id_number': worker.id_number,
|
'worker_id_number': worker.id_number,
|
||||||
'day_rate': float(worker.daily_rate),
|
'day_rate': float(worker.daily_rate),
|
||||||
@ -1718,6 +1830,7 @@ def preview_payslip(request, worker_id):
|
|||||||
'net_pay': log_amount + adj_total,
|
'net_pay': log_amount + adj_total,
|
||||||
'logs': unpaid_logs,
|
'logs': unpaid_logs,
|
||||||
'active_loans': loans_list,
|
'active_loans': loans_list,
|
||||||
|
'pay_period': pay_period,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user