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>
1075 lines
51 KiB
HTML
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' %}✎{% 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;">▶ {{ 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 %} |