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:
parent
89f109afb4
commit
b450bd3c39
131
core/templates/core/_adjustment_row.html
Normal file
131
core/templates/core/_adjustment_row.html
Normal 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);">−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">—</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">—</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">—</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>
|
||||
@ -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>·</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;">
|
||||
<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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user