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:
Konrad du Plessis 2026-03-24 21:07:28 +02:00
parent 44a0030c46
commit 409e7bfd57
5 changed files with 470 additions and 66 deletions

View File

@ -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',)

View File

@ -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),
),
]

View File

@ -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

View File

@ -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)

View File

@ -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 15Feb 14, Feb 15Mar 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,
})