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>
1179 lines
56 KiB
HTML
1179 lines
56 KiB
HTML
{% extends 'base.html' %}
|
||
{% load static %}
|
||
|
||
{% block title %}Payroll Dashboard | Fox Fitt{% endblock %}
|
||
|
||
{% block content %}
|
||
<!-- Chart.js CDN -->
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||
|
||
<div class="container py-4">
|
||
|
||
{# === PAGE HEADER === #}
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h1 class="h3 mb-0" style="font-family: 'Poppins', sans-serif;">Payroll Dashboard</h1>
|
||
<div class="d-flex gap-2">
|
||
<button type="button" class="btn btn-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 %}
|