feat(adjustments): Adjustments tab — nav + filter bar + flat table + row actions

Reuses existing modals (#editAdjustmentModal, delete confirm flow) —
zero new JS for row actions. Choices.js lazy-inits only when the tab
is active. Stats row scoped to filter set. Subquery pattern on team
filter (CLAUDE.md). Group-by + bulk-delete + cross-filter come in
Tasks 5/6/7.
This commit is contained in:
Konrad du Plessis 2026-04-23 15:34:09 +02:00
parent 89f109afb4
commit b450bd3c39
2 changed files with 350 additions and 0 deletions

View File

@ -0,0 +1,131 @@
{# === _adjustment_row.html ===
Single <tr> used by BOTH the flat Adjustments view and (later) the grouped view.
Context:
- `adj` — a PayrollAdjustment instance
- `additive_types` — list of type labels that are additive (used to decide
whether the amount should be prefixed with + or - in the display)
Row actions differ based on whether the adjustment has already been paid:
- Paid -> single [View Payslip] icon button
- Unpaid -> three buttons: [Preview][Edit][x]
(these reuse the existing modals on the dashboard — no new JS)
#}
{% load format_tags %}
<tr data-adj-id="{{ adj.id }}" class="{% if adj.payroll_record %}adj-row-paid{% else %}adj-row-unpaid{% endif %}">
{# --- Bulk-select checkbox --- #}
{# Paid rows show a disabled checkbox so the column stays aligned; #}
{# only unpaid rows can be bulk-selected for deletion (feature comes in Task 6). #}
<td class="bulk-select-cell">
{% if adj.payroll_record %}
<input type="checkbox" class="form-check-input" disabled title="Paid rows cannot be bulk-deleted">
{% else %}
<input type="checkbox" class="form-check-input adj-bulk-checkbox" value="{{ adj.id }}">
{% endif %}
</td>
{# --- Date --- #}
<td>{{ adj.date|date:"d M Y" }}</td>
{# --- Worker name (clickable link to the worker profile page) --- #}
<td>
<a href="{% url 'worker_detail' adj.worker.id %}" class="text-decoration-none">
{{ adj.worker.name }}
</a>
</td>
{# --- Type badge (colour comes from the .badge-type-<slug> CSS class) --- #}
<td><span class="badge-type-{{ adj.type|type_slug }}">{{ adj.type }}</span></td>
{# --- Amount (sign reflects additive vs deductive) --- #}
<td class="text-end" style="font-variant-numeric: tabular-nums;">
{% if adj.type in additive_types %}
<span style="color: var(--text-primary);">+R {{ adj.amount|money }}</span>
{% else %}
<span style="color: var(--text-primary);">&minus;R {{ adj.amount|money }}</span>
{% endif %}
</td>
{# --- Project (clickable if present, dash if missing) --- #}
<td>
{% if adj.project %}
<a href="{% url 'project_detail' adj.project.id %}" class="text-decoration-none">
{{ adj.project.name }}
</a>
{% else %}<span class="text-muted">&mdash;</span>{% endif %}
</td>
{# --- Team (worker's first team, if any — many workers are unteamed) --- #}
<td>
{% with team=adj.worker.teams.first %}
{% if team %}{{ team.name }}{% else %}<span class="text-muted">&mdash;</span>{% endif %}
{% endwith %}
</td>
{# --- Description (truncated; full text shown in a hover tooltip) --- #}
<td>
{% if adj.description %}
<span title="{{ adj.description }}" data-bs-toggle="tooltip">
{{ adj.description|truncatechars:40 }}
</span>
{% else %}<span class="text-muted">&mdash;</span>{% endif %}
</td>
{# --- Status: Paid #N (links to the payslip) or Unpaid badge --- #}
<td>
{% if adj.payroll_record %}
<a href="{% url 'payslip_detail' adj.payroll_record.id %}" class="badge bg-success text-decoration-none">
Paid #{{ adj.payroll_record.id }}
</a>
{% else %}
<span class="badge bg-warning text-dark">Unpaid</span>
{% endif %}
</td>
{# --- Row actions (eye + pen + x for unpaid; eye only for paid) --- #}
<td class="text-end">
{% if adj.payroll_record %}
{# PAID: view payslip only #}
<a href="{% url 'payslip_detail' adj.payroll_record.id %}"
class="btn btn-sm btn-outline-secondary"
title="View payslip" data-bs-toggle="tooltip">
<i class="fas fa-eye"></i>
</a>
{% else %}
{# UNPAID: preview + edit + delete #}
{# Preview button — class .preview-payslip-btn is already wired up in the
main dashboard JS (opens the preview modal for this worker). #}
<button type="button"
class="btn btn-sm btn-outline-info preview-payslip-btn"
data-worker-id="{{ adj.worker.id }}"
data-worker-name="{{ adj.worker.name }}"
title="Preview payslip" data-bs-toggle="tooltip">
<i class="fas fa-eye"></i>
</button>
{# Edit button — class .adjustment-badge is already wired up in the
main dashboard JS (populates + opens #editAdjustmentModal). We reuse
it here so no new JS is needed for editing. #}
<button type="button"
class="btn btn-sm btn-outline-primary adjustment-badge"
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|default:'' }}"
data-adj-project="{{ adj.project_id|default:'' }}"
data-adj-worker="{{ adj.worker.name }}"
title="Edit" data-bs-toggle="tooltip">
<i class="fas fa-pen"></i>
</button>
{# Delete button — opens the existing #deleteConfirmModal directly
(short-circuits the edit modal's usual two-step flow). #}
<button type="button"
class="btn btn-sm btn-outline-danger adj-delete-btn"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-worker="{{ adj.worker.name }}"
title="Delete" data-bs-toggle="tooltip">
<i class="fas fa-times"></i>
</button>
{% endif %}
</td>
</tr>

View File

@ -1,5 +1,6 @@
{% extends 'base.html' %}
{% load static %}
{% load format_tags %}
{% block title %}Payroll Dashboard | FoxFitt{% endblock %}
@ -265,6 +266,14 @@
<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>
{# =============================================== #}
@ -539,6 +548,164 @@
</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' %}
{# --- Sticky filter bar (Choices.js enhances the multi-selects below) --- #}
<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 multi-select (Bonus / Overtime / etc.) --- #}
<div class="flex-grow-1" style="min-width: 180px;">
<label class="form-label small mb-1">Type</label>
<select name="type" class="form-select form-select-sm adj-multi" multiple
data-placeholder="All types">
{% for t in adj_type_choices %}
<option value="{{ t }}" {% if t in adj_filter_values.type %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
{# --- Workers multi-select (cross-filtered by Teams in Task 7) --- #}
<div class="flex-grow-1" style="min-width: 180px;">
<label class="form-label small mb-1">Workers</label>
<select name="worker" id="adjWorkerSelect" class="form-select form-select-sm adj-multi" multiple
data-placeholder="All workers">
{% for w in all_workers_for_filter %}
<option value="{{ w.id }}" {% if w.id in adj_filter_values.worker %}selected{% endif %}>{{ w.name }}</option>
{% endfor %}
</select>
</div>
{# --- Teams multi-select --- #}
<div class="flex-grow-1" style="min-width: 160px;">
<label class="form-label small mb-1">Teams</label>
<select name="team" id="adjTeamSelect" class="form-select form-select-sm adj-multi" multiple
data-placeholder="All teams">
{% for t in all_teams_for_filter %}
<option value="{{ t.id }}" {% if t.id in adj_filter_values.team %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
{# --- Status single-select (Unpaid / Paid / All) --- #}
<div style="min-width: 120px;">
<label class="form-label small mb-1">Status</label>
<select name="adj_status" class="form-select form-select-sm">
<option value="" {% if not adj_filter_values.adj_status %}selected{% endif %}>All</option>
<option value="unpaid" {% if adj_filter_values.adj_status == 'unpaid' %}selected{% endif %}>Unpaid</option>
<option value="paid" {% if adj_filter_values.adj_status == 'paid' %}selected{% endif %}>Paid</option>
</select>
</div>
{# --- Date range --- #}
<div style="min-width: 130px;">
<label class="form-label small mb-1">From</label>
<input type="date" name="adj_date_from" class="form-control form-control-sm"
value="{{ adj_filter_values.adj_date_from }}">
</div>
<div style="min-width: 130px;">
<label class="form-label small mb-1">To</label>
<input type="date" name="adj_date_to" class="form-control form-control-sm"
value="{{ adj_filter_values.adj_date_to }}">
</div>
{# --- Sort state (column-header clicks will set these via JS in Task 9) --- #}
<input type="hidden" name="sort" value="{{ adj_filter_values.sort }}">
<input type="hidden" name="order" value="{{ adj_filter_values.order }}">
{# --- Apply / Clear buttons --- #}
<div class="d-flex gap-2">
<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>
{# --- Stats row (scoped to the currently-filtered set) --- #}
<div class="d-flex flex-wrap gap-3 mb-3 px-2 small text-muted">
<span><strong>{{ adj_total_count }}</strong> adjustment{{ adj_total_count|pluralize }}</span>
<span>&middot;</span>
<span><strong>{{ adj_unpaid_count }}</strong> unpaid (R {{ adj_unpaid_sum|money }})</span>
<span>&middot;</span>
<span>+R {{ adj_additive_sum|money }} net additive</span>
<span>&middot;</span>
<span>&minus;R {{ adj_deductive_sum|money }} net deductive</span>
</div>
{# --- Flat table of adjustments --- #}
{% if adj_page.object_list %}
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="adjSelectAll"
title="Select all unpaid on this page">
</th>
<th class="sortable" data-sort="date">Date <i class="fas fa-sort sort-arrow"></i></th>
<th class="sortable" data-sort="worker">Worker <i class="fas fa-sort sort-arrow"></i></th>
<th>Type</th>
<th class="text-end sortable" data-sort="amount">Amount <i class="fas fa-sort sort-arrow"></i></th>
<th>Project</th>
<th>Team</th>
<th>Description</th>
<th class="sortable" data-sort="status">Status <i class="fas fa-sort sort-arrow"></i></th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for adj in adj_page.object_list %}
{% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# --- Pagination --- #}
{% if adj_page.has_other_pages %}
<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" href="?{{ request.GET.urlencode }}&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" href="?{{ request.GET.urlencode }}&page={{ adj_page.next_page_number }}">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
{# Simple placeholder empty state — visual polish comes in Task 10 #}
<div class="adj-empty-state">
<div class="adj-empty-icon"><i class="fas fa-inbox"></i></div>
<p>No adjustments match these filters.</p>
</div>
{% endif %}
{% endif %}
</div>
{# ================================================================== #}
@ -903,6 +1070,19 @@
{{ 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 ===
@ -3054,6 +3234,45 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// =================================================================
// ADJUSTMENTS TAB — Choices.js multi-selects + 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')) {
// --- Lazy-init Choices.js on the three multi-selects ---
// Choices.js is heavy, so we only enhance selects on this tab.
if (typeof Choices !== 'undefined') {
document.querySelectorAll('#adjFilterForm .adj-multi').forEach(function(sel) {
new Choices(sel, {
removeItemButton: true,
shouldSort: false,
placeholder: true,
placeholderValue: sel.getAttribute('data-placeholder') || '',
});
});
}
// --- Direct delete buttons on each unpaid row ---
// Short-circuits the edit modal's usual 2-step delete flow by opening
// #deleteConfirmModal directly with the correct form action + labels.
document.querySelectorAll('.adj-delete-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var adjId = this.dataset.adjId;
var adjType = this.dataset.adjType;
var adjWorker = this.dataset.adjWorker;
var deleteForm = document.getElementById('deleteAdjForm');
if (!deleteForm) return; // Safety: modal not loaded
deleteForm.action = '/payroll/adjustment/' + adjId + '/delete/';
document.getElementById('deleteAdjType').textContent = adjType;
document.getElementById('deleteAdjWorker').textContent = adjWorker;
bootstrap.Modal.getOrCreateInstance(
document.getElementById('deleteConfirmModal')
).show();
});
});
}
}); // end DOMContentLoaded
</script>