38686-vm/core/templates/core/payroll_dashboard.html
Konrad du Plessis 2e6881b7a4 Add batch pay feature and fix pay period cutoff logic
Batch Pay: new button on payroll dashboard lets admins pay multiple
workers at once using team pay schedules. Shows preview modal with
eligible workers, then processes all payments in one click.

Fix: "Split at Pay Date" now uses cutoff_date (end of last completed
period) instead of current period end. This includes ALL overdue work
across completed periods, not just one period.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 22:16:21 +02:00

2340 lines
112 KiB
HTML

{% extends 'base.html' %}
{% load static %}
{% block title %}Payroll Dashboard | Fox Fitt{% endblock %}
{% block content %}
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<div class="container py-4">
{# === PAGE HEADER === #}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Payroll Dashboard</h1>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary shadow-sm" id="batchPayBtn" title="Pay all workers with a configured pay schedule for their current pay period">
<i class="fas fa-users fa-sm me-1"></i> Batch Pay
</button>
<button type="button" class="btn btn-accent shadow-sm" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
<i class="fas fa-plus fa-sm me-1"></i> Add Adjustment
</button>
<button type="button" class="btn btn-outline-warning shadow-sm" data-bs-toggle="modal" data-bs-target="#priceOvertimeModal">
<i class="fas fa-clock fa-sm me-1"></i> Price Overtime
</button>
</div>
</div>
{# === ANALYTICS CARDS === #}
{# Left side: 3 single-value stat cards (2 on top + 1 below) #}
{# Right side: Project breakdown card spanning full height — no scroll #}
<div class="row g-3 mb-4">
{# --- Left column: stat cards --- #}
<div class="col-xl-7">
<div class="row g-3 h-100">
{# Outstanding Total — with breakdown of wages vs adjustments #}
<div class="col-sm-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #ef4444;">
Outstanding Payments</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ outstanding_total|floatformat:2 }}</div>
{# === BREAKDOWN — only shown when there are pending adjustments === #}
{% if pending_adj_add_total or pending_adj_sub_total %}
<div class="mt-2 pt-2 border-top" style="font-size: 0.75rem; color: #64748b;">
<div class="d-flex justify-content-between">
<span>Unpaid wages</span>
<span>R {{ unpaid_wages_total|floatformat:2 }}</span>
</div>
{% if pending_adj_add_total %}
<div class="d-flex justify-content-between">
<span>+ Additions</span>
<span class="text-success">R {{ pending_adj_add_total|floatformat:2 }}</span>
</div>
{% endif %}
{% if pending_adj_sub_total %}
<div class="d-flex justify-content-between">
<span>- Deductions</span>
<span class="text-danger">-R {{ pending_adj_sub_total|floatformat:2 }}</span>
</div>
{% endif %}
</div>
{% endif %}
<div class="mt-1" style="font-size: 0.65rem; color: #94a3b8;">
<i class="fas fa-info-circle"></i> Loan repayments deducted at payment time
</div>
</div>
<i class="fas fa-exclamation-circle fa-2x text-danger opacity-25"></i>
</div>
</div>
</div>
</div>
{# Recent Payments #}
<div class="col-sm-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #10b981;">
Paid (Last 60 Days)</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ recent_payments_total|floatformat:2 }}</div>
</div>
<i class="fas fa-check-circle fa-2x text-success opacity-25"></i>
</div>
</div>
</div>
</div>
{# Active Loans — spans full width below the first two #}
<div class="col-12">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between">
<div>
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #f59e0b;">
Active Loans & Advances ({{ active_loans_count }})</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">R {{ active_loans_balance|floatformat:2 }}</div>
</div>
<i class="fas fa-hand-holding-usd fa-2x text-warning opacity-25"></i>
</div>
</div>
</div>
</div>
</div>
</div>
{# --- Right column: project breakdown (grows to fit all projects) --- #}
<div class="col-xl-5 d-flex">
<div class="card stat-card py-2 w-100">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-center justify-content-between mb-3">
<div class="text-xs font-weight-bold text-uppercase" style="color: #3b82f6;">
Outstanding by Project</div>
<i class="fas fa-chart-pie fa-2x text-primary opacity-25"></i>
</div>
{% if outstanding_project_costs %}
<div class="flex-grow-1">
{% for pc in outstanding_project_costs %}
<div class="d-flex justify-content-between align-items-center {% if not forloop.last %}mb-2 pb-2 border-bottom{% endif %}">
<span class="fw-semibold text-gray-800">{{ pc.name }}</span>
<span class="fw-bold" style="color: #3b82f6;">R {{ pc.cost|floatformat:2 }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="flex-grow-1 d-flex align-items-center justify-content-center">
<span class="text-muted"><i class="fas fa-check-circle me-1"></i> No outstanding amounts</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# === CHARTS === #}
<div class="row mb-4">
<div class="col-lg-6 mb-4 mb-lg-0">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
{# === CHART TOGGLE: Overall vs By Worker === #}
{# Two small buttons to switch between the total line chart #}
{# and a per-worker stacked bar chart breakdown. #}
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold" style="color: var(--primary-dark);">Monthly Payroll</h6>
<div class="btn-group btn-group-sm" role="group" aria-label="Chart view toggle">
<button type="button" class="btn btn-sm btn-accent" id="btnOverall">
<i class="fas fa-chart-line fa-sm me-1"></i>Overall
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnByWorker">
<i class="fas fa-user fa-sm me-1"></i>By Worker
</button>
</div>
</div>
</div>
<div class="card-body">
{# --- Overall view (default): the existing line chart --- #}
<div id="monthlyOverallView">
<canvas id="monthlyChart" height="200"></canvas>
</div>
{# --- By Worker view (hidden): worker dropdown + stacked bar --- #}
<div id="monthlyWorkerView" style="display: none;">
<div class="mb-3">
<select id="workerChartSelect" class="form-select form-select-sm">
<option value="">-- Select Worker --</option>
</select>
</div>
<div id="workerChartContainer">
<canvas id="workerChart" height="200"></canvas>
</div>
{# Placeholder shown when no worker is selected #}
<div id="workerChartPlaceholder" class="text-center py-4 text-muted">
<i class="fas fa-chart-bar fa-2x mb-2 d-block opacity-50"></i>
<span>Select a worker to see their payment breakdown</span>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-sm border-0 h-100">
<div class="card-header py-3 bg-white">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">Cost by Project (Monthly)</h6>
</div>
<div class="card-body">
<canvas id="projectChart" height="200"></canvas>
</div>
</div>
</div>
</div>
{# === TAB NAVIGATION === #}
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link {% if active_tab == 'pending' %}active{% endif %}" href="?status=pending">
<i class="fas fa-clock me-1"></i> Pending Payments
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if active_tab == 'paid' %}active{% endif %}" href="?status=paid">
<i class="fas fa-check-circle me-1"></i> Payment History
</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if active_tab == 'loans' %}active{% endif %}" href="?status=loans">
<i class="fas fa-hand-holding-usd me-1"></i> Loans & Advances
</a>
</li>
</ul>
{# =============================================== #}
{# === PENDING PAYMENTS TAB === #}
{# =============================================== #}
{% if active_tab == 'pending' %}
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Worker</th>
<th scope="col">Days</th>
<th scope="col">Day Rate</th>
<th scope="col">Log Amount</th>
<th scope="col">Adjustments</th>
<th scope="col">Net Adj</th>
<th scope="col" class="fw-bold">Total</th>
<th scope="col" class="pe-4 text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for wd in workers_data %}
<tr>
<td class="ps-4 align-middle">
<strong>{{ wd.worker.name }}</strong>
</td>
<td class="align-middle">{{ wd.unpaid_count }}</td>
<td class="align-middle">R {{ wd.day_rate }}</td>
<td class="align-middle">R {{ wd.unpaid_amount|floatformat:2 }}</td>
<td class="align-middle">
{# Show each pending adjustment as a badge #}
{% for adj in wd.adjustments %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-success{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
style="cursor: pointer;"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-amount="{{ adj.amount }}"
data-adj-date="{{ adj.date|date:'Y-m-d' }}"
data-adj-description="{{ adj.description }}"
data-adj-project="{{ adj.project_id|default:'' }}"
data-adj-worker="{{ adj.worker.name }}">
{% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
{{ adj.type }}
{% if adj.project %}({{ adj.project.name }}){% endif %}
</span>
{% endfor %}
{% if not wd.adjustments %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="align-middle {% if wd.adj_amount >= 0 %}text-success{% else %}text-danger{% endif %}">
{% if wd.adj_amount >= 0 %}+{% endif %}R {{ wd.adj_amount|floatformat:2 }}
</td>
<td class="align-middle fw-bold">R {{ wd.total_payable|floatformat:2 }}</td>
<td class="pe-4 align-middle text-end">
<div class="d-flex gap-1 justify-content-end">
<button type="button" class="btn btn-sm btn-outline-info preview-payslip-btn"
data-worker-id="{{ wd.worker.id }}"
data-worker-name="{{ wd.worker.name }}">
<i class="fas fa-eye"></i>
</button>
<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"
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>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="fas fa-check-circle fa-2x mb-3 d-block opacity-50"></i>
No pending payments. All workers are paid up!
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# =============================================== #}
{# === PAYMENT HISTORY TAB === #}
{# =============================================== #}
{% if active_tab == 'paid' %}
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Date</th>
<th scope="col">Worker</th>
<th scope="col">Amount Paid</th>
<th scope="col">Work Logs</th>
<th scope="col">Adjustments</th>
<th scope="col" class="pe-4 text-end">Payslip</th>
</tr>
</thead>
<tbody>
{% for record in paid_records %}
<tr>
<td class="ps-4 align-middle">{{ record.date }}</td>
<td class="align-middle"><strong>{{ record.worker.name }}</strong></td>
<td class="align-middle">R {{ record.amount_paid|floatformat:2 }}</td>
<td class="align-middle">
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
</td>
<td class="align-middle">
{% for adj in record.adjustments.all %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-success{% else %}bg-danger{% endif %} me-1">
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
</span>
{% empty %}
<span class="text-muted">-</span>
{% endfor %}
</td>
<td class="pe-4 align-middle text-end">
<a href="{% url 'payslip_detail' record.id %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-file-alt me-1"></i> View
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
<i class="fas fa-inbox fa-2x mb-3 d-block opacity-50"></i>
No payment history yet.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# =============================================== #}
{# === LOANS TAB === #}
{# =============================================== #}
{% if active_tab == 'loans' %}
<div class="mb-3 d-flex gap-2">
<a href="?status=loans&loan_status=active"
class="btn btn-sm {% if loan_filter == 'active' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
Active
</a>
<a href="?status=loans&loan_status=history"
class="btn btn-sm {% if loan_filter == 'history' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
History
</a>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Worker</th>
<th scope="col">Type</th>
<th scope="col">Principal</th>
<th scope="col">Balance</th>
<th scope="col">Date</th>
<th scope="col">Reason</th>
<th scope="col" class="pe-4">Status</th>
</tr>
</thead>
<tbody>
{% for loan in loans %}
<tr>
<td class="ps-4 align-middle"><strong>{{ loan.worker.name }}</strong></td>
<td class="align-middle">
{% if loan.loan_type == 'advance' %}
<span class="badge bg-info text-dark">Advance</span>
{% else %}
<span class="badge bg-primary">Loan</span>
{% endif %}
</td>
<td class="align-middle">R {{ loan.principal_amount|floatformat:2 }}</td>
<td class="align-middle">R {{ loan.remaining_balance|floatformat:2 }}</td>
<td class="align-middle">{{ loan.date }}</td>
<td class="align-middle">{{ loan.reason|default:"-" }}</td>
<td class="pe-4 align-middle">
{% if loan.active %}
<span class="badge bg-warning text-dark">Active</span>
{% else %}
<span class="badge bg-success">Paid Off</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-5 text-muted">
<i class="fas fa-hand-holding-usd fa-2x mb-3 d-block opacity-50"></i>
{% if loan_filter == 'active' %}No active loans or advances.{% else %}No loan/advance history.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
{# ================================================================== #}
{# === MODALS === #}
{# ================================================================== #}
{# --- ADD ADJUSTMENT MODAL --- #}
<div class="modal fade" id="addAdjustmentModal" tabindex="-1" aria-labelledby="addAdjLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form method="POST" action="{% url 'add_adjustment' %}">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="addAdjLabel">Add Payroll Adjustment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
{# Type #}
<div class="col-md-6">
<label class="form-label">Type</label>
<select name="type" class="form-select" required id="addAdjType">
{% for value, label in adjustment_types %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
{# Project — required for most types, hidden for Loan types #}
<div class="col-md-6" id="addAdjProjectGroup">
<label class="form-label">Project</label>
<select name="project" class="form-select" id="addAdjProject" required>
<option value="">-- Select Project --</option>
{% for p in active_projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
{# Amount #}
<div class="col-md-6">
<label class="form-label">Amount (R)</label>
<input type="number" name="amount" class="form-control" step="0.01" min="0.01" required>
</div>
{# Date #}
<div class="col-md-6">
<label class="form-label">Date</label>
<input type="date" name="date" class="form-control" value="{{ today|date:'Y-m-d' }}">
</div>
{# Workers - multi-select with team helper #}
<div class="col-12">
<label class="form-label">Workers</label>
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
<select id="addAdjTeamSelect" class="form-select form-select-sm" style="max-width: 250px;">
<option value="">Quick select by team...</option>
{% for team in all_teams %}
<option value="{{ team.id }}">{{ team.name }}</option>
{% endfor %}
</select>
<a href="#" id="adjSelectAll" class="small text-primary text-decoration-none text-nowrap">Select All</a>
<a href="#" id="adjDeselectAll" class="small text-muted text-decoration-none text-nowrap">Clear</a>
</div>
<div style="max-height: 200px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 4px; padding: 8px;">
{% for w in all_workers %}
<div class="form-check">
<input class="form-check-input add-adj-worker" type="checkbox"
name="workers" value="{{ w.id }}" id="addW{{ w.id }}">
<label class="form-check-label" for="addW{{ w.id }}">{{ w.name }}</label>
</div>
{% endfor %}
</div>
{# Counter + validation message for workers #}
<div class="form-text mt-1">
<span id="adjSelectedCount">0</span> worker(s) selected
</div>
<div class="invalid-feedback" id="addAdjWorkerError" style="display: none;">
Please select at least one worker.
</div>
</div>
{# Description #}
<div class="col-12">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" rows="2" placeholder="Reason for this adjustment..."></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-plus me-1"></i> Add Adjustment
</button>
</div>
</form>
</div>
</div>
</div>
{# --- EDIT ADJUSTMENT MODAL --- #}
<div class="modal fade" id="editAdjustmentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="POST" id="editAdjForm" action="">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Edit Adjustment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<strong id="editAdjWorkerName"></strong>
</div>
<div class="row g-3">
{# Type (only Bonus ↔ Deduction is allowed) #}
<div class="col-md-6">
<label class="form-label">Type</label>
<select name="type" class="form-select" id="editAdjType">
{% for value, label in adjustment_types %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
{# Project #}
<div class="col-md-6">
<label class="form-label">Project</label>
<select name="project" class="form-select" id="editAdjProject">
<option value="">-- Select Project --</option>
{% for p in active_projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
{# Amount #}
<div class="col-md-6">
<label class="form-label">Amount (R)</label>
<input type="number" name="amount" class="form-control" id="editAdjAmount"
step="0.01" min="0.01" required>
</div>
{# Date #}
<div class="col-md-6">
<label class="form-label">Date</label>
<input type="date" name="date" class="form-control" id="editAdjDate">
</div>
{# Description #}
<div class="col-12">
<label class="form-label">Description</label>
<textarea name="description" class="form-control" id="editAdjDescription" rows="2"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger btn-sm" id="editAdjDeleteBtn">
<i class="fas fa-trash me-1"></i> Delete
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-1"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
{# --- DELETE CONFIRMATION MODAL --- #}
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<form method="POST" id="deleteAdjForm" action="">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title text-danger">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this <strong id="deleteAdjType"></strong> adjustment for <strong id="deleteAdjWorker"></strong>?</p>
<p class="text-muted small mb-0">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger btn-sm">
<i class="fas fa-trash me-1"></i> Delete
</button>
</div>
</form>
</div>
</div>
</div>
{# --- PRICE OVERTIME MODAL --- #}
<div class="modal fade" id="priceOvertimeModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<form method="POST" action="{% url 'price_overtime' %}">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-clock me-2"></i>Price Unpriced Overtime</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# Filter row #}
<div class="row g-2 mb-3">
<div class="col-md-4">
<input type="text" id="otFilterWorker" class="form-control form-control-sm" placeholder="Filter by worker...">
</div>
<div class="col-md-4">
<select id="otFilterProject" class="form-select form-select-sm">
<option value="">All Projects</option>
{% for p in active_projects %}
<option value="{{ p.name }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 d-flex align-items-center gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="otSelectAll">
<label class="form-check-label small" for="otSelectAll">Select All</label>
</div>
</div>
</div>
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top">
<tr>
<th><input type="checkbox" class="form-check-input" id="otCheckAll"></th>
<th>Worker</th>
<th>Date</th>
<th>Project</th>
<th>Overtime</th>
<th>Rate %</th>
</tr>
</thead>
<tbody id="otTableBody">
{# Populated by JavaScript from overtime_data_json #}
</tbody>
</table>
</div>
<p class="text-muted small mt-2 mb-0" id="otCountMsg">Loading overtime data...</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-calculator me-1"></i> Price Selected
</button>
</div>
</form>
</div>
</div>
</div>
{# --- PREVIEW PAYSLIP MODAL --- #}
{# === BATCH PAY MODAL === #}
{# Shows a preview of which workers will be paid (based on team pay schedules), #}
{# then lets the admin confirm to process all payments at once. #}
<div class="modal fade" id="batchPayModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-users me-2"></i>Batch Pay by Schedule</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="batchPayModalBody">
{# Content loaded via JavaScript #}
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2 small">Loading preview...</p>
</div>
</div>
<div class="modal-footer" id="batchPayModalFooter" style="display:none;">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-accent" id="confirmBatchPayBtn">
<i class="fas fa-money-bill-wave me-1"></i> Confirm & Pay All
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="previewPayslipModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-file-invoice me-2"></i>Payslip Preview & Repayments</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="previewPayslipBody">
{# Content loaded via JavaScript #}
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2 small">Loading preview...</p>
</div>
</div>
</div>
</div>
</div>
{# ================================================================== #}
{# === JAVASCRIPT === #}
{# ================================================================== #}
{# Django's json_script filter safely outputs JSON without XSS risk #}
{{ overtime_data_json|json_script:"otDataJson" }}
{{ team_workers_map_json|json_script:"teamWorkersJson" }}
{{ chart_labels_json|json_script:"chartLabelsJson" }}
{{ chart_totals_json|json_script:"chartTotalsJson" }}
{{ project_chart_json|json_script:"projectChartJson" }}
{{ worker_chart_json|json_script:"workerChartJson" }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// === SAFE DATA LOADING ===
// json_script outputs data in a <script type="application/json"> tag,
// which is the Django-recommended way to pass data to JavaScript safely.
const allOtData = JSON.parse(document.getElementById('otDataJson').textContent);
const teamWorkersMap = JSON.parse(document.getElementById('teamWorkersJson').textContent);
const chartLabels = JSON.parse(document.getElementById('chartLabelsJson').textContent);
const chartTotals = JSON.parse(document.getElementById('chartTotalsJson').textContent);
const projectChartData = JSON.parse(document.getElementById('projectChartJson').textContent);
const workerChartData = JSON.parse(document.getElementById('workerChartJson').textContent);
// === HELPER: Format currency ===
function fmt(val) {
return 'R ' + parseFloat(val).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
// === HELPER: Create a table cell with text ===
function createTd(text, className) {
const td = document.createElement('td');
td.textContent = text;
if (className) td.className = className;
return td;
}
// =================================================================
// CHART.JS — Monthly Totals (Line Chart)
// Wrapped in try-catch so a Chart.js failure doesn't prevent
// the rest of the page's JavaScript from running.
// =================================================================
try {
const monthlyCtx = document.getElementById('monthlyChart');
if (monthlyCtx) {
new Chart(monthlyCtx, {
type: 'line',
data: {
labels: chartLabels,
datasets: [{
label: 'Total Paid',
data: chartTotals,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return fmt(context.parsed.y);
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(val) { return 'R ' + val.toLocaleString(); }
}
}
}
}
});
}
} catch (e) {
console.warn('Monthly chart failed to render:', e);
}
// =================================================================
// CHART.JS — Per-Project Costs (Stacked Bar Chart)
// =================================================================
try {
const projectCtx = document.getElementById('projectChart');
if (projectCtx) {
// Color palette for projects
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
const datasets = projectChartData.map(function(proj, i) {
return {
label: proj.name,
data: proj.data,
backgroundColor: colors[i % colors.length],
};
});
new Chart(projectCtx, {
type: 'bar',
data: {
labels: chartLabels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + fmt(context.parsed.y);
},
// Show the month total at the bottom of the tooltip
footer: function(tooltipItems) {
var total = 0;
tooltipItems[0].chart.data.datasets.forEach(function(ds) {
total += ds.data[tooltipItems[0].dataIndex] || 0;
});
return 'Total: ' + fmt(total);
}
}
}
},
scales: {
x: { stacked: true },
y: {
stacked: true,
beginAtZero: true,
ticks: {
callback: function(val) { return 'R ' + val.toLocaleString(); }
}
}
}
}
});
}
} catch (e) {
console.warn('Project chart failed to render:', e);
}
// =================================================================
// CHART TOGGLE — Switch between "Overall" line chart and
// "By Worker" stacked bar chart in the Monthly Payroll card.
// =================================================================
var btnOverall = document.getElementById('btnOverall');
var btnByWorker = document.getElementById('btnByWorker');
var monthlyOverallView = document.getElementById('monthlyOverallView');
var monthlyWorkerView = document.getElementById('monthlyWorkerView');
if (btnOverall && btnByWorker) {
// Switch to "Overall" — show the line chart, hide the worker view
btnOverall.addEventListener('click', function() {
monthlyOverallView.style.display = '';
monthlyWorkerView.style.display = 'none';
btnOverall.className = 'btn btn-sm btn-accent';
btnByWorker.className = 'btn btn-sm btn-outline-secondary';
});
// Switch to "By Worker" — hide line chart, show worker dropdown + bar chart
btnByWorker.addEventListener('click', function() {
monthlyOverallView.style.display = 'none';
monthlyWorkerView.style.display = '';
btnByWorker.className = 'btn btn-sm btn-accent';
btnOverall.className = 'btn btn-sm btn-outline-secondary';
});
}
// =================================================================
// CHART.JS — Per-Worker Breakdown (Stacked Bar Chart)
// Shows base pay, overtime, bonuses as positive bars going up,
// and deductions, loan repayments, advance payments going down.
// =================================================================
var workerChartSelect = document.getElementById('workerChartSelect');
var workerChartContainer = document.getElementById('workerChartContainer');
var workerChartPlaceholder = document.getElementById('workerChartPlaceholder');
var workerChartInstance = null; // Track so we can destroy before re-creating
if (workerChartSelect) {
// Hide the chart canvas initially (show placeholder instead)
if (workerChartContainer) workerChartContainer.style.display = 'none';
// Populate the worker dropdown from pre-computed data
// (already sorted alphabetically by the server)
Object.keys(workerChartData).forEach(function(workerId) {
var opt = document.createElement('option');
opt.value = workerId;
opt.textContent = workerChartData[workerId].name;
workerChartSelect.appendChild(opt);
});
// When a worker is selected, render their stacked bar chart
workerChartSelect.addEventListener('change', function() {
var workerId = this.value;
var canvasEl = document.getElementById('workerChart');
// No worker selected — show placeholder, hide chart
if (!workerId) {
if (workerChartPlaceholder) workerChartPlaceholder.style.display = '';
if (workerChartContainer) workerChartContainer.style.display = 'none';
if (workerChartInstance) {
workerChartInstance.destroy();
workerChartInstance = null;
}
return;
}
// Worker selected — hide placeholder, show chart canvas
if (workerChartPlaceholder) workerChartPlaceholder.style.display = 'none';
if (workerChartContainer) workerChartContainer.style.display = '';
// Destroy the previous chart before creating a new one
if (workerChartInstance) {
workerChartInstance.destroy();
workerChartInstance = null;
}
var data = workerChartData[workerId];
if (!data) return;
// Pull out monthly values into separate arrays (one per component)
var basePay = data.months.map(function(m) { return m.base; });
var overtime = data.months.map(function(m) { return m.overtime; });
var bonus = data.months.map(function(m) { return m.bonus; });
var newLoan = data.months.map(function(m) { return m.new_loan; });
// Deductive items shown as negative values (bars go below zero)
var deduction = data.months.map(function(m) { return -m.deduction; });
var loanRepayment = data.months.map(function(m) { return -m.loan_repayment; });
var advance = data.months.map(function(m) { return -m.advance; });
// Build datasets — only include types that have non-zero data
// so the legend doesn't show irrelevant items
var datasets = [];
if (basePay.some(function(v) { return v > 0; })) {
datasets.push({
label: 'Base Pay',
data: basePay,
backgroundColor: '#10b981',
stack: 'positive',
});
}
if (overtime.some(function(v) { return v > 0; })) {
datasets.push({
label: 'Overtime',
data: overtime,
backgroundColor: '#f59e0b',
stack: 'positive',
});
}
if (bonus.some(function(v) { return v > 0; })) {
datasets.push({
label: 'Bonus',
data: bonus,
backgroundColor: '#3b82f6',
stack: 'positive',
});
}
if (newLoan.some(function(v) { return v > 0; })) {
datasets.push({
label: 'New Loan',
data: newLoan,
backgroundColor: '#8b5cf6',
stack: 'positive',
});
}
if (deduction.some(function(v) { return v < 0; })) {
datasets.push({
label: 'Deductions',
data: deduction,
backgroundColor: '#ef4444',
stack: 'negative',
});
}
if (loanRepayment.some(function(v) { return v < 0; })) {
datasets.push({
label: 'Loan Repayments',
data: loanRepayment,
backgroundColor: '#991b1b',
stack: 'negative',
});
}
if (advance.some(function(v) { return v < 0; })) {
datasets.push({
label: 'Advance Payments',
data: advance,
backgroundColor: '#7c3aed',
stack: 'negative',
});
}
try {
workerChartInstance = new Chart(canvasEl, {
type: 'bar',
data: {
labels: chartLabels,
datasets: datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: function(context) {
var val = context.parsed.y;
var prefix = val >= 0 ? '+' : '';
return context.dataset.label + ': ' + prefix + fmt(val);
}
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 12,
padding: 8,
font: { size: 11 }
}
}
},
scales: {
x: { stacked: true },
y: {
stacked: true,
ticks: {
callback: function(val) { return 'R ' + val.toLocaleString(); }
}
}
}
}
});
} catch (e) {
console.warn('Worker chart failed to render:', e);
}
});
}
// =================================================================
// OVERTIME MODAL — Build table rows using DOM methods (no innerHTML)
// =================================================================
const otTableBody = document.getElementById('otTableBody');
const otCountMsg = document.getElementById('otCountMsg');
const otFilterWorker = document.getElementById('otFilterWorker');
const otFilterProject = document.getElementById('otFilterProject');
const otSelectAll = document.getElementById('otSelectAll');
const otCheckAll = document.getElementById('otCheckAll');
function buildOtTable(data) {
// Clear existing rows safely
while (otTableBody && otTableBody.firstChild) {
otTableBody.removeChild(otTableBody.firstChild);
}
if (data.length === 0) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.setAttribute('colspan', '6');
td.className = 'text-center py-3 text-muted';
td.textContent = 'No unpriced overtime found.';
tr.appendChild(td);
otTableBody.appendChild(tr);
if (otCountMsg) otCountMsg.textContent = '0 entries';
return;
}
data.forEach(function(entry) {
const tr = document.createElement('tr');
// Checkbox cell
const tdCheck = document.createElement('td');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'form-check-input ot-check';
checkbox.checked = true;
tdCheck.appendChild(checkbox);
tr.appendChild(tdCheck);
// Worker name
tr.appendChild(createTd(entry.worker_name));
// Date
tr.appendChild(createTd(entry.date));
// Project
tr.appendChild(createTd(entry.project));
// Overtime label
tr.appendChild(createTd(entry.ot_label));
// Rate % select
const tdRate = document.createElement('td');
const select = document.createElement('select');
select.className = 'form-select form-select-sm';
select.style.width = '100px';
select.name = 'rate_pct[]';
[100, 150, 200].forEach(function(pct) {
const opt = document.createElement('option');
opt.value = pct;
opt.textContent = pct + '%';
if (pct === 150) opt.selected = true;
select.appendChild(opt);
});
tdRate.appendChild(select);
tr.appendChild(tdRate);
// Hidden inputs for form submission
const hiddenLogId = document.createElement('input');
hiddenLogId.type = 'hidden';
hiddenLogId.name = 'log_id[]';
hiddenLogId.value = entry.log_id;
hiddenLogId.className = 'ot-hidden';
tr.appendChild(hiddenLogId);
const hiddenWorkerId = document.createElement('input');
hiddenWorkerId.type = 'hidden';
hiddenWorkerId.name = 'worker_id[]';
hiddenWorkerId.value = entry.worker_id;
hiddenWorkerId.className = 'ot-hidden';
tr.appendChild(hiddenWorkerId);
// Store data on the row for filtering
tr.dataset.worker = entry.worker_name.toLowerCase();
tr.dataset.project = entry.project;
otTableBody.appendChild(tr);
});
if (otCountMsg) {
otCountMsg.textContent = data.length + ' unpriced overtime entr' + (data.length === 1 ? 'y' : 'ies');
}
}
// Initial build
buildOtTable(allOtData);
// Filter overtime table
function filterOtTable() {
if (!otTableBody) return;
const workerFilter = (otFilterWorker ? otFilterWorker.value : '').toLowerCase();
const projectFilter = otFilterProject ? otFilterProject.value : '';
let visibleCount = 0;
Array.from(otTableBody.children).forEach(function(tr) {
if (!tr.dataset.worker) return; // skip "no data" row
const matchWorker = !workerFilter || tr.dataset.worker.indexOf(workerFilter) !== -1;
const matchProject = !projectFilter || tr.dataset.project === projectFilter;
tr.style.display = (matchWorker && matchProject) ? '' : 'none';
if (matchWorker && matchProject) visibleCount++;
});
if (otCountMsg) {
otCountMsg.textContent = visibleCount + ' of ' + allOtData.length + ' entries shown';
}
}
if (otFilterWorker) otFilterWorker.addEventListener('input', filterOtTable);
if (otFilterProject) otFilterProject.addEventListener('change', filterOtTable);
// Select All / Deselect All
function toggleAllOt(checked) {
if (!otTableBody) return;
otTableBody.querySelectorAll('.ot-check').forEach(function(cb) {
if (cb.closest('tr').style.display !== 'none') {
cb.checked = checked;
}
});
}
if (otSelectAll) otSelectAll.addEventListener('change', function() { toggleAllOt(this.checked); });
if (otCheckAll) otCheckAll.addEventListener('change', function() { toggleAllOt(this.checked); });
// Before submit: remove hidden inputs for unchecked rows
const otForm = document.querySelector('#priceOvertimeModal form');
if (otForm) {
otForm.addEventListener('submit', function() {
otTableBody.querySelectorAll('tr').forEach(function(tr) {
const cb = tr.querySelector('.ot-check');
if (cb && !cb.checked) {
tr.querySelectorAll('.ot-hidden').forEach(function(h) { h.disabled = true; });
tr.querySelector('select').disabled = true;
}
});
});
}
// =================================================================
// ADD ADJUSTMENT — Team quick-select, project visibility,
// worker count display, and form validation
// =================================================================
const addAdjType = document.getElementById('addAdjType');
const addAdjProjectGroup = document.getElementById('addAdjProjectGroup');
const addAdjProject = document.getElementById('addAdjProject');
const addAdjTeamSelect = document.getElementById('addAdjTeamSelect');
const addAdjWorkerCheckboxes = document.querySelectorAll('.add-adj-worker');
const adjSelectedCount = document.getElementById('adjSelectedCount');
const addAdjWorkerError = document.getElementById('addAdjWorkerError');
// Show/hide project field based on adjustment type.
// Also toggles the HTML "required" attribute so browser validation
// only enforces the project when the type actually needs one.
function toggleProjectField() {
if (!addAdjType || !addAdjProjectGroup) return;
// Loan types and repayments don't need a project.
// Advance Payment DOES need a project (for cost tracking).
var noProjectTypes = ['New Loan', 'Loan Repayment', 'Advance Repayment'];
var needsProject = noProjectTypes.indexOf(addAdjType.value) === -1;
addAdjProjectGroup.style.display = needsProject ? '' : 'none';
if (addAdjProject) {
if (needsProject) {
addAdjProject.setAttribute('required', '');
} else {
addAdjProject.removeAttribute('required');
addAdjProject.value = ''; // Clear selection when hidden
}
}
}
if (addAdjType) {
addAdjType.addEventListener('change', toggleProjectField);
toggleProjectField();
}
// Update the "X worker(s) selected" counter
function updateWorkerCount() {
var count = 0;
addAdjWorkerCheckboxes.forEach(function(cb) {
if (cb.checked) count++;
});
if (adjSelectedCount) adjSelectedCount.textContent = count;
// Hide the error message when workers are selected
if (addAdjWorkerError && count > 0) {
addAdjWorkerError.style.display = 'none';
}
}
addAdjWorkerCheckboxes.forEach(function(cb) {
cb.addEventListener('change', updateWorkerCount);
});
// Select All / Deselect All links
var adjSelectAllLink = document.getElementById('adjSelectAll');
var adjDeselectAllLink = document.getElementById('adjDeselectAll');
if (adjSelectAllLink) {
adjSelectAllLink.addEventListener('click', function(e) {
e.preventDefault();
addAdjWorkerCheckboxes.forEach(function(cb) { cb.checked = true; });
updateWorkerCount();
});
}
if (adjDeselectAllLink) {
adjDeselectAllLink.addEventListener('click', function(e) {
e.preventDefault();
addAdjWorkerCheckboxes.forEach(function(cb) { cb.checked = false; });
updateWorkerCount();
});
}
// Team quick-select: check workers in that team
if (addAdjTeamSelect) {
addAdjTeamSelect.addEventListener('change', function() {
var teamId = this.value;
if (!teamId) return;
var workerIds = teamWorkersMap[teamId] || [];
addAdjWorkerCheckboxes.forEach(function(cb) {
if (workerIds.indexOf(parseInt(cb.value)) !== -1) {
cb.checked = true;
}
});
updateWorkerCount();
});
}
// Form validation: ensure at least one worker is selected before submit.
// Without this, the form would submit and silently create 0 adjustments.
var addAdjForm = document.querySelector('#addAdjustmentModal form');
if (addAdjForm) {
addAdjForm.addEventListener('submit', function(e) {
var anyChecked = false;
addAdjWorkerCheckboxes.forEach(function(cb) {
if (cb.checked) anyChecked = true;
});
if (!anyChecked) {
e.preventDefault();
if (addAdjWorkerError) {
addAdjWorkerError.style.display = 'block';
}
// Scroll to the workers section so the error is visible
var workersLabel = addAdjForm.querySelector('.col-12');
if (workersLabel) workersLabel.scrollIntoView({behavior: 'smooth', block: 'center'});
return false;
}
});
}
// =================================================================
// EDIT ADJUSTMENT — Click badge to open edit modal
// =================================================================
document.querySelectorAll('.adjustment-badge').forEach(function(badge) {
badge.addEventListener('click', function() {
const adjId = this.dataset.adjId;
const adjType = this.dataset.adjType;
const adjAmount = this.dataset.adjAmount;
const adjDate = this.dataset.adjDate;
const adjDesc = this.dataset.adjDescription;
const adjProject = this.dataset.adjProject;
const adjWorker = this.dataset.adjWorker;
// Set form action URL
const editForm = document.getElementById('editAdjForm');
editForm.action = '/payroll/adjustment/' + adjId + '/edit/';
// Populate fields
document.getElementById('editAdjWorkerName').textContent = adjWorker + ' — ' + adjType;
document.getElementById('editAdjType').value = adjType;
document.getElementById('editAdjAmount').value = adjAmount;
document.getElementById('editAdjDate').value = adjDate;
document.getElementById('editAdjDescription').value = adjDesc;
// Pre-select project
const editProjectSelect = document.getElementById('editAdjProject');
editProjectSelect.value = adjProject || '';
// Disable type field unless it's Bonus or Deduction
const editTypeSelect = document.getElementById('editAdjType');
if (adjType !== 'Bonus' && adjType !== 'Deduction') {
editTypeSelect.disabled = true;
} else {
editTypeSelect.disabled = false;
}
// Wire delete button
document.getElementById('editAdjDeleteBtn').onclick = function() {
const deleteForm = document.getElementById('deleteAdjForm');
deleteForm.action = '/payroll/adjustment/' + adjId + '/delete/';
document.getElementById('deleteAdjType').textContent = adjType;
document.getElementById('deleteAdjWorker').textContent = adjWorker;
// Close edit modal, open delete modal
var editModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('editAdjustmentModal'));
editModal.hide();
// Wait for edit modal to finish hiding before showing delete modal
document.getElementById('editAdjustmentModal').addEventListener('hidden.bs.modal', function handler() {
document.getElementById('editAdjustmentModal').removeEventListener('hidden.bs.modal', handler);
bootstrap.Modal.getOrCreateInstance(document.getElementById('deleteConfirmModal')).show();
});
};
// Show the modal
bootstrap.Modal.getOrCreateInstance(document.getElementById('editAdjustmentModal')).show();
});
});
// =================================================================
// 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
function refreshPreview(workerId, modalBody) {
// Show loading spinner (safe hardcoded content)
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
var loadingDiv = document.createElement('div');
loadingDiv.className = 'text-center py-4';
var spinner = document.createElement('div');
spinner.className = 'spinner-border text-primary';
spinner.setAttribute('role', 'status');
loadingDiv.appendChild(spinner);
var loadingText = document.createElement('p');
loadingText.className = 'text-muted mt-2 small';
loadingText.textContent = 'Loading preview...';
loadingDiv.appendChild(loadingText);
modalBody.appendChild(loadingDiv);
// Fetch preview data
fetch('/payroll/preview/' + workerId + '/')
.then(function(resp) {
if (!resp.ok) throw new Error('Network error');
return resp.json();
})
.then(function(data) {
// Clear loading
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
// === 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';
var h4 = document.createElement('h4');
h4.className = 'mb-1';
h4.textContent = data.worker_name;
header.appendChild(h4);
if (data.worker_id_number) {
var idP = document.createElement('p');
idP.className = 'text-muted small mb-0';
idP.textContent = 'ID: ' + data.worker_id_number;
header.appendChild(idP);
}
modalBody.appendChild(header);
// =============================================================
// 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);
// Show current period info + cutoff date
// cutoff_date = end of the last COMPLETED period (everything due for payment)
periodInfo.appendChild(document.createTextNode(
data.pay_period.team_name + ' \u2014 ' +
data.pay_period.frequency + ' | Current period: ' +
data.pay_period.start + ' to ' + data.pay_period.end +
' | Pay through: ' + data.pay_period.cutoff_date
));
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 (up to ' + data.pay_period.cutoff_date + ')'
));
splitBtn.addEventListener('click', function() {
// Use cutoff_date (end of last completed period) — includes ALL
// overdue work, not just one period. Leaves current in-progress
// period for the next pay run.
var cutoff = data.pay_period.cutoff_date;
// Uncheck logs after the cutoff date
modalBody.querySelectorAll('.log-checkbox').forEach(function(cb) {
cb.checked = (cb.dataset.logDate <= cutoff);
});
// Uncheck adjustments after the cutoff date
modalBody.querySelectorAll('.adj-checkbox').forEach(function(cb) {
cb.checked = (cb.dataset.adjDate <= cutoff);
});
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';
modalBody.appendChild(earningsH6);
var earningsRow = document.createElement('div');
earningsRow.className = 'd-flex justify-content-between mb-1';
earningsLabel = document.createElement('span');
earningsLabel.textContent = data.days_worked + ' day(s) \u00d7 ' + fmt(data.day_rate);
earningsRow.appendChild(earningsLabel);
earningsVal = document.createElement('strong');
earningsVal.textContent = fmt(data.log_amount);
earningsRow.appendChild(earningsVal);
modalBody.appendChild(earningsRow);
// =============================================================
// 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 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 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) {
var descSmall = document.createElement('small');
descSmall.className = 'text-muted ms-1';
descSmall.textContent = '\u2014 ' + adj.description;
label.appendChild(descSmall);
}
labelWrap.appendChild(label);
var val = document.createElement('span');
val.className = adj.sign === '+' ? 'text-success' : 'text-danger';
val.textContent = adj.sign + fmt(adj.amount);
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 — updates dynamically
// =============================================================
var netHr = document.createElement('hr');
netHr.className = 'my-3';
modalBody.appendChild(netHr);
var netRow = document.createElement('div');
netRow.className = 'd-flex justify-content-between';
var netLabel = document.createElement('h5');
netLabel.className = 'fw-bold';
netLabel.textContent = 'Net Pay';
netRow.appendChild(netLabel);
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 ===
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
// inline repayment forms so the admin can deduct right here.
// =============================================================
if (data.active_loans && data.active_loans.length > 0) {
var loansHr = document.createElement('hr');
loansHr.className = 'my-3';
modalBody.appendChild(loansHr);
var loansH6 = document.createElement('h6');
loansH6.className = 'fw-bold mb-2';
loansH6.textContent = 'Outstanding Loans & Advances';
modalBody.appendChild(loansH6);
data.active_loans.forEach(function(loan) {
// Card container for each loan/advance
var card = document.createElement('div');
card.className = 'border rounded p-2 mb-2';
card.style.backgroundColor = '#f8f9fa';
// Row 1: Type badge + Balance
var topRow = document.createElement('div');
topRow.className = 'd-flex justify-content-between align-items-center mb-1';
var badge = document.createElement('span');
badge.className = 'badge ' + (loan.type === 'advance' ? 'bg-info text-dark' : 'bg-primary');
badge.textContent = loan.type_label;
topRow.appendChild(badge);
var balSpan = document.createElement('span');
balSpan.className = 'fw-bold text-danger';
balSpan.textContent = 'Balance: ' + fmt(loan.balance);
topRow.appendChild(balSpan);
card.appendChild(topRow);
// Row 2: Details (principal, date, reason)
var detailRow = document.createElement('div');
detailRow.className = 'small text-muted mb-2';
detailRow.textContent = 'Principal: ' + fmt(loan.principal) + ' | ' + loan.date;
if (loan.reason) {
detailRow.textContent += ' | ' + loan.reason;
}
card.appendChild(detailRow);
// Row 3: Inline repayment form
var formRow = document.createElement('div');
formRow.className = 'd-flex gap-2 align-items-center';
var amtInput = document.createElement('input');
amtInput.type = 'number';
amtInput.className = 'form-control form-control-sm';
amtInput.style.maxWidth = '130px';
amtInput.placeholder = 'Amount';
amtInput.step = '0.01';
amtInput.min = '0.01';
amtInput.max = loan.balance;
amtInput.value = loan.balance;
formRow.appendChild(amtInput);
var noteInput = document.createElement('input');
noteInput.type = 'text';
noteInput.className = 'form-control form-control-sm';
noteInput.placeholder = 'Note (optional)';
noteInput.style.flex = '1';
formRow.appendChild(noteInput);
var deductBtn = document.createElement('button');
deductBtn.type = 'button';
deductBtn.className = 'btn btn-sm btn-outline-danger';
deductBtn.title = 'Add ' + loan.type_label + ' Repayment';
var btnIcon = document.createElement('i');
btnIcon.className = 'fas fa-minus-circle me-1';
deductBtn.appendChild(btnIcon);
deductBtn.appendChild(document.createTextNode('Deduct'));
formRow.appendChild(deductBtn);
card.appendChild(formRow);
// Wire up the "Deduct" button — POST via AJAX, then refresh
deductBtn.addEventListener('click', function() {
var repayAmt = parseFloat(amtInput.value);
if (!repayAmt || repayAmt <= 0) {
amtInput.classList.add('is-invalid');
return;
}
if (repayAmt > loan.balance) {
repayAmt = loan.balance;
amtInput.value = loan.balance;
}
amtInput.classList.remove('is-invalid');
// Disable form while submitting
deductBtn.disabled = true;
amtInput.disabled = true;
noteInput.disabled = true;
deductBtn.textContent = 'Adding...';
fetch('/payroll/repayment/' + workerId + '/', {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'Content-Type': 'application/json'
},
body: JSON.stringify({
loan_id: loan.id,
amount: repayAmt,
description: noteInput.value
})
})
.then(function(resp) {
if (!resp.ok) return resp.json().then(function(e) { throw new Error(e.error || 'Error'); });
return resp.json();
})
.then(function(result) {
// Show brief success feedback
var msg = document.createElement('div');
msg.className = 'alert alert-success py-1 px-2 small mt-2 mb-0';
msg.textContent = result.message;
card.appendChild(msg);
// After a brief pause, re-fetch the entire preview
setTimeout(function() {
refreshPreview(workerId, modalBody);
}, 800);
})
.catch(function(err) {
// Re-enable form on error
deductBtn.disabled = false;
amtInput.disabled = false;
noteInput.disabled = false;
deductBtn.textContent = '';
var errIcon2 = document.createElement('i');
errIcon2.className = 'fas fa-minus-circle me-1';
deductBtn.appendChild(errIcon2);
deductBtn.appendChild(document.createTextNode('Deduct'));
var errMsg = document.createElement('div');
errMsg.className = 'alert alert-danger py-1 px-2 small mt-2 mb-0';
errMsg.textContent = err.message || 'Something went wrong.';
card.appendChild(errMsg);
setTimeout(function() {
if (errMsg.parentNode) errMsg.parentNode.removeChild(errMsg);
}, 3000);
});
});
modalBody.appendChild(card);
});
}
})
.catch(function() {
// Show error (safe hardcoded content)
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
var errDiv = document.createElement('div');
errDiv.className = 'text-center py-4 text-danger';
var errIcon3 = document.createElement('i');
errIcon3.className = 'fas fa-exclamation-triangle fs-3';
errDiv.appendChild(errIcon3);
var errText = document.createElement('p');
errText.className = 'mt-2';
errText.textContent = 'Could not load preview.';
errDiv.appendChild(errText);
modalBody.appendChild(errDiv);
});
}
// Wire up preview buttons to call the reusable refreshPreview function
document.querySelectorAll('.preview-payslip-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var workerId = this.dataset.workerId;
var modalBody = document.getElementById('previewPayslipBody');
// Show modal first, then fetch data
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewPayslipModal')).show();
refreshPreview(workerId, modalBody);
});
});
// =================================================================
// PAY FORM — Disable button after click to prevent double-submission.
// Shows a spinner so the user knows the payment is being processed.
// Backend also has select_for_update() as a safety net in case the
// frontend disable fails (slow JS on mobile, etc.).
// =================================================================
document.querySelectorAll('.pay-form').forEach(function(form) {
form.addEventListener('submit', function(e) {
var btn = this.querySelector('button[type="submit"]');
// If already disabled, block the second submit entirely
if (btn.disabled) {
e.preventDefault();
return false;
}
btn.disabled = true;
// Replace button content with a spinner + "Processing..."
while (btn.firstChild) btn.removeChild(btn.firstChild);
var spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm me-2';
spinner.setAttribute('role', 'status');
btn.appendChild(spinner);
btn.appendChild(document.createTextNode('Processing...'));
});
});
// =================================================================
// === BATCH PAY FLOW ===
// Step 1: Click "Batch Pay" → fetch preview → show confirmation modal
// Step 2: Click "Confirm & Pay All" → POST to batch_pay → redirect
// =================================================================
// --- Helper: Format number as money (e.g., 45320 → "45,320.00") ---
function formatMoney(num) {
return Number(num).toLocaleString('en-ZA', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// --- Helper: Build the "Skipped workers" collapsible section ---
function buildSkippedSection(skipped) {
var div = document.createElement('div');
div.className = 'mt-2';
var toggle = document.createElement('a');
toggle.href = '#';
toggle.className = 'text-muted small';
var warnIcon = document.createElement('i');
warnIcon.className = 'fas fa-exclamation-triangle me-1';
toggle.appendChild(warnIcon);
toggle.appendChild(document.createTextNode(skipped.length + ' worker(s) skipped '));
var chevron = document.createElement('i');
chevron.className = 'fas fa-chevron-down ms-1';
toggle.appendChild(chevron);
div.appendChild(toggle);
var list = document.createElement('div');
list.style.display = 'none';
list.className = 'mt-2';
var ul = document.createElement('ul');
ul.className = 'list-unstyled small text-muted mb-0';
skipped.forEach(function(s) {
var li = document.createElement('li');
li.textContent = s.worker_name + ' — ' + s.reason;
ul.appendChild(li);
});
list.appendChild(ul);
div.appendChild(list);
toggle.addEventListener('click', function(e) {
e.preventDefault();
list.style.display = list.style.display === 'none' ? 'block' : 'none';
});
return div;
}
// --- Helper: Update batch summary when checkboxes change ---
function updateBatchSummary(data, summaryEl) {
var checked = document.querySelectorAll('.batch-worker-cb:checked');
var count = checked.length;
var total = 0;
checked.forEach(function(cb) {
var idx = parseInt(cb.dataset.index);
total += data.eligible[idx].net_pay;
});
// Clear and rebuild summary safely
while (summaryEl.firstChild) summaryEl.removeChild(summaryEl.firstChild);
var leftSpan = document.createElement('span');
var strong = document.createElement('strong');
strong.textContent = count + ' worker(s)';
leftSpan.appendChild(strong);
leftSpan.appendChild(document.createTextNode(' selected for payment'));
summaryEl.appendChild(leftSpan);
var rightSpan = document.createElement('span');
rightSpan.className = 'fw-bold';
rightSpan.textContent = 'Total: R ' + formatMoney(total);
summaryEl.appendChild(rightSpan);
}
// --- Helper: Build a table row for an eligible worker ---
function buildWorkerRow(w, idx) {
var tr = document.createElement('tr');
// Checkbox cell
var tdCb = document.createElement('td');
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'batch-worker-cb';
cb.dataset.index = idx;
cb.checked = true;
tdCb.appendChild(cb);
tr.appendChild(tdCb);
// Worker name
var tdName = document.createElement('td');
tdName.textContent = w.worker_name;
tr.appendChild(tdName);
// Team badge
var tdTeam = document.createElement('td');
var badge = document.createElement('span');
badge.className = 'badge bg-secondary';
badge.textContent = w.team_name;
tdTeam.appendChild(badge);
tr.appendChild(tdTeam);
// Period
var tdPeriod = document.createElement('td');
var small = document.createElement('small');
small.textContent = w.period;
tdPeriod.appendChild(small);
tr.appendChild(tdPeriod);
// Days
var tdDays = document.createElement('td');
tdDays.className = 'text-end';
tdDays.textContent = w.days;
tr.appendChild(tdDays);
// Net pay
var tdNet = document.createElement('td');
tdNet.className = 'text-end fw-bold';
tdNet.textContent = 'R ' + formatMoney(w.net_pay);
tr.appendChild(tdNet);
return tr;
}
var batchPayBtn = document.getElementById('batchPayBtn');
if (batchPayBtn) {
batchPayBtn.addEventListener('click', function() {
var modal = new bootstrap.Modal(document.getElementById('batchPayModal'));
var body = document.getElementById('batchPayModalBody');
var footer = document.getElementById('batchPayModalFooter');
footer.style.display = 'none';
// Show loading spinner
while (body.firstChild) body.removeChild(body.firstChild);
var loadDiv = document.createElement('div');
loadDiv.className = 'text-center py-4';
var spinner = document.createElement('div');
spinner.className = 'spinner-border text-primary';
spinner.setAttribute('role', 'status');
loadDiv.appendChild(spinner);
var loadText = document.createElement('p');
loadText.className = 'text-muted mt-2 small';
loadText.textContent = 'Calculating pay periods...';
loadDiv.appendChild(loadText);
body.appendChild(loadDiv);
modal.show();
// Fetch batch pay preview (dry run — no payments made)
fetch('/payroll/batch-pay/preview/')
.then(function(resp) { return resp.json(); })
.then(function(data) {
while (body.firstChild) body.removeChild(body.firstChild);
// --- No eligible workers ---
if (data.eligible.length === 0) {
var noData = document.createElement('div');
noData.className = 'text-center py-4';
var icon = document.createElement('i');
icon.className = 'fas fa-check-circle fa-3x text-muted mb-3 d-block';
noData.appendChild(icon);
var msg = document.createElement('p');
msg.className = 'text-muted';
msg.textContent = 'No workers eligible for batch payment — no completed pay periods with unpaid work.';
noData.appendChild(msg);
body.appendChild(noData);
if (data.skipped.length > 0) {
body.appendChild(buildSkippedSection(data.skipped));
}
return;
}
// --- Summary header ---
var summary = document.createElement('div');
summary.className = 'alert alert-info d-flex justify-content-between align-items-center mb-3';
var leftSpan = document.createElement('span');
var strong = document.createElement('strong');
strong.textContent = data.worker_count + ' worker(s)';
leftSpan.appendChild(strong);
leftSpan.appendChild(document.createTextNode(' eligible for payment'));
summary.appendChild(leftSpan);
var rightSpan = document.createElement('span');
rightSpan.className = 'fw-bold';
rightSpan.textContent = 'Total: R ' + formatMoney(data.total_amount);
summary.appendChild(rightSpan);
body.appendChild(summary);
// --- Eligible workers table ---
var table = document.createElement('table');
table.className = 'table table-sm table-hover mb-3';
// Table header
var thead = document.createElement('thead');
var headerRow = document.createElement('tr');
var thCb = document.createElement('th');
thCb.style.width = '30px';
var selectAllCb = document.createElement('input');
selectAllCb.type = 'checkbox';
selectAllCb.id = 'batchSelectAll';
selectAllCb.checked = true;
thCb.appendChild(selectAllCb);
headerRow.appendChild(thCb);
['Worker', 'Team', 'Period'].forEach(function(text) {
var th = document.createElement('th');
th.textContent = text;
headerRow.appendChild(th);
});
['Days', 'Net Pay'].forEach(function(text) {
var th = document.createElement('th');
th.className = 'text-end';
th.textContent = text;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
// Table body
var tbody = document.createElement('tbody');
data.eligible.forEach(function(w, idx) {
tbody.appendChild(buildWorkerRow(w, idx));
});
table.appendChild(tbody);
body.appendChild(table);
// --- Select All checkbox behavior ---
selectAllCb.addEventListener('change', function() {
var cbs = body.querySelectorAll('.batch-worker-cb');
for (var i = 0; i < cbs.length; i++) {
cbs[i].checked = selectAllCb.checked;
}
updateBatchSummary(data, summary);
});
// Update summary when individual checkboxes change
body.addEventListener('change', function(e) {
if (e.target.classList.contains('batch-worker-cb')) {
updateBatchSummary(data, summary);
var allCbs = body.querySelectorAll('.batch-worker-cb');
var allChecked = true;
for (var i = 0; i < allCbs.length; i++) {
if (!allCbs[i].checked) { allChecked = false; break; }
}
selectAllCb.checked = allChecked;
}
});
// --- Skipped workers (collapsible) ---
if (data.skipped.length > 0) {
body.appendChild(buildSkippedSection(data.skipped));
}
// Store data for the confirm button
window._batchPayData = data.eligible;
// Show footer with Confirm button
footer.style.display = '';
// Reset confirm button state
var cBtn = document.getElementById('confirmBatchPayBtn');
cBtn.disabled = false;
while (cBtn.firstChild) cBtn.removeChild(cBtn.firstChild);
var btnIcon = document.createElement('i');
btnIcon.className = 'fas fa-money-bill-wave me-1';
cBtn.appendChild(btnIcon);
cBtn.appendChild(document.createTextNode('Confirm & Pay All'));
})
.catch(function() {
while (body.firstChild) body.removeChild(body.firstChild);
var errDiv = document.createElement('div');
errDiv.className = 'alert alert-danger';
errDiv.textContent = 'Failed to load batch preview. Please try again.';
body.appendChild(errDiv);
});
});
}
// --- Confirm & Pay All button ---
var confirmBatchBtn = document.getElementById('confirmBatchPayBtn');
if (confirmBatchBtn) {
confirmBatchBtn.addEventListener('click', function() {
var btn = this;
// Gather checked workers
var workers = [];
document.querySelectorAll('.batch-worker-cb:checked').forEach(function(cb) {
var idx = parseInt(cb.dataset.index);
var w = window._batchPayData[idx];
workers.push({
worker_id: w.worker_id,
log_ids: w.log_ids,
adj_ids: w.adj_ids,
});
});
if (workers.length === 0) {
alert('No workers selected. Check at least one worker to proceed.');
return;
}
// Disable button and show processing state
btn.disabled = true;
while (btn.firstChild) btn.removeChild(btn.firstChild);
var sp = document.createElement('span');
sp.className = 'spinner-border spinner-border-sm me-2';
sp.setAttribute('role', 'status');
btn.appendChild(sp);
btn.appendChild(document.createTextNode('Processing ' + workers.length + ' payment(s)...'));
// POST to batch pay endpoint
fetch('/payroll/batch-pay/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}',
},
body: JSON.stringify({ workers: workers }),
}).then(function() {
// Redirect to refresh page and show Django success messages
window.location.href = '/payroll/';
}).catch(function() {
btn.disabled = false;
while (btn.firstChild) btn.removeChild(btn.firstChild);
var retryIcon = document.createElement('i');
retryIcon.className = 'fas fa-money-bill-wave me-1';
btn.appendChild(retryIcon);
btn.appendChild(document.createTextNode('Confirm & Pay All'));
alert('Batch payment failed. Please try again.');
});
});
}
}); // end DOMContentLoaded
</script>
{% endblock %}