38686-vm/core/templates/core/payroll_dashboard.html
Konrad du Plessis 19e565a088 Fix payroll dashboard JS crash + add calendar view to work history
1. Fix json_script double-encoding bug: payroll_dashboard view was
   passing json.dumps() strings to template context, then json_script
   filter serialized them AGAIN. JavaScript received strings instead
   of arrays, crashing the entire DOMContentLoaded handler and
   preventing preview, edit/delete, and other features from working.
   Fix: pass raw Python objects, let json_script handle serialization.

2. Add defense-in-depth: wrap Chart.js initialization in try-catch
   blocks and use Bootstrap getOrCreateInstance() for modals.

3. Add calendar view to work history: monthly grid with day cells
   showing work log indicators, click-to-see-details panel, month
   navigation, and responsive mobile layout. Ported from V2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:31:32 +02:00

1179 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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-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 === #}
<div class="row g-4 mb-4">
{# Outstanding Total #}
<div class="col-xl-3 col-md-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<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>
</div>
<div class="col-auto">
<i class="fas fa-exclamation-circle fa-2x text-danger opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
{# Recent Payments #}
<div class="col-xl-3 col-md-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<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>
<div class="col-auto">
<i class="fas fa-check-circle fa-2x text-success opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
{# Outstanding by Project #}
<div class="col-xl-3 col-md-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #3b82f6;">
Outstanding by Project</div>
<div class="mb-0 text-gray-800" style="font-size: 0.85rem; max-height: 60px; overflow-y: auto;">
{% if outstanding_project_costs %}
<ul class="list-unstyled mb-0">
{% for pc in outstanding_project_costs %}
<li><strong>{{ pc.name }}:</strong> R {{ pc.cost|floatformat:2 }}</li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
<div class="col-auto">
<i class="fas fa-chart-pie fa-2x text-primary opacity-50"></i>
</div>
</div>
</div>
</div>
</div>
{# Loans #}
<div class="col-xl-3 col-md-6">
<div class="card stat-card h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs font-weight-bold text-uppercase mb-1" style="color: #f59e0b;">
Active Loans ({{ active_loans_count }})</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
R {{ active_loans_balance|floatformat:2 }}
</div>
</div>
<div class="col-auto">
<i class="fas fa-hand-holding-usd fa-2x text-warning opacity-50"></i>
</div>
</div>
</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">
<h6 class="m-0 font-weight-bold" style="color: #0f172a;">Monthly Payroll Totals</h6>
</div>
<div class="card-body">
<canvas id="monthlyChart" height="200"></canvas>
</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
</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' %}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' %}+{% 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">
<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' %}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 Loans
</a>
<a href="?status=loans&loan_status=history"
class="btn btn-sm {% if loan_filter == 'history' %}btn-accent{% else %}btn-outline-secondary{% endif %}">
Loan 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">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">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="6" 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.{% else %}No loan 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 #}
<div class="col-md-6" id="addAdjProjectGroup">
<label class="form-label">Project</label>
<select name="project" class="form-select" id="addAdjProject">
<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">
<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>
</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>
</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 --- #}
<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</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" }}
<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);
// === 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);
}
}
}
},
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);
}
// =================================================================
// 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
// =================================================================
const addAdjType = document.getElementById('addAdjType');
const addAdjProjectGroup = document.getElementById('addAdjProjectGroup');
const addAdjTeamSelect = document.getElementById('addAdjTeamSelect');
// Show/hide project field based on adjustment type
function toggleProjectField() {
if (!addAdjType || !addAdjProjectGroup) return;
// Loan and Loan Repayment don't need a project
const noProjectTypes = ['New Loan', 'Loan Repayment'];
addAdjProjectGroup.style.display = noProjectTypes.indexOf(addAdjType.value) !== -1 ? 'none' : '';
}
if (addAdjType) {
addAdjType.addEventListener('change', toggleProjectField);
toggleProjectField();
}
// Team quick-select: check workers in that team
if (addAdjTeamSelect) {
addAdjTeamSelect.addEventListener('change', function() {
const teamId = this.value;
if (!teamId) return;
const workerIds = teamWorkersMap[teamId] || [];
document.querySelectorAll('.add-adj-worker').forEach(function(cb) {
if (workerIds.indexOf(parseInt(cb.value)) !== -1) {
cb.checked = true;
}
});
});
}
// =================================================================
// 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)
// =================================================================
document.querySelectorAll('.preview-payslip-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const workerId = this.dataset.workerId;
const workerName = this.dataset.workerName;
const modalBody = document.getElementById('previewPayslipBody');
// Show loading spinner (safe hardcoded content)
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
const loadingDiv = document.createElement('div');
loadingDiv.className = 'text-center py-4';
const spinner = document.createElement('div');
spinner.className = 'spinner-border text-primary';
spinner.setAttribute('role', 'status');
loadingDiv.appendChild(spinner);
const loadingText = document.createElement('p');
loadingText.className = 'text-muted mt-2 small';
loadingText.textContent = 'Loading preview...';
loadingDiv.appendChild(loadingText);
modalBody.appendChild(loadingDiv);
// Show modal
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewPayslipModal')).show();
// 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 ===
// Worker header
const header = document.createElement('div');
header.className = 'border-bottom pb-3 mb-3';
const h4 = document.createElement('h4');
h4.className = 'mb-1';
h4.textContent = data.worker_name;
header.appendChild(h4);
if (data.worker_id_number) {
const idP = document.createElement('p');
idP.className = 'text-muted small mb-0';
idP.textContent = 'ID: ' + data.worker_id_number;
header.appendChild(idP);
}
modalBody.appendChild(header);
// Earnings section
const earningsH6 = document.createElement('h6');
earningsH6.className = 'fw-bold mb-2';
earningsH6.textContent = 'Earnings';
modalBody.appendChild(earningsH6);
const earningsRow = document.createElement('div');
earningsRow.className = 'd-flex justify-content-between mb-1';
const earningsLabel = document.createElement('span');
earningsLabel.textContent = data.days_worked + ' day(s) × ' + fmt(data.day_rate);
earningsRow.appendChild(earningsLabel);
const earningsVal = document.createElement('strong');
earningsVal.textContent = fmt(data.log_amount);
earningsRow.appendChild(earningsVal);
modalBody.appendChild(earningsRow);
// Adjustments section
if (data.adjustments && data.adjustments.length > 0) {
const adjHr = document.createElement('hr');
modalBody.appendChild(adjHr);
const adjH6 = document.createElement('h6');
adjH6.className = 'fw-bold mb-2';
adjH6.textContent = 'Adjustments';
modalBody.appendChild(adjH6);
data.adjustments.forEach(function(adj) {
const row = document.createElement('div');
row.className = 'd-flex justify-content-between mb-1';
const label = document.createElement('span');
label.textContent = adj.type + (adj.project ? ' (' + adj.project + ')' : '');
if (adj.description) {
const descSmall = document.createElement('small');
descSmall.className = 'text-muted ms-1';
descSmall.textContent = '— ' + adj.description;
label.appendChild(descSmall);
}
row.appendChild(label);
const val = document.createElement('span');
val.className = adj.sign === '+' ? 'text-success' : 'text-danger';
val.textContent = adj.sign + fmt(adj.amount);
row.appendChild(val);
modalBody.appendChild(row);
});
}
// Net pay
const netHr = document.createElement('hr');
netHr.className = 'my-3';
modalBody.appendChild(netHr);
const netRow = document.createElement('div');
netRow.className = 'd-flex justify-content-between';
const netLabel = document.createElement('h5');
netLabel.className = 'fw-bold';
netLabel.textContent = 'Net Pay';
netRow.appendChild(netLabel);
const 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);
// Work log details
if (data.logs && data.logs.length > 0) {
const logsHr = document.createElement('hr');
modalBody.appendChild(logsHr);
const logsH6 = document.createElement('h6');
logsH6.className = 'fw-bold mb-2';
logsH6.textContent = 'Work Log Details';
modalBody.appendChild(logsH6);
const table = document.createElement('table');
table.className = 'table table-sm mb-0';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['Date', 'Project'].forEach(function(h) {
const th = document.createElement('th');
th.textContent = h;
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
data.logs.forEach(function(log) {
const tr = document.createElement('tr');
tr.appendChild(createTd(log.date));
tr.appendChild(createTd(log.project));
tbody.appendChild(tr);
});
table.appendChild(tbody);
modalBody.appendChild(table);
}
})
.catch(function() {
// Show error (safe hardcoded content)
while (modalBody.firstChild) modalBody.removeChild(modalBody.firstChild);
const errDiv = document.createElement('div');
errDiv.className = 'text-center py-4 text-danger';
const errIcon = document.createElement('i');
errIcon.className = 'fas fa-exclamation-triangle fs-3';
errDiv.appendChild(errIcon);
const errText = document.createElement('p');
errText.className = 'mt-2';
errText.textContent = 'Could not load preview.';
errDiv.appendChild(errText);
modalBody.appendChild(errDiv);
});
});
});
// =================================================================
// PAY FORM — Disable button after click to prevent double-submission
// =================================================================
document.querySelectorAll('.pay-form').forEach(function(form) {
form.addEventListener('submit', function() {
const btn = this.querySelector('button[type="submit"]');
btn.disabled = true;
btn.textContent = 'Processing...';
});
});
}); // end DOMContentLoaded
</script>
{% endblock %}