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.
4148 lines
208 KiB
HTML
4148 lines
208 KiB
HTML
{% 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>·</span>
|
||
<span><strong>{{ adj_unpaid_count }}</strong> unpaid (R {{ adj_unpaid_sum|money }})</span>
|
||
<span>·</span>
|
||
<span>+R {{ adj_additive_sum|money }} net additive</span>
|
||
<span>·</span>
|
||
<span>−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 }} ·
|
||
{% if group.net_sum >= 0 %}+{% else %}−{% 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 %}
|