38686-vm/core/templates/core/payroll_dashboard.html
Konrad du Plessis 6f66faf06a feat(adjustments): filter bar v2 — unify all 5 filters as pills + density pass
Konrad's feedback on the shipped Adjustments tab: "this interface
layout is very ugly. And the selection dropdown menus text is a bit
large." Plus: the 'Show as' toggle sits too close to the filter bar.

Design doc: docs/plans/2026-04-23-adjustments-filter-bar-v2-design.md

Changes:

1. All 5 filters become pill-popovers of identical shape
   - Type / Workers / Teams: unchanged (already pills)
   - Status: was <select> + <label>, now pill → popover with 3 radios
   - Date: was inline inputs + preset links + '...' toggle, now pill →
     popover with Single/Range mode toggle + picker(s) + presets + OK/Cancel
   - Pill labels update to 'Status: Unpaid' / 'Date: 24 Apr 2026' /
     'Date: 20 Apr – 26 Apr 2026' for at-a-glance state
   - Apply + Clear pushed to right end via .adj-apply-group (margin-left: auto)

2. Popover density pass
   - .adj-checkbox-list / .adj-radio-list font-size 0.8rem (~12.8px)
   - .adj-cb-row padding trimmed to 0.15rem 0.25rem
   - Checkbox visual size 0.9em
   - Popover footer buttons 0.75rem font, 0.25rem 0.6rem padding
   - Popover max-width 360px (was ~420px)
   - 7-type popover drops from ~320px tall to ~240px

3. Spacing fix above 'Show as:' toggle
   - .adj-groupby-toggle now has margin-top: 1rem + margin-bottom: 0.75rem
   - Clear visual separation from the sticky filter bar

4. Filter-bar alignment
   - align-items: center (was end, now all children are same height)
   - Gap tightened to 0.5rem

Backend contract unchanged (query params identical). No test changes
(65/65 still pass). Committed popover JS uses the same
.adj-hidden-inputs pattern as the checkbox filters — Status + Date
each have their own commit/revert logic that rewrites their hidden
inputs on OK. XSS-safe throughout (replaceChildren() + textContent,
no innerHTML with user data).

Gated the generic checkbox-popover OK/Cancel handler to
['type', 'worker', 'team'] so the new Status/Date popovers aren't
accidentally re-committed via commitCheckboxes.
2026-04-23 22:00:27 +02:00

