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