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)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'supervisor', 'active')
|
||||
list_filter = ('active', 'supervisor')
|
||||
list_display = ('name', 'supervisor', 'pay_frequency', 'pay_start_date', 'active')
|
||||
list_editable = ('pay_frequency', 'pay_start_date')
|
||||
list_filter = ('active', 'supervisor', 'pay_frequency')
|
||||
search_fields = ('name',)
|
||||
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
|
||||
|
||||
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)
|
||||
workers = models.ManyToManyField(Worker, related_name='teams')
|
||||
supervisor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='supervised_teams')
|
||||
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):
|
||||
return self.name
|
||||
|
||||
|
||||
@ -273,7 +273,8 @@
|
||||
<form method="POST" action="{% url 'process_payment' wd.worker.id %}"
|
||||
class="d-inline pay-form">
|
||||
{% 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
|
||||
</button>
|
||||
</form>
|
||||
@ -1355,6 +1356,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// PREVIEW PAYSLIP — Fetch JSON and build DOM (no innerHTML for data)
|
||||
// Refactored into refreshPreview() so we can re-fetch after adding
|
||||
// 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
|
||||
@ -1385,6 +1388,49 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// === 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
|
||||
var header = document.createElement('div');
|
||||
header.className = 'border-bottom pb-3 mb-3';
|
||||
@ -1400,7 +1446,116 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
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');
|
||||
earningsH6.className = 'fw-bold mb-2';
|
||||
earningsH6.textContent = 'Earnings';
|
||||
@ -1408,27 +1563,69 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
var earningsRow = document.createElement('div');
|
||||
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);
|
||||
earningsRow.appendChild(earningsLabel);
|
||||
var earningsVal = document.createElement('strong');
|
||||
earningsVal = document.createElement('strong');
|
||||
earningsVal.textContent = fmt(data.log_amount);
|
||||
earningsRow.appendChild(earningsVal);
|
||||
modalBody.appendChild(earningsRow);
|
||||
|
||||
// Adjustments section
|
||||
// =============================================================
|
||||
// ADJUSTMENTS WITH CHECKBOXES — select which to include
|
||||
// =============================================================
|
||||
if (data.adjustments && data.adjustments.length > 0) {
|
||||
var adjHr = document.createElement('hr');
|
||||
modalBody.appendChild(adjHr);
|
||||
|
||||
var adjH6 = document.createElement('h6');
|
||||
adjH6.className = 'fw-bold mb-2';
|
||||
adjH6.textContent = 'Adjustments';
|
||||
adjH6.className = 'fw-bold mb-2 d-flex justify-content-between align-items-center';
|
||||
|
||||
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);
|
||||
|
||||
data.adjustments.forEach(function(adj) {
|
||||
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');
|
||||
label.textContent = adj.type + (adj.project ? ' (' + adj.project + ')' : '');
|
||||
if (adj.description) {
|
||||
@ -1437,16 +1634,34 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
descSmall.textContent = '\u2014 ' + adj.description;
|
||||
label.appendChild(descSmall);
|
||||
}
|
||||
row.appendChild(label);
|
||||
labelWrap.appendChild(label);
|
||||
|
||||
var val = document.createElement('span');
|
||||
val.className = adj.sign === '+' ? 'text-success' : 'text-danger';
|
||||
val.textContent = adj.sign + fmt(adj.amount);
|
||||
row.appendChild(val);
|
||||
labelWrap.appendChild(val);
|
||||
|
||||
row.appendChild(labelWrap);
|
||||
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');
|
||||
netHr.className = 'my-3';
|
||||
modalBody.appendChild(netHr);
|
||||
@ -1457,27 +1672,96 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
netLabel.className = 'fw-bold';
|
||||
netLabel.textContent = 'Net Pay';
|
||||
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.textContent = fmt(data.net_pay);
|
||||
netRow.appendChild(netVal);
|
||||
modalBody.appendChild(netRow);
|
||||
|
||||
// === NEGATIVE NET PAY WARNING ===
|
||||
// If net pay is negative (e.g., advance repayment exceeds earnings),
|
||||
// show a warning so the admin can edit the repayment before paying.
|
||||
if (data.net_pay < 0) {
|
||||
var warnDiv = document.createElement('div');
|
||||
warnDiv.className = 'alert alert-warning py-2 px-3 small mt-2 mb-0';
|
||||
var warnIcon = document.createElement('i');
|
||||
warnIcon.className = 'fas fa-exclamation-triangle me-2';
|
||||
warnDiv.appendChild(warnIcon);
|
||||
warnDiv.appendChild(document.createTextNode(
|
||||
'Net pay is negative. Consider editing the Advance Repayment ' +
|
||||
'amount on the Pending Payments table before processing.'
|
||||
));
|
||||
modalBody.appendChild(warnDiv);
|
||||
}
|
||||
negWarnDiv = document.createElement('div');
|
||||
negWarnDiv.className = 'alert alert-warning py-2 px-3 small mt-2 mb-0';
|
||||
negWarnDiv.style.display = data.net_pay < 0 ? '' : 'none';
|
||||
var warnIcon = document.createElement('i');
|
||||
warnIcon.className = 'fas fa-exclamation-triangle me-2';
|
||||
negWarnDiv.appendChild(warnIcon);
|
||||
negWarnDiv.appendChild(document.createTextNode(
|
||||
'Net pay is negative. Consider editing the Advance Repayment ' +
|
||||
'amount on the Pending Payments table before processing.'
|
||||
));
|
||||
modalBody.appendChild(negWarnDiv);
|
||||
|
||||
// =============================================================
|
||||
// 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
|
||||
@ -1628,39 +1912,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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() {
|
||||
// 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)
|
||||
|
||||
|
||||
# === 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 ===
|
||||
# The main page users see after logging in. Shows different content
|
||||
# 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.
|
||||
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)
|
||||
unpaid_logs = worker.work_logs.exclude(
|
||||
all_unpaid_logs = worker.work_logs.exclude(
|
||||
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()
|
||||
logs_amount = log_count * worker.daily_rate
|
||||
|
||||
# Find pending adjustments
|
||||
pending_adjs = list(worker.adjustments.filter(payroll_record__isnull=True))
|
||||
# Find 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)
|
||||
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:
|
||||
# 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)
|
||||
|
||||
# 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 = []
|
||||
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()}
|
||||
if worker.id not in paid_worker_ids:
|
||||
unpaid_logs.append({
|
||||
'id': log.id,
|
||||
'date': log.date.strftime('%Y-%m-%d'),
|
||||
'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_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(
|
||||
payroll_record__isnull=True
|
||||
).select_related('project')
|
||||
@ -1684,11 +1780,13 @@ def preview_payslip(request, worker_id):
|
||||
sign = '+' if adj.type in ADDITIVE_TYPES else '-'
|
||||
adj_total += float(adj.amount) if adj.type in ADDITIVE_TYPES else -float(adj.amount)
|
||||
adjustments_list.append({
|
||||
'id': adj.id,
|
||||
'type': adj.type,
|
||||
'amount': float(adj.amount),
|
||||
'sign': sign,
|
||||
'description': adj.description,
|
||||
'project': adj.project.name if adj.project else '',
|
||||
'date': adj.date.strftime('%Y-%m-%d'),
|
||||
})
|
||||
|
||||
# === ACTIVE LOANS & ADVANCES ===
|
||||
@ -1707,7 +1805,21 @@ def preview_payslip(request, worker_id):
|
||||
'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({
|
||||
'worker_id': worker.id,
|
||||
'worker_name': worker.name,
|
||||
'worker_id_number': worker.id_number,
|
||||
'day_rate': float(worker.daily_rate),
|
||||
@ -1718,6 +1830,7 @@ def preview_payslip(request, worker_id):
|
||||
'net_pay': log_amount + adj_total,
|
||||
'logs': unpaid_logs,
|
||||
'active_loans': loans_list,
|
||||
'pay_period': pay_period,
|
||||
})
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user