4148 lines
208 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends 'base.html' %}
{% load static %}
{% load format_tags %}
{% block title %}Payroll Dashboard | FoxFitt{% endblock %}
{% block content %}
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script>
// === CHART.JS THEME DEFAULTS ===
// Read CSS variable colours so chart axes/grid lines adapt to dark mode
(function() {
var style = getComputedStyle(document.documentElement);
var textColor = style.getPropertyValue('--text-secondary').trim() || '#64748b';
var borderColor = style.getPropertyValue('--border-default').trim() || '#e2e8f0';
if (typeof Chart !== 'undefined') {
Chart.defaults.color = textColor;
Chart.defaults.borderColor = borderColor;
}
})();
</script>
<div class="container py-4">
{# === PAGE HEADER === #}
{# On desktop: title left, buttons right in a row #}
{# On mobile: title on top, buttons below in a 2x2 grid #}
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-3 mb-4">
<h1 class="page-title mb-0"><i class="fas fa-wallet me-2" style="color: var(--accent);"></i>Payroll Dashboard</h1>
<div class="d-flex flex-wrap gap-2 payroll-actions">
<button type="button" class="btn btn-outline-info shadow-sm btn-sm btn-md-normal" id="workerLookupBtn">
<i class="fas fa-id-card fa-sm me-1"></i> Worker Lookup
</button>
<button type="button" class="btn btn-primary shadow-sm btn-sm btn-md-normal" 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-outline-success shadow-sm btn-sm btn-md-normal fw-bold" 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 btn-sm btn-md-normal" data-bs-toggle="modal" data-bs-target="#priceOvertimeModal">
<i class="fas fa-clock fa-sm me-1"></i> Price Overtime
</button>
</div>
</div>
{# === ANALYTICS SUMMARY BAR — compact row of key numbers === #}
{# Always visible. Clicking "Show Details" expands the full stat cards and charts below. #}
<div class="card mb-3">
<div class="card-body py-2 px-3">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
{# Key numbers in a compact row #}
<div class="d-flex flex-wrap gap-3 analytics-summary">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-exclamation-circle" style="color: var(--color-danger); font-size: 0.8rem;"></i>
<div>
<div style="font-size: 0.65rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;">Outstanding</div>
<div class="fw-bold" style="font-size: 0.9rem;">R {{ outstanding_total|floatformat:2 }}</div>
</div>
</div>
<div style="border-left: 1px solid var(--border-default); height: 30px;"></div>
<div class="d-flex align-items-center gap-2">
<i class="fas fa-check-circle" style="color: var(--color-success); font-size: 0.8rem;"></i>
<div>
<div style="font-size: 0.65rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;">Paid (60d)</div>
<div class="fw-bold" style="font-size: 0.9rem;">R {{ recent_payments_total|floatformat:2 }}</div>
</div>
</div>
<div style="border-left: 1px solid var(--border-default); height: 30px;"></div>
<div class="d-flex align-items-center gap-2">
<i class="fas fa-hand-holding-usd" style="color: var(--color-warning); font-size: 0.8rem;"></i>
<div>
<div style="font-size: 0.65rem; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.03em;">Loans ({{ active_loans_count }})</div>
<div class="fw-bold" style="font-size: 0.9rem;">R {{ active_loans_balance|floatformat:2 }}</div>
</div>
</div>
</div>
{# Toggle button to expand/collapse full analytics #}
<button type="button" class="btn btn-sm btn-outline-secondary" id="analyticsToggle" style="font-size: 0.75rem;">
<i class="fas fa-chart-bar me-1"></i><span id="analyticsToggleText">Show Details</span>
</button>
</div>
</div>
</div>
{# === FULL ANALYTICS (hidden by default — toggled by button above) === #}
<div id="analyticsDetail" style="display: none;">
{# --- Stat cards row --- #}
<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="stat-card stat-card--danger h-100 p-3">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="stat-label">Outstanding Payments</div>
<div class="stat-value">R {{ outstanding_total|floatformat:2 }}</div>
{% if pending_adj_add_total or pending_adj_sub_total %}
<div class="mt-2 pt-2" style="border-top: 1px solid var(--border-subtle); font-size: 0.75rem; color: var(--text-secondary);">
<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 style="color: var(--color-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 style="color: var(--color-danger);">-R {{ pending_adj_sub_total|floatformat:2 }}</span>
</div>
{% endif %}
</div>
{% endif %}
<div class="mt-1" style="font-size: 0.65rem; color: var(--text-tertiary);">
<i class="fas fa-info-circle"></i> Loan repayments deducted at payment time
</div>
</div>
<div class="stat-icon stat-icon--danger">
<i class="fas fa-exclamation-circle"></i>
</div>
</div>
</div>
</div>
{# Recent Payments #}
<div class="col-sm-6">
<div class="stat-card stat-card--success h-100 p-3">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="stat-label">Paid (Last 60 Days)</div>
<div class="stat-value">R {{ recent_payments_total|floatformat:2 }}</div>
</div>
<div class="stat-icon stat-icon--success">
<i class="fas fa-check-circle"></i>
</div>
</div>
</div>
</div>
{# Active Loans — spans full width below the first two #}
<div class="col-12">
<div class="stat-card stat-card--warning h-100 p-3">
<div class="d-flex align-items-start justify-content-between">
<div>
<div class="stat-label">Active Loans & Advances ({{ active_loans_count }})</div>
<div class="stat-value">R {{ active_loans_balance|floatformat:2 }}</div>
</div>
<div class="stat-icon stat-icon--warning">
<i class="fas fa-hand-holding-usd"></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="stat-card stat-card--info p-3 w-100">
<div class="d-flex flex-column h-100">
<div class="d-flex align-items-start justify-content-between mb-3">
<div class="stat-label">Outstanding by Project</div>
<div class="stat-icon stat-icon--info">
<i class="fas fa-chart-pie"></i>
</div>
</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{% endif %}" {% if not forloop.last %}style="border-bottom: 1px solid var(--border-subtle);"{% endif %}>
<span class="fw-semibold">{{ pc.name }}</span>
<span class="fw-bold" style="color: var(--color-info);">R {{ pc.cost|floatformat:2 }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="flex-grow-1 d-flex align-items-center justify-content-center">
<span style="color: var(--text-tertiary);"><i class="fas fa-check-circle me-1"></i> No outstanding amounts</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# --- Charts row --- #}
<div class="row mb-4">
<div class="col-lg-6 mb-4 mb-lg-0">
<div class="card h-100">
<div class="card-header py-3">
{# === CHART TOGGLE: Overall vs By Worker === #}
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 fw-bold">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 h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold">Cost by Project (Monthly)</h6>
</div>
<div class="card-body">
<canvas id="projectChart" height="200"></canvas>
</div>
</div>
</div>
</div>
</div>{# /analyticsDetail #}
{# === 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>
{# === ADJUSTMENTS TAB LINK === #}
{# Task 4: flat table view of every payroll adjustment with filters, #}
{# bulk actions, and row edit/delete. See _adjustment_row.html. #}
<li class="nav-item" role="presentation">
<a class="nav-link {% if active_tab == 'adjustments' %}active{% endif %}" href="?status=adjustments">
<i class="fas fa-sliders-h me-1"></i> Adjustments
</a>
</li>
</ul>
{# =============================================== #}
{# === PENDING PAYMENTS TAB === #}
{# =============================================== #}
{% if active_tab == 'pending' %}
{# === PENDING PAYMENTS FILTER BAR === #}
{# Lets admin filter by team, show only overdue workers, or exclude workers with loans #}
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap" id="pendingFilters">
<div class="d-flex align-items-center gap-2">
<label class="text-muted small mb-0" for="pendingTeamFilter">Team:</label>
<select id="pendingTeamFilter" class="form-select form-select-sm" style="width: auto;">
<option value="">All Teams</option>
{% for team in all_teams %}
<option value="{{ team.name }}">{{ team.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input" id="pendingOverdueOnly">
<label class="form-check-label text-muted small" for="pendingOverdueOnly">Overdue only</label>
</div>
<div class="d-flex align-items-center gap-2">
<label class="text-muted small mb-0" for="pendingLoanFilter">Loans:</label>
<select id="pendingLoanFilter" class="form-select form-select-sm" style="width: auto;">
<option value="">All Workers</option>
<option value="with">With loans only</option>
<option value="without">Without loans</option>
</select>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="pendingTable">
{# On mobile: hide Days, Day Rate, Log Amount, Adjustments, Net Adj columns #}
{# Only show: Worker (with badges), Total, Adjust + Pay buttons #}
{# All details are accessible by tapping the worker name (opens lookup modal) #}
<thead class="table-light">
<tr>
<th scope="col" class="ps-4">Worker</th>
<th scope="col" class="d-none d-md-table-cell">Days</th>
<th scope="col" class="d-none d-md-table-cell">Day Rate</th>
<th scope="col" class="d-none d-md-table-cell">Log Amount</th>
<th scope="col" class="d-none d-lg-table-cell">Adjustments</th>
<th scope="col" class="d-none d-md-table-cell">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 data-team="{{ wd.team_name }}"
data-overdue="{{ wd.is_overdue|yesno:'true,false' }}"
data-has-loan="{{ wd.has_loan|yesno:'true,false' }}">
<td class="ps-4 align-middle">
<div>
<a href="#" class="worker-lookup-link fw-bold"
data-worker-id="{{ wd.worker.id }}">{{ wd.worker.name }}</a>
</div>
{% if wd.is_overdue or wd.has_loan %}
<div class="mt-1 d-flex gap-1">
{% if wd.is_overdue %}
<span class="badge bg-danger" style="font-size: 0.6rem;" title="Has unpaid work from a completed pay period (since {{ wd.earliest_unpaid|date:'d M Y' }})">Overdue</span>
{% endif %}
{% if wd.has_loan %}
<span class="badge bg-warning" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>
{% endif %}
</div>
{% endif %}
</td>
<td class="align-middle d-none d-md-table-cell">{{ wd.unpaid_count }}</td>
<td class="align-middle d-none d-md-table-cell">R {{ wd.day_rate }}</td>
<td class="align-middle d-none d-md-table-cell">R {{ wd.unpaid_amount|floatformat:2 }}</td>
<td class="align-middle d-none d-lg-table-cell">
{# Show each pending adjustment as a badge #}
{% for adj in wd.adjustments %}
{# Badge colour logic: #}
{# GREEN = earned money (Bonus, Overtime) or debt recovery (Loan/Advance Repayment) #}
{# YELLOW = loan-related outflow (New Loan, Advance Payment) — matches the Loan tag #}
{# RED = deductions (Deduction) #}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% 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 d-none d-md-table-cell {% 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>
<button type="button" class="btn btn-sm btn-outline-secondary quick-adjust-btn"
data-worker-id="{{ wd.worker.id }}"
data-worker-project="{{ wd.last_project_id|default:'' }}"
title="Add adjustment for {{ wd.worker.name }}">
<i class="fas fa-sliders-h"></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><span class="d-none d-sm-inline"> Pay</span>
</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">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
{# On mobile: hide Date, Work Logs, Adjustments columns #}
{# Only show: Worker, Amount Paid, View button #}
<thead class="table-light">
<tr>
<th scope="col" class="ps-4 d-none d-md-table-cell">Date</th>
<th scope="col" class="ps-4 ps-md-0">Worker</th>
<th scope="col">Amount Paid</th>
<th scope="col" class="d-none d-md-table-cell">Work Logs</th>
<th scope="col" class="d-none d-lg-table-cell">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 d-none d-md-table-cell">{{ record.date }}</td>
<td class="align-middle ps-4 ps-md-0"><a href="#" class="worker-lookup-link fw-bold"
data-worker-id="{{ record.worker.id }}">{{ record.worker.name }}</a></td>
<td class="align-middle">R {{ record.amount_paid|floatformat:2 }}</td>
<td class="align-middle d-none d-md-table-cell">
{{ record.work_logs.count }} day{{ record.work_logs.count|pluralize }}
</td>
<td class="align-middle d-none d-lg-table-cell">
{% for adj in record.adjustments.all %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% 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><span class="d-none d-sm-inline"> View</span>
</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">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
{# On mobile: hide Principal, Date, Reason, Status columns #}
{# Only show: Worker, Type, Balance #}
<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" class="d-none d-md-table-cell">Date</th>
<th scope="col" class="d-none d-lg-table-cell">Reason</th>
<th scope="col" class="pe-4 d-none d-md-table-cell">Status</th>
</tr>
</thead>
<tbody>
{% for loan in loans %}
<tr>
<td class="ps-4 align-middle"><a href="#" class="worker-lookup-link fw-bold"
data-worker-id="{{ loan.worker.id }}">{{ loan.worker.name }}</a></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 d-none d-md-table-cell">{{ loan.date }}</td>
<td class="align-middle d-none d-lg-table-cell">{{ loan.reason|default:"-" }}</td>
<td class="pe-4 align-middle d-none d-md-table-cell">
{% 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 %}
{# =============================================== #}
{# === ADJUSTMENTS TAB === #}
{# =============================================== #}
{# Flat, filterable table of every PayrollAdjustment in the system. #}
{# Filters run server-side via GET params (type, worker, team, status, date range). #}
{# Row actions reuse the existing Edit / Delete / Preview modals so no new JS is needed. #}
{% if active_tab == 'adjustments' %}
{# --- Cross-filter data: (team_id, worker_id) pairs for the Workers popover --- #}
{{ team_worker_pairs_json|json_script:"adjTeamWorkerPairs" }}
{# --- Sticky filter bar (pill-popover checkbox filters for Type/Workers/Teams) --- #}
<div class="adjustments-filter-bar" id="adjustmentsFilters">
<form method="get" action="{% url 'payroll_dashboard' %}"
class="d-flex flex-wrap gap-3 align-items-end w-100" id="adjFilterForm">
<input type="hidden" name="status" value="adjustments">
{# === Type filter: pill-button opens popover with checkbox list === #}
{# Replaced the Choices.js chip multi-select (Apr 2026): the chip #}
{# pattern dominated the filter bar when multiple options were #}
{# picked. Now: a compact pill shows "Type" (or "Type (N)") and #}
{# clicking it opens a popover with search + checkbox list + OK. #}
{# OK commits to hidden inputs below; the Apply button submits. #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjTypePill" data-adj-filter="type"
aria-expanded="false" aria-controls="adjTypePopover">
<i class="fas fa-tag me-1"></i>
<span class="filter-pill__label" data-pill-label>Type</span>
<span class="filter-pill__count ms-1" data-pill-count hidden></span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjTypePopover" role="dialog"
aria-label="Filter by type" hidden>
<div class="filter-popover__body">
<input type="search" class="form-control form-control-sm mb-2"
placeholder="Search types..." data-popover-search>
<div class="adj-checkbox-list" data-popover-list>
{% for t in adj_type_choices %}
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="checkbox" class="form-check-input adj-filter-cb"
data-adj-filter="type" value="{{ t }}"
{% if t in adj_filter_values.type %}checked{% endif %}>
<span class="adj-cb-label">{{ t }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="filter-popover__footer d-flex flex-wrap gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary me-auto"
data-popover-action="select-all">All</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="invert">Invert</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="clear">Clear</button>
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
{# Hidden inputs — the actual form-submit state. Rewritten on OK. #}
<div class="adj-hidden-inputs" data-adj-filter="type">
{% for v in adj_filter_values.type %}
<input type="hidden" name="type" value="{{ v }}">
{% endfor %}
</div>
</div>
{# === Workers filter: same pill + popover pattern as Type === #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjWorkerPill" data-adj-filter="worker"
aria-expanded="false" aria-controls="adjWorkerPopover">
<i class="fas fa-user me-1"></i>
<span class="filter-pill__label" data-pill-label>Workers</span>
<span class="filter-pill__count ms-1" data-pill-count hidden></span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjWorkerPopover" role="dialog"
aria-label="Filter by workers" hidden>
<div class="filter-popover__body">
<input type="search" class="form-control form-control-sm mb-2"
placeholder="Search workers..." data-popover-search>
<div class="adj-checkbox-list" data-popover-list>
{% for w in all_workers_for_filter %}
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="checkbox" class="form-check-input adj-filter-cb"
data-adj-filter="worker" value="{{ w.id }}"
{% if w.id in adj_filter_values.worker %}checked{% endif %}>
<span class="adj-cb-label">{{ w.name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="filter-popover__footer d-flex flex-wrap gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary me-auto"
data-popover-action="select-all">All</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="invert">Invert</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="clear">Clear</button>
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
<div class="adj-hidden-inputs" data-adj-filter="worker">
{% for v in adj_filter_values.worker %}
<input type="hidden" name="worker" value="{{ v }}">
{% endfor %}
</div>
</div>
{# === Teams filter: same pill + popover pattern as Type === #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjTeamPill" data-adj-filter="team"
aria-expanded="false" aria-controls="adjTeamPopover">
<i class="fas fa-users me-1"></i>
<span class="filter-pill__label" data-pill-label>Teams</span>
<span class="filter-pill__count ms-1" data-pill-count hidden></span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjTeamPopover" role="dialog"
aria-label="Filter by teams" hidden>
<div class="filter-popover__body">
<input type="search" class="form-control form-control-sm mb-2"
placeholder="Search teams..." data-popover-search>
<div class="adj-checkbox-list" data-popover-list>
{% for t in all_teams_for_filter %}
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="checkbox" class="form-check-input adj-filter-cb"
data-adj-filter="team" value="{{ t.id }}"
{% if t.id in adj_filter_values.team %}checked{% endif %}>
<span class="adj-cb-label">{{ t.name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="filter-popover__footer d-flex flex-wrap gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary me-auto"
data-popover-action="select-all">All</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="invert">Invert</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="clear">Clear</button>
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
<div class="adj-hidden-inputs" data-adj-filter="team">
{% for v in adj_filter_values.team %}
<input type="hidden" name="team" value="{{ v }}">
{% endfor %}
</div>
</div>
{# --- Status filter: pill-button opens popover with 3 radios --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjStatusPill" data-adj-filter="adj_status"
aria-expanded="false" aria-controls="adjStatusPopover">
<i class="fas fa-filter me-1"></i>
<span class="filter-pill__label" data-pill-label>Status</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjStatusPopover" role="dialog"
aria-label="Filter by status" hidden>
<div class="filter-popover__body">
<div class="adj-radio-list" data-popover-list>
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="radio" class="form-check-input adj-status-radio"
name="adj_status_pending" value=""
{% if not adj_filter_values.adj_status %}checked{% endif %}>
<span class="adj-cb-label">All</span>
</label>
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="radio" class="form-check-input adj-status-radio"
name="adj_status_pending" value="unpaid"
{% if adj_filter_values.adj_status == 'unpaid' %}checked{% endif %}>
<span class="adj-cb-label">Unpaid</span>
</label>
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="radio" class="form-check-input adj-status-radio"
name="adj_status_pending" value="paid"
{% if adj_filter_values.adj_status == 'paid' %}checked{% endif %}>
<span class="adj-cb-label">Paid</span>
</label>
</div>
</div>
<div class="filter-popover__footer d-flex gap-1 justify-content-end">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
{# Actual submit value — rewritten by the Status popover OK handler #}
<div class="adj-hidden-inputs" data-adj-filter="adj_status">
{% if adj_filter_values.adj_status %}
<input type="hidden" name="adj_status" value="{{ adj_filter_values.adj_status }}">
{% endif %}
</div>
</div>
{# --- Date filter: pill-button opens popover with mode + pickers + presets --- #}
{# Mode toggle (Single / Range) sits inside the popover. Presets live inside too. #}
{# Backend contract unchanged: hidden inputs adj_date_from + adj_date_to. #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjDatePill" data-adj-filter="date"
aria-expanded="false" aria-controls="adjDatePopover">
<i class="fas fa-calendar me-1"></i>
<span class="filter-pill__label" data-pill-label>Date</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjDatePopover" role="dialog"
aria-label="Filter by date" hidden>
<div class="filter-popover__body">
{# Mode toggle #}
<div class="btn-group w-100 mb-2" role="group" aria-label="Date mode">
<input type="radio" class="btn-check" name="adj_date_mode_pending"
id="adjDateModeSingle" value="single" checked>
<label class="btn btn-outline-secondary btn-sm" for="adjDateModeSingle">Single</label>
<input type="radio" class="btn-check" name="adj_date_mode_pending"
id="adjDateModeRange" value="range">
<label class="btn btn-outline-secondary btn-sm" for="adjDateModeRange">Range</label>
</div>
{# Single-mode picker #}
<div id="adjDateSingleFields">
<label class="form-label small mb-1" for="adjDateSingle">Date</label>
<input type="date" class="form-control form-control-sm" id="adjDateSingle">
</div>
{# Range-mode pickers #}
<div id="adjDateRangeFields" class="row g-2 d-none">
<div class="col-6">
<label class="form-label small mb-1" for="adjDateFrom">From</label>
<input type="date" class="form-control form-control-sm" id="adjDateFrom">
</div>
<div class="col-6">
<label class="form-label small mb-1" for="adjDateTo">To</label>
<input type="date" class="form-control form-control-sm" id="adjDateTo">
</div>
</div>
{# Preset quick-links #}
<div class="d-flex gap-2 mt-2 small" id="adjDatePresets">
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="today">Today</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="week">Week</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="month">Month</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="clear">Clear</button>
</div>
</div>
<div class="filter-popover__footer d-flex gap-1 justify-content-end">
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
{# Actual submit values — rewritten by the Date popover OK handler #}
<div class="adj-hidden-inputs" data-adj-filter="date">
{% if adj_filter_values.adj_date_from %}
<input type="hidden" name="adj_date_from" value="{{ adj_filter_values.adj_date_from }}">
{% endif %}
{% if adj_filter_values.adj_date_to %}
<input type="hidden" name="adj_date_to" value="{{ adj_filter_values.adj_date_to }}">
{% endif %}
</div>
</div>
{# --- Sort state (column-header clicks set these via JS) --- #}
<input type="hidden" name="sort" value="{{ adj_filter_values.sort }}">
<input type="hidden" name="order" value="{{ adj_filter_values.order }}">
{# --- Group-by state (keeps Flat/By Type/By Worker across Apply) --- #}
<input type="hidden" name="group_by" value="{{ adj_filter_values.group_by }}">
{# --- Apply / Clear (pushed to the right end via .adj-apply-group) --- #}
<div class="adj-apply-group">
<button type="submit" class="btn btn-sm btn-accent">
<i class="fas fa-filter me-1"></i>Apply
</button>
<a href="?status=adjustments" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
</div>
{# --- Group-by toggle: Flat / By Type / By Worker --- #}
{# Clicking a pill sets the ?group_by= querystring; url_replace preserves #}
{# all other filters (type, worker, team, status, dates) so the view #}
{# re-renders the SAME filtered set, just re-bucketed. #}
<div class="d-flex align-items-center gap-2 mb-3 adj-groupby-toggle">
<span class="text-muted small">Show as:</span>
<div class="btn-group" role="group" aria-label="Group rows by">
<a href="?{% url_replace request 'group_by' '' %}"
class="btn {% if not adj_filter_values.group_by %}btn-accent{% else %}btn-outline-secondary{% endif %}">Flat</a>
<a href="?{% url_replace request 'group_by' 'type' %}"
class="btn {% if adj_filter_values.group_by == 'type' %}btn-accent{% else %}btn-outline-secondary{% endif %}">By Type</a>
<a href="?{% url_replace request 'group_by' 'worker' %}"
class="btn {% if adj_filter_values.group_by == 'worker' %}btn-accent{% else %}btn-outline-secondary{% endif %}">By Worker</a>
</div>
</div>
{# --- Stats row (scoped to the currently-filtered set) --- #}
<div class="d-flex flex-wrap gap-3 mb-3 px-2 small text-muted">
<span><strong>{{ adj_total_count }}</strong> adjustment{{ adj_total_count|pluralize }}</span>
<span>&middot;</span>
<span><strong>{{ adj_unpaid_count }}</strong> unpaid (R {{ adj_unpaid_sum|money }})</span>
<span>&middot;</span>
<span>+R {{ adj_additive_sum|money }} net additive</span>
<span>&middot;</span>
<span>&minus;R {{ adj_deductive_sum|money }} net deductive</span>
</div>
{# --- Flat table of adjustments --- #}
{% if adj_page.object_list %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead>
<tr>
<th style="width: 40px;">
{# aria-label is the accessible name screen readers announce; #}
{# title= is kept as the mouse-hover tooltip for sighted users. #}
{# Distinct id from the Add-Adjustment modal's own #adjSelectAll #}
{# anchor (line ~940) — duplicate ids are invalid HTML and caused the #}
{# modal's Select-All handler to silently bind to this checkbox instead. #}
<input type="checkbox" class="form-check-input" id="adjTableSelectAll"
aria-label="Select all unpaid adjustments on this page"
title="Select all unpaid on this page">
</th>
{# === Sortable column headers — click toggles sort via filter form === #}
{# Each sortable <th> reflects the current sort/order from adj_filter_values. #}
{# The arrow icon is fa-sort (inactive), fa-sort-down (desc) or fa-sort-up (asc). #}
{# JS click handler is wired up in the DOMContentLoaded block further down. #}
<th class="sortable{% if adj_filter_values.sort == 'date' %} sorted{% endif %}"
data-sort="date" role="button" tabindex="0">
Date
<i class="fas {% if adj_filter_values.sort == 'date' %}{% if adj_filter_values.order == 'asc' %}fa-sort-up{% else %}fa-sort-down{% endif %}{% else %}fa-sort{% endif %} sort-arrow"></i>
</th>
<th class="sortable{% if adj_filter_values.sort == 'worker' %} sorted{% endif %}"
data-sort="worker" role="button" tabindex="0">
Worker
<i class="fas {% if adj_filter_values.sort == 'worker' %}{% if adj_filter_values.order == 'asc' %}fa-sort-up{% else %}fa-sort-down{% endif %}{% else %}fa-sort{% endif %} sort-arrow"></i>
</th>
<th>Type</th>
<th class="text-end sortable{% if adj_filter_values.sort == 'amount' %} sorted{% endif %}"
data-sort="amount" role="button" tabindex="0">
Amount
<i class="fas {% if adj_filter_values.sort == 'amount' %}{% if adj_filter_values.order == 'asc' %}fa-sort-up{% else %}fa-sort-down{% endif %}{% else %}fa-sort{% endif %} sort-arrow"></i>
</th>
<th>Project</th>
<th>Team</th>
<th>Description</th>
<th class="sortable{% if adj_filter_values.sort == 'status' %} sorted{% endif %}"
data-sort="status" role="button" tabindex="0">
Status
<i class="fas {% if adj_filter_values.sort == 'status' %}{% if adj_filter_values.order == 'asc' %}fa-sort-up{% else %}fa-sort-down{% endif %}{% else %}fa-sort{% endif %} sort-arrow"></i>
</th>
<th class="text-end">Actions</th>
</tr>
</thead>
{% if adj_groups %}
{# Grouped view: one <tbody> per group with a clickable #}
{# header row. Each group's <tbody> is a Bootstrap collapse #}
{# target so clicking the header hides or shows its rows. #}
{# Default state: every group expanded (class="collapse show"). #}
{% for group in adj_groups %}
<tbody>
<tr class="adj-group-header"
{% if adj_filter_values.group_by == 'type' %}data-type="{{ group.label }}"{% endif %}
data-bs-toggle="collapse"
data-bs-target="#adj-group-{{ group.slug }}"
aria-expanded="true" aria-controls="adj-group-{{ group.slug }}">
<td colspan="10">
<i class="fas fa-chevron-down"></i>
<span class="adj-group-label">{{ group.label }}</span>
<span class="adj-group-meta">
{{ group.count }} row{{ group.count|pluralize }} &middot;
{% if group.net_sum >= 0 %}+{% else %}&minus;{% endif %}R {{ group.net_sum|money_abs }} net
</span>
</td>
</tr>
</tbody>
<tbody id="adj-group-{{ group.slug }}" class="collapse show">
{% for adj in group.rows %}
{% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
{% endfor %}
</tbody>
{% endfor %}
{% else %}
{# Flat view (default when no group_by selected) — unchanged from Task 4. #}
<tbody>
{% for adj in adj_page.object_list %}
{% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
{% endfor %}
</tbody>
{% endif %}
</table>
</div>
</div>
</div>
{# --- Pagination: flat view only. When grouped, group headers act as --- #}
{# --- their own navigation and totals cover the whole filtered set. --- #}
{% if adj_page.has_other_pages and not adj_groups %}
<nav class="mt-3 d-flex justify-content-center">
<ul class="pagination pagination-sm">
{% if adj_page.has_previous %}
<li class="page-item">
<a class="page-link" rel="prev" aria-label="Previous page"
href="?{% url_replace request 'page' adj_page.previous_page_number %}">Previous</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ adj_page.number }} of {{ adj_page.paginator.num_pages }}</span>
</li>
{% if adj_page.has_next %}
<li class="page-item">
<a class="page-link" rel="next" aria-label="Next page"
href="?{% url_replace request 'page' adj_page.next_page_number %}">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
{# No rows match the current filter set. Two recovery paths: clear filters (re-fetch unfiltered) or add a new adjustment (opens the existing #addAdjustmentModal). #}
<div class="adj-empty-state card">
<div class="card-body text-center py-5">
<div class="adj-empty-icon"><i class="fas fa-inbox"></i></div>
<h6 class="mb-2">No adjustments match these filters.</h6>
<p class="text-muted small mb-3">Try clearing filters or adding a new adjustment.</p>
<div class="d-flex gap-2 justify-content-center">
<a href="?status=adjustments" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-times me-1"></i>Clear filters
</a>
<button type="button" class="btn btn-sm btn-accent"
data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
<i class="fas fa-plus me-1"></i>Add adjustment
</button>
</div>
</div>
</div>
{% endif %}
{# Floating bulk action bar: fixed at the bottom of the viewport, centred horizontally. #}
{# Shown when >= 1 unpaid row is selected. CSS .adj-bulk-bar + animation shipped in Task 2. #}
<div class="adj-bulk-bar" id="adjBulkBar" hidden>
<span><strong id="adjBulkCount">0</strong> selected</span>
<button type="button" class="btn btn-sm btn-outline-danger" id="adjBulkDeleteBtn">
<i class="fas fa-trash me-1"></i>Delete
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="adjBulkClearBtn">Clear</button>
</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 var(--border-default); border-radius: var(--radius-sm); padding: 8px; background: var(--bg-inset);">
{% 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>
{# Pay Immediately — only shown for New Loan type #}
{# When checked, the loan is paid to the worker right away and #}
{# a payslip is emailed to Spark. When unchecked, the loan sits #}
{# in pending payments and gets included in the next pay cycle. #}
<div class="col-12" id="addAdjPayImmediatelyGroup" style="display: none;">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="pay_immediately"
id="addAdjPayImmediately" value="1" checked>
<label class="form-check-label" for="addAdjPayImmediately">
<i class="fas fa-bolt me-1 text-warning"></i>
<strong>Pay Immediately</strong>
<span class="text-muted small">— send payslip to Spark now and record as paid</span>
</label>
</div>
</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>
{# === WORKER LOOKUP MODAL === #}
{# Shows a comprehensive financial report card for any active worker. #}
{# Triggered by clicking a worker name or the "Worker Lookup" button. #}
<div class="modal fade" id="workerLookupModal" 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-id-card me-2"></i>Worker Lookup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# Worker selector dropdown #}
<div class="mb-3">
<select id="workerLookupSelect" class="form-select">
<option value="">Select a worker...</option>
{% for w in all_workers %}
<option value="{{ w.id }}">{{ w.name }}</option>
{% endfor %}
</select>
</div>
{# Report card content — populated dynamically by JavaScript #}
<div id="workerLookupBody">
<div class="text-center py-5 text-muted">
<i class="fas fa-id-card fa-3x mb-3 opacity-25"></i>
<p>Select a worker to view their report card.</p>
</div>
</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" }}
{# === CHOICES.JS CDN — loaded only when the Adjustments tab is active === #}
{# Used by the Type / Workers / Teams multi-select filters. If the CDN fails #}
{# the <select multiple> elements still work (native browser rendering). #}
{% if active_tab == 'adjustments' %}
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/styles/choices.min.css"
integrity="sha384-9oHz8X4XgvL+WkhPjPTMHviP0FM/eWUHWFmAVXKJ3PnbIK8Vi2ranPMgb0LZhaeQ"
crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/scripts/choices.min.js"
integrity="sha384-9r5e85TmdjVjyjYzZAV3TG5A6tcrmD7JjNBGfT2r1wp9txUPttent/DMiMuOwRNG"
crossorigin="anonymous"></script>
{% endif %}
<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;
}
// =================================================================
// PENDING PAYMENTS TABLE — Team / Overdue / Loan Filters
// Shows/hides rows based on filter selections. Pure client-side.
// =================================================================
var pendingTable = document.getElementById('pendingTable');
var pendingTeamFilter = document.getElementById('pendingTeamFilter');
var pendingOverdueOnly = document.getElementById('pendingOverdueOnly');
var pendingLoanFilter = document.getElementById('pendingLoanFilter');
if (pendingTable && pendingTeamFilter) {
function applyPendingFilters() {
var team = pendingTeamFilter.value;
var overdueOnly = pendingOverdueOnly ? pendingOverdueOnly.checked : false;
var loanMode = pendingLoanFilter ? pendingLoanFilter.value : '';
var rows = pendingTable.querySelectorAll('tbody tr[data-team]');
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var teamMatch = !team || row.dataset.team === team;
var overdueMatch = !overdueOnly || row.dataset.overdue === 'true';
var loanMatch = !loanMode
|| (loanMode === 'with' && row.dataset.hasLoan === 'true')
|| (loanMode === 'without' && row.dataset.hasLoan !== 'true');
row.style.display = (teamMatch && overdueMatch && loanMatch) ? '' : 'none';
}
}
pendingTeamFilter.addEventListener('change', applyPendingFilters);
if (pendingOverdueOnly) pendingOverdueOnly.addEventListener('change', applyPendingFilters);
if (pendingLoanFilter) pendingLoanFilter.addEventListener('change', applyPendingFilters);
}
// =================================================================
// 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 and "Pay Immediately" checkbox based on adjustment type.
// Also toggles the HTML "required" attribute so browser validation
// only enforces the project when the type actually needs one.
var addAdjPayImmediatelyGroup = document.getElementById('addAdjPayImmediatelyGroup');
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
}
}
// "Pay Immediately" checkbox — only shown for New Loan type
if (addAdjPayImmediatelyGroup) {
addAdjPayImmediatelyGroup.style.display = (addAdjType.value === 'New Loan') ? '' : 'none';
}
}
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();
});
}
// === QUICK ADJUST BUTTON ===
// Opens the Add Adjustment modal with one worker pre-checked
// and their most recent project pre-selected.
var _quickAdjustOpen = false; // Flag to distinguish quick-adjust from header button
document.querySelectorAll('.quick-adjust-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
_quickAdjustOpen = true;
var workerId = this.dataset.workerId;
var projectId = this.dataset.workerProject;
// Uncheck all workers, then check only the target worker
addAdjWorkerCheckboxes.forEach(function(cb) {
cb.checked = (cb.value === workerId);
});
updateWorkerCount();
// Pre-select the worker's most recent project (if available)
if (projectId && addAdjProject) {
addAdjProject.value = projectId;
}
// Reset type to default (Bonus) and trigger field visibility update
if (addAdjType) {
addAdjType.value = 'Bonus';
toggleProjectField();
}
// Open the modal
var modal = new bootstrap.Modal(document.getElementById('addAdjustmentModal'));
modal.show();
});
});
// When the modal is opened from the HEADER button (not quick-adjust),
// clear any pre-selected workers and project from a previous quick-adjust.
var addAdjModal = document.getElementById('addAdjustmentModal');
if (addAdjModal) {
addAdjModal.addEventListener('show.bs.modal', function() {
if (_quickAdjustOpen) {
_quickAdjustOpen = false;
return; // Quick-adjust already set the values
}
// Reset: uncheck all workers, clear project, reset type
addAdjWorkerCheckboxes.forEach(function(cb) { cb.checked = false; });
updateWorkerCount();
if (addAdjProject) addAdjProject.value = '';
if (addAdjType) {
addAdjType.value = 'Bonus';
toggleProjectField();
}
});
}
// 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 = 'var(--color-info-bg)';
periodInfo.style.borderColor = 'var(--color-info)';
periodInfo.style.color = 'var(--color-info)';
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 = 'var(--bg-inset)';
// 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');
tr.dataset.team = w.team_name; // Used by team filter
tr.dataset.hasLoan = w.has_loan ? 'true' : 'false'; // Used by loan filter
// 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;
}
// === BATCH PAY MODAL: Radio buttons control split mode ===
// 'schedule' = split at last paydate (default), 'all' = pay everything unpaid
// --- Helper: Load batch preview for the given mode ---
// Persistent radio group reference (survives DOM removal/re-append)
var _batchRadioGroup = null;
function loadBatchPreview(mode) {
var body = document.getElementById('batchPayModalBody');
var footer = document.getElementById('batchPayModalFooter');
footer.style.display = 'none';
// Clear all content from modal body
while (body.firstChild) body.removeChild(body.firstChild);
// === Radio button group (created once, re-appended each time) ===
if (!_batchRadioGroup) {
_batchRadioGroup = document.createElement('div');
_batchRadioGroup.id = 'batchModeRadioGroup';
_batchRadioGroup.className = 'btn-group w-100 mb-3';
_batchRadioGroup.setAttribute('role', 'group');
// "Until Last Paydate" radio
var radioSchedule = document.createElement('input');
radioSchedule.type = 'radio';
radioSchedule.className = 'btn-check';
radioSchedule.name = 'batchMode';
radioSchedule.id = 'batchModeSchedule';
radioSchedule.value = 'schedule';
radioSchedule.checked = (mode === 'schedule');
_batchRadioGroup.appendChild(radioSchedule);
var labelSchedule = document.createElement('label');
labelSchedule.className = 'btn btn-outline-primary';
labelSchedule.setAttribute('for', 'batchModeSchedule');
var iconSchedule = document.createElement('i');
iconSchedule.className = 'fas fa-calendar-check me-1';
labelSchedule.appendChild(iconSchedule);
labelSchedule.appendChild(document.createTextNode('Until Last Paydate'));
_batchRadioGroup.appendChild(labelSchedule);
// "Pay All" radio
var radioAll = document.createElement('input');
radioAll.type = 'radio';
radioAll.className = 'btn-check';
radioAll.name = 'batchMode';
radioAll.id = 'batchModeAll';
radioAll.value = 'all';
radioAll.checked = (mode === 'all');
_batchRadioGroup.appendChild(radioAll);
var labelAll = document.createElement('label');
labelAll.className = 'btn btn-outline-primary';
labelAll.setAttribute('for', 'batchModeAll');
var iconAll = document.createElement('i');
iconAll.className = 'fas fa-list me-1';
labelAll.appendChild(iconAll);
labelAll.appendChild(document.createTextNode('Pay All'));
_batchRadioGroup.appendChild(labelAll);
// Re-fetch preview when radio changes
radioSchedule.addEventListener('change', function() { loadBatchPreview('schedule'); });
radioAll.addEventListener('change', function() { loadBatchPreview('all'); });
}
// Re-append radio group to DOM first, THEN set checked state.
// Radio buttons only properly toggle siblings when attached to the DOM.
body.appendChild(_batchRadioGroup);
_batchRadioGroup.querySelector('#batchModeSchedule').checked = (mode === 'schedule');
_batchRadioGroup.querySelector('#batchModeAll').checked = (mode === 'all');
// Show loading spinner below radio buttons
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);
// Fetch batch pay preview with mode parameter
fetch('/payroll/batch-pay/preview/?mode=' + mode)
.then(function(resp) { return resp.json(); })
.then(function(data) {
// Clear everything except the radio group
while (body.children.length > 1) body.removeChild(body.lastChild);
// --- 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';
if (mode === 'schedule') {
msg.textContent = 'No workers eligible for batch payment — no completed pay periods with unpaid work.';
} else {
msg.textContent = 'No workers with unpaid work found.';
}
noData.appendChild(msg);
body.appendChild(noData);
if (data.skipped.length > 0) {
body.appendChild(buildSkippedSection(data.skipped));
}
return;
}
// --- Team filter dropdown ---
// Extract unique team names from the eligible workers
var teamNames = [];
data.eligible.forEach(function(w) {
if (teamNames.indexOf(w.team_name) === -1) teamNames.push(w.team_name);
});
teamNames.sort();
var filterRow = document.createElement('div');
filterRow.className = 'd-flex align-items-center gap-2 mb-3';
var filterLabel = document.createElement('label');
filterLabel.className = 'text-muted small mb-0';
filterLabel.textContent = 'Filter by team:';
filterLabel.setAttribute('for', 'batchTeamFilter');
filterRow.appendChild(filterLabel);
var filterSelect = document.createElement('select');
filterSelect.id = 'batchTeamFilter';
filterSelect.className = 'form-select form-select-sm';
filterSelect.style.width = 'auto';
var optAll = document.createElement('option');
optAll.value = '';
optAll.textContent = 'All Teams';
filterSelect.appendChild(optAll);
teamNames.forEach(function(name) {
var opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
filterSelect.appendChild(opt);
});
filterRow.appendChild(filterSelect);
// Loan filter dropdown (All / With loans only / Without loans)
var batchLoanFilter = null;
var anyHasLoan = data.eligible.some(function(w) { return w.has_loan; });
if (anyHasLoan) {
var loanDiv = document.createElement('div');
loanDiv.className = 'd-flex align-items-center gap-2 ms-3';
var loanLabel = document.createElement('label');
loanLabel.className = 'text-muted small mb-0';
loanLabel.textContent = 'Loans:';
loanLabel.setAttribute('for', 'batchLoanFilter');
loanDiv.appendChild(loanLabel);
batchLoanFilter = document.createElement('select');
batchLoanFilter.id = 'batchLoanFilter';
batchLoanFilter.className = 'form-select form-select-sm';
batchLoanFilter.style.width = 'auto';
var opts = [['', 'All Workers'], ['with', 'With loans only'], ['without', 'Without loans']];
opts.forEach(function(o) {
var opt = document.createElement('option');
opt.value = o[0];
opt.textContent = o[1];
batchLoanFilter.appendChild(opt);
});
loanDiv.appendChild(batchLoanFilter);
filterRow.appendChild(loanDiv);
}
body.appendChild(filterRow);
// --- 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 ---
// Only affects VISIBLE rows (respects team filter)
selectAllCb.addEventListener('change', function() {
var rows = tbody.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
if (rows[i].style.display !== 'none') {
rows[i].querySelector('.batch-worker-cb').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);
// Check if all VISIBLE checkboxes are checked
var allChecked = true;
var rows = tbody.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
if (rows[i].style.display !== 'none') {
if (!rows[i].querySelector('.batch-worker-cb').checked) {
allChecked = false; break;
}
}
}
selectAllCb.checked = allChecked;
}
});
// --- Shared filter function (team + loan filters combined) ---
function applyBatchFilters() {
var selectedTeam = filterSelect.value;
var loanMode = batchLoanFilter ? batchLoanFilter.value : '';
var rows = tbody.querySelectorAll('tr');
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var teamMatch = !selectedTeam || row.dataset.team === selectedTeam;
var loanMatch = !loanMode
|| (loanMode === 'with' && row.dataset.hasLoan === 'true')
|| (loanMode === 'without' && row.dataset.hasLoan !== 'true');
if (teamMatch && loanMatch) {
row.style.display = '';
row.querySelector('.batch-worker-cb').checked = true;
} else {
row.style.display = 'none';
row.querySelector('.batch-worker-cb').checked = false;
}
}
selectAllCb.checked = true;
updateBatchSummary(data, summary);
}
filterSelect.addEventListener('change', applyBatchFilters);
if (batchLoanFilter) {
batchLoanFilter.addEventListener('change', applyBatchFilters);
}
// --- 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.children.length > 1) body.removeChild(body.lastChild);
var errDiv = document.createElement('div');
errDiv.className = 'alert alert-danger';
errDiv.textContent = 'Failed to load batch preview. Please try again.';
body.appendChild(errDiv);
});
}
var batchPayBtn = document.getElementById('batchPayBtn');
if (batchPayBtn) {
batchPayBtn.addEventListener('click', function() {
var modal = new bootstrap.Modal(document.getElementById('batchPayModal'));
// Reset radio group so it gets re-created fresh each time modal opens
_batchRadioGroup = null;
modal.show();
// Default mode: split at last paydate
loadBatchPreview('schedule');
});
}
// --- 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.');
});
});
}
// ================================================================
// === WORKER LOOKUP ===
// Fetches a worker's financial report card via AJAX and displays
// it in the Worker Lookup modal. Can be triggered by clicking any
// worker name on the dashboard, or via the "Worker Lookup" button.
// ================================================================
var workerLookupModal = document.getElementById('workerLookupModal');
var workerLookupSelect = document.getElementById('workerLookupSelect');
var workerLookupBody = document.getElementById('workerLookupBody');
var workerLookupBtn = document.getElementById('workerLookupBtn');
// === HELPER: format a number as South African Rand ===
function formatRand(amount) {
return 'R ' + Number(amount).toLocaleString('en-ZA', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
}
// === HELPER: format a date string (YYYY-MM-DD) for display ===
function formatDate(dateStr) {
if (!dateStr) return '';
var parts = dateStr.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return parseInt(parts[2]) + ' ' + months[parseInt(parts[1]) - 1] + ' ' + parts[0];
}
// === HELPER: create a text element safely (no innerHTML with user data) ===
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
// === LOAD WORKER DATA AND RENDER THE REPORT CARD ===
function loadWorkerLookup(workerId) {
// Show spinner while loading
workerLookupBody.textContent = '';
var spinner = el('div', 'text-center py-4');
var spinEl = el('div', 'spinner-border text-primary');
spinEl.setAttribute('role', 'status');
spinner.appendChild(spinEl);
spinner.appendChild(el('p', 'text-muted mt-2 small', 'Loading report card...'));
workerLookupBody.appendChild(spinner);
fetch('/payroll/worker-lookup/' + workerId + '/')
.then(function(resp) { return resp.json(); })
.then(function(data) {
renderWorkerLookup(data);
})
.catch(function() {
workerLookupBody.textContent = '';
workerLookupBody.appendChild(
el('div', 'alert alert-danger', 'Failed to load worker data. Please try again.')
);
});
}
// === RENDER THE FULL REPORT CARD FROM JSON DATA ===
function renderWorkerLookup(data) {
workerLookupBody.textContent = '';
// --- IDENTITY SECTION ---
var identity = el('div', 'mb-4');
identity.appendChild(el('h4', 'mb-1', data.name));
var detailLine1 = el('div', 'text-muted small');
detailLine1.appendChild(el('span', '', 'ID: ' + data.id_number));
if (data.phone) {
detailLine1.appendChild(document.createTextNode(' \u00B7 Phone: ' + data.phone));
}
identity.appendChild(detailLine1);
var detailLine2 = el('div', 'text-muted small');
if (data.employment_date) {
detailLine2.appendChild(document.createTextNode('Employed: ' + formatDate(data.employment_date)));
}
if (data.team) {
detailLine2.appendChild(document.createTextNode(' \u00B7 Team: ' + data.team));
}
identity.appendChild(detailLine2);
if (data.current_project) {
var projLine = el('div', 'text-muted small');
projLine.appendChild(document.createTextNode(
'Current Project: ' + data.current_project + ' \u00B7 ' + data.days_on_project + ' days on project'
));
identity.appendChild(projLine);
}
workerLookupBody.appendChild(identity);
// --- QUICK STATS (4 cards in a row) ---
var statsRow = el('div', 'row g-2 mb-4');
var stats = [
{ label: 'Amount Payable', value: data.amount_payable, color: 'var(--text-primary)' },
{ label: 'Outstanding Loans', value: data.outstanding_loans, color: 'var(--color-warning)' },
{ label: 'Paid This Month', value: data.paid_this_month, color: 'var(--color-success)' },
{ label: 'Loans This Year', value: data.loans_this_year, color: 'var(--color-danger)' },
];
stats.forEach(function(stat) {
var col = el('div', 'col-6 col-md-3');
var card = el('div', 'stat-card h-100');
card.style.padding = '0.75rem';
var label = el('div', 'stat-label');
label.style.color = stat.color;
label.textContent = stat.label;
card.appendChild(label);
card.appendChild(el('div', 'fw-bold', formatRand(stat.value)));
col.appendChild(card);
statsRow.appendChild(col);
});
workerLookupBody.appendChild(statsRow);
// --- RECENT ACTIVITY ---
var actSection = el('div', 'mb-4');
var actHeader = el('h6', 'text-muted small text-uppercase mb-2', 'Recent Activity');
actSection.appendChild(actHeader);
var activities = [
{ icon: 'fas fa-money-bill-wave', label: 'Last Payslip Paid', data: data.last_payslip, color: 'text-success' },
{ icon: 'fas fa-file-invoice-dollar', label: 'Last Loan Given', data: data.last_loan, color: 'text-primary' },
{ icon: 'fas fa-hand-holding-usd', label: 'Last Loan Repayment', data: data.last_repayment, color: 'text-info' },
{ icon: 'fas fa-bolt', label: 'Last Advance Paid', data: data.last_advance, color: 'text-warning' },
];
activities.forEach(function(act) {
var row = el('div', 'd-flex justify-content-between align-items-center py-1 border-bottom');
row.style.fontSize = '0.78rem';
var left = el('div', '');
var icon = el('i', act.icon + ' me-2 ' + act.color);
icon.style.fontSize = '0.7rem';
left.appendChild(icon);
left.appendChild(document.createTextNode(act.label));
row.appendChild(left);
if (act.data) {
var right = el('div', 'text-end');
right.appendChild(el('span', 'fw-bold me-2', formatRand(act.data.amount)));
right.appendChild(el('span', 'text-muted', formatDate(act.data.date)));
if (act.data.reason) {
right.appendChild(document.createTextNode(' '));
right.appendChild(el('span', 'text-muted fst-italic', '(' + act.data.reason + ')'));
}
row.appendChild(right);
} else {
row.appendChild(el('span', 'text-muted', 'None'));
}
actSection.appendChild(row);
});
workerLookupBody.appendChild(actSection);
// --- ACTIVE LOANS TABLE (only shown if the worker has active loans) ---
if (data.active_loans && data.active_loans.length > 0) {
var loanSection = el('div', 'mb-4');
loanSection.appendChild(el('h6', 'text-muted small text-uppercase mb-2', 'Active Loans'));
var table = el('table', 'table table-sm table-hover mb-0');
var thead = el('thead', 'table-light');
var headRow = el('tr', '');
['Type', 'Amount', 'Balance', 'Date', 'Reason'].forEach(function(h) {
headRow.appendChild(el('th', 'small', h));
});
thead.appendChild(headRow);
table.appendChild(thead);
var tbody = el('tbody', '');
data.active_loans.forEach(function(loan) {
var tr = el('tr', '');
tr.appendChild(el('td', 'small', loan.type));
tr.appendChild(el('td', 'small', formatRand(loan.principal)));
tr.appendChild(el('td', 'small fw-bold', formatRand(loan.balance)));
tr.appendChild(el('td', 'small', formatDate(loan.date)));
tr.appendChild(el('td', 'small', loan.reason || '-'));
tbody.appendChild(tr);
});
table.appendChild(tbody);
var tableWrap = el('div', 'table-responsive');
tableWrap.appendChild(table);
loanSection.appendChild(tableWrap);
workerLookupBody.appendChild(loanSection);
}
// --- PAID THIS YEAR ---
var yearSection = el('div', 'mb-4 p-3 rounded');
yearSection.style.backgroundColor = 'var(--bg-inset)';
var yearLabel = el('span', 'text-muted small text-uppercase', 'Paid This Year: ');
var yearValue = el('span', 'fw-bold', formatRand(data.paid_this_year));
yearSection.appendChild(yearLabel);
yearSection.appendChild(yearValue);
workerLookupBody.appendChild(yearSection);
// --- SIZING & INFO ---
var infoSection = el('div', 'mb-2');
infoSection.appendChild(el('h6', 'text-muted small text-uppercase mb-2', 'Sizing & Info'));
// Sizing line — only show if at least one size is filled in
var hasSizing = data.shoe_size || data.overall_top_size || data.pants_size || data.tshirt_size;
if (hasSizing) {
var sizeLine = el('div', 'small mb-1');
var sizeParts = [];
if (data.shoe_size) sizeParts.push('Shoe: ' + data.shoe_size);
if (data.overall_top_size) sizeParts.push('Overall Top: ' + data.overall_top_size);
if (data.pants_size) sizeParts.push('Pants: ' + data.pants_size);
if (data.tshirt_size) sizeParts.push('T-Shirt: ' + data.tshirt_size);
sizeLine.textContent = sizeParts.join(' \u00B7 ');
infoSection.appendChild(sizeLine);
}
// Drivers license
var licLine = el('div', 'small mb-1');
licLine.textContent = 'Drivers License: ' + (data.has_drivers_license ? '\u2705 Yes' : '\u274C No');
infoSection.appendChild(licLine);
// Notes
if (data.notes) {
var notesLabel = el('div', 'small text-muted mt-2', 'Notes:');
infoSection.appendChild(notesLabel);
var notesText = el('div', 'small p-2 rounded');
notesText.style.backgroundColor = 'var(--bg-inset)';
notesText.textContent = data.notes;
infoSection.appendChild(notesText);
}
workerLookupBody.appendChild(infoSection);
}
// === EVENT LISTENERS ===
// "Worker Lookup" button in header — opens modal with empty selection
if (workerLookupBtn) {
workerLookupBtn.addEventListener('click', function() {
workerLookupSelect.value = '';
workerLookupBody.textContent = '';
var placeholder = el('div', 'text-center py-5 text-muted');
placeholder.appendChild(el('i', 'fas fa-id-card fa-3x mb-3 opacity-25'));
placeholder.appendChild(el('p', '', 'Select a worker to view their report card.'));
workerLookupBody.appendChild(placeholder);
var modal = new bootstrap.Modal(workerLookupModal);
modal.show();
});
}
// Dropdown change — load the selected worker's data
if (workerLookupSelect) {
workerLookupSelect.addEventListener('change', function() {
if (this.value) {
loadWorkerLookup(this.value);
}
});
}
// Clickable worker names — open modal with that worker pre-loaded
document.querySelectorAll('.worker-lookup-link').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
var workerId = this.dataset.workerId;
workerLookupSelect.value = workerId;
loadWorkerLookup(workerId);
var modal = new bootstrap.Modal(workerLookupModal);
modal.show();
});
});
// === ANALYTICS TOGGLE — show/hide the full stat cards and charts ===
var analyticsToggle = document.getElementById('analyticsToggle');
var analyticsDetail = document.getElementById('analyticsDetail');
var analyticsToggleText = document.getElementById('analyticsToggleText');
if (analyticsToggle && analyticsDetail) {
analyticsToggle.addEventListener('click', function() {
var isHidden = analyticsDetail.style.display === 'none';
analyticsDetail.style.display = isHidden ? '' : 'none';
analyticsToggleText.textContent = isHidden ? 'Hide Details' : 'Show Details';
// Remember preference
localStorage.setItem('foxfitt-analytics', isHidden ? 'open' : 'closed');
});
// Restore saved preference
if (localStorage.getItem('foxfitt-analytics') === 'open') {
analyticsDetail.style.display = '';
analyticsToggleText.textContent = 'Hide Details';
}
}
// =================================================================
// ADJUSTMENTS TAB — pill-popover filter module + direct delete button
// Both blocks are no-ops on other tabs (the filter bar element only
// exists when the Adjustments tab is active).
// =================================================================
if (document.getElementById('adjustmentsFilters')) {
// === Pill-popover filter module ===
// Replaces the prior Choices.js chip-multiselect for Type / Workers / Teams.
// Each pill opens a popover with a checkbox list + search + action buttons.
// OK commits the pending checkbox state into the hidden <input> block that
// the form submits; Cancel / Esc / click-outside revert to the URL state.
// --- Display labels shown on each pill button ---
var filterLabels = { type: 'Type', worker: 'Workers', team: 'Teams' };
// --- Close every adj popover except (optionally) one we want to keep open ---
function closeAllAdjPopovers(except) {
document.querySelectorAll('.adj-filter-pill').forEach(function(pill) {
var id = pill.getAttribute('aria-controls');
if (id !== except) {
var pop = document.getElementById(id);
if (pop) pop.hidden = true;
pill.setAttribute('aria-expanded', 'false');
}
});
}
// --- Rewrite the pill's label based on how many hidden inputs exist ---
// 0 selected: "Type"
// 1 selected: "Type: Bonus" (looks up checkbox label text)
// 2+ selected: "Type (2)" with a count badge
function renderPillLabel(filterName) {
var pill = document.querySelector('.adj-filter-pill[data-adj-filter="' + filterName + '"]');
var labelEl = pill.querySelector('[data-pill-label]');
var countEl = pill.querySelector('[data-pill-count]');
var hiddenInputs = document.querySelectorAll(
'.adj-hidden-inputs[data-adj-filter="' + filterName + '"] input[type=hidden]'
);
var n = hiddenInputs.length;
if (n === 0) {
labelEl.textContent = filterLabels[filterName];
countEl.hidden = true;
} else if (n === 1) {
var v = hiddenInputs[0].value;
// Prefer the checkbox's visible label text over the raw value (IDs read poorly)
var cb = document.querySelector(
'.adj-filter-cb[data-adj-filter="' + filterName + '"][value="' + v + '"]'
);
var text = cb ? cb.closest('label').querySelector('.adj-cb-label').textContent : v;
labelEl.textContent = filterLabels[filterName] + ': ' + text;
countEl.hidden = true;
} else {
labelEl.textContent = filterLabels[filterName];
countEl.textContent = '(' + n + ')';
countEl.hidden = false;
}
}
// --- Reset checkboxes to match the current hidden-input set (Cancel / initial load) ---
function revertCheckboxes(filterName) {
var values = Array.from(document.querySelectorAll(
'.adj-hidden-inputs[data-adj-filter="' + filterName + '"] input[type=hidden]'
)).map(function(i) { return i.value; });
document.querySelectorAll(
'.adj-filter-cb[data-adj-filter="' + filterName + '"]'
).forEach(function(cb) {
cb.checked = values.indexOf(cb.value) !== -1;
});
}
// --- Commit checkbox state into the hidden-input block (OK) ---
// Clears then rebuilds the hidden inputs so the form submits exactly
// the set of values currently ticked. Uses replaceChildren() to clear
// (DOM-safe — no innerHTML) then createElement per checked box.
function commitCheckboxes(filterName) {
var container = document.querySelector(
'.adj-hidden-inputs[data-adj-filter="' + filterName + '"]'
);
container.replaceChildren(); // wipe all previous hidden inputs
document.querySelectorAll(
'.adj-filter-cb[data-adj-filter="' + filterName + '"]:checked'
).forEach(function(cb) {
var inp = document.createElement('input');
inp.type = 'hidden';
inp.name = filterName;
inp.value = cb.value;
container.appendChild(inp);
});
renderPillLabel(filterName);
}
// --- Initial paint: set pill labels from the URL's hidden-input state ---
['type', 'worker', 'team'].forEach(renderPillLabel);
// --- Pill click: toggle its popover (and close any sibling popover) ---
document.querySelectorAll('.adj-filter-pill').forEach(function(pill) {
pill.addEventListener('click', function(ev) {
ev.stopPropagation();
var popoverId = pill.getAttribute('aria-controls');
var pop = document.getElementById(popoverId);
var isOpen = !pop.hidden;
if (isOpen) {
pop.hidden = true;
pill.setAttribute('aria-expanded', 'false');
} else {
closeAllAdjPopovers(popoverId);
pop.hidden = false;
pill.setAttribute('aria-expanded', 'true');
// Focus the search input so the user can type immediately
var search = pop.querySelector('[data-popover-search]');
if (search) setTimeout(function() { search.focus(); }, 0);
}
});
});
// --- Click outside any filter-pill-wrap closes all popovers ---
document.addEventListener('click', function(ev) {
if (!ev.target.closest('.filter-pill-wrap')) closeAllAdjPopovers();
});
// --- Esc closes all popovers (handy when focus is in the search box) ---
document.addEventListener('keydown', function(ev) {
if (ev.key === 'Escape') closeAllAdjPopovers();
});
// --- OK / Cancel buttons inside each CHECKBOX-style popover ---
// The Status and Date popovers below use radios / date inputs instead
// of checkboxes, so they register their own OK/Cancel handlers and are
// skipped here by gating on data-adj-filter in ['type', 'worker', 'team'].
var checkboxFilters = ['type', 'worker', 'team'];
document.querySelectorAll('#adjustmentsFilters .filter-popover').forEach(function(pop) {
var filterName = pop.closest('.filter-pill-wrap')
.querySelector('.adj-filter-pill').getAttribute('data-adj-filter');
if (checkboxFilters.indexOf(filterName) === -1) return;
var okBtn = pop.querySelector('.popover-ok');
var cancelBtn = pop.querySelector('.popover-cancel');
okBtn.addEventListener('click', function() {
commitCheckboxes(filterName);
closeAllAdjPopovers();
});
cancelBtn.addEventListener('click', function() {
revertCheckboxes(filterName);
closeAllAdjPopovers();
});
});
// --- Select All / Clear / Invert buttons ---
// Important: these operate on VISIBLE rows only — so if the user has
// typed into the search box, "All" ticks only the matching entries.
document.querySelectorAll('#adjustmentsFilters [data-popover-action]').forEach(function(btn) {
btn.addEventListener('click', function() {
var action = btn.getAttribute('data-popover-action');
var pop = btn.closest('.filter-popover');
var visibleRows = Array.from(pop.querySelectorAll('.adj-cb-row'))
.filter(function(r) { return r.style.display !== 'none'; });
visibleRows.forEach(function(row) {
var cb = row.querySelector('.adj-filter-cb');
if (action === 'select-all') cb.checked = true;
else if (action === 'clear') cb.checked = false;
else if (action === 'invert') cb.checked = !cb.checked;
});
});
});
// --- Search input: live-filter visible checkbox rows ---
document.querySelectorAll('#adjustmentsFilters [data-popover-search]').forEach(function(search) {
search.addEventListener('input', function() {
var q = search.value.toLowerCase().trim();
var pop = search.closest('.filter-popover');
pop.querySelectorAll('.adj-cb-row').forEach(function(row) {
var label = row.querySelector('.adj-cb-label').textContent.toLowerCase();
row.style.display = (!q || label.indexOf(q) !== -1) ? '' : 'none';
});
});
});
// === ADJUSTMENTS TAB — Team -> Workers cross-filter ===
// When a Workers popover opens, disable worker checkboxes that
// aren't in any currently-URL-selected team. Also re-disable on
// Teams popover OK so a fresh team choice immediately narrows
// the visible workers.
// Scope: entire active roster (not date-range-scoped) — cross-filter
// is about data possibility, not data in this period.
var pairsEl = document.getElementById('adjTeamWorkerPairs');
if (pairsEl) {
var pairs = JSON.parse(pairsEl.textContent);
// team_id -> Set(worker_id)
var workersByTeam = {};
pairs.forEach(function(p) {
if (!workersByTeam[p.team_id]) workersByTeam[p.team_id] = new Set();
workersByTeam[p.team_id].add(p.worker_id);
});
function currentTeamIds() {
// Read COMMITTED state (hidden inputs), not the popover's
// pending state — the user hasn't clicked OK yet.
return Array.from(document.querySelectorAll(
'.adj-hidden-inputs[data-adj-filter="team"] input[type=hidden]'
)).map(function(i) { return parseInt(i.value, 10); });
}
function urlSelectedWorkerIds() {
// Ditto — the committed Workers state (to preserve already-selected
// workers even if they're now out-of-team).
return Array.from(document.querySelectorAll(
'.adj-hidden-inputs[data-adj-filter="worker"] input[type=hidden]'
)).map(function(i) { return parseInt(i.value, 10); });
}
function applyWorkerCrossFilter() {
var teamIds = currentTeamIds();
var keepWorkerIds = new Set(urlSelectedWorkerIds());
if (teamIds.length === 0) {
// No team filter -> show all workers
document.querySelectorAll(
'.adj-filter-cb[data-adj-filter="worker"]'
).forEach(function(cb) {
cb.disabled = false;
cb.closest('.adj-cb-row').style.display = '';
});
return;
}
// Build the set of valid worker IDs for the selected teams
var validIds = new Set();
teamIds.forEach(function(tid) {
if (workersByTeam[tid]) {
workersByTeam[tid].forEach(function(wid) { validIds.add(wid); });
}
});
// Hide any row whose worker isn't valid AND isn't already
// selected (already-selected stays visible so user can untick).
document.querySelectorAll(
'.adj-filter-cb[data-adj-filter="worker"]'
).forEach(function(cb) {
var wid = parseInt(cb.value, 10);
var ok = validIds.has(wid) || keepWorkerIds.has(wid);
cb.disabled = !ok;
// Visually hide disabled rows entirely — less clutter
cb.closest('.adj-cb-row').style.display = ok ? '' : 'none';
});
}
// Run cross-filter whenever the Workers popover opens
var workersPill = document.querySelector('.adj-filter-pill[data-adj-filter="worker"]');
if (workersPill) {
workersPill.addEventListener('click', function() {
// Defer to next tick so the popover is open and DOM queries
// see the right elements.
setTimeout(applyWorkerCrossFilter, 0);
});
}
// Re-apply cross-filter when Teams popover OK fires (the committed
// hidden inputs have been rewritten by then).
var teamsPopover = document.getElementById('adjTeamPopover');
if (teamsPopover) {
teamsPopover.querySelector('.popover-ok').addEventListener('click', function() {
// Same tick is fine — commitCheckboxes ran synchronously above.
applyWorkerCrossFilter();
});
}
// Initial apply on page load (URL may already have team= selected)
applyWorkerCrossFilter();
}
// === ADJUSTMENTS TAB — Status pill popover ===
// Single hidden input adj_status, rewritten by the popover OK handler.
// The 3 radios inside the popover hold PENDING state; committed state
// lives in the .adj-hidden-inputs[data-adj-filter="adj_status"] div.
var statusPill = document.getElementById('adjStatusPill');
if (statusPill) {
var statusPopover = document.getElementById('adjStatusPopover');
var statusLabel = statusPill.querySelector('[data-pill-label]');
var statusHiddenContainer = document.querySelector(
'.adj-hidden-inputs[data-adj-filter="adj_status"]'
);
function currentStatusValue() {
var hidden = statusHiddenContainer.querySelector('input[type="hidden"]');
return hidden ? hidden.value : '';
}
function statusValueToLabel(v) {
if (v === 'unpaid') return 'Status: Unpaid';
if (v === 'paid') return 'Status: Paid';
return 'Status';
}
function renderStatusPillLabel() {
statusLabel.textContent = statusValueToLabel(currentStatusValue());
}
function revertStatusRadios() {
// Match radio state to committed hidden input
var v = currentStatusValue();
document.querySelectorAll('input[name="adj_status_pending"]').forEach(function(r) {
r.checked = (r.value === v);
});
}
function commitStatus() {
// Rewrite hidden input from whichever radio is checked
var checked = document.querySelector('input[name="adj_status_pending"]:checked');
var newVal = checked ? checked.value : '';
statusHiddenContainer.replaceChildren(); // XSS-safe clear
if (newVal) {
var inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'adj_status';
inp.value = newVal;
statusHiddenContainer.appendChild(inp);
}
renderStatusPillLabel();
}
// Initial pill label reflects committed state
renderStatusPillLabel();
// OK + Cancel wiring
statusPopover.querySelector('.popover-ok').addEventListener('click', function() {
commitStatus();
closeAllAdjPopovers();
});
statusPopover.querySelector('.popover-cancel').addEventListener('click', function() {
revertStatusRadios();
closeAllAdjPopovers();
});
}
// === ADJUSTMENTS TAB — Date pill popover (mode toggle + pickers + presets) ===
// Three inputs inside the popover hold PENDING state:
// - adjDateSingle (single-mode date)
// - adjDateFrom / adjDateTo (range-mode dates)
// On OK the selected mode decides which pair of values gets written
// into the hidden inputs adj_date_from + adj_date_to. Cancel reverts.
var datePill = document.getElementById('adjDatePill');
if (datePill) {
var datePopover = document.getElementById('adjDatePopover');
var datePillLabel = datePill.querySelector('[data-pill-label]');
var dateHiddenContainer = document.querySelector(
'.adj-hidden-inputs[data-adj-filter="date"]'
);
var adjDateSingle = document.getElementById('adjDateSingle');
var adjDateFrom = document.getElementById('adjDateFrom');
var adjDateTo = document.getElementById('adjDateTo');
var modeSingleRadio = document.getElementById('adjDateModeSingle');
var modeRangeRadio = document.getElementById('adjDateModeRange');
var singleFields = document.getElementById('adjDateSingleFields');
var rangeFields = document.getElementById('adjDateRangeFields');
function committedDateFrom() {
var f = dateHiddenContainer.querySelector('input[name="adj_date_from"]');
return f ? f.value : '';
}
function committedDateTo() {
var t = dateHiddenContainer.querySelector('input[name="adj_date_to"]');
return t ? t.value : '';
}
function humanDate(ymd) {
// "2026-04-24" -> "24 Apr 2026"
if (!ymd) return '';
var parts = ymd.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return parseInt(parts[2], 10) + ' ' + months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
function renderDatePillLabel() {
var f = committedDateFrom();
var t = committedDateTo();
if (!f && !t) { datePillLabel.textContent = 'Date'; return; }
if (f && t && f === t) {
datePillLabel.textContent = 'Date: ' + humanDate(f);
} else if (f && t) {
datePillLabel.textContent = 'Date: ' + humanDate(f) + ' ' + humanDate(t);
} else {
datePillLabel.textContent = 'Date: ' + humanDate(f || t);
}
}
function applyDateMode(rangeMode) {
modeRangeRadio.checked = rangeMode;
modeSingleRadio.checked = !rangeMode;
singleFields.classList.toggle('d-none', rangeMode);
rangeFields.classList.toggle('d-none', !rangeMode);
}
function populatePendingFromCommitted() {
// Seed the popover's input values from the committed hidden inputs
var f = committedDateFrom();
var t = committedDateTo();
if (f && t && f !== t) {
// Range
adjDateFrom.value = f;
adjDateTo.value = t;
adjDateSingle.value = '';
applyDateMode(true);
} else {
// Single (or empty)
adjDateSingle.value = f || t || '';
adjDateFrom.value = '';
adjDateTo.value = '';
applyDateMode(false);
}
}
function commitDate() {
var newFrom = '', newTo = '';
if (modeRangeRadio.checked) {
newFrom = adjDateFrom.value || '';
newTo = adjDateTo.value || '';
} else {
newFrom = newTo = adjDateSingle.value || '';
}
dateHiddenContainer.replaceChildren();
if (newFrom) {
var f = document.createElement('input');
f.type = 'hidden'; f.name = 'adj_date_from'; f.value = newFrom;
dateHiddenContainer.appendChild(f);
}
if (newTo) {
var t = document.createElement('input');
t.type = 'hidden'; t.name = 'adj_date_to'; t.value = newTo;
dateHiddenContainer.appendChild(t);
}
renderDatePillLabel();
}
// Initial state
populatePendingFromCommitted();
renderDatePillLabel();
// Mode toggle syncs the visible section
modeSingleRadio.addEventListener('change', function() { applyDateMode(false); });
modeRangeRadio.addEventListener('change', function() { applyDateMode(true); });
// Preset buttons populate the pending fields (do NOT commit)
function isoDate(d) { return d.toISOString().slice(0, 10); }
document.querySelectorAll('#adjDatePresets [data-preset]').forEach(function(btn) {
btn.addEventListener('click', function() {
var preset = btn.getAttribute('data-preset');
var today = new Date();
if (preset === 'today') {
adjDateSingle.value = isoDate(today);
applyDateMode(false);
} else if (preset === 'week') {
var dayOffset = (today.getDay() + 6) % 7;
var weekStart = new Date(today);
weekStart.setDate(today.getDate() - dayOffset);
var weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
adjDateFrom.value = isoDate(weekStart);
adjDateTo.value = isoDate(weekEnd);
applyDateMode(true);
} else if (preset === 'month') {
var monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
var monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
adjDateFrom.value = isoDate(monthStart);
adjDateTo.value = isoDate(monthEnd);
applyDateMode(true);
} else if (preset === 'clear') {
adjDateSingle.value = '';
adjDateFrom.value = '';
adjDateTo.value = '';
applyDateMode(false);
}
});
});
// OK + Cancel wiring
datePopover.querySelector('.popover-ok').addEventListener('click', function() {
commitDate();
closeAllAdjPopovers();
});
datePopover.querySelector('.popover-cancel').addEventListener('click', function() {
populatePendingFromCommitted(); // revert
closeAllAdjPopovers();
});
}
// === ADJUSTMENTS TAB — Sortable column headers ===
// Click a <th class="sortable"> to sort by that column. Same column
// twice flips asc/desc. A different column resets to desc. Submits
// the filter form so the sort persists in the URL and survives
// pagination. Keyboard Enter/Space also works (role="button").
// Backend whitelists allowed columns in sort_map (see views.py),
// so the data-sort values here must match: date / worker / amount / status.
var adjFilterForm = document.getElementById('adjFilterForm');
if (adjFilterForm) {
// Scope the query to the adjustments tab. The table sits after the
// filter bar as a sibling, so we walk up to the tab container and
// collect every sortable header inside it.
var adjFilterBar = document.getElementById('adjustmentsFilters');
var adjTabRoot = adjFilterBar ? adjFilterBar.parentElement : document;
var sortInput = adjFilterForm.querySelector('input[name="sort"]');
var orderInput = adjFilterForm.querySelector('input[name="order"]');
adjTabRoot.querySelectorAll('th.sortable').forEach(function(th) {
// Clicking a sortable <th> mutates the hidden sort/order inputs
// and submits the form so the URL carries the new state.
function triggerSort() {
if (!sortInput || !orderInput) return;
var col = th.getAttribute('data-sort');
if (sortInput.value === col) {
// Same column clicked again — flip direction.
orderInput.value = orderInput.value === 'asc' ? 'desc' : 'asc';
} else {
// Different column — switch to it, start at desc (default).
sortInput.value = col;
orderInput.value = 'desc';
}
adjFilterForm.submit();
}
th.addEventListener('click', triggerSort);
// Keyboard access: Enter or Space triggers the same sort.
// preventDefault stops Space from scrolling the page.
th.addEventListener('keydown', function(ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
triggerSort();
}
});
});
}
// === ADJUSTMENTS TAB — bulk select + delete ===
// The per-row checkboxes come from _adjustment_row.html (class
// .adj-bulk-checkbox on unpaid rows only; disabled dummy checkbox on
// paid rows for visual alignment). The header has #adjTableSelectAll
// (renamed from #adjSelectAll to avoid a duplicate-id collision with
// the Add-Adjustment modal's own Select-All anchor) that toggles all
// visible unpaid checkboxes. A floating action bar (#adjBulkBar)
// appears when >=1 row is selected.
var bulkBar = document.getElementById('adjBulkBar');
var bulkCount = document.getElementById('adjBulkCount');
var bulkDeleteBtn = document.getElementById('adjBulkDeleteBtn');
var bulkClearBtn = document.getElementById('adjBulkClearBtn');
var selectAll = document.getElementById('adjTableSelectAll');
// CSRF via cookie — Django middleware sets this cookie on GET requests.
function getCookie(name) {
var m = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return m ? decodeURIComponent(m[2]) : '';
}
function getCheckedIds() {
return Array.from(document.querySelectorAll('.adj-bulk-checkbox:checked'))
.map(function(cb) { return cb.value; });
}
function refreshBulkBar() {
if (!bulkBar || !bulkCount) return;
var ids = getCheckedIds();
bulkCount.textContent = ids.length;
bulkBar.hidden = ids.length === 0;
}
// Per-row checkbox change
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
cb.addEventListener('change', refreshBulkBar);
});
// Select-all toggles all visible interactive checkboxes
if (selectAll) {
selectAll.addEventListener('change', function() {
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
cb.checked = selectAll.checked;
});
refreshBulkBar();
});
}
// Clear selection
if (bulkClearBtn) {
bulkClearBtn.addEventListener('click', function() {
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
cb.checked = false;
});
if (selectAll) selectAll.checked = false;
refreshBulkBar();
});
}
// Delete selected — confirm + POST + reload
if (bulkDeleteBtn) {
bulkDeleteBtn.addEventListener('click', function() {
var ids = getCheckedIds();
if (ids.length === 0) return;
if (!confirm('Delete ' + ids.length + ' adjustment' +
(ids.length === 1 ? '' : 's') +
'? This cannot be undone.')) return;
var form = new FormData();
ids.forEach(function(id) { form.append('adjustment_ids', id); });
fetch('{% url "bulk_delete_adjustments" %}', {
method: 'POST',
body: form,
credentials: 'same-origin',
headers: { 'X-CSRFToken': getCookie('csrftoken') },
})
.then(function(r) { return r.json().then(function(data) {
return { ok: r.ok, data: data };
}); })
.then(function(res) {
if (!res.ok) {
alert('Bulk delete failed: ' + (res.data && res.data.error || 'unknown error'));
return;
}
// Simple reload — server-rendered view reflects the deletion
window.location.reload();
})
.catch(function(err) {
alert('Bulk delete failed: ' + err);
});
});
}
}
}); // end DOMContentLoaded
</script>
{% endblock %}