38156-vm/core/templates/core/payroll_dashboard.html
Konrad du Plessis d6d2d1a8c4 Highlight Advance Payment in type dropdown with separator and color
Adds a visual separator line before Advance Payment in the adjustment
type dropdown and styles it with blue color and a triangle marker to
distinguish it from regular adjustment types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 23:47:07 +02:00

1075 lines
51 KiB
HTML

{% extends 'base.html' %}
{% load humanize %}
{% block title %}Payroll Dashboard - Fox Fitt{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2 fw-bold text-dark">Payroll Dashboard</h1>
<div>
<button class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
+ Add Adjustment
</button>
</div>
</div>
<!-- Analytics Section -->
<div class="row mb-5">
<!-- Outstanding Payments (Global) -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100 bg-warning bg-opacity-10">
<div class="card-body">
<h6 class="text-uppercase text-muted fw-bold small">Outstanding Payments</h6>
<div class="display-6 fw-bold text-dark">R {{ outstanding_total|intcomma }}</div>
<p class="text-muted small mb-0">Total pending (including adjustments)</p>
</div>
</div>
</div>
<!-- Recent Payments -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100 bg-success bg-opacity-10">
<div class="card-body">
<h6 class="text-uppercase text-muted fw-bold small">Recent Payments (2 Months)</h6>
<div class="display-6 fw-bold text-dark">R {{ recent_payments_total|intcomma }}</div>
<p class="text-muted small mb-0">Total paid out</p>
</div>
</div>
</div>
<!-- Outstanding Project Costs -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100 bg-danger bg-opacity-10">
<div class="card-header bg-transparent border-0 fw-bold small text-uppercase text-muted pb-0">Outstanding by Project</div>
<div class="card-body overflow-auto pt-2" style="max-height: 150px;">
{% if outstanding_project_costs %}
<ul class="list-group list-group-flush bg-transparent">
{% for p in outstanding_project_costs %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0 py-2 bg-transparent border-bottom border-danger border-opacity-25">
<span>{{ p.name }}</span>
<span class="fw-bold text-dark">R {{ p.cost|intcomma }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted small mb-0">No outstanding project costs.</p>
{% endif %}
</div>
</div>
</div>
<!-- Project Costs (Total History) -->
<div class="col-xl-3 col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-bold small text-uppercase text-muted">Project Costs (History)</div>
<div class="card-body overflow-auto" style="max-height: 150px;">
{% if project_costs %}
<ul class="list-group list-group-flush">
{% for p in project_costs %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0 py-2">
<span>{{ p.name }}</span>
<span class="fw-bold text-dark">R {{ p.cost|intcomma }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted small mb-0">No active project costs.</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Charts Section -->
<div class="row mb-3">
<div class="col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-bold small text-uppercase text-muted">Monthly Payroll Totals</div>
<div class="card-body">
<canvas id="payrollTotalsChart" height="220"></canvas>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-bold small text-uppercase text-muted">Labour Cost per Project</div>
<div class="card-body">
<canvas id="projectCostsChart" height="220"></canvas>
</div>
</div>
</div>
</div>
<!-- Overtime Chart Row -->
<div class="row mb-5">
<div class="col-12">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-bold small text-uppercase text-muted">Overtime History</div>
<div class="card-body">
<canvas id="otHistoryChart" height="150"></canvas>
</div>
</div>
</div>
</div>
<!-- Filter Tabs -->
<ul class="nav nav-pills mb-4">
<li class="nav-item">
<a class="nav-link {% if active_tab == 'pending' %}active{% endif %}" href="?status=pending">Pending Payments</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'paid' %}active{% endif %}" href="?status=paid">Payment History</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'all' %}active{% endif %}" href="?status=all">All Records</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'loans' %}active{% endif %}" href="?status=loans">Loans</a>
</li>
</ul>
<!-- Pending Payments Table -->
{% if active_tab == 'pending' or active_tab == 'all' %}
{% if workers_data %}
<div class="card border-0 shadow-sm mb-5">
<div class="card-header bg-white fw-bold">Pending Payments</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">Worker Name</th>
<th>Breakdown</th>
<th>Net Payable</th>
<th class="text-end pe-4">Action</th>
</tr>
</thead>
<tbody>
{% for item in workers_data %}
<tr>
<td class="ps-4">
<div class="fw-bold text-dark">
{{ item.worker.name }}
{% if item.ot_hours_unpriced > 0 %}
<span class="badge bg-warning text-dark ms-2">OT: {{ item.ot_hours_unpriced|floatformat:2 }} Days</span>
{% endif %}
</div>
<div class="small text-muted">ID: {{ item.worker.id_no }}</div>
{% if item.adjustments %}
<div class="mt-1">
{% for adj in item.adjustments %}
<span class="badge {% if adj.type == 'ADVANCE' %}bg-info text-dark{% else %}bg-secondary opacity-75{% endif %} small adj-badge" role="button"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-type-display="{{ adj.get_type_display }}"
data-adj-amount="{{ adj.amount }}"
data-adj-description="{{ adj.description }}"
data-adj-date="{{ adj.date|date:'Y-m-d' }}"
data-adj-worker="{{ item.worker.name }}"
title="{% if adj.type == 'ADVANCE' %}Click to delete (cannot edit){% else %}Click to edit{% endif %}">
{{ adj.get_type_display }}: R {{ adj.amount }} {% if adj.type != 'ADVANCE' %}&#9998;{% endif %}
</span>
{% endfor %}
</div>
{% endif %}
</td>
<td>
<div class="small">
<div>Work: {{ item.unpaid_count }} days (R {{ item.unpaid_amount|intcomma }})</div>
<div class="{% if item.adj_amount < 0 %}text-danger{% else %}text-success{% endif %}">
Adjustments: R {{ item.adj_amount|intcomma }}
</div>
</div>
</td>
<td class="fw-bold fs-5 {% if item.total_payable < 0 %}text-danger{% else %}text-success{% endif %}">
R {{ item.total_payable|intcomma }}
</td>
<td class="text-end pe-4">
{% if item.total_payable > 0 or item.has_pending_advances %}
<div class="d-flex gap-1 justify-content-end">
{% if item.ot_hours_unpriced > 0 %}
<button type="button" class="btn btn-sm btn-warning text-dark price-ot-btn"
data-worker-id="{{ item.worker.id }}" data-worker-name="{{ item.worker.name }}">
Price OT
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-secondary preview-payslip-btn"
data-worker-id="{{ item.worker.id }}">Preview</button>
<form action="{% url 'process_payment' item.worker.id %}" method="post" class="d-inline">
{% csrf_token %}
{% if item.total_payable > 0 %}
<button type="submit" class="btn btn-sm btn-success"
onclick="return confirm('Confirm payment of R {{ item.total_payable }} to {{ item.worker.name }}? This will email the receipt.')">
Pay Now
</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-success"
onclick="return confirm('Close out R0 balance for {{ item.worker.name }}? Advances cover all earned wages. This will mark work logs as paid.')">
Close Out (R0)
</button>
{% endif %}
</form>
</div>
{% else %}
<button class="btn btn-sm btn-secondary" disabled>Nothing to Pay</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% elif active_tab == 'pending' %}
<div class="alert alert-info shadow-sm mb-4">
<div class="d-flex align-items-center">
<div class="fs-4 me-3">🎉</div>
<div>
<strong>All caught up!</strong><br>
There are no outstanding payments for active workers.
</div>
</div>
</div>
{% endif %}
{% endif %}
<!-- Payment History Table -->
{% if active_tab == 'paid' or active_tab == 'all' %}
{% if paid_records %}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-bold">Payment History</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">Date</th>
<th>Payslip ID</th>
<th>Worker</th>
<th>Net Amount</th>
<th class="text-end pe-4">Action</th>
</tr>
</thead>
<tbody>
{% for record in paid_records %}
<tr>
<td class="ps-4">{{ record.date|date:"M d, Y" }}</td>
<td class="text-muted">#{{ record.id|stringformat:"06d" }}</td>
<td>
<div class="fw-bold">{{ record.worker.name }}</div>
<div class="small text-muted">{{ record.worker.id_no }}</div>
</td>
<td class="fw-bold">R {{ record.amount|intcomma }}</td>
<td class="text-end pe-4">
<a href="{% url 'payslip_detail' record.id %}" class="btn btn-sm btn-outline-primary">
View Payslip
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="alert alert-light border shadow-sm">
No payment history found.
</div>
{% endif %}
{% endif %}
<!-- Loans Section -->
{% if active_tab == 'loans' %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link {% if loan_filter == 'active' %}active{% endif %}" href="?status=loans&loan_status=active">Outstanding</a>
</li>
<li class="nav-item">
<a class="nav-link {% if loan_filter == 'history' %}active{% endif %}" href="?status=loans&loan_status=history">Repaid</a>
</li>
</ul>
</div>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addLoanModal">
+ New Loan
</button>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4">Date Issued</th>
<th>Worker</th>
<th>Original Amount</th>
<th>Balance</th>
<th>Reason</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for loan in loans %}
<tr>
<td class="ps-4 text-nowrap">{{ loan.date|date:"M d, Y" }}</td>
<td class="fw-medium">{{ loan.worker.name }}</td>
<td>R {{ loan.amount }}</td>
<td class="fw-bold {% if loan.balance > 0 %}text-danger{% else %}text-success{% endif %}">
R {{ loan.balance }}
</td>
<td><small class="text-muted">{{ loan.reason|default:"-" }}</small></td>
<td>
{% if loan.is_active %}
<span class="badge bg-warning text-dark">Active</span>
{% else %}
<span class="badge bg-success">Repaid</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
No loans found in this category.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Price Overtime Modal -->
<div class="modal fade" id="priceOTModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold">Price Overtime for <span id="otModalWorkerName"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row mb-3 align-items-end">
<div class="col-md-4">
<label class="form-label fw-bold">Bulk Rate %</label>
<div class="input-group">
<input type="number" id="otBulkRate" class="form-control" value="50" min="0" step="5">
<button class="btn btn-outline-secondary" type="button" id="otApplyBulk">Apply to All</button>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Project</th>
<th>OT Duration</th>
<th style="width: 150px;">Rate %</th>
</tr>
</thead>
<tbody id="otModalTableBody">
<!-- Rows injected by JS -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="otSaveBtn">Create Adjustments</button>
</div>
</div>
</div>
</div>
<!-- Add Loan Modal -->
<div class="modal fade" id="addLoanModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold">Issue New Loan</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'add_loan' %}" method="POST">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Worker</label>
<select name="worker" class="form-select" required>
<option value="">Select a worker...</option>
{% for worker in all_workers %}
<option value="{{ worker.id }}">{{ worker.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Amount (R)</label>
<input type="number" name="amount" class="form-control" step="0.01" min="1" required>
</div>
<div class="mb-3">
<label class="form-label">Date Issued</label>
<input type="date" name="date" class="form-control" value="{% now 'Y-m-d' %}">
</div>
<div class="mb-3">
<label class="form-label">Reason / Notes</label>
<textarea name="reason" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Loan</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Adjustment Modal -->
<div class="modal fade" id="addAdjustmentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title fw-bold">Add Payroll Adjustment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'add_adjustment' %}" method="POST">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Workers</label>
<div class="mb-2 d-flex gap-2 align-items-center">
<select id="adjTeamFilter" class="form-select form-select-sm" style="max-width: 200px;">
<option value="">Filter 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 id="adjWorkerCheckboxes" class="border rounded p-2" style="max-height: 200px; overflow-y: auto;">
{% for worker in all_workers %}
<div class="form-check">
<input class="form-check-input adj-worker-cb" type="checkbox" name="workers" value="{{ worker.id }}" id="adjWorker{{ worker.id }}">
<label class="form-check-label" for="adjWorker{{ worker.id }}">{{ worker.name }}</label>
</div>
{% endfor %}
</div>
<div class="form-text mt-1"><span id="adjSelectedCount">0</span> worker(s) selected</div>
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" class="form-select" required>
{% for code, label in adjustment_types %}
{% if code == 'ADVANCE' %}
<option disabled>────────────────</option>
<option value="{{ code }}" style="color: #0d6efd; font-weight: bold;">&#9654; {{ label }}</option>
{% else %}
<option value="{{ code }}">{{ label }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Amount (R)</label>
<input type="number" name="amount" class="form-control" step="0.01" min="1" required>
<div class="form-text">For deductions, enter a positive number. It will be subtracted automatically.</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" name="description" class="form-control" placeholder="e.g. Public Holiday Bonus" required>
</div>
<div class="mb-3">
<label class="form-label">Date</label>
<input type="date" name="date" class="form-control" value="{% now 'Y-m-d' %}">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save 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">
<div class="modal-header">
<h5 class="modal-title fw-bold">Edit Adjustment — <span id="editAdjWorkerName"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="editAdjForm" method="POST">
{% csrf_token %}
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" id="editAdjType" class="form-select">
{% for code, label in adjustment_types %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
</select>
<div class="form-text" id="editAdjTypeHint" style="display:none;">Type cannot be changed for Loan, Overtime, or Repayment adjustments.</div>
</div>
<div class="mb-3">
<label class="form-label">Amount (R)</label>
<input type="number" name="amount" id="editAdjAmount" class="form-control" step="0.01" min="0.01" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" name="description" id="editAdjDescription" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Date</label>
<input type="date" name="date" id="editAdjDate" class="form-control" required>
</div>
</div>
<div class="modal-footer d-flex justify-content-between">
<button type="button" class="btn btn-outline-danger" id="editAdjDeleteBtn">Delete</button>
<div>
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Adjustment Confirmation Modal -->
<div class="modal fade" id="deleteAdjustmentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header bg-danger bg-opacity-10">
<h5 class="modal-title fw-bold 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 class="mb-1">Delete <strong><span id="deleteAdjTypeDisplay"></span></strong> of <strong>R <span id="deleteAdjAmount"></span></strong>?</p>
<div id="deleteAdjWarning" class="alert alert-warning small mt-2" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<form id="deleteAdjForm" method="POST" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Yes, Delete</button>
</form>
</div>
</div>
</div>
</div>
<!-- Advance Confirmation Modal -->
<div class="modal fade" id="advanceConfirmModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-info bg-opacity-10">
<h5 class="modal-title fw-bold">Confirm Advance Payment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-info small mb-3">
This will immediately create a payslip for each worker below and send it to Spark Receipt. This cannot be undone.
</div>
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Worker</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody id="advanceConfirmWorkerList"></tbody>
<tfoot>
<tr class="fw-bold">
<td>Total</td>
<td class="text-end" id="advanceConfirmTotal"></td>
</tr>
</tfoot>
</table>
<div class="mt-2 small text-muted" id="advanceConfirmDescription"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-info" id="advanceConfirmSubmit">Yes, Send Advance</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Team → Worker mapping for adjustment modal
const teamWorkersMap = {{ team_workers_map_json|safe }};
// Worker checkboxes: Select All / Clear / Team filter / Counter
const allWorkerCbs = document.querySelectorAll('.adj-worker-cb');
const adjSelectedCount = document.getElementById('adjSelectedCount');
const adjTeamFilter = document.getElementById('adjTeamFilter');
const adjSelectAll = document.getElementById('adjSelectAll');
const adjDeselectAll = document.getElementById('adjDeselectAll');
function updateWorkerCount() {
const count = document.querySelectorAll('.adj-worker-cb:checked').length;
adjSelectedCount.textContent = count;
}
// Update count when any checkbox changes
allWorkerCbs.forEach(function(cb) {
cb.addEventListener('change', updateWorkerCount);
});
if (adjSelectAll) {
adjSelectAll.addEventListener('click', function(e) {
e.preventDefault();
allWorkerCbs.forEach(function(cb) { cb.checked = true; });
updateWorkerCount();
});
}
if (adjDeselectAll) {
adjDeselectAll.addEventListener('click', function(e) {
e.preventDefault();
allWorkerCbs.forEach(function(cb) { cb.checked = false; });
if (adjTeamFilter) adjTeamFilter.value = '';
updateWorkerCount();
});
}
// Team dropdown: auto-select workers in that team
if (adjTeamFilter) {
adjTeamFilter.addEventListener('change', function() {
const teamId = this.value;
if (!teamId) return; // "Filter by team..." selected — do nothing
const workerIds = teamWorkersMap[teamId] || [];
allWorkerCbs.forEach(function(cb) {
if (workerIds.includes(parseInt(cb.value))) {
cb.checked = true;
}
});
updateWorkerCount();
});
}
// Form validation: require at least one worker checked
const addAdjForm = document.querySelector('#addAdjustmentModal form');
if (addAdjForm) {
const advanceConfirmModalEl = document.getElementById('advanceConfirmModal');
const advanceConfirmModal = new bootstrap.Modal(advanceConfirmModalEl);
const addAdjModal = bootstrap.Modal.getInstance(document.getElementById('addAdjustmentModal')) ||
new bootstrap.Modal(document.getElementById('addAdjustmentModal'));
addAdjForm.addEventListener('submit', function(e) {
// Check at least one worker is selected
const checkedWorkers = document.querySelectorAll('.adj-worker-cb:checked');
if (checkedWorkers.length === 0) {
e.preventDefault();
alert('Please select at least one worker.');
return;
}
// Advance confirmation
const typeSelect = addAdjForm.querySelector('select[name="type"]');
if (typeSelect.value !== 'ADVANCE') return; // let non-ADVANCE submit normally
e.preventDefault();
// Gather selected worker names
const selectedWorkers = [];
checkedWorkers.forEach(function(cb) {
const label = document.querySelector('label[for="' + cb.id + '"]');
selectedWorkers.push(label ? label.textContent : cb.value);
});
const amount = addAdjForm.querySelector('input[name="amount"]').value;
const description = addAdjForm.querySelector('input[name="description"]').value;
// Populate confirmation modal using safe DOM methods
const tbody = document.getElementById('advanceConfirmWorkerList');
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
selectedWorkers.forEach(function(name) {
const tr = document.createElement('tr');
const tdName = document.createElement('td');
tdName.textContent = name;
const tdAmount = document.createElement('td');
tdAmount.className = 'text-end';
tdAmount.textContent = 'R ' + parseFloat(amount).toFixed(2);
tr.appendChild(tdName);
tr.appendChild(tdAmount);
tbody.appendChild(tr);
});
const total = (selectedWorkers.length * parseFloat(amount)).toFixed(2);
document.getElementById('advanceConfirmTotal').textContent = 'R ' + total + ' (' + selectedWorkers.length + ' worker' + (selectedWorkers.length > 1 ? 's' : '') + ')';
document.getElementById('advanceConfirmDescription').textContent = description ? 'Description: ' + description : '';
// Hide add modal, show confirmation
addAdjModal.hide();
advanceConfirmModal.show();
// Wire confirm button to actually submit
document.getElementById('advanceConfirmSubmit').onclick = function() {
advanceConfirmModal.hide();
addAdjForm.submit();
};
});
}
// Edit/Delete Adjustment Modal Logic
const editModalEl = document.getElementById('editAdjustmentModal');
const deleteModalEl = document.getElementById('deleteAdjustmentModal');
if (editModalEl) {
const editModal = new bootstrap.Modal(editModalEl);
const deleteModal = new bootstrap.Modal(deleteModalEl);
let currentAdjId = null;
let currentAdjType = null;
// Badge click → open Edit modal (or Delete directly for ADVANCE)
document.querySelectorAll('.adj-badge').forEach(function(badge) {
badge.addEventListener('click', function() {
currentAdjId = this.dataset.adjId;
currentAdjType = this.dataset.adjType;
// ADVANCE cannot be edited — go straight to delete confirmation
if (currentAdjType === 'ADVANCE') {
document.getElementById('deleteAdjTypeDisplay').textContent = this.dataset.adjTypeDisplay;
document.getElementById('deleteAdjAmount').textContent = this.dataset.adjAmount;
const warning = document.getElementById('deleteAdjWarning');
warning.textContent = 'The advance payment record and payslip remain in history. Only the pending deduction will be removed from the dashboard.';
warning.style.display = 'block';
document.getElementById('deleteAdjForm').action = '/payroll/adjustment/' + currentAdjId + '/delete/';
deleteModal.show();
return;
}
document.getElementById('editAdjWorkerName').textContent = this.dataset.adjWorker;
document.getElementById('editAdjAmount').value = this.dataset.adjAmount;
document.getElementById('editAdjDescription').value = this.dataset.adjDescription;
document.getElementById('editAdjDate').value = this.dataset.adjDate;
const typeSelect = document.getElementById('editAdjType');
typeSelect.value = currentAdjType;
// Lock type field for non-BONUS/DEDUCTION
const locked = !['BONUS', 'DEDUCTION'].includes(currentAdjType);
typeSelect.disabled = locked;
document.getElementById('editAdjTypeHint').style.display = locked ? 'block' : 'none';
// Set form action
document.getElementById('editAdjForm').action = '/payroll/adjustment/' + currentAdjId + '/edit/';
editModal.show();
});
});
// Delete button in edit modal → open Delete confirmation
document.getElementById('editAdjDeleteBtn').addEventListener('click', function() {
document.getElementById('deleteAdjTypeDisplay').textContent = document.getElementById('editAdjType').selectedOptions[0].text;
document.getElementById('deleteAdjAmount').textContent = document.getElementById('editAdjAmount').value;
// Type-specific warnings
const warning = document.getElementById('deleteAdjWarning');
if (currentAdjType === 'LOAN') {
warning.textContent = 'This will also delete the linked Loan and any unpaid repayment adjustments.';
warning.style.display = 'block';
} else if (currentAdjType === 'OVERTIME') {
warning.textContent = 'This will un-price the overtime so it can be re-priced later.';
warning.style.display = 'block';
} else if (currentAdjType === 'ADVANCE') {
warning.textContent = 'The advance payment record and payslip remain in history. Only the pending deduction will be removed from the dashboard.';
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
document.getElementById('deleteAdjForm').action = '/payroll/adjustment/' + currentAdjId + '/delete/';
editModal.hide();
deleteModal.show();
});
}
const labels = {{ chart_labels_json|safe }};
const totals = {{ chart_totals_json|safe }};
const projectData = {{ project_chart_json|safe }};
const allOtData = {{ overtime_data_json|default:"[]"|safe }};
const otHistoryTotals = {{ ot_history_json|default:"[]"|safe }};
// Overtime Modal Logic
const otModalEl = document.getElementById('priceOTModal');
if (otModalEl) {
const otModal = new bootstrap.Modal(otModalEl);
document.body.addEventListener('click', function(e) {
if (e.target.classList.contains('price-ot-btn')) {
const workerId = parseInt(e.target.dataset.workerId);
const workerName = e.target.dataset.workerName;
document.getElementById('otModalWorkerName').textContent = workerName;
const tbody = document.getElementById('otModalTableBody');
tbody.innerHTML = '';
// Filter data for this worker
const workerLogs = allOtData.filter(d => d.worker_id === workerId);
workerLogs.forEach(log => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${log.date}</td>
<td>${log.project}</td>
<td>${log.ot_label}</td>
<td>
<input type="number" class="form-control form-control-sm ot-rate-input"
data-log-id="${log.log_id}"
data-worker-id="${log.worker_id}"
value="50" min="0" step="5">
</td>
`;
tbody.appendChild(tr);
});
otModal.show();
}
});
const bulkBtn = document.getElementById('otApplyBulk');
if (bulkBtn) {
bulkBtn.addEventListener('click', function() {
const rate = document.getElementById('otBulkRate').value;
document.querySelectorAll('.ot-rate-input').forEach(input => {
input.value = rate;
});
});
}
const saveBtn = document.getElementById('otSaveBtn');
if (saveBtn) {
saveBtn.addEventListener('click', function() {
const form = document.createElement('form');
form.method = 'POST';
form.action = '{% url "price_overtime" %}';
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '{{ csrf_token }}';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = csrfToken;
form.appendChild(csrfInput);
document.querySelectorAll('.ot-rate-input').forEach(input => {
const logId = document.createElement('input');
logId.type = 'hidden';
logId.name = 'log_id';
logId.value = input.dataset.logId;
form.appendChild(logId);
const workerId = document.createElement('input');
workerId.type = 'hidden';
workerId.name = 'worker_id';
workerId.value = input.dataset.workerId;
form.appendChild(workerId);
const rate = document.createElement('input');
rate.type = 'hidden';
rate.name = 'rate_pct';
rate.value = input.value;
form.appendChild(rate);
});
document.body.appendChild(form);
form.submit();
});
}
}
// Colour palette
const colours = ['#10b981','#3b82f6','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#84cc16'];
// Chart 1: Monthly Payroll Totals
new Chart(document.getElementById('payrollTotalsChart'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Paid Out (R)',
data: totals,
backgroundColor: '#10b981',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: { callback: v => 'R ' + v.toLocaleString() }
}
}
}
});
// Chart 2: Per-Project Monthly Costs (stacked)
const projectDatasets = projectData.map(function(p, i) {
return {
label: p.name,
data: p.data,
backgroundColor: colours[i % colours.length],
borderRadius: 2
};
});
new Chart(document.getElementById('projectCostsChart'), {
type: 'bar',
data: {
labels: labels,
datasets: projectDatasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, padding: 15 } }
},
scales: {
x: { stacked: true },
y: {
stacked: true,
beginAtZero: true,
ticks: { callback: v => 'R ' + v.toLocaleString() }
}
}
}
});
// Chart 3: Overtime History (New)
new Chart(document.getElementById('otHistoryChart'), {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Overtime Paid (R)',
data: otHistoryTotals,
backgroundColor: '#f59e0b',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: { callback: v => 'R ' + v.toLocaleString() }
}
}
}
});
});
</script>
<!-- Preview Payslip Modal -->
<div class="modal fade" id="previewPayslipModal" tabindex="-1" aria-labelledby="previewPayslipLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title" id="previewPayslipLabel">Payslip Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="previewPayslipBody">
<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 border-0 pt-0">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const modal = new bootstrap.Modal(document.getElementById('previewPayslipModal'));
const modalBody = document.getElementById('previewPayslipBody');
document.querySelectorAll('.preview-payslip-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const workerId = this.dataset.workerId;
// Show modal with spinner
modalBody.innerHTML = '<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>';
modal.show();
fetch('/payroll/preview/' + workerId + '/', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
const fmt = v => 'R ' + parseFloat(v).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
let html = '';
// Header
html += '<div class="text-center border-bottom pb-3 mb-3">';
html += '<div class="text-muted small text-uppercase">Payment to Beneficiary</div>';
html += '<h3 class="fw-bold mb-0">' + data.worker_name + '</h3>';
html += '<span class="badge bg-warning text-dark">PREVIEW</span>';
html += '</div>';
// Worker info
html += '<div class="bg-light rounded p-3 mb-3 small">';
html += '<div class="row">';
html += '<div class="col-sm-4"><strong>Beneficiary:</strong> ' + data.worker_name + '</div>';
html += '<div class="col-sm-4"><strong>ID Number:</strong> ' + data.worker_id_no + '</div>';
html += '<div class="col-sm-4"><strong>Date:</strong> ' + data.date + '</div>';
html += '</div></div>';
// Items table
html += '<table class="table table-sm mb-3">';
html += '<thead class="table-light"><tr><th>Description</th><th class="text-end">Amount</th></tr></thead><tbody>';
// Base pay
html += '<tr><td>Base Pay (' + data.days_worked + ' days @ ' + fmt(data.day_rate) + '/day)</td>';
html += '<td class="text-end">' + fmt(data.base_pay) + '</td></tr>';
// Adjustments
data.adjustments.forEach(function(adj) {
const sign = adj.is_deduction ? '-' : '+';
const cls = adj.is_deduction ? 'text-danger' : 'text-success';
html += '<tr><td>' + adj.type + ': ' + adj.description + '</td>';
html += '<td class="text-end ' + cls + '">' + sign + ' ' + fmt(adj.amount) + '</td></tr>';
});
html += '</tbody></table>';
// Net pay
const netClass = data.net_pay >= 0 ? 'text-success' : 'text-danger';
html += '<div class="border-top pt-2 text-end">';
html += '<span class="fs-5 fw-bold ' + netClass + '">Net Pay: ' + fmt(data.net_pay) + '</span>';
html += '</div>';
modalBody.innerHTML = html;
})
.catch(function(err) {
modalBody.innerHTML = '<div class="text-center py-4 text-danger"><i class="bi bi-exclamation-triangle fs-3"></i><p class="mt-2">Could not load preview.</p></div>';
});
});
});
});
</script>
{% endblock %}