diff --git a/core/admin.py b/core/admin.py index 1fd949f..307b99f 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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',) diff --git a/core/migrations/0005_team_pay_frequency_team_pay_start_date.py b/core/migrations/0005_team_pay_frequency_team_pay_start_date.py new file mode 100644 index 0000000..de50a5a --- /dev/null +++ b/core/migrations/0005_team_pay_frequency_team_pay_start_date.py @@ -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), + ), + ] diff --git a/core/models.py b/core/models.py index 8debd72..5a52f9c 100644 --- a/core/models.py +++ b/core/models.py @@ -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 diff --git a/core/templates/core/payroll_dashboard.html b/core/templates/core/payroll_dashboard.html index 99b6aa3..de6cf83 100644 --- a/core/templates/core/payroll_dashboard.html +++ b/core/templates/core/payroll_dashboard.html @@ -273,7 +273,8 @@
{% csrf_token %} -
@@ -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) diff --git a/core/views.py b/core/views.py index 29c4eb1..1a584dc 100644 --- a/core/views.py +++ b/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, })