Compare commits
43 Commits
3dab09cea3
...
6f66faf06a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f66faf06a | ||
|
|
620f433d06 | ||
|
|
672c32cfb6 | ||
|
|
3fe3e5aa01 | ||
|
|
269d86259a | ||
|
|
9bb9ede300 | ||
|
|
7b71048376 | ||
|
|
c851b49dea | ||
|
|
6905703492 | ||
|
|
4c3e90f2a7 | ||
|
|
5f2e6d8c74 | ||
|
|
03f177e7d0 | ||
|
|
e5d06f91e5 | ||
|
|
0862805623 | ||
|
|
4c1cdb6210 | ||
|
|
4f15e4bd5f | ||
|
|
b59eb313c0 | ||
|
|
e088192103 | ||
|
|
06b3315641 | ||
|
|
b450bd3c39 | ||
|
|
89f109afb4 | ||
|
|
10d381e2ae | ||
|
|
a20a025d46 | ||
|
|
97d8a69212 | ||
|
|
cf82215511 | ||
|
|
54080a3e0a | ||
|
|
c26d2e07d0 | ||
|
|
dcc0eebb7d | ||
|
|
f6975bfb2f | ||
|
|
0bbf2caae5 | ||
|
|
1d00a3a68f | ||
|
|
c1937cd89d | ||
|
|
3fa3cdcf35 | ||
|
|
71f8558ff5 | ||
|
|
ffb3ef6800 | ||
|
|
5c4162d2eb | ||
|
|
6d2c72f6d1 | ||
|
|
b52ae47257 | ||
|
|
acbad1558e | ||
|
|
06f2e71d87 | ||
|
|
124b3f61b6 | ||
|
|
12edafa441 | ||
|
|
30d0991956 |
@ -179,6 +179,8 @@ USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
|
||||
| `/projects/report/csv/` | `project_batch_report_csv` | Admin: project batch report as CSV download |
|
||||
| `/toggle/<model>/<id>/` | `toggle_active` | Admin: AJAX toggle active status |
|
||||
| `/payroll/` | `payroll_dashboard` | Admin: pending payments, loans, charts |
|
||||
| `/payroll/?status=adjustments` | `payroll_dashboard` | Admin: browse ALL payroll adjustments (filter by type, worker, team, status, date; group-by type/worker; bulk-delete unpaid; row actions open existing modals) |
|
||||
| `/payroll/adjustments/bulk-delete/` | `bulk_delete_adjustments` | Admin: POST-only; delete multiple unpaid adjustments in one shot via fetch() with X-CSRFToken cookie |
|
||||
| `/payroll/pay/<worker_id>/` | `process_payment` | Admin: process payment (atomic) |
|
||||
| `/payroll/price-overtime/` | `price_overtime` | Admin: AJAX price unpriced OT entries |
|
||||
| `/payroll/adjustment/add/` | `add_adjustment` | Admin: create adjustment |
|
||||
|
||||
130
core/templates/core/_adjustment_row.html
Normal file
130
core/templates/core/_adjustment_row.html
Normal file
@ -0,0 +1,130 @@
|
||||
{# === _adjustment_row.html — row partial for the Adjustments tab === #}
|
||||
{% comment %}
|
||||
Single table row 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 by paid status:
|
||||
- Paid -> single View Payslip icon button
|
||||
- Unpaid -> three buttons: Preview, Edit, Delete
|
||||
(these reuse the existing modals on the dashboard -- no new JS)
|
||||
{% endcomment %}
|
||||
{% 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 (class="worker-lookup-link" opens the Worker Lookup modal) --- #}
|
||||
<td>
|
||||
<a href="#" class="worker-lookup-link text-decoration-none"
|
||||
data-worker-id="{{ adj.worker.id }}">{{ 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 %}
|
||||
{# Link lands on the History tab of the project detail page — the #}
|
||||
{# most useful landing for a user who clicked a historical #}
|
||||
{# adjustment. Tab activation is driven by the #history fragment #}
|
||||
{# via a small helper in projects/detail.html. #}
|
||||
<a href="{% url 'project_detail' adj.project.id %}#history"
|
||||
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) --- #}
|
||||
{# Uses `teams.all` (NOT `teams.first`) because the view's #}
|
||||
{# .prefetch_related('worker__teams') populates `_prefetched_objects_cache` #}
|
||||
{# for `.all()` calls — `.first()` would ignore the cache and fire a #}
|
||||
{# fresh `ORDER BY ... LIMIT 1` SQL query per row (up to ~50 per page). #}
|
||||
<td>
|
||||
{% with teams=adj.worker.teams.all %}
|
||||
{% if teams %}{{ teams.0.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 %}
|
||||
{# Eye icon on paid rows opens the same Payslip Preview modal used on #}
|
||||
{# the Pending tab (instead of navigating to the payslip detail page). #}
|
||||
{# The "Paid #N" badge in the Status column still links to the #}
|
||||
{# historical payslip for users who want to jump to the PDF view. #}
|
||||
<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>
|
||||
{% else %}
|
||||
{# UNPAID row: Preview + Edit only. Single-row delete happens #}
|
||||
{# inside the Edit Adjustment modal; bulk delete uses the row #}
|
||||
{# checkboxes + floating action bar (shared entry point). #}
|
||||
<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>
|
||||
<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>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
@ -1,160 +0,0 @@
|
||||
{% comment %}
|
||||
=== REPORT CONFIGURATION MODAL (shared partial) ===
|
||||
Renders the "Generate Report" modal and its month-vs-custom-dates
|
||||
toggle script. Included by both the Dashboard (index.html) and the
|
||||
Report page (report.html) so users can launch a new report from
|
||||
either place without duplicating the modal HTML or the JS.
|
||||
|
||||
Requires in the parent template context:
|
||||
- `projects` (queryset of Project, for the project dropdown)
|
||||
- `teams` (queryset of Team, for the team dropdown)
|
||||
|
||||
If those are missing, the dropdowns simply show "All Projects" /
|
||||
"All Teams" — no crash.
|
||||
{% endcomment %}
|
||||
|
||||
<div class="modal fade" id="reportConfigModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-file-alt me-2" style="color: var(--accent);"></i>Generate Report</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="get" action="{% url 'generate_report' %}" id="reportForm">
|
||||
<div class="modal-body">
|
||||
<div class="row g-3">
|
||||
<!-- Date Mode Toggle -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Date Selection</label>
|
||||
<div class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="date_mode" id="modeMonth" value="month" checked>
|
||||
<label class="btn btn-outline-secondary" for="modeMonth">
|
||||
<i class="fas fa-calendar-alt me-1"></i>Month(s)
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="date_mode" id="modeCustom" value="custom">
|
||||
<label class="btn btn-outline-secondary" for="modeCustom">
|
||||
<i class="fas fa-calendar-week me-1"></i>Custom Dates
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Month Range Picker (shown by default) -->
|
||||
<div class="col-6" id="fromMonthGroup">
|
||||
<label class="form-label fw-semibold">From</label>
|
||||
<input type="month" name="from_month" class="form-control" id="reportFromMonth">
|
||||
</div>
|
||||
<div class="col-6" id="toMonthGroup">
|
||||
<label class="form-label fw-semibold">To</label>
|
||||
<input type="month" name="to_month" class="form-control" id="reportToMonth">
|
||||
</div>
|
||||
|
||||
<!-- Custom Date Range (hidden by default) -->
|
||||
<div class="col-6 d-none" id="startDateGroup">
|
||||
<label class="form-label fw-semibold">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-control" id="reportStartDate">
|
||||
</div>
|
||||
<div class="col-6 d-none" id="endDateGroup">
|
||||
<label class="form-label fw-semibold">End Date</label>
|
||||
<input type="date" name="end_date" class="form-control" id="reportEndDate">
|
||||
</div>
|
||||
|
||||
<!-- Project Filter (optional) -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Project <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select name="project" class="form-select report-multi" multiple data-placeholder="All projects (leave empty for all)">
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}"{% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Team Filter (optional) -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Team <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select name="team" class="form-select report-multi" multiple data-placeholder="All teams (leave empty for all)">
|
||||
{% for t in teams %}
|
||||
<option value="{{ t.id }}"{% if t.id|stringformat:"s" in selected_team_ids %} selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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-accent"><i class="fas fa-chart-bar me-1"></i>Generate</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
=== REPORT MODAL — toggle month range vs custom dates ===
|
||||
Defaults both month pickers to the current month on page load so
|
||||
clicking "Generate" without changing anything produces a
|
||||
current-month report. Guarded by `if (!modeMonth)` so it's a no-op
|
||||
on pages that don't include the modal.
|
||||
-->
|
||||
<script>
|
||||
(function() {
|
||||
var modeMonth = document.getElementById('modeMonth');
|
||||
if (!modeMonth) return; // modal not on this page — skip
|
||||
var modeCustom = document.getElementById('modeCustom');
|
||||
var fromMonthGroup = document.getElementById('fromMonthGroup');
|
||||
var toMonthGroup = document.getElementById('toMonthGroup');
|
||||
var startGroup = document.getElementById('startDateGroup');
|
||||
var endGroup = document.getElementById('endDateGroup');
|
||||
var fromMonth = document.getElementById('reportFromMonth');
|
||||
var toMonth = document.getElementById('reportToMonth');
|
||||
|
||||
// Default both month pickers to current month
|
||||
var now = new Date();
|
||||
var curMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
|
||||
if (fromMonth) fromMonth.value = curMonth;
|
||||
if (toMonth) toMonth.value = curMonth;
|
||||
|
||||
function toggleMode() {
|
||||
if (modeMonth.checked) {
|
||||
fromMonthGroup.classList.remove('d-none');
|
||||
toMonthGroup.classList.remove('d-none');
|
||||
startGroup.classList.add('d-none');
|
||||
endGroup.classList.add('d-none');
|
||||
} else {
|
||||
fromMonthGroup.classList.add('d-none');
|
||||
toMonthGroup.classList.add('d-none');
|
||||
startGroup.classList.remove('d-none');
|
||||
endGroup.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
modeMonth.addEventListener('change', toggleMode);
|
||||
if (modeCustom) modeCustom.addEventListener('change', toggleMode);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# === CHOICES.JS — multi-select enhancement (admin-only) === #}
|
||||
{# Loaded CDN-only; falls back to native <select multiple> if the CDN fails. #}
|
||||
{% if user.is_staff or user.is_superuser %}
|
||||
<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"
|
||||
defer></script>
|
||||
<script>
|
||||
(function() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof Choices === 'undefined') return; // graceful fallback
|
||||
document.querySelectorAll('.report-multi').forEach(function(el) {
|
||||
new Choices(el, {
|
||||
removeItemButton: true,
|
||||
shouldSort: false,
|
||||
placeholder: true,
|
||||
placeholderValue: el.getAttribute('data-placeholder') || '',
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
@ -184,7 +184,11 @@
|
||||
<i class="fas fa-receipt"></i>
|
||||
<span>New Receipt</span>
|
||||
</a>
|
||||
<a href="#" class="quick-action" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
|
||||
{# === GENERATE REPORT — PLAIN LINK === #}
|
||||
{# No modal: lands on /report/ with the current month pre-filled; #}
|
||||
{# inline pill filters on the page handle any further tweaking. #}
|
||||
<a href="{% url 'generate_report' %}?from_month={% now 'Y-m' %}&to_month={% now 'Y-m' %}"
|
||||
class="quick-action">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<span>Generate Report</span>
|
||||
</a>
|
||||
@ -599,9 +603,4 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- === REPORT CONFIGURATION MODAL === -->
|
||||
<!-- Extracted to a shared partial so the report page can use the same
|
||||
modal without duplicating the HTML or the toggle script. -->
|
||||
{% include 'core/_report_config_modal.html' %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -565,6 +565,7 @@
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Start</th>
|
||||
<th>Last Activity</th>
|
||||
<th class="r">Working Days</th>
|
||||
<th class="r">Total Cost</th>
|
||||
<th class="r">Avg R / Working Day</th>
|
||||
@ -573,6 +574,7 @@
|
||||
<tr>
|
||||
<td class="name">{{ item.project }}</td>
|
||||
<td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span style="color:#cbd5e1;">—</span>{% endif %}</td>
|
||||
<td>{% if item.last_activity %}{{ item.last_activity|date:"d M Y" }}{% else %}<span style="color:#cbd5e1;">—</span>{% endif %}</td>
|
||||
<td class="r">{% if item.working_days %}{{ item.working_days }}{% else %}<span style="color:#cbd5e1;">—</span>{% endif %}</td>
|
||||
<td class="total">R {{ item.total|money }}</td>
|
||||
<td class="r">{% if item.working_days %}R {{ item.avg_per_working_day|money }}{% else %}<span style="color:#cbd5e1;">—</span>{% endif %}</td>
|
||||
|
||||
@ -181,4 +181,22 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// === Activate tab from URL hash ===
|
||||
// When the page loads with #history / #profile / #supervisors / #teams /
|
||||
// #workers in the URL, open that tab automatically. This lets other
|
||||
// pages link directly to a specific tab — e.g. the Adjustments row
|
||||
// partial links to /projects/<id>/#history so a user clicking a project
|
||||
// name from an adjustment lands on the relevant history view.
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!window.location.hash) return;
|
||||
var target = document.querySelector(
|
||||
'[data-bs-toggle="tab"][data-bs-target="' + window.location.hash + '"]'
|
||||
);
|
||||
if (target) {
|
||||
new bootstrap.Tab(target).show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -17,10 +17,8 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 mt-md-0">
|
||||
<!-- New Report: opens the same config modal as the Dashboard -->
|
||||
<button type="button" class="btn btn-primary shadow-sm" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
|
||||
<i class="fas fa-plus me-1"></i>New Report
|
||||
</button>
|
||||
{# "New Report" removed — the inline filter pills below ARE the #}
|
||||
{# new-report interface now. Date/project/team pills edit in-place. #}
|
||||
<a href="{% url 'generate_report_pdf' %}?{{ query_string }}" class="btn btn-accent shadow-sm">
|
||||
<i class="fas fa-download me-1"></i>Download PDF
|
||||
</a>
|
||||
@ -30,25 +28,167 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === FILTER PILLS === #}
|
||||
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none">
|
||||
<span class="filter-pill">
|
||||
<i class="fas fa-calendar me-1"></i>{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}
|
||||
</span>
|
||||
<span class="filter-pill">
|
||||
<i class="fas fa-folder me-1"></i>{{ project_name }}
|
||||
{# === FILTER PILLS (interactive — pill-as-dropdown) === #}
|
||||
{# Each pill is a clickable button that opens an inline popover with the #}
|
||||
{# relevant editor. The Apply button appears only when at least one pill #}
|
||||
{# has uncommitted changes. See the JS module lower in this file. #}
|
||||
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none" id="filter-pill-strip">
|
||||
|
||||
{# --- Date pill --- #}
|
||||
<div class="filter-pill-wrap position-relative">
|
||||
<button type="button"
|
||||
class="filter-pill filter-pill--editable"
|
||||
id="filter-pill-date"
|
||||
data-filter="date"
|
||||
aria-expanded="false"
|
||||
aria-controls="popover-date">
|
||||
<i class="fas fa-calendar me-1"></i>
|
||||
<span class="filter-pill__label">{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}</span>
|
||||
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
|
||||
</button>
|
||||
<div class="filter-popover" id="popover-date" role="dialog" aria-label="Edit date range" hidden>
|
||||
<div class="filter-popover__body">
|
||||
<label class="form-label fw-semibold small">Date Selection</label>
|
||||
<div class="btn-group w-100 mb-3" role="group">
|
||||
<input type="radio" class="btn-check" name="popover_date_mode" id="popDateModeMonth" value="month" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="popDateModeMonth">
|
||||
<i class="fas fa-calendar-alt me-1"></i>Month(s)
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="popover_date_mode" id="popDateModeCustom" value="custom">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="popDateModeCustom">
|
||||
<i class="fas fa-calendar-week me-1"></i>Custom Dates
|
||||
</label>
|
||||
</div>
|
||||
{# --- Month-mode pickers --- #}
|
||||
{# Visual order follows English reading: "From (optional) ... Until". #}
|
||||
{# "Until" is the anchor — always filled, defaults to the URL's #}
|
||||
{# to_month or the current YYYY-MM when there's no filter. #}
|
||||
{# "From" is optional — blank means a single-month report and the #}
|
||||
{# JS submits from_month = to_month. #}
|
||||
<div class="row g-2" id="popoverMonthFields">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">
|
||||
From
|
||||
<span class="text-muted" style="font-size: 0.6rem; letter-spacing: 0;">(optional)</span>
|
||||
</label>
|
||||
<input type="month" id="popoverFromMonth" class="form-control form-control-sm">
|
||||
<div class="form-text small"
|
||||
style="font-size: 0.72rem; color: var(--text-tertiary);">
|
||||
Leave blank for a single month
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
{# Tooltip on the info icon avoids the inline hint #}
|
||||
{# wrapping to two lines inside a narrow column. #}
|
||||
{# Global Bootstrap tooltip initialiser (base.html) #}
|
||||
{# picks up data-bs-toggle="tooltip" automatically. #}
|
||||
<label class="form-label small">
|
||||
Until
|
||||
<i class="fas fa-info-circle ms-1"
|
||||
data-bs-toggle="tooltip"
|
||||
title="Single month select"
|
||||
style="color: var(--text-tertiary); font-size: 0.85em; cursor: help;"></i>
|
||||
</label>
|
||||
<input type="month" id="popoverToMonth" class="form-control form-control-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2 d-none" id="popoverCustomFields">
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Start</label>
|
||||
<input type="date" id="popoverStartDate" class="form-control form-control-sm">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small">End</label>
|
||||
<input type="date" id="popoverEndDate" class="form-control form-control-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-popover__footer">
|
||||
<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>
|
||||
|
||||
{# --- Projects pill --- #}
|
||||
<div class="filter-pill-wrap position-relative">
|
||||
<button type="button"
|
||||
class="filter-pill filter-pill--editable"
|
||||
id="filter-pill-projects"
|
||||
data-filter="projects"
|
||||
aria-expanded="false"
|
||||
aria-controls="popover-projects">
|
||||
<i class="fas fa-folder me-1"></i>
|
||||
<span class="filter-pill__label">{{ project_name }}</span>
|
||||
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
|
||||
</button>
|
||||
{% if selected_project_ids %}
|
||||
<a href="?{{ query_string_without_project|default:query_string }}" class="filter-pill__x" aria-label="Clear project filter">×</a>
|
||||
<a href="?{{ query_string_without_project|default:query_string }}"
|
||||
class="filter-pill__x"
|
||||
aria-label="Clear project filter"
|
||||
title="Clear project filter">×</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="filter-pill">
|
||||
<i class="fas fa-users me-1"></i>{{ team_name }}
|
||||
<div class="filter-popover" id="popover-projects" role="dialog" aria-label="Edit projects" hidden>
|
||||
<div class="filter-popover__body">
|
||||
<label class="form-label fw-semibold small">Projects</label>
|
||||
<select id="popoverProjects" class="form-select report-multi" multiple data-placeholder="All projects (leave empty for all)">
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}"{% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-popover__footer">
|
||||
<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>
|
||||
|
||||
{# --- Teams pill --- #}
|
||||
<div class="filter-pill-wrap position-relative">
|
||||
<button type="button"
|
||||
class="filter-pill filter-pill--editable"
|
||||
id="filter-pill-teams"
|
||||
data-filter="teams"
|
||||
aria-expanded="false"
|
||||
aria-controls="popover-teams">
|
||||
<i class="fas fa-users me-1"></i>
|
||||
<span class="filter-pill__label">{{ team_name }}</span>
|
||||
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
|
||||
</button>
|
||||
{% if selected_team_ids %}
|
||||
<a href="?{{ query_string_without_team|default:query_string }}" class="filter-pill__x" aria-label="Clear team filter">×</a>
|
||||
<a href="?{{ query_string_without_team|default:query_string }}"
|
||||
class="filter-pill__x"
|
||||
aria-label="Clear team filter"
|
||||
title="Clear team filter">×</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<div class="filter-popover" id="popover-teams" role="dialog" aria-label="Edit teams" hidden>
|
||||
<div class="filter-popover__body">
|
||||
<label class="form-label fw-semibold small">Teams</label>
|
||||
<select id="popoverTeams" class="form-select report-multi" multiple data-placeholder="All teams (leave empty for all)">
|
||||
{% for t in teams %}
|
||||
<option value="{{ t.id }}"{% if t.id|stringformat:"s" in selected_team_ids %} selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-popover__footer">
|
||||
<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>
|
||||
|
||||
{# No global Apply button — each popover's OK commits + reloads directly. #}
|
||||
</div>
|
||||
|
||||
{# --- Cross-filter data for the JS module --- #}
|
||||
{{ project_team_pairs_json|json_script:"projectTeamPairs" }}
|
||||
|
||||
{# --- Expose current URL filter state so the cross-filter can disable #}
|
||||
{# dropdown options that are invalid given the OTHER pill's selection. #}
|
||||
{{ selected_project_ids|json_script:"urlSelectedProjectIds" }}
|
||||
{{ selected_team_ids|json_script:"urlSelectedTeamIds" }}
|
||||
|
||||
<!-- === PRINT HEADER === -->
|
||||
<div class="d-none d-print-block mb-4">
|
||||
<h2 class="text-center fw-bold mb-1">FoxFitt Construction — Payroll Report</h2>
|
||||
@ -106,6 +246,7 @@
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Start</th>
|
||||
<th>Last Activity</th>
|
||||
<th class="text-end">Working Days</th>
|
||||
<th class="text-end">Total Cost</th>
|
||||
<th class="text-end">Avg R / Working Day</th>
|
||||
@ -116,6 +257,7 @@
|
||||
<tr>
|
||||
<td class="fw-medium">{{ item.project }}</td>
|
||||
<td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td>{% if item.last_activity %}{{ item.last_activity|date:"d M Y" }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td class="text-end">{{ item.working_days|default:"—" }}</td>
|
||||
<td class="text-end fw-semibold">R {{ item.total|money }}</td>
|
||||
<td class="text-end">{% if item.working_days %}R {{ item.avg_per_working_day|money }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
@ -384,18 +526,326 @@
|
||||
<div class="d-flex justify-content-between align-items-center d-print-none">
|
||||
<a href="{% url 'home' %}" class="btn btn-outline-secondary"><i class="fas fa-arrow-left me-1"></i>Back to Dashboard</a>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
|
||||
<i class="fas fa-plus me-1"></i>New Report
|
||||
</button>
|
||||
<a href="{% url 'generate_report_pdf' %}?{{ query_string }}" class="btn btn-accent"><i class="fas fa-download me-1"></i>Download PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- === REPORT CONFIGURATION MODAL ===
|
||||
Shared partial — same modal the Dashboard uses, so clicking
|
||||
"New Report" here opens the familiar config screen without
|
||||
navigating away. -->
|
||||
{% include 'core/_report_config_modal.html' %}
|
||||
{# === CHOICES.JS CDN — admin-only === #}
|
||||
{# The pill popovers enhance their <select multiple> elements with #}
|
||||
{# Choices.js. Falls back to a native multi-select if the CDN fails. #}
|
||||
{% if user.is_staff or user.is_superuser %}
|
||||
<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"
|
||||
defer></script>
|
||||
{% endif %}
|
||||
|
||||
{# === INLINE FILTERS — PILL POPOVER MODULE === #}
|
||||
{# Scoped IIFE; runs once on DOMContentLoaded. #}
|
||||
{# #}
|
||||
{# Flow: each pill opens a popover; popover's OK button rebuilds the URL #}
|
||||
{# (keeping other filters intact) and navigates → full SSR page reload. #}
|
||||
{# Cancel just closes the popover. No "dirty" state, no global Apply. #}
|
||||
{# #}
|
||||
{# XSS-safe: textContent only; we never write user strings via innerHTML. #}
|
||||
{% if user.is_staff or user.is_superuser %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- Bail gracefully if Choices.js failed to load ---
|
||||
if (typeof Choices === 'undefined') {
|
||||
console.warn('[inline-filters] Choices.js not loaded; pills will not enhance');
|
||||
return;
|
||||
}
|
||||
|
||||
// === CONTEXT from json_script tags ===
|
||||
var pairsEl = document.getElementById('projectTeamPairs');
|
||||
var urlProjectsEl = document.getElementById('urlSelectedProjectIds');
|
||||
var urlTeamsEl = document.getElementById('urlSelectedTeamIds');
|
||||
var pairs = pairsEl ? JSON.parse(pairsEl.textContent) : [];
|
||||
function toIntArr(s) {
|
||||
return (JSON.parse(s || '[]') || [])
|
||||
.map(function(v) { return parseInt(v, 10); })
|
||||
.filter(function(n) { return !isNaN(n); });
|
||||
}
|
||||
// URL state used only for cross-filter option-disabling on popover open.
|
||||
// (No pending-state diffing because OK always submits immediately.)
|
||||
var urlProjects = toIntArr(urlProjectsEl && urlProjectsEl.textContent);
|
||||
var urlTeams = toIntArr(urlTeamsEl && urlTeamsEl.textContent);
|
||||
|
||||
// --- Cross-filter lookup indices ---
|
||||
var projectToTeams = {}, teamToProjects = {};
|
||||
pairs.forEach(function(pair) {
|
||||
var pid = pair.project_id, tid = pair.team_id;
|
||||
if (!projectToTeams[pid]) projectToTeams[pid] = new Set();
|
||||
if (!teamToProjects[tid]) teamToProjects[tid] = new Set();
|
||||
projectToTeams[pid].add(tid);
|
||||
teamToProjects[tid].add(pid);
|
||||
});
|
||||
|
||||
// --- Choices.js instances (lazy init on first open) ---
|
||||
var projectsChoices = null, teamsChoices = null;
|
||||
|
||||
// --- DOM refs ---
|
||||
var pills = {
|
||||
date: document.getElementById('filter-pill-date'),
|
||||
projects: document.getElementById('filter-pill-projects'),
|
||||
teams: document.getElementById('filter-pill-teams'),
|
||||
};
|
||||
var popovers = {
|
||||
date: document.getElementById('popover-date'),
|
||||
projects: document.getElementById('popover-projects'),
|
||||
teams: document.getElementById('popover-teams'),
|
||||
};
|
||||
var fromMonthInput = document.getElementById('popoverFromMonth');
|
||||
var toMonthInput = document.getElementById('popoverToMonth');
|
||||
var startDateInput = document.getElementById('popoverStartDate');
|
||||
var endDateInput = document.getElementById('popoverEndDate');
|
||||
|
||||
// === INITIALISE DATE INPUTS from URL ===
|
||||
var qs = new URLSearchParams(window.location.search);
|
||||
var urlFromMonth = qs.get('from_month') || '';
|
||||
var urlToMonth = qs.get('to_month') || '';
|
||||
var urlStartDate = qs.get('start_date') || '';
|
||||
var urlEndDate = qs.get('end_date') || '';
|
||||
|
||||
// "Until" is the anchor — always filled. Falls back to current month
|
||||
// (YYYY-MM) when the URL has no to_month (e.g. no filters yet).
|
||||
function currentYearMonth() {
|
||||
var d = new Date();
|
||||
var m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return d.getFullYear() + '-' + m;
|
||||
}
|
||||
toMonthInput.value = urlToMonth || currentYearMonth();
|
||||
// "From" is optional: blank signals single-month mode. Range URLs
|
||||
// (from != to) fill it; single-month URLs (from == to) leave it blank.
|
||||
fromMonthInput.value = (urlFromMonth && urlFromMonth === urlToMonth) ? '' : urlFromMonth;
|
||||
startDateInput.value = urlStartDate;
|
||||
endDateInput.value = urlEndDate;
|
||||
|
||||
var initialDateMode = (urlStartDate || urlEndDate) ? 'custom' : 'month';
|
||||
document.getElementById('popDateModeMonth').checked = (initialDateMode === 'month');
|
||||
document.getElementById('popDateModeCustom').checked = (initialDateMode === 'custom');
|
||||
document.getElementById('popoverMonthFields').classList.toggle('d-none', initialDateMode !== 'month');
|
||||
document.getElementById('popoverCustomFields').classList.toggle('d-none', initialDateMode !== 'custom');
|
||||
|
||||
// === POPOVER OPEN / CLOSE ===
|
||||
function closeAllPopovers(except) {
|
||||
Object.keys(popovers).forEach(function(key) {
|
||||
if (key !== except) {
|
||||
popovers[key].hidden = true;
|
||||
pills[key].setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
function openPopover(key) {
|
||||
closeAllPopovers(key);
|
||||
popovers[key].hidden = false;
|
||||
pills[key].setAttribute('aria-expanded', 'true');
|
||||
|
||||
// Lazy-init Choices.js the first time each multi-select is shown
|
||||
if (key === 'projects' && !projectsChoices) {
|
||||
projectsChoices = new Choices(document.getElementById('popoverProjects'), {
|
||||
removeItemButton: true, shouldSort: false, placeholder: true,
|
||||
placeholderValue: 'All projects (leave empty for all)',
|
||||
});
|
||||
}
|
||||
if (key === 'teams' && !teamsChoices) {
|
||||
teamsChoices = new Choices(document.getElementById('popoverTeams'), {
|
||||
removeItemButton: true, shouldSort: false, placeholder: true,
|
||||
placeholderValue: 'All teams (leave empty for all)',
|
||||
});
|
||||
}
|
||||
// Cross-filter: disable options that are invalid given the OTHER pill's
|
||||
// current URL selection. (e.g., open Teams with Project X in URL → hide
|
||||
// teams that never logged on Project X.)
|
||||
if (key === 'projects') applyCrossFilter('projects');
|
||||
if (key === 'teams') applyCrossFilter('teams');
|
||||
|
||||
// Auto-open the Choices.js dropdown so options are visible
|
||||
// immediately — the pill click means "show me the list." Without
|
||||
// this, the user has to click the search input first, which feels
|
||||
// like an extra step. Deferred one tick so it runs AFTER any
|
||||
// cross-filter destroy/recreate above has settled.
|
||||
if (key === 'projects' && projectsChoices) {
|
||||
setTimeout(function() { projectsChoices.showDropdown(true); }, 0);
|
||||
}
|
||||
if (key === 'teams' && teamsChoices) {
|
||||
setTimeout(function() { teamsChoices.showDropdown(true); }, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pill click: toggle popover ---
|
||||
Object.keys(pills).forEach(function(key) {
|
||||
pills[key].addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
var isOpen = !popovers[key].hidden;
|
||||
if (isOpen) {
|
||||
popovers[key].hidden = true;
|
||||
pills[key].setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
openPopover(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Click outside the pill group closes all popovers
|
||||
document.addEventListener('click', function(ev) {
|
||||
if (!ev.target.closest('.filter-pill-wrap')) closeAllPopovers();
|
||||
});
|
||||
// Esc closes popovers
|
||||
document.addEventListener('keydown', function(ev) {
|
||||
if (ev.key === 'Escape') closeAllPopovers();
|
||||
});
|
||||
|
||||
// === POPOVER OK / CANCEL HANDLERS ===
|
||||
// OK submits immediately via navigation. Cancel just closes the popover;
|
||||
// any Choices.js changes the user made are reset to URL state so the next
|
||||
// open starts fresh.
|
||||
document.querySelectorAll('.filter-popover').forEach(function(pop) {
|
||||
var okBtn = pop.querySelector('.popover-ok');
|
||||
var cancelBtn = pop.querySelector('.popover-cancel');
|
||||
okBtn.addEventListener('click', function() {
|
||||
if (pop.id === 'popover-date') submitDateFilter();
|
||||
else if (pop.id === 'popover-projects') submitProjectsFilter();
|
||||
else if (pop.id === 'popover-teams') submitTeamsFilter();
|
||||
// Navigation happens inside submit functions — nothing else to do.
|
||||
});
|
||||
cancelBtn.addEventListener('click', function() {
|
||||
// Reset Choices.js widgets to URL state in case the user had
|
||||
// selected something. Date inputs reset on the next open via
|
||||
// URL reload (no in-page state to revert otherwise).
|
||||
if (pop.id === 'popover-projects' && projectsChoices) {
|
||||
rebuildChoicesSelection(projectsChoices, urlProjects);
|
||||
}
|
||||
if (pop.id === 'popover-teams' && teamsChoices) {
|
||||
rebuildChoicesSelection(teamsChoices, urlTeams);
|
||||
}
|
||||
closeAllPopovers();
|
||||
});
|
||||
});
|
||||
|
||||
// === DATE MODE TOGGLE inside the date popover ===
|
||||
document.getElementById('popDateModeMonth').addEventListener('change', function() {
|
||||
document.getElementById('popoverMonthFields').classList.remove('d-none');
|
||||
document.getElementById('popoverCustomFields').classList.add('d-none');
|
||||
});
|
||||
document.getElementById('popDateModeCustom').addEventListener('change', function() {
|
||||
document.getElementById('popoverMonthFields').classList.add('d-none');
|
||||
document.getElementById('popoverCustomFields').classList.remove('d-none');
|
||||
});
|
||||
|
||||
// === SUBMIT HANDLERS ===
|
||||
// Each rebuilds the URL using the current popover's inputs (keeping the
|
||||
// other filters intact) and navigates → full SSR page reload. Matches
|
||||
// the original modal's contract; the report re-renders server-side.
|
||||
function submitDateFilter() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
// Clear all date-family params (current + legacy modal-form params)
|
||||
params.delete('from_month');
|
||||
params.delete('to_month');
|
||||
params.delete('start_date');
|
||||
params.delete('end_date');
|
||||
params.delete('date_mode');
|
||||
params.delete('search_terms');
|
||||
|
||||
var isCustom = document.getElementById('popDateModeCustom').checked;
|
||||
if (isCustom) {
|
||||
if (startDateInput.value) params.set('start_date', startDateInput.value);
|
||||
if (endDateInput.value) params.set('end_date', endDateInput.value);
|
||||
} else {
|
||||
// Month mode: "Until" is the anchor (always required; defaults
|
||||
// to current month in the picker); "From" is optional (blank =
|
||||
// single-month, so use Until for both ends).
|
||||
var to = toMonthInput.value || currentYearMonth();
|
||||
var from = fromMonthInput.value || to;
|
||||
if (from) params.set('from_month', from);
|
||||
if (to) params.set('to_month', to);
|
||||
}
|
||||
navigateTo(params);
|
||||
}
|
||||
function submitProjectsFilter() {
|
||||
if (!projectsChoices) { closeAllPopovers(); return; }
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
params.delete('project');
|
||||
projectsChoices.getValue(true).forEach(function(id) {
|
||||
params.append('project', id);
|
||||
});
|
||||
navigateTo(params);
|
||||
}
|
||||
function submitTeamsFilter() {
|
||||
if (!teamsChoices) { closeAllPopovers(); return; }
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
params.delete('team');
|
||||
teamsChoices.getValue(true).forEach(function(id) {
|
||||
params.append('team', id);
|
||||
});
|
||||
navigateTo(params);
|
||||
}
|
||||
function navigateTo(params) {
|
||||
window.location = window.location.pathname + '?' + params.toString();
|
||||
}
|
||||
|
||||
// === CROSS-FILTER ===
|
||||
// Read-time only: when a popover opens, disable options that are invalid
|
||||
// given the OTHER pill's current URL selection. Since each OK submits to
|
||||
// URL directly, we don't need runtime auto-removal or pending-state sync.
|
||||
function applyCrossFilter(justOpened) {
|
||||
if (justOpened === 'projects' && projectsChoices) {
|
||||
if (urlTeams.length === 0) return; // no constraint
|
||||
var validPids = new Set();
|
||||
urlTeams.forEach(function(tid) {
|
||||
if (teamToProjects[tid]) {
|
||||
teamToProjects[tid].forEach(function(pid) { validPids.add(pid); });
|
||||
}
|
||||
});
|
||||
var sel = document.getElementById('popoverProjects');
|
||||
Array.from(sel.options).forEach(function(opt) {
|
||||
var pid = parseInt(opt.value, 10);
|
||||
// Always leave currently-URL-selected items enabled so they
|
||||
// remain visible as removable chips — user can unpick them.
|
||||
opt.disabled = !validPids.has(pid) && !urlProjects.includes(pid);
|
||||
});
|
||||
projectsChoices.destroy();
|
||||
projectsChoices = new Choices(sel, {
|
||||
removeItemButton: true, shouldSort: false, placeholder: true,
|
||||
placeholderValue: 'All projects (leave empty for all)',
|
||||
});
|
||||
}
|
||||
if (justOpened === 'teams' && teamsChoices) {
|
||||
if (urlProjects.length === 0) return;
|
||||
var validTids = new Set();
|
||||
urlProjects.forEach(function(pid) {
|
||||
if (projectToTeams[pid]) {
|
||||
projectToTeams[pid].forEach(function(tid) { validTids.add(tid); });
|
||||
}
|
||||
});
|
||||
var selT = document.getElementById('popoverTeams');
|
||||
Array.from(selT.options).forEach(function(opt) {
|
||||
var tid = parseInt(opt.value, 10);
|
||||
opt.disabled = !validTids.has(tid) && !urlTeams.includes(tid);
|
||||
});
|
||||
teamsChoices.destroy();
|
||||
teamsChoices = new Choices(selT, {
|
||||
removeItemButton: true, shouldSort: false, placeholder: true,
|
||||
placeholderValue: 'All teams (leave empty for all)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper: rebuild a Choices.js widget's selection from a list of IDs ---
|
||||
function rebuildChoicesSelection(instance, ids) {
|
||||
instance.removeActiveItems();
|
||||
var idStrs = ids.map(String);
|
||||
if (idStrs.length > 0) {
|
||||
instance.setChoiceByValue(idStrs);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -27,6 +27,22 @@ def money(value):
|
||||
return formatted.replace(",", " ")
|
||||
|
||||
|
||||
# === type_slug filter ===
|
||||
# Converts adjustment-type labels like "Advance Payment" to
|
||||
# CSS-class-friendly slugs like "advance-payment". Used by the Adjustments
|
||||
# tab to pick the right colour badge class per row.
|
||||
@register.filter
|
||||
def type_slug(value):
|
||||
"""Return a hyphen-separated lowercase version of `value`.
|
||||
|
||||
Used in templates: `<span class="badge-type-{{ adj.type|type_slug }}">`.
|
||||
Returns '' for None / empty — no class generated, no crash.
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
return value.lower().replace(' ', '-')
|
||||
|
||||
|
||||
@register.filter
|
||||
def dictlookup(d, key):
|
||||
"""Look up a dict value by a dynamic key.
|
||||
@ -42,3 +58,50 @@ def dictlookup(d, key):
|
||||
return d.get(key)
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# === url_replace tag ===
|
||||
# Returns the current request's querystring with one key replaced/removed.
|
||||
# Used by filter toggles and pagination links so they don't accumulate
|
||||
# repeated keys (e.g. ?page=2&page=3) on each click. Pass value='' (or None)
|
||||
# to drop the key entirely; pass a real value to set/replace it.
|
||||
@register.simple_tag
|
||||
def url_replace(request, key, value):
|
||||
"""Clone the current GET querystring, set/remove one key, and return it encoded.
|
||||
|
||||
Plain-English: Django templates can't easily rebuild a querystring with
|
||||
one value swapped out. This tag does it — hand it the request, the key
|
||||
you want to change, and the new value (or empty string to drop it), and
|
||||
you get back a ready-to-paste `?...` string. Prevents the `?page=2&page=3`
|
||||
stacking bug that happens if you just append `&page=X` to the raw
|
||||
`request.GET.urlencode`.
|
||||
"""
|
||||
qd = request.GET.copy()
|
||||
if value == '' or value is None:
|
||||
qd.pop(key, None)
|
||||
else:
|
||||
qd[key] = str(value)
|
||||
return qd.urlencode()
|
||||
|
||||
|
||||
# === money_abs filter ===
|
||||
# Formats the ABSOLUTE value of a Decimal in ZAR style. Callers handle
|
||||
# the sign explicitly via template logic (see the Adjustments-tab group
|
||||
# header which renders "+R 800.00" for a positive net_sum and
|
||||
# "-R 100.00" for a negative one). Pairs with the existing `money`
|
||||
# filter and avoids rendering "R -100.00" which reads as a minus
|
||||
# squished against the R.
|
||||
@register.filter
|
||||
def money_abs(value):
|
||||
"""Format the absolute value of a number in ZAR money style.
|
||||
|
||||
Plain-English: same as `money`, but always returns the positive
|
||||
version of the number. The caller is responsible for emitting its
|
||||
own `+` or `-` sign in the template. Useful when the sign needs to
|
||||
appear to the LEFT of the `R` (e.g. `+R 800.00`) rather than
|
||||
attached to the number (which would render `R -800.00` and read
|
||||
confusingly).
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
return money(abs(value))
|
||||
|
||||
490
core/tests.py
490
core/tests.py
@ -9,7 +9,7 @@ from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment
|
||||
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment, Loan
|
||||
from core.views import _build_work_log_payroll_context, _build_report_context
|
||||
|
||||
|
||||
@ -692,6 +692,12 @@ class ChapterOneEnrichmentTests(TestCase):
|
||||
self.assertEqual(by_name['C1']['working_days'], 4)
|
||||
self.assertEqual(by_name['C1']['avg_per_working_day'], Decimal('200.00'))
|
||||
self.assertEqual(by_name['C1']['start_date'], datetime.date(2026, 1, 1))
|
||||
# last_activity = the most recent WorkLog.date (4th of March here)
|
||||
self.assertEqual(
|
||||
by_name['C1']['last_activity'], datetime.date(2026, 3, 4),
|
||||
'alltime_projects rows should expose the most-recent log date '
|
||||
'so the report can show "Last Activity" per project'
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@ -749,3 +755,485 @@ class ReportMultiFilterTests(TestCase):
|
||||
self.assertEqual(len(ctx['worker_breakdown']), 1)
|
||||
# All three records are for the same worker, R 100 each = R 300
|
||||
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('300.00'))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === TESTS FOR INLINE FILTERS (Report Page) ===
|
||||
# Pill-as-dropdown + cross-filter feature. Most behaviour is template/JS;
|
||||
# the only backend surface is the project_team_pairs_json context key that
|
||||
# powers the client-side Team<->Project cross-filter.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class InlineFiltersPairsContextTests(TestCase):
|
||||
"""Report view must serialise distinct (project_id, team_id) pairs for
|
||||
the pill-popover cross-filter JS."""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(
|
||||
username='admin-if', password='pass', is_staff=True
|
||||
)
|
||||
self.p1 = Project.objects.create(name='P1')
|
||||
self.p2 = Project.objects.create(name='P2')
|
||||
self.t1 = Team.objects.create(name='T1', supervisor=self.admin)
|
||||
self.t2 = Team.objects.create(name='T2', supervisor=self.admin)
|
||||
self.w = Worker.objects.create(
|
||||
name='W', id_number='W1', monthly_salary=Decimal('4000')
|
||||
)
|
||||
# Log t1 on p1, t2 on p2 — so pairs should be [(p1,t1), (p2,t2)]
|
||||
for proj, team in [(self.p1, self.t1), (self.p2, self.t2)]:
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 1),
|
||||
project=proj, team=team, supervisor=self.admin,
|
||||
)
|
||||
log.workers.add(self.w)
|
||||
|
||||
def test_pairs_context_key_populated(self):
|
||||
# The context value is a raw Python list of dicts; Django's
|
||||
# |json_script filter handles the single JSON serialisation at
|
||||
# template render time (no double-encoding).
|
||||
self.client.login(username='admin-if', password='pass')
|
||||
url = reverse('generate_report')
|
||||
resp = self.client.get(url + '?from_month=2026-03&to_month=2026-04')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
pairs = resp.context['project_team_pairs_json']
|
||||
# Each entry has both project_id and team_id
|
||||
for p in pairs:
|
||||
self.assertIn('project_id', p)
|
||||
self.assertIn('team_id', p)
|
||||
# Expected pairs (as tuples for set comparison)
|
||||
pair_set = {(p['project_id'], p['team_id']) for p in pairs}
|
||||
self.assertIn((self.p1.id, self.t1.id), pair_set)
|
||||
self.assertIn((self.p2.id, self.t2.id), pair_set)
|
||||
|
||||
def test_pairs_excludes_null_project_or_team(self):
|
||||
"""Logs with null project or null team should not appear in pairs."""
|
||||
# Add a log with team=None
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 2),
|
||||
project=self.p1, team=None, supervisor=self.admin,
|
||||
)
|
||||
log.workers.add(self.w)
|
||||
|
||||
self.client.login(username='admin-if', password='pass')
|
||||
resp = self.client.get(reverse('generate_report') + '?from_month=2026-03&to_month=2026-04')
|
||||
pairs = resp.context['project_team_pairs_json']
|
||||
# No pair should have team_id=None
|
||||
self.assertTrue(all(p['team_id'] is not None for p in pairs))
|
||||
|
||||
def test_pairs_renders_as_valid_json_in_template(self):
|
||||
"""End-to-end: the rendered HTML must contain a single, valid JSON
|
||||
array inside the <script id="projectTeamPairs"> tag — NOT a
|
||||
JSON-encoded string (which was the bug that broke all pill
|
||||
interactions before the context key was changed from
|
||||
`json.dumps(pairs)` to raw `pairs`)."""
|
||||
import json as _json
|
||||
import re
|
||||
|
||||
self.client.login(username='admin-if', password='pass')
|
||||
resp = self.client.get(reverse('generate_report') + '?from_month=2026-03&to_month=2026-04')
|
||||
html = resp.content.decode('utf-8')
|
||||
|
||||
# Extract the JSON payload inside <script id="projectTeamPairs">...</script>
|
||||
match = re.search(
|
||||
r'<script id="projectTeamPairs"[^>]*>(.*?)</script>',
|
||||
html, re.DOTALL
|
||||
)
|
||||
self.assertIsNotNone(match, 'projectTeamPairs <script> tag missing')
|
||||
payload = match.group(1).strip()
|
||||
|
||||
# Must parse to a LIST, not a string.
|
||||
parsed = _json.loads(payload)
|
||||
self.assertIsInstance(parsed, list,
|
||||
"Double-encoded JSON regression: browser's JSON.parse "
|
||||
"would return a string here, killing pairs.forEach() in the "
|
||||
"pill-popover JS. See 2026-04-23 bugfix.")
|
||||
# And the list members must be dicts with project_id + team_id
|
||||
for entry in parsed:
|
||||
self.assertIsInstance(entry, dict)
|
||||
self.assertIn('project_id', entry)
|
||||
self.assertIn('team_id', entry)
|
||||
|
||||
def test_pickers_and_pairs_are_date_scoped(self):
|
||||
"""Checkpoint-1 refinement: projects/teams lists + the pair map
|
||||
include only entries with WorkLog activity INSIDE the selected
|
||||
date range — NOT entire-history entries. Entries that are in the
|
||||
URL's `?project=` or `?team=` selection are always preserved,
|
||||
though, so the user's pick can never vanish."""
|
||||
# Add a third project/team that ONLY worked outside the report window
|
||||
out_project = Project.objects.create(name='P-out')
|
||||
out_team = Team.objects.create(name='T-out', supervisor=self.admin)
|
||||
out_log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 1, 15), # outside March window
|
||||
project=out_project, team=out_team, supervisor=self.admin,
|
||||
)
|
||||
out_log.workers.add(self.w)
|
||||
|
||||
self.client.login(username='admin-if', password='pass')
|
||||
# Request only March 2026 — Jan logs should be excluded
|
||||
resp = self.client.get(
|
||||
reverse('generate_report') + '?from_month=2026-03&to_month=2026-03'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Picker lists: out-of-range project + team should NOT appear
|
||||
project_ids = {p.id for p in resp.context['projects']}
|
||||
team_ids = {t.id for t in resp.context['teams']}
|
||||
self.assertIn(self.p1.id, project_ids)
|
||||
self.assertIn(self.p2.id, project_ids)
|
||||
self.assertNotIn(out_project.id, project_ids,
|
||||
'Out-of-range project must not appear in the date-scoped list')
|
||||
self.assertIn(self.t1.id, team_ids)
|
||||
self.assertIn(self.t2.id, team_ids)
|
||||
self.assertNotIn(out_team.id, team_ids,
|
||||
'Out-of-range team must not appear in the date-scoped list')
|
||||
|
||||
# Pair map: must also be date-scoped
|
||||
pair_set = {(p['project_id'], p['team_id'])
|
||||
for p in resp.context['project_team_pairs_json']}
|
||||
self.assertNotIn((out_project.id, out_team.id), pair_set,
|
||||
'Out-of-range pair must not appear in the cross-filter map')
|
||||
|
||||
def test_url_selected_projects_survive_even_out_of_range(self):
|
||||
"""A project explicitly in the URL's ?project= selection must
|
||||
remain in the picker list even if it has no logs in the current
|
||||
date range — otherwise the user couldn't see (or deselect) their
|
||||
own pick."""
|
||||
out_project = Project.objects.create(name='P-out')
|
||||
# Never logs anything in any date range
|
||||
|
||||
self.client.login(username='admin-if', password='pass')
|
||||
resp = self.client.get(
|
||||
reverse('generate_report')
|
||||
+ '?from_month=2026-03&to_month=2026-03'
|
||||
+ f'&project={out_project.id}'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
project_ids = {p.id for p in resp.context['projects']}
|
||||
self.assertIn(out_project.id, project_ids,
|
||||
'URL-selected project must survive the date-scope filter')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === TESTS FOR |type_slug FILTER ===
|
||||
# Used by Adjustments tab to build CSS class names from type labels.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TypeSlugFilterTests(TestCase):
|
||||
"""format_tags.type_slug converts adjustment-type labels to slugs."""
|
||||
|
||||
def test_spaces_become_hyphens_and_lowercased(self):
|
||||
from core.templatetags.format_tags import type_slug
|
||||
self.assertEqual(type_slug('Advance Payment'), 'advance-payment')
|
||||
self.assertEqual(type_slug('New Loan'), 'new-loan')
|
||||
self.assertEqual(type_slug('Bonus'), 'bonus')
|
||||
|
||||
def test_empty_or_none_returns_empty_string(self):
|
||||
from core.templatetags.format_tags import type_slug
|
||||
self.assertEqual(type_slug(''), '')
|
||||
self.assertEqual(type_slug(None), '')
|
||||
|
||||
def test_idempotent_on_already_slugged(self):
|
||||
from core.templatetags.format_tags import type_slug
|
||||
self.assertEqual(type_slug('bonus'), 'bonus')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === TESTS FOR ADJUSTMENTS TAB (payroll_dashboard ?status=adjustments) ===
|
||||
# Covers the new tab's backend: filters, sort, stats, pagination.
|
||||
# Each test creates its own fresh fixture via setUp.
|
||||
# NOTE: PayrollRecord only accepts worker/date/amount_paid (see core/models.py).
|
||||
# The plan spec used days_worked/total_amount — those do NOT exist. Adapted.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AdjustmentsTabTests(TestCase):
|
||||
"""New Adjustments tab on /payroll/?status=adjustments."""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(
|
||||
username='adj-admin', password='pass', is_staff=True, is_superuser=True
|
||||
)
|
||||
self.sup = User.objects.create_user(
|
||||
username='adj-sup', password='pass'
|
||||
)
|
||||
self.w1 = Worker.objects.create(
|
||||
name='Alice', id_number='A1', monthly_salary=Decimal('4000')
|
||||
)
|
||||
self.w2 = Worker.objects.create(
|
||||
name='Bob', id_number='B1', monthly_salary=Decimal('4000')
|
||||
)
|
||||
# Two teams, BOTH workers in BOTH teams, so the naive M2M JOIN
|
||||
# multiplies rows by team count. Exercises the subquery fix.
|
||||
self.team = Team.objects.create(name='Alpha', supervisor=self.admin)
|
||||
self.team2 = Team.objects.create(name='Beta', supervisor=self.admin)
|
||||
self.team.workers.add(self.w1, self.w2)
|
||||
self.team2.workers.add(self.w1, self.w2)
|
||||
self.proj = Project.objects.create(name='Site X')
|
||||
# 3 unpaid adjustments — 1 bonus Alice, 1 bonus Bob, 1 deduction Alice
|
||||
self.a1 = PayrollAdjustment.objects.create(
|
||||
worker=self.w1, project=self.proj, type='Bonus',
|
||||
amount=Decimal('500'), date=datetime.date(2026, 4, 10),
|
||||
description='April bonus',
|
||||
)
|
||||
self.a2 = PayrollAdjustment.objects.create(
|
||||
worker=self.w2, project=self.proj, type='Bonus',
|
||||
amount=Decimal('300'), date=datetime.date(2026, 4, 11),
|
||||
description='Project milestone',
|
||||
)
|
||||
self.a3 = PayrollAdjustment.objects.create(
|
||||
worker=self.w1, project=self.proj, type='Deduction',
|
||||
amount=Decimal('100'), date=datetime.date(2026, 3, 28),
|
||||
description='Missing tool',
|
||||
)
|
||||
self.url = reverse('payroll_dashboard') + '?status=adjustments'
|
||||
|
||||
def _login_admin(self):
|
||||
self.client.login(username='adj-admin', password='pass')
|
||||
|
||||
def test_admin_sees_adjustments_tab(self):
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.context['active_tab'], 'adjustments')
|
||||
# All 3 fixture adjustments should be in the listing
|
||||
self.assertEqual(len(resp.context['adj_page'].object_list), 3)
|
||||
|
||||
def test_supervisor_forbidden(self):
|
||||
self.client.login(username='adj-sup', password='pass')
|
||||
resp = self.client.get(self.url)
|
||||
# Existing payroll_dashboard pattern: non-admin is redirected home
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
|
||||
def test_type_multi_filter(self):
|
||||
"""?type=Bonus&type=Deduction returns the UNION (3 rows: 2 bonuses + 1
|
||||
deduction), not the intersection."""
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url + '&type=Bonus&type=Deduction')
|
||||
self.assertEqual(resp.context['adj_total_count'], 3)
|
||||
ids = {a.id for a in resp.context['adj_page'].object_list}
|
||||
self.assertEqual(ids, {self.a1.id, self.a2.id, self.a3.id})
|
||||
|
||||
def test_worker_multi_filter(self):
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url + f'&worker={self.w1.id}')
|
||||
ids = {a.id for a in resp.context['adj_page'].object_list}
|
||||
self.assertIn(self.a1.id, ids)
|
||||
self.assertNotIn(self.a2.id, ids)
|
||||
self.assertIn(self.a3.id, ids)
|
||||
|
||||
def test_team_filter_uses_subquery_no_inflation(self):
|
||||
"""Filtering by team must NOT multiply rows. With 2 teams x 2 workers x 3
|
||||
adjustments, a naive worker__teams__id__in filter would return 6 inflated
|
||||
rows; the subquery pattern returns the true 3. See CLAUDE.md ORM gotcha."""
|
||||
self._login_admin()
|
||||
resp = self.client.get(
|
||||
self.url + f'&team={self.team.id}&team={self.team2.id}'
|
||||
)
|
||||
# .count() at the queryset level would blow up under inflation —
|
||||
# asserting it guards against regressions more strictly than checking
|
||||
# the paginator's object_list length.
|
||||
self.assertEqual(resp.context['adj_total_count'], 3)
|
||||
self.assertEqual(len(resp.context['adj_page'].object_list), 3)
|
||||
|
||||
def test_status_filter_unpaid(self):
|
||||
self._login_admin()
|
||||
# Mark a1 as paid — PayrollRecord fields are worker/date/amount_paid
|
||||
pr = PayrollRecord.objects.create(
|
||||
worker=self.w1, date=datetime.date(2026, 4, 15),
|
||||
amount_paid=Decimal('4000'),
|
||||
)
|
||||
self.a1.payroll_record = pr
|
||||
self.a1.save()
|
||||
resp = self.client.get(self.url + '&adj_status=unpaid')
|
||||
ids = {a.id for a in resp.context['adj_page'].object_list}
|
||||
self.assertNotIn(self.a1.id, ids)
|
||||
self.assertIn(self.a2.id, ids)
|
||||
self.assertIn(self.a3.id, ids)
|
||||
|
||||
def test_date_range_filter(self):
|
||||
self._login_admin()
|
||||
# March 1 to March 31 -> only a3 (dated 28 Mar)
|
||||
resp = self.client.get(
|
||||
self.url + '&adj_date_from=2026-03-01&adj_date_to=2026-03-31'
|
||||
)
|
||||
ids = {a.id for a in resp.context['adj_page'].object_list}
|
||||
self.assertEqual(ids, {self.a3.id})
|
||||
|
||||
def test_stats_scoped_to_filtered_set(self):
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url + '&type=Bonus')
|
||||
# 2 bonuses, 0 paid, total R 800 additive, 0 deductive
|
||||
self.assertEqual(resp.context['adj_total_count'], 2)
|
||||
self.assertEqual(resp.context['adj_unpaid_count'], 2)
|
||||
self.assertEqual(resp.context['adj_additive_sum'], Decimal('800.00'))
|
||||
self.assertEqual(resp.context['adj_deductive_sum'], Decimal('0.00'))
|
||||
|
||||
def test_group_by_type(self):
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url + '&group_by=type')
|
||||
groups = resp.context['adj_groups']
|
||||
self.assertIsNotNone(groups)
|
||||
labels = {g['label'] for g in groups}
|
||||
self.assertEqual(labels, {'Bonus', 'Deduction'})
|
||||
bonus_group = next(g for g in groups if g['label'] == 'Bonus')
|
||||
self.assertEqual(bonus_group['count'], 2)
|
||||
self.assertEqual(bonus_group['net_sum'], Decimal('800.00')) # +R 500 + +R 300
|
||||
deduction_group = next(g for g in groups if g['label'] == 'Deduction')
|
||||
self.assertEqual(deduction_group['net_sum'], Decimal('-100.00'))
|
||||
# Groups must be ordered by descending |net_sum| — biggest impact
|
||||
# first. |800| > |100| so Bonus must come before Deduction.
|
||||
self.assertEqual(groups[0]['label'], 'Bonus')
|
||||
|
||||
def test_group_by_worker(self):
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url + '&group_by=worker')
|
||||
groups = resp.context['adj_groups']
|
||||
self.assertIsNotNone(groups)
|
||||
labels = {g['label'] for g in groups}
|
||||
self.assertEqual(labels, {'Alice', 'Bob'})
|
||||
alice = next(g for g in groups if g['label'] == 'Alice')
|
||||
# Alice: +R 500 bonus + (-R 100) deduction = +R 400 net
|
||||
self.assertEqual(alice['count'], 2)
|
||||
self.assertEqual(alice['net_sum'], Decimal('400.00'))
|
||||
|
||||
def test_bulk_delete_only_affects_unpaid(self):
|
||||
"""POST /payroll/adjustments/bulk-delete/ with mixed paid+unpaid IDs
|
||||
deletes ONLY the unpaid rows. Paid rows are untouched (payroll
|
||||
history is immutable)."""
|
||||
self._login_admin()
|
||||
# Pay a1 (leave a2, a3 unpaid)
|
||||
pr = PayrollRecord.objects.create(
|
||||
worker=self.w1, date=datetime.date(2026, 4, 15),
|
||||
amount_paid=Decimal('4000'),
|
||||
)
|
||||
self.a1.payroll_record = pr
|
||||
self.a1.save()
|
||||
resp = self.client.post(
|
||||
reverse('bulk_delete_adjustments'),
|
||||
{'adjustment_ids': [self.a1.id, self.a2.id, self.a3.id]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertEqual(body['deleted'], 2)
|
||||
self.assertEqual(body['requested'], 3)
|
||||
# a1 survives (paid), a2 + a3 gone
|
||||
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
|
||||
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a2.id).exists())
|
||||
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a3.id).exists())
|
||||
|
||||
def test_bulk_delete_requires_admin(self):
|
||||
"""Non-admin supervisors cannot bulk-delete adjustments."""
|
||||
self.client.login(username='adj-sup', password='pass')
|
||||
resp = self.client.post(
|
||||
reverse('bulk_delete_adjustments'),
|
||||
{'adjustment_ids': [self.a1.id]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
# a1 still present
|
||||
self.assertTrue(PayrollAdjustment.objects.filter(id=self.a1.id).exists())
|
||||
|
||||
def test_bulk_delete_cascades_new_loan(self):
|
||||
"""Bulk-deleting a 'New Loan' adjustment must also delete its
|
||||
linked Loan row AND any still-unpaid Loan Repayment adjustments
|
||||
— same cascade as the single-row delete_adjustment view. Without
|
||||
this, the bulk endpoint would orphan Loan rows and leave pending
|
||||
repayments in place."""
|
||||
# Create a Loan + New Loan adjustment + unpaid repayment
|
||||
loan = Loan.objects.create(
|
||||
worker=self.w1,
|
||||
principal_amount=Decimal('1000'),
|
||||
remaining_balance=Decimal('1000'),
|
||||
date=datetime.date(2026, 4, 1),
|
||||
loan_type='loan',
|
||||
)
|
||||
new_loan_adj = PayrollAdjustment.objects.create(
|
||||
worker=self.w1, project=self.proj, type='New Loan',
|
||||
amount=Decimal('1000'), date=datetime.date(2026, 4, 1),
|
||||
description='Test loan', loan=loan,
|
||||
)
|
||||
unpaid_repayment = PayrollAdjustment.objects.create(
|
||||
worker=self.w1, project=self.proj, type='Loan Repayment',
|
||||
amount=Decimal('500'), date=datetime.date(2026, 5, 1),
|
||||
description='First repayment', loan=loan,
|
||||
)
|
||||
|
||||
self._login_admin()
|
||||
resp = self.client.post(
|
||||
reverse('bulk_delete_adjustments'),
|
||||
{'adjustment_ids': [new_loan_adj.id]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertEqual(body['deleted'], 1)
|
||||
|
||||
# New Loan adjustment gone
|
||||
self.assertFalse(PayrollAdjustment.objects.filter(id=new_loan_adj.id).exists())
|
||||
# Linked Loan row gone (cascade)
|
||||
self.assertFalse(Loan.objects.filter(id=loan.id).exists())
|
||||
# Unpaid repayment gone (cascade)
|
||||
self.assertFalse(PayrollAdjustment.objects.filter(id=unpaid_repayment.id).exists())
|
||||
|
||||
def test_bulk_delete_skips_loan_with_paid_repayments(self):
|
||||
"""If a 'New Loan' has any paid repayments, bulk-delete must
|
||||
refuse to delete it (would lose audit trail). Other rows in the
|
||||
batch are unaffected."""
|
||||
loan = Loan.objects.create(
|
||||
worker=self.w1,
|
||||
principal_amount=Decimal('1000'),
|
||||
remaining_balance=Decimal('500'),
|
||||
date=datetime.date(2026, 4, 1),
|
||||
loan_type='loan',
|
||||
)
|
||||
new_loan_adj = PayrollAdjustment.objects.create(
|
||||
worker=self.w1, project=self.proj, type='New Loan',
|
||||
amount=Decimal('1000'), date=datetime.date(2026, 4, 1),
|
||||
description='Test loan', loan=loan,
|
||||
)
|
||||
# One PAID repayment
|
||||
pr = PayrollRecord.objects.create(
|
||||
worker=self.w1, date=datetime.date(2026, 5, 1),
|
||||
amount_paid=Decimal('500'),
|
||||
)
|
||||
PayrollAdjustment.objects.create(
|
||||
worker=self.w1, project=self.proj, type='Loan Repayment',
|
||||
amount=Decimal('500'), date=datetime.date(2026, 5, 1),
|
||||
description='Paid', loan=loan, payroll_record=pr,
|
||||
)
|
||||
|
||||
self._login_admin()
|
||||
# Send the New Loan plus a2 (unpaid Bonus) — expect only a2 to delete
|
||||
resp = self.client.post(
|
||||
reverse('bulk_delete_adjustments'),
|
||||
{'adjustment_ids': [new_loan_adj.id, self.a2.id]},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertEqual(body['deleted'], 1) # only a2
|
||||
self.assertEqual(body['requested'], 2)
|
||||
self.assertEqual(body['skipped_reasons'], {'has_paid_repayments': 1})
|
||||
|
||||
# New Loan survives, Loan survives, a2 gone
|
||||
self.assertTrue(PayrollAdjustment.objects.filter(id=new_loan_adj.id).exists())
|
||||
self.assertTrue(Loan.objects.filter(id=loan.id).exists())
|
||||
self.assertFalse(PayrollAdjustment.objects.filter(id=self.a2.id).exists())
|
||||
|
||||
def test_team_worker_pairs_json_context_key(self):
|
||||
"""Cross-filter map is a raw Python list of {team_id, worker_id}
|
||||
dicts. Django's |json_script filter handles serialisation at
|
||||
template render time (no double-encoding — see the 2026-04-23
|
||||
inline-filters regression test)."""
|
||||
self._login_admin()
|
||||
resp = self.client.get(self.url)
|
||||
pairs = resp.context['team_worker_pairs_json']
|
||||
self.assertIsInstance(pairs, list)
|
||||
for entry in pairs:
|
||||
self.assertIn('team_id', entry)
|
||||
self.assertIn('worker_id', entry)
|
||||
# Our fixture: two teams (Alpha + Beta) with both workers in each
|
||||
pair_set = {(p['team_id'], p['worker_id']) for p in pairs}
|
||||
self.assertIn((self.team.id, self.w1.id), pair_set)
|
||||
self.assertIn((self.team.id, self.w2.id), pair_set)
|
||||
self.assertIn((self.team2.id, self.w1.id), pair_set)
|
||||
self.assertIn((self.team2.id, self.w2.id), pair_set)
|
||||
|
||||
@ -54,6 +54,9 @@ urlpatterns = [
|
||||
# Delete an unpaid adjustment
|
||||
path('payroll/adjustment/<int:adj_id>/delete/', views.delete_adjustment, name='delete_adjustment'),
|
||||
|
||||
# Bulk-delete multiple unpaid adjustments at once (Adjustments tab)
|
||||
path('payroll/adjustments/bulk-delete/', views.bulk_delete_adjustments, name='bulk_delete_adjustments'),
|
||||
|
||||
# Preview a worker's payslip (AJAX — returns JSON)
|
||||
path('payroll/preview/<int:worker_id>/', views.preview_payslip, name='preview_payslip'),
|
||||
|
||||
|
||||
422
core/views.py
422
core/views.py
@ -17,6 +17,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
|
||||
from django.middleware.csrf import get_token
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.urls import reverse
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
@ -2138,6 +2139,15 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
|
||||
start_dates = dict(
|
||||
Project.objects.values_list('name', 'start_date')
|
||||
)
|
||||
# Lookup the most recent WorkLog.date for each project (for the new
|
||||
# "Last Activity" column — helps Konrad spot which projects are dormant
|
||||
# without having to scroll through date pickers).
|
||||
last_activity = dict(
|
||||
all_time_logs.filter(project__isnull=False)
|
||||
.values('project__name')
|
||||
.annotate(last=Max('date'))
|
||||
.values_list('project__name', 'last')
|
||||
)
|
||||
alltime_projects = []
|
||||
for row in alltime_projects_raw:
|
||||
name = row['project']
|
||||
@ -2149,6 +2159,7 @@ def _build_report_context(start_date, end_date, project_ids=None, team_ids=None)
|
||||
'worker_days': row['worker_days'],
|
||||
'total': total,
|
||||
'start_date': start_dates.get(name), # may be None
|
||||
'last_activity': last_activity.get(name), # may be None
|
||||
'working_days': wdays,
|
||||
'avg_per_working_day': avg,
|
||||
})
|
||||
@ -2393,13 +2404,56 @@ def generate_report(request):
|
||||
return qd.urlencode()
|
||||
context['query_string_without_project'] = _qs_without('project')
|
||||
context['query_string_without_team'] = _qs_without('team')
|
||||
# Pass projects and teams so the "New Report" modal's dropdowns can
|
||||
# populate (same lists the Dashboard modal uses)
|
||||
context['projects'] = Project.objects.all().order_by('name')
|
||||
context['teams'] = Team.objects.all().order_by('name')
|
||||
# For the modal's <select multiple> pre-selection: stringify the IDs so
|
||||
# the template's `{% if p.id|stringformat:"s" in selected_project_ids %}`
|
||||
# comparison works (Django templates compare strings to strings).
|
||||
# === Date-scoped pickers + cross-filter ===
|
||||
# Admin UX decision (Konrad, 2026-04-23 Checkpoint 1 feedback):
|
||||
# The project/team pills should only show entries that actually have
|
||||
# WorkLog activity within the currently-selected date range. Same for
|
||||
# the (project_id, team_id) pair map that powers the cross-filter.
|
||||
# Rationale: "show me teams I'm actually looking at right now," not
|
||||
# "every team that ever existed."
|
||||
#
|
||||
# Guarantee: a project or team that's currently in the URL selection
|
||||
# MUST remain in the list — even if it has no logs in this window —
|
||||
# so the user can always see and deselect their own picks.
|
||||
logs_in_range = WorkLog.objects.filter(
|
||||
date__gte=start_date, date__lte=end_date,
|
||||
)
|
||||
project_ids_in_range = set(
|
||||
logs_in_range.values_list('project_id', flat=True).distinct()
|
||||
)
|
||||
team_ids_in_range = set(
|
||||
logs_in_range.values_list('team_id', flat=True).distinct()
|
||||
)
|
||||
# Logs without a project/team contribute a None — drop it
|
||||
project_ids_in_range.discard(None)
|
||||
team_ids_in_range.discard(None)
|
||||
|
||||
# Union with the user's URL selections so picks never vanish
|
||||
selected_p_int = {int(x) for x in (project_ids or [])}
|
||||
selected_t_int = {int(x) for x in (team_ids or [])}
|
||||
project_ids_to_show = project_ids_in_range | selected_p_int
|
||||
team_ids_to_show = team_ids_in_range | selected_t_int
|
||||
|
||||
# Cross-filter pair map, scoped to the same date range
|
||||
# (raw Python list — |json_script in the template handles serialisation)
|
||||
pairs = list(
|
||||
logs_in_range
|
||||
.filter(project__isnull=False, team__isnull=False)
|
||||
.values('project_id', 'team_id')
|
||||
.distinct()
|
||||
)
|
||||
context['project_team_pairs_json'] = pairs
|
||||
|
||||
# Picker lists (only projects/teams with activity in this window,
|
||||
# union'd with current URL selection)
|
||||
context['projects'] = (
|
||||
Project.objects.filter(id__in=project_ids_to_show).order_by('name')
|
||||
)
|
||||
context['teams'] = (
|
||||
Team.objects.filter(id__in=team_ids_to_show).order_by('name')
|
||||
)
|
||||
# Template's `{% if p.id|stringformat:"s" in selected_project_ids %}`
|
||||
# comparison needs strings on both sides.
|
||||
context['selected_project_ids'] = [str(p) for p in (project_ids or [])]
|
||||
context['selected_team_ids'] = [str(t) for t in (team_ids or [])]
|
||||
|
||||
@ -2480,6 +2534,54 @@ def toggle_active(request, model_name, item_id):
|
||||
return JsonResponse({'error': 'Item not found'}, status=404)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === ADJUSTMENT GROUPING HELPER ===
|
||||
# Used by the Adjustments tab's By Type / By Worker render path.
|
||||
# Plain-English: takes a flat list of PayrollAdjustment rows and regroups
|
||||
# them into buckets keyed by adjustment type or by worker. The result is
|
||||
# a list of group-dicts the template can iterate, each carrying a label,
|
||||
# CSS-friendly slug, the list of rows in the bucket, a count, and the
|
||||
# net signed sum of amounts (additives count +, deductives count -).
|
||||
# =============================================================================
|
||||
|
||||
def _group_adjustments(adjustments, group_by):
|
||||
"""Regroup a flat list/queryset of PayrollAdjustment into buckets.
|
||||
|
||||
`group_by` is 'type' or 'worker'. Returns a list of dicts:
|
||||
{'label', 'slug', 'rows', 'count', 'net_sum'}
|
||||
|
||||
Ordered by descending magnitude of net_sum so the highest-impact
|
||||
bucket sits at the top of the view (big groups first).
|
||||
"""
|
||||
from collections import defaultdict
|
||||
buckets = defaultdict(list)
|
||||
for adj in adjustments:
|
||||
key = adj.type if group_by == 'type' else adj.worker_id
|
||||
buckets[key].append(adj)
|
||||
|
||||
groups = []
|
||||
for key, rows in buckets.items():
|
||||
if group_by == 'type':
|
||||
label = key
|
||||
slug = key.lower().replace(' ', '-')
|
||||
else: # worker
|
||||
label = rows[0].worker.name
|
||||
slug = f'worker-{key}'
|
||||
net_sum = sum(
|
||||
(r.amount if r.type in ADDITIVE_TYPES else -r.amount)
|
||||
for r in rows
|
||||
)
|
||||
groups.append({
|
||||
'label': label,
|
||||
'slug': slug,
|
||||
'rows': rows,
|
||||
'count': len(rows),
|
||||
'net_sum': net_sum,
|
||||
})
|
||||
groups.sort(key=lambda g: -abs(g['net_sum']))
|
||||
return groups
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === PAYROLL DASHBOARD ===
|
||||
# The main payroll page. Shows per-worker breakdown of what's owed,
|
||||
@ -2843,6 +2945,158 @@ def payroll_dashboard(request):
|
||||
'active_loans_count': active_loans_count,
|
||||
'active_loans_balance': active_loans_balance,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# === ADJUSTMENTS TAB CONTEXT ===
|
||||
# This block only runs when the user is on the Adjustments tab
|
||||
# (i.e. the URL has ?status=adjustments). It builds a filtered, sorted,
|
||||
# paginated list of adjustments plus the little stats cards above it.
|
||||
#
|
||||
# Group-by rendering, bulk-select, and Team->Workers cross-filter
|
||||
# will be added in later tasks — this task just covers the basic data.
|
||||
# =========================================================================
|
||||
if status_filter == 'adjustments':
|
||||
from django.core.paginator import Paginator
|
||||
from django.utils.dateparse import parse_date
|
||||
|
||||
# --- Read the filter choices the user picked from the URL ---
|
||||
# Lists come in as ?type=Bonus&type=Deduction etc.
|
||||
type_filter = request.GET.getlist('type')
|
||||
worker_filter = [
|
||||
int(v) for v in request.GET.getlist('worker') if v.strip().isdigit()
|
||||
]
|
||||
team_filter = [
|
||||
int(v) for v in request.GET.getlist('team') if v.strip().isdigit()
|
||||
]
|
||||
adj_status = request.GET.get('adj_status', '').strip()
|
||||
adj_date_from = request.GET.get('adj_date_from', '').strip()
|
||||
adj_date_to = request.GET.get('adj_date_to', '').strip()
|
||||
sort_col = request.GET.get('sort', 'date').strip()
|
||||
sort_order = request.GET.get('order', 'desc').strip()
|
||||
|
||||
# --- Base queryset with eager-loading of related tables ---
|
||||
# select_related pulls worker/project/payment in the same SQL query
|
||||
# so we don't hit the database once per row later.
|
||||
adjustments = PayrollAdjustment.objects.select_related(
|
||||
'worker', 'project', 'payroll_record'
|
||||
).prefetch_related('worker__teams')
|
||||
|
||||
# --- Apply each filter only if the user actually set one ---
|
||||
if type_filter:
|
||||
adjustments = adjustments.filter(type__in=type_filter)
|
||||
if worker_filter:
|
||||
adjustments = adjustments.filter(worker_id__in=worker_filter)
|
||||
if team_filter:
|
||||
# SUBQUERY PATTERN (CLAUDE.md "M2M filter + aggregate inflation"):
|
||||
# Joining straight on workers__teams would multiply the row count
|
||||
# if a worker is on multiple teams, so we pick the matching worker
|
||||
# IDs in a subquery first and then filter the outer queryset
|
||||
# without any JOIN expansion.
|
||||
adjustments = adjustments.filter(
|
||||
worker__in=Worker.objects.filter(
|
||||
teams__id__in=team_filter
|
||||
).values('id')
|
||||
)
|
||||
if adj_status == 'unpaid':
|
||||
adjustments = adjustments.filter(payroll_record__isnull=True)
|
||||
elif adj_status == 'paid':
|
||||
adjustments = adjustments.filter(payroll_record__isnull=False)
|
||||
if adj_date_from:
|
||||
parsed = parse_date(adj_date_from)
|
||||
if parsed:
|
||||
adjustments = adjustments.filter(date__gte=parsed)
|
||||
if adj_date_to:
|
||||
parsed = parse_date(adj_date_to)
|
||||
if parsed:
|
||||
adjustments = adjustments.filter(date__lte=parsed)
|
||||
|
||||
# --- Sort the results ---
|
||||
# The URL's "sort" value is a short label; translate it to the
|
||||
# actual database column. Unknown values fall back to date.
|
||||
sort_map = {
|
||||
'date': 'date',
|
||||
'worker': 'worker__name',
|
||||
'amount': 'amount',
|
||||
'status': 'payroll_record',
|
||||
}
|
||||
sort_field = sort_map.get(sort_col, 'date')
|
||||
if sort_order == 'desc':
|
||||
sort_field = '-' + sort_field
|
||||
# Secondary key "-id" keeps rows in a stable order when the
|
||||
# main sort key has ties (e.g. two adjustments on the same date).
|
||||
adjustments = adjustments.order_by(sort_field, '-id')
|
||||
|
||||
# --- Stats cards (all computed BEFORE pagination) ---
|
||||
# These numbers always reflect what the current filter produces,
|
||||
# not just what fits on the current page.
|
||||
adj_total_count = adjustments.count()
|
||||
unpaid_qs = adjustments.filter(payroll_record__isnull=True)
|
||||
adj_unpaid_count = unpaid_qs.count()
|
||||
adj_unpaid_sum = unpaid_qs.aggregate(
|
||||
total=Sum('amount')
|
||||
)['total'] or Decimal('0.00')
|
||||
adj_additive_sum = adjustments.filter(
|
||||
type__in=ADDITIVE_TYPES
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
|
||||
adj_deductive_sum = adjustments.filter(
|
||||
type__in=DEDUCTIVE_TYPES
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
|
||||
|
||||
# --- Group-by rendering (optional; None = flat view) ---
|
||||
# When the user clicks the "By Type" or "By Worker" toggle above
|
||||
# the table, we bucket the FULL filtered queryset (not the paginated
|
||||
# slice) so each group's row-count and net-sum reflect the whole
|
||||
# filter, not just whatever landed on this page. Pagination is
|
||||
# suppressed in the template when grouped (the group headers act
|
||||
# as their own navigation).
|
||||
group_by = request.GET.get('group_by', '').strip()
|
||||
adj_groups = None
|
||||
if group_by in ('type', 'worker'):
|
||||
adj_groups = _group_adjustments(list(adjustments), group_by)
|
||||
|
||||
# --- Pagination: 50 rows per page (flat view only) ---
|
||||
paginator = Paginator(adjustments, 50)
|
||||
adj_page = paginator.get_page(request.GET.get('page', 1))
|
||||
|
||||
# --- Everything the Adjustments tab template will need ---
|
||||
context.update({
|
||||
'adj_page': adj_page,
|
||||
'adj_groups': adj_groups,
|
||||
'adj_total_count': adj_total_count,
|
||||
'adj_unpaid_count': adj_unpaid_count,
|
||||
'adj_unpaid_sum': adj_unpaid_sum,
|
||||
'adj_additive_sum': adj_additive_sum,
|
||||
'adj_deductive_sum': adj_deductive_sum,
|
||||
'adj_filter_values': {
|
||||
'type': type_filter,
|
||||
'worker': worker_filter,
|
||||
'team': team_filter,
|
||||
'adj_status': adj_status,
|
||||
'adj_date_from': adj_date_from,
|
||||
'adj_date_to': adj_date_to,
|
||||
'sort': sort_col,
|
||||
'order': sort_order,
|
||||
'group_by': group_by,
|
||||
},
|
||||
# Flat list of type labels for the Adjustments tab filter dropdown.
|
||||
# Stored under a separate key so we don't clobber the existing
|
||||
# 'adjustment_types' context var (which is TYPE_CHOICES tuples
|
||||
# used by the Add/Edit adjustment modals).
|
||||
'adj_type_choices': list(ADDITIVE_TYPES) + list(DEDUCTIVE_TYPES),
|
||||
'all_workers_for_filter': Worker.objects.filter(active=True).order_by('name'),
|
||||
'all_teams_for_filter': Team.objects.filter(active=True).order_by('name'),
|
||||
# Task 4 will use this to decide +/- signs on each row.
|
||||
'additive_types': list(ADDITIVE_TYPES),
|
||||
# === CROSS-FILTER SOURCE: (team_id, worker_id) PAIRS ===
|
||||
# Consumed by the popover JS to disable Workers checkboxes that
|
||||
# aren't in any currently-URL-selected team. Raw Python list
|
||||
# — |json_script in the template handles safe serialisation
|
||||
# (NOT json.dumps — see the 2026-04-23 inline-filters regression).
|
||||
'team_worker_pairs_json': list(
|
||||
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
|
||||
),
|
||||
})
|
||||
|
||||
return render(request, 'core/payroll_dashboard.html', context)
|
||||
|
||||
|
||||
@ -3647,6 +3901,63 @@ def edit_adjustment(request, adj_id):
|
||||
# Removes an unpaid adjustment. Handles cascade logic for Loans and Overtime.
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# === ADJUSTMENT CASCADE DELETE HELPER ===
|
||||
# Shared by delete_adjustment (single row) and bulk_delete_adjustments (many
|
||||
# rows) so both paths have identical semantics. "New Loan" and "Advance
|
||||
# Payment" each own a linked Loan row that needs teardown; "Overtime" needs
|
||||
# its worker un-priced from the WorkLog. Without this helper, bulk-delete
|
||||
# would orphan Loan rows and leave priced_workers stale.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _delete_adjustment_with_cascade(adj):
|
||||
"""Delete one PayrollAdjustment, cascading through its linked objects.
|
||||
|
||||
Returns a tuple `(ok: bool, reason: str or None)`:
|
||||
- `(True, None)` — row deleted successfully (with any cascades done)
|
||||
- `(False, 'paid')` — adjustment already paid; refuse
|
||||
- `(False, 'has_paid_repayments')` — linked Loan has paid repayments;
|
||||
deleting it would lose the repayment audit trail
|
||||
|
||||
Cascade rules:
|
||||
- New Loan / Advance Payment: delete the linked `Loan` row plus any
|
||||
still-unpaid repayment adjustments. If ANY repayment has already
|
||||
been paid, abort (otherwise we'd lose history of money that
|
||||
already moved).
|
||||
- Overtime: remove the worker from work_log.priced_workers so the
|
||||
overtime can be re-priced cleanly later.
|
||||
- Other types: plain delete, no cascade.
|
||||
"""
|
||||
if adj.payroll_record is not None:
|
||||
return False, 'paid'
|
||||
|
||||
adj_type = adj.type
|
||||
|
||||
if adj_type in ('New Loan', 'Advance Payment') and adj.loan:
|
||||
repayment_type = 'Advance Repayment' if adj_type == 'Advance Payment' else 'Loan Repayment'
|
||||
paid_repayments = PayrollAdjustment.objects.filter(
|
||||
loan=adj.loan,
|
||||
type=repayment_type,
|
||||
payroll_record__isnull=False,
|
||||
)
|
||||
if paid_repayments.exists():
|
||||
return False, 'has_paid_repayments'
|
||||
# Delete all still-unpaid repayments, then the Loan itself
|
||||
PayrollAdjustment.objects.filter(
|
||||
loan=adj.loan,
|
||||
type=repayment_type,
|
||||
payroll_record__isnull=True,
|
||||
).delete()
|
||||
adj.loan.delete()
|
||||
elif adj_type == 'Overtime' and adj.work_log:
|
||||
# "Un-price" the overtime — worker can be re-priced cleanly later
|
||||
adj.work_log.priced_workers.remove(adj.worker)
|
||||
|
||||
adj.delete()
|
||||
return True, None
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_adjustment(request, adj_id):
|
||||
if request.method != 'POST':
|
||||
@ -3655,52 +3966,83 @@ def delete_adjustment(request, adj_id):
|
||||
return HttpResponseForbidden("Not authorized.")
|
||||
|
||||
adj = get_object_or_404(PayrollAdjustment, id=adj_id)
|
||||
|
||||
# Can't delete adjustments that have been paid
|
||||
if adj.payroll_record is not None:
|
||||
messages.error(request, 'Cannot delete a paid adjustment.')
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
adj_type = adj.type
|
||||
worker_name = adj.worker.name
|
||||
|
||||
# === CASCADE DELETE for New Loan and Advance Payment ===
|
||||
# Both create Loan records that need cleanup when deleted.
|
||||
if adj_type in ('New Loan', 'Advance Payment') and adj.loan:
|
||||
# Determine which repayment type to look for
|
||||
repayment_type = 'Advance Repayment' if adj_type == 'Advance Payment' else 'Loan Repayment'
|
||||
|
||||
# Check if any paid repayments exist for this loan/advance
|
||||
paid_repayments = PayrollAdjustment.objects.filter(
|
||||
loan=adj.loan,
|
||||
type=repayment_type,
|
||||
payroll_record__isnull=False,
|
||||
)
|
||||
if paid_repayments.exists():
|
||||
ok, reason = _delete_adjustment_with_cascade(adj)
|
||||
if not ok:
|
||||
if reason == 'paid':
|
||||
messages.error(request, 'Cannot delete a paid adjustment.')
|
||||
elif reason == 'has_paid_repayments':
|
||||
label = 'advance' if adj_type == 'Advance Payment' else 'loan'
|
||||
messages.error(
|
||||
request,
|
||||
f'Cannot delete {label} for {worker_name} — it has paid repayments.'
|
||||
)
|
||||
return redirect('payroll_dashboard')
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
# Delete all unpaid repayments for this loan/advance, then the loan itself
|
||||
PayrollAdjustment.objects.filter(
|
||||
loan=adj.loan,
|
||||
type=repayment_type,
|
||||
payroll_record__isnull=True,
|
||||
).delete()
|
||||
adj.loan.delete()
|
||||
|
||||
elif adj_type == 'Overtime' and adj.work_log:
|
||||
# "Un-price" the overtime — remove worker from priced_workers M2M
|
||||
adj.work_log.priced_workers.remove(adj.worker)
|
||||
|
||||
adj.delete()
|
||||
messages.success(request, f'{adj_type} adjustment for {worker_name} deleted.')
|
||||
return redirect('payroll_dashboard')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === BULK DELETE ADJUSTMENTS (Adjustments tab) ===
|
||||
# POST /payroll/adjustments/bulk-delete/ with adjustment_ids[] body.
|
||||
# Only unpaid adjustments are deleted; paid rows survive because payroll
|
||||
# history is immutable (matches the existing edit_adjustment view, which
|
||||
# also refuses to edit paid rows).
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def bulk_delete_adjustments(request):
|
||||
"""Delete multiple unpaid PayrollAdjustment rows with full cascade.
|
||||
|
||||
Body (form-encoded): `adjustment_ids` — repeated once per ID.
|
||||
Returns JSON: `{"deleted": N, "requested": M, "skipped_reasons": {...}}`.
|
||||
Admin-only; supervisors get 403. POST-only; anything else gets 405
|
||||
from @require_POST.
|
||||
|
||||
Cascade: each row is deleted via `_delete_adjustment_with_cascade`
|
||||
(shared with the single-row `delete_adjustment` view) so bulk and
|
||||
single-row have identical semantics. Rows that fail (already paid,
|
||||
or a linked loan with paid repayments) are counted in `skipped_reasons`
|
||||
but don't block the rest of the batch.
|
||||
"""
|
||||
if not is_admin(request.user):
|
||||
return JsonResponse({'error': 'Admin access required'}, status=403)
|
||||
|
||||
# Int-coerce and drop non-digit values (defensive against garbled input —
|
||||
# ids are client-generated so any non-digit would crash the queryset).
|
||||
raw_ids = request.POST.getlist('adjustment_ids')
|
||||
ids = [int(v) for v in raw_ids if v.strip().isdigit()]
|
||||
|
||||
# Fetch each adjustment individually — we need the cascade helper to
|
||||
# operate per-row (it deletes the linked Loan / unprices Overtime).
|
||||
# Pre-filtering for .payroll_record__isnull=True is fine as an upfront
|
||||
# short-circuit but the helper double-checks anyway.
|
||||
adjustments = list(PayrollAdjustment.objects.filter(
|
||||
id__in=ids,
|
||||
payroll_record__isnull=True,
|
||||
).select_related('loan', 'work_log', 'worker'))
|
||||
|
||||
deleted = 0
|
||||
skipped_reasons = {}
|
||||
for adj in adjustments:
|
||||
ok, reason = _delete_adjustment_with_cascade(adj)
|
||||
if ok:
|
||||
deleted += 1
|
||||
else:
|
||||
skipped_reasons[reason] = skipped_reasons.get(reason, 0) + 1
|
||||
|
||||
return JsonResponse({
|
||||
'deleted': deleted,
|
||||
'requested': len(ids),
|
||||
'skipped_reasons': skipped_reasons,
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === PREVIEW PAYSLIP (AJAX) ===
|
||||
# Returns a JSON preview of what a worker's payslip would look like.
|
||||
|
||||
199
docs/plans/2026-04-23-adjustments-filter-bar-v2-design.md
Normal file
199
docs/plans/2026-04-23-adjustments-filter-bar-v2-design.md
Normal file
@ -0,0 +1,199 @@
|
||||
# Adjustments Filter Bar v2 — Design (23 Apr 2026)
|
||||
|
||||
## Origin
|
||||
|
||||
Konrad on the shipped Adjustments tab (commit `672c32c`):
|
||||
|
||||
> _"this interface layout is very ugly. And the selection dropdown menus text is a bit large don't you think?"_
|
||||
|
||||
Follow-up: _"the spacing above the Show As section is bad"_.
|
||||
|
||||
Short redesign after the 22-task feature work shipped. Pure polish — no
|
||||
backend changes, no test changes, no new URL params.
|
||||
|
||||
## Goal
|
||||
|
||||
Tighten the visual vocabulary of the Adjustments filter bar. Today the
|
||||
bar mixes three control shapes (popover pills for Type/Workers/Teams, a
|
||||
native `<select>` for Status, and a compound widget for Date with
|
||||
separate inputs + preset links + mode-toggle button). Five different
|
||||
control treatments crammed into one sticky row reads as noisy.
|
||||
|
||||
Secondary: the popover checkbox list uses Bootstrap's default
|
||||
`.form-check-input` ~14–16px font and ~0.2rem row padding, which makes
|
||||
the 7-type Type popover ~320px tall — larger than it needs to be.
|
||||
|
||||
Tertiary: the "Show as: Flat / By Type / By Worker" toggle sits
|
||||
directly below the filter bar with no vertical breathing room; they
|
||||
read as one cramped block.
|
||||
|
||||
## Who it's for
|
||||
|
||||
Admins (`is_staff` or `is_superuser`). Same audience as the whole
|
||||
Adjustments feature.
|
||||
|
||||
## 1. Unified pill vocabulary — 5 pills, identical treatment
|
||||
|
||||
Every filter becomes a `.filter-pill.adj-filter-pill`:
|
||||
|
||||
- Same height (~32px)
|
||||
- Same border, same hover/focus, same chevron
|
||||
- Same label typography
|
||||
- Same popover anchoring (reuses Feature-1 `.filter-popover`)
|
||||
|
||||
| Filter | Current | After |
|
||||
|--------|---------|-------|
|
||||
| Type | Pill popover with checkboxes | *(unchanged — already correct)* |
|
||||
| Workers | Pill popover with checkboxes | *(unchanged)* |
|
||||
| Teams | Pill popover with checkboxes | *(unchanged)* |
|
||||
| **Status** | Native `<select>` + `<label>` above | **Pill popover** with 3 radios (All / Unpaid / Paid) |
|
||||
| **Date** | `<label>` + 2 `<input>`s + preset links + `...` toggle, all inline | **Pill popover** with mode toggle + picker(s) + preset buttons inside |
|
||||
|
||||
### Pill label formatting
|
||||
|
||||
At-a-glance the pill label reveals what's active:
|
||||
|
||||
| State | Label |
|
||||
|-------|-------|
|
||||
| Empty | `Type`, `Workers`, `Teams`, `Status`, `Date` |
|
||||
| 1 selected | `Type: Bonus`, `Status: Unpaid`, `Date: 24 Apr 2026` |
|
||||
| 2+ selected | `Type` with a `(2)` count badge after the label |
|
||||
| Date range | `Date: 20 Apr – 26 Apr` |
|
||||
|
||||
## 2. Popover density — smaller font, tighter rows
|
||||
|
||||
| Property | Current | After |
|
||||
|----------|---------|-------|
|
||||
| Checkbox-list font size | ~14px (Bootstrap default) | `0.8rem` (~12.8px) |
|
||||
| Checkbox row padding | `0.2rem 0.25rem` | `0.15rem 0.25rem` |
|
||||
| Checkbox visual size | 1em | `0.9em` |
|
||||
| Popover footer buttons | Bootstrap `btn-sm` defaults | `font-size: 0.75rem; padding: 0.25rem 0.5rem` |
|
||||
| Checkbox-list max-height | `280px` | `260px` |
|
||||
| Popover max width | `420px` | `360px` |
|
||||
|
||||
Net effect on the Type popover (7 rows): drops from ~320px tall to ~240px.
|
||||
|
||||
## 3. Status pill — popover with 3 radios
|
||||
|
||||
```
|
||||
[Status ▾] Status popover:
|
||||
┌──────────────────┐
|
||||
│ ○ All │
|
||||
│ ○ Unpaid │
|
||||
│ ○ Paid │
|
||||
├──────────────────┤
|
||||
│ [Cancel] [OK] │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
- Hidden `<input type="hidden" name="adj_status" value="...">` holds
|
||||
committed state, rewritten by popover OK
|
||||
- Pill label: `Status` / `Status: Unpaid` / `Status: Paid`
|
||||
- Cancel reverts radio to committed state; Esc / click-outside
|
||||
close without committing (matches existing popover pattern)
|
||||
|
||||
## 4. Date pill — popover with mode + picker(s) + presets
|
||||
|
||||
```
|
||||
[Date ▾] Date popover:
|
||||
┌───────────────────────┐
|
||||
│ [Single] Range Custom │ ← mode toggle
|
||||
│ │
|
||||
│ Date: [ 24/04/2026 ] │ ← picker(s)
|
||||
│ │
|
||||
│ Today · Week · Month │ ← presets
|
||||
├───────────────────────┤
|
||||
│ [Cancel] [OK] │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
- Three modes:
|
||||
- **Single** (default): one picker; submit sends `adj_date_from = adj_date_to = picked`
|
||||
- **Range**: two pickers (From / To); submit sends both
|
||||
- **Custom** — same as range but with a "(custom date, not month-aligned)" hint
|
||||
*(Simplification possibility: drop Custom; Range covers it. Leaning YAGNI.
|
||||
Will decide at impl time — if no clean semantic gain, Custom goes.)*
|
||||
- Presets (Today / This week / This month / Clear) work the same as
|
||||
the current inline presets, just live inside the popover now
|
||||
- Pill label formatting:
|
||||
- Empty: `Date`
|
||||
- Single: `Date: 24 Apr 2026`
|
||||
- Range: `Date: 20 Apr – 26 Apr 2026`
|
||||
- Hidden form inputs `adj_date_from` + `adj_date_to` hold committed state
|
||||
|
||||
## 5. Filter bar layout
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ [Type ▾] [Workers ▾] [Teams ▾] [Status ▾] [Date ▾] [Apply] C │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
↑ 1rem spacing
|
||||
Show as: [Flat] [By Type] [By Worker]
|
||||
```
|
||||
|
||||
- Remove all `<label>Status</label>` / `<label>Date</label>` elements
|
||||
above native widgets — the pill text carries that job now
|
||||
- `.adjustments-filter-bar` keeps `display: flex; flex-wrap: wrap;
|
||||
gap: 0.75rem; align-items: center;` (was `end` for the native widget
|
||||
baseline; `center` now that all children are the same pill height)
|
||||
- Apply + Clear sit at the right end with `margin-left: auto` on their
|
||||
wrapper
|
||||
- The "Show as" row gains `margin-top: 1rem` so there's clear
|
||||
separation from the filter bar
|
||||
- The Show-as toggle buttons get `padding: 0.3rem 0.75rem; font-size:
|
||||
0.8rem` to match pill height
|
||||
|
||||
## 6. Spacing + density tokens
|
||||
|
||||
Add two tokens inside the adjustments block of `static/css/custom.css`:
|
||||
|
||||
```css
|
||||
/* Adjustments tab — shared density tokens for the filter strip */
|
||||
--adj-filter-font-size: 0.8rem;
|
||||
--adj-filter-height: 32px;
|
||||
```
|
||||
|
||||
Every filter control picks them up so height + text size stay in lockstep.
|
||||
|
||||
## 7. Implementation plan (short — it's template + CSS only)
|
||||
|
||||
1. **Template** (`payroll_dashboard.html`):
|
||||
- Replace the `<select name="adj_status">` + its `<label>` with a Status pill-popover (radios)
|
||||
- Replace the 6-line Date widget markup with a Date pill-popover (mode toggle, pickers, presets all inside)
|
||||
- Keep the existing hidden `adj_status` / `adj_date_from` / `adj_date_to` pattern — just write them from the new popovers' OK handlers
|
||||
2. **JS** (same `<script>` block):
|
||||
- Tiny Status popover wiring (radios → hidden input on OK; label update via pill-label helper)
|
||||
- Move Date presets + mode toggle logic INSIDE the Date popover handler
|
||||
- Reuse `commitCheckboxes` pattern for the Status/Date hidden-input rewrite
|
||||
3. **CSS** (`custom.css`):
|
||||
- Density tokens (2 CSS vars)
|
||||
- Font-size drops on `.adj-checkbox-list` + popover footer buttons
|
||||
- Shrink `.adj-cb-row` padding + checkbox size
|
||||
- `.adj-groupby-toggle` gets `margin-top: 1rem`
|
||||
- `.adj-groupby-toggle .btn` matches pill size
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- **Backend changes** — none. Query param contract stays identical
|
||||
(`?status=adjustments&type=X&worker=Y&team=Z&adj_status=paid&adj_date_from=2026-04-20&adj_date_to=2026-04-26`).
|
||||
- **Test changes** — none. All 65 tests exercise the URL contract, not the DOM shape.
|
||||
- **AJAX partial rendering** — still full page reload on Apply. Matches the rest of the app.
|
||||
- **Saved filter presets** — URLs are bookmarkable already; YAGNI.
|
||||
- **"Custom date" mode** — if it's semantically identical to "Range",
|
||||
drop it at impl time.
|
||||
|
||||
## 9. Risks + rollback
|
||||
|
||||
Template-only; rollback = revert the commit. No data, schema, migration,
|
||||
or URL-contract impact.
|
||||
|
||||
Biggest risk: a copy-paste error in the Date popover's mode-toggle JS
|
||||
could leave someone with a submit that posts empty dates. Mitigation:
|
||||
the existing "apply single mode fallback" behaviour (blank `adj_date_to`
|
||||
→ server treats as no-filter) already makes this safe.
|
||||
|
||||
## 10. Next step
|
||||
|
||||
Implement directly (no writing-plans needed — single-commit template +
|
||||
CSS polish, no new business logic, no new tests). Ship alongside the
|
||||
Adjustments feature in the same `ai-dev` branch.
|
||||
669
docs/plans/2026-04-23-adjustments-tab-design.md
Normal file
669
docs/plans/2026-04-23-adjustments-tab-design.md
Normal file
@ -0,0 +1,669 @@
|
||||
# Payroll Adjustments Tab — Design (23 Apr 2026)
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new **Adjustments** tab to the payroll dashboard (alongside Pending / History / Loans & Advances) that lets admins browse ALL payroll adjustments across all workers — filter by type, worker, team, paid/unpaid status, and date — with semantic colour-coded badges, bulk delete, group-by-type / group-by-worker render, and inline actions matching the rest of the dashboard.
|
||||
|
||||
Today the only place to see a list of every adjustment is `/admin/core/payrolladjustment/` (Django admin). Adjustments surface per-worker on the Pending tab, per-worker in the Worker Payment Hub modal, and per-worker on the payslip detail page — but nowhere as a unified, filterable list.
|
||||
|
||||
## Origin
|
||||
|
||||
Konrad's request from a brainstorming session on 23 Apr 2026:
|
||||
|
||||
> _"At the moment I can only lookup loans and advances easily but I would like to be able to see all payroll adjustments and filter between type (keep loans and advances separate as they are both loans and record keep is very important)."_
|
||||
|
||||
Followed by a UX refinement:
|
||||
|
||||
> _"Can we have the Adjustments tab next to Loans and advances on the payroll dashboard?"_
|
||||
|
||||
And a premium-UX request:
|
||||
|
||||
> _"So in Adjustments we need filters for type of adjustment (multiselect), Teams, Name (Multiselect), paid/unpaid, Date of Adjustment... let us keep loans dirty yellow, adjustments dark pastel blue, deductions deep purple, bonuses dirty pastel green, overtime dirty pink."_
|
||||
|
||||
## Who it's for
|
||||
|
||||
**Admins** (`is_staff` or `is_superuser`). Supervisors keep no payroll-dashboard access.
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
- **URL**: existing `/payroll/` view with new `?status=adjustments` query param (mirrors the pending/paid/loans pattern already in `payroll_dashboard_view`)
|
||||
- **Template**: extend `core/templates/core/payroll_dashboard.html` — add tab in the nav-tabs strip + new content block gated on `active_tab == 'adjustments'`
|
||||
- **Modal reuse**: the existing `#addAdjustmentModal`, `#editAdjustmentModal`, `#payslipPreviewModal` all live on the payroll dashboard already. The Adjustments tab's row actions just trigger them — zero duplication.
|
||||
- **New**: CSS semantic badge palette (7 types × dark/light themes = 14 colour tokens + 7 badge classes), one bulk-delete endpoint, and a group-by rendering layer
|
||||
- **No model changes. No migrations.**
|
||||
|
||||
## 1. Colour palette — semantic type badges
|
||||
|
||||
### Mapping: 7 adjustment types → 5 colour categories
|
||||
|
||||
Loan/Advance **repayment** sub-types use the same category colour as their outgoing counterparts but with ~15% more saturation — "same family, hotter signal" so eyes instantly catch "this one is money coming back, not going out".
|
||||
|
||||
| Type | Category | Saturation |
|
||||
|---|---|---|
|
||||
| Bonus | Bonus | base |
|
||||
| Overtime | Overtime | base |
|
||||
| Deduction | Deduction | base |
|
||||
| New Loan | Loans | base |
|
||||
| Loan Repayment | Loans | +15% |
|
||||
| Advance Payment | Advances | base |
|
||||
| Advance Repayment | Advances | +15% |
|
||||
|
||||
### CSS tokens
|
||||
|
||||
Added to `static/css/custom.css` `:root` (dark) and `:root.light` blocks:
|
||||
|
||||
```css
|
||||
/* Dark theme — badge palette */
|
||||
--badge-bonus-bg: #5b8260; --badge-bonus-fg: #e8f3ea;
|
||||
--badge-overtime-bg: #a16881; --badge-overtime-fg: #fce4ec;
|
||||
--badge-deduction-bg: #5b4f8c; --badge-deduction-fg: #e0daf3;
|
||||
--badge-loan-bg: #9b7f39; --badge-loan-fg: #fef4d1;
|
||||
--badge-loan-rep-bg: #b48a1a; --badge-loan-rep-fg: #fef4d1; /* +15% saturation */
|
||||
--badge-advance-bg: #3e5c7b; --badge-advance-fg: #d7e5f2;
|
||||
--badge-advance-rep-bg: #2f679a; --badge-advance-rep-fg: #d7e5f2; /* +15% saturation */
|
||||
|
||||
/* Light theme — overrides in :root.light */
|
||||
--badge-bonus-bg: #d7e8d9; --badge-bonus-fg: #385640;
|
||||
--badge-overtime-bg: #f3d1dd; --badge-overtime-fg: #703347;
|
||||
--badge-deduction-bg: #d8d0ef; --badge-deduction-fg: #3b2f6d;
|
||||
--badge-loan-bg: #f0dc9d; --badge-loan-fg: #6a5320;
|
||||
--badge-loan-rep-bg: #f7d873; --badge-loan-rep-fg: #5a4418;
|
||||
--badge-advance-bg: #bccee0; --badge-advance-fg: #243b56;
|
||||
--badge-advance-rep-bg: #9ec1dd; --badge-advance-rep-fg: #1d3550;
|
||||
```
|
||||
|
||||
Each badge class maps type name → token:
|
||||
```css
|
||||
.badge-type-bonus { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
||||
.badge-type-overtime { background: var(--badge-overtime-bg); color: var(--badge-overtime-fg); }
|
||||
.badge-type-deduction { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
|
||||
.badge-type-new-loan { background: var(--badge-loan-bg); color: var(--badge-loan-fg); }
|
||||
.badge-type-loan-repayment { background: var(--badge-loan-rep-bg); color: var(--badge-loan-rep-fg); }
|
||||
.badge-type-advance-payment { background: var(--badge-advance-bg); color: var(--badge-advance-fg); }
|
||||
.badge-type-advance-repayment { background: var(--badge-advance-rep-bg); color: var(--badge-advance-rep-fg); }
|
||||
```
|
||||
|
||||
Template slug helper: a small template filter `|type_slug` that turns `"Advance Payment"` → `"advance-payment"` for class naming.
|
||||
|
||||
**All badges use Inter, 11pt, weight 500, `padding: 0.3rem 0.7rem`, `border-radius: 999px`.** Amount is shown NOT on the badge (badge shows type only); amount is a separate cell with `+` / `−` prefix.
|
||||
|
||||
## 2. Tab markup (adds one `<li>` to existing nav-tabs)
|
||||
|
||||
In `payroll_dashboard.html` at around line 252:
|
||||
|
||||
```django
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<!-- existing: Pending, History, Loans & Advances -->
|
||||
<li class="nav-item">
|
||||
<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>
|
||||
```
|
||||
|
||||
## 3. Filter bar — sticky, multi-select, with cross-filter
|
||||
|
||||
Five filters in a single row under the tab:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ [Type ▾ 2] [Workers ▾ 3] [Teams ▾ 1] [Status ▾] [Date 📅] [Apply] │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Filter | UI | Behaviour |
|
||||
|---|---|---|
|
||||
| **Type** | Choices.js multi-select, 7 options (the 7 adjustment types). Selected chips show the badge colour of that type. | empty = all types |
|
||||
| **Workers** | Choices.js multi-select, searchable. Cross-filtered by selected Teams (see below). | empty = all workers |
|
||||
| **Teams** | Choices.js multi-select, all active teams | empty = all teams |
|
||||
| **Status** | Native single-select: All / Unpaid / Paid | default All |
|
||||
| **Date** | Single date picker. A `…` button toggles range mode (From / To). | Single date = exact; range = inclusive bounds |
|
||||
| **Apply** | Button visible only when filters dirty | Submits via `?status=adjustments&type=Bonus&type=Overtime&worker=1&worker=2&team=3&adj_status=unpaid&adj_date_from=...&adj_date_to=...&group_by=type` |
|
||||
|
||||
**Sticky:** the filter bar stays at the top as the table scrolls below it. `position: sticky; top: 0; z-index: 10; background: var(--bg-card);`.
|
||||
|
||||
### Cross-filter — Team → Workers
|
||||
|
||||
When Team(s) are selected, the Workers dropdown shows only workers in those teams. Implementation mirrors Feature 1's project↔team cross-filter:
|
||||
|
||||
- Backend: compute a JSON map of `(team_id, worker_id)` pairs from `Team.workers.through`:
|
||||
```python
|
||||
team_worker_pairs = list(
|
||||
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
|
||||
)
|
||||
context['team_worker_pairs_json'] = json.dumps(team_worker_pairs)
|
||||
```
|
||||
- Frontend: when the Workers popover opens, filter visible options based on current team selection. Auto-remove now-invalid worker selections with a toast ("Alice removed — not in selected teams").
|
||||
- Scope: entire active roster (not filtered by date range) — cross-filter is about data possibility, not data in this period.
|
||||
|
||||
One-way: Teams filter Workers. (Worker→Team filter is less useful — you usually pick teams first, then drill down to specific workers within them.)
|
||||
|
||||
## 4. Columns + row actions
|
||||
|
||||
### Flat view (no grouping)
|
||||
|
||||
| # | Column | Sort | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | `☐` (bulk checkbox) | No | Only on unpaid rows (paid rows show disabled checkbox) |
|
||||
| 2 | Date | Yes ▲▼ | `d M Y` format |
|
||||
| 3 | Worker | Yes | Link to `/workers/<id>/` |
|
||||
| 4 | Type | No (filter instead) | Badge with `.badge-type-<slug>` |
|
||||
| 5 | Amount | Yes | Right-aligned, tabular-nums. Sign: `+` for additive, `−` for deductive. Plain text colour (`--text-primary`) |
|
||||
| 6 | Project | No | Link to `/projects/<id>/` or `—` |
|
||||
| 7 | Team | No | `worker.teams.first().name` or `—` |
|
||||
| 8 | Description | No | Truncate 40 chars + `title` attr tooltip for full text |
|
||||
| 9 | Status | Yes | Badge: `Paid #123` (success, links `/payroll/payslip/<pk>/`) or `Unpaid` (warning) |
|
||||
| 10 | Actions | No | See below |
|
||||
|
||||
**Sortable:** 4 columns (Date, Worker, Amount, Status) with click-to-toggle. Bootstrap sort arrows in the headers. URL state: `?sort=<col>&order=<asc|desc>`.
|
||||
|
||||
### Row actions
|
||||
|
||||
Inline buttons matching the rest of the dashboard — NO expandable rows:
|
||||
|
||||
- **Unpaid row:**
|
||||
- `[Preview]` — opens existing `#payslipPreviewModal` for that worker (same as Pending Payments tab's Preview)
|
||||
- `[Edit]` — opens existing `#editAdjustmentModal` pre-filled (same as Pending tab's Edit)
|
||||
- `[×]` (delete) — opens existing delete confirm flow
|
||||
- **Paid row:**
|
||||
- `[View Payslip]` — links to `/payroll/payslip/<pk>/` (same as History tab's View)
|
||||
|
||||
Zero new modals. Zero new JS — reuse the handlers already on the payroll dashboard.
|
||||
|
||||
## 5. Group-by toggle
|
||||
|
||||
Three radio pills above the table (next to the filter bar):
|
||||
|
||||
```
|
||||
Show as: [ Flat ● ] [ By Type ○ ] [ By Worker ○ ]
|
||||
```
|
||||
|
||||
### When grouped:
|
||||
|
||||
```
|
||||
▾ BONUS · 3 rows · +R 1 500 ───────────────────────────────────
|
||||
22 Apr 2026 Alice M. Bonus +R 500 Wilkot ... Unpaid [Preview][Edit][×]
|
||||
18 Apr 2026 Bob N. Bonus +R 500 Alpha ... Paid #8 [View]
|
||||
...
|
||||
▾ OVERTIME · 5 rows · +R 750 ──────────────────────────────────
|
||||
...
|
||||
▸ DEDUCTION · 2 rows · −R 400 ───────────────────────────────── (collapsed)
|
||||
...
|
||||
```
|
||||
|
||||
- Click group header → collapse/expand (Bootstrap Collapse, smooth animation)
|
||||
- Group header shows: `<category>` · row count · net sum (`+R` for net-additive groups, `−R` for net-deductive)
|
||||
- Default: all groups expanded
|
||||
- URL state: `?group_by=type` or `?group_by=worker`
|
||||
|
||||
### By-worker grouping
|
||||
|
||||
Same shape but grouped by `worker.id`:
|
||||
|
||||
```
|
||||
▾ Alice Mokoena · 4 rows · +R 1 200 ─────────────────
|
||||
...
|
||||
▾ Bob Ndlovu · 2 rows · +R 300 ──────────────────────
|
||||
...
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
Backend:
|
||||
```python
|
||||
if group_by == 'type':
|
||||
groups = defaultdict(list)
|
||||
for adj in paginated_adjustments:
|
||||
groups[adj.type].append(adj)
|
||||
rendered_groups = [
|
||||
{'label': t, 'slug': slug(t), 'rows': rows,
|
||||
'count': len(rows), 'net_sum': sum_with_sign(rows)}
|
||||
for t, rows in groups.items()
|
||||
]
|
||||
elif group_by == 'worker':
|
||||
# same shape, groups keyed by worker_id; label = worker.name
|
||||
```
|
||||
|
||||
Template iterates over `rendered_groups` if grouping is active, else over the flat page. Both paths render the same row-level HTML (shared partial `_adjustment_row.html`) so there's no markup duplication.
|
||||
|
||||
## 6. Bulk actions
|
||||
|
||||
### Checkbox column
|
||||
|
||||
Leftmost column with `<input type="checkbox" name="bulk_select">` on each unpaid row. Paid rows show a disabled greyed-out checkbox (visually consistent, not interactive). "Select all" checkbox in the thead toggles all visible unpaid rows on the current page.
|
||||
|
||||
### Floating action bar
|
||||
|
||||
When ≥1 row is selected, a small bar slides up from the bottom of the viewport (not the page — fixed position):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 3 selected · [🗑 Delete] [Clear selection] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `Delete` → confirms via native `confirm()` → POSTs to new endpoint `/payroll/adjustments/bulk-delete/`
|
||||
- `Clear selection` → unticks all, bar slides away
|
||||
|
||||
### Bulk-delete endpoint
|
||||
|
||||
```python
|
||||
@login_required
|
||||
def bulk_delete_adjustments(request):
|
||||
"""Delete multiple unpaid adjustments at once.
|
||||
|
||||
Only unpaid adjustments can be deleted (paid ones are locked into payroll
|
||||
history). POST body: list of adjustment IDs. Returns JSON with success
|
||||
count + error count. Admin-only.
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'error': 'POST required'}, status=405)
|
||||
if not is_admin(request.user):
|
||||
return JsonResponse({'error': 'Not authorized'}, status=403)
|
||||
|
||||
ids = request.POST.getlist('adjustment_ids')
|
||||
# Only unpaid adjustments; silently skip any paid ones (defensive against UI bugs)
|
||||
to_delete = PayrollAdjustment.objects.filter(
|
||||
id__in=ids, payroll_record__isnull=True
|
||||
)
|
||||
deleted_count = to_delete.count()
|
||||
to_delete.delete()
|
||||
|
||||
return JsonResponse({'deleted': deleted_count, 'requested': len(ids)})
|
||||
```
|
||||
|
||||
URL: `path('payroll/adjustments/bulk-delete/', views.bulk_delete_adjustments, name='bulk_delete_adjustments')`.
|
||||
|
||||
## 7. Header stats
|
||||
|
||||
Directly under the filter bar, above the table:
|
||||
|
||||
```
|
||||
247 adjustments · 38 unpaid (R 126 500) · +R 45 000 net additive · −R 2 100 net deductive
|
||||
```
|
||||
|
||||
All four numbers scoped to the current filter set (not the whole DB). Updates on Apply.
|
||||
|
||||
## 8. Date filter — single vs range
|
||||
|
||||
The Date field is a single date picker by default. A small `…` (ellipsis) button next to it toggles to range mode:
|
||||
|
||||
- **Single mode** (default): pick ONE date. Filter: `date = selected_date`.
|
||||
- **Range mode**: two date pickers (From / To). Filter: `from ≤ date ≤ to`.
|
||||
|
||||
Single mode covers Konrad's "see adjustments for a specific date" use case. Range mode handles "this week" / "this month" audit queries.
|
||||
|
||||
Preset quick-buttons above the picker: `[Today] [This week] [This month] [Clear]`. Clicking a preset fills the range and stays in range mode.
|
||||
|
||||
## 9. URL state & bookmarkability
|
||||
|
||||
Every filter + sort + group-by choice lives in the querystring. Shareable/bookmarkable:
|
||||
|
||||
```
|
||||
/payroll/?status=adjustments
|
||||
&type=Bonus&type=Overtime ← multi-value (same key repeated)
|
||||
&worker=1&worker=3
|
||||
&team=2
|
||||
&adj_status=unpaid
|
||||
&adj_date_from=2026-03-01
|
||||
&adj_date_to=2026-04-30
|
||||
&group_by=type
|
||||
&sort=amount&order=desc
|
||||
&page=2
|
||||
```
|
||||
|
||||
## 10. Empty state
|
||||
|
||||
When filters return 0 rows:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 📭 │
|
||||
│ No adjustments match these filters. │
|
||||
│ │
|
||||
│ [Clear filters] [Add new adjustment] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Action buttons offer immediate recovery paths.
|
||||
|
||||
## 11. Backend changes
|
||||
|
||||
### New branch in `payroll_dashboard` view
|
||||
|
||||
Around line 2496 where `status_filter` is parsed:
|
||||
|
||||
```python
|
||||
elif status_filter == 'adjustments':
|
||||
active_tab = 'adjustments'
|
||||
# Parse filters
|
||||
type_filter = request.GET.getlist('type')
|
||||
worker_filter = [int(v) for v in request.GET.getlist('worker') if v.strip().isdigit()]
|
||||
team_filter = [int(v) for v in request.GET.getlist('team') if v.strip().isdigit()]
|
||||
adj_status = request.GET.get('adj_status', '').strip()
|
||||
adj_date_from = request.GET.get('adj_date_from', '').strip()
|
||||
adj_date_to = request.GET.get('adj_date_to', '').strip()
|
||||
group_by = request.GET.get('group_by', '').strip()
|
||||
sort_col = request.GET.get('sort', 'date').strip()
|
||||
sort_order = request.GET.get('order', 'desc').strip()
|
||||
|
||||
adjustments = PayrollAdjustment.objects.select_related(
|
||||
'worker', 'project', 'payroll_record'
|
||||
).prefetch_related('worker__teams')
|
||||
|
||||
if type_filter:
|
||||
adjustments = adjustments.filter(type__in=type_filter)
|
||||
if worker_filter:
|
||||
adjustments = adjustments.filter(worker_id__in=worker_filter)
|
||||
if team_filter:
|
||||
# Subquery pattern per CLAUDE.md ORM gotcha — avoids M2M JOIN inflation
|
||||
adjustments = adjustments.filter(
|
||||
worker__in=Worker.objects.filter(teams__id__in=team_filter).values('id')
|
||||
)
|
||||
if adj_status == 'unpaid':
|
||||
adjustments = adjustments.filter(payroll_record__isnull=True)
|
||||
elif adj_status == 'paid':
|
||||
adjustments = adjustments.filter(payroll_record__isnull=False)
|
||||
if adj_date_from:
|
||||
adjustments = adjustments.filter(date__gte=parse_date(adj_date_from))
|
||||
if adj_date_to:
|
||||
adjustments = adjustments.filter(date__lte=parse_date(adj_date_to))
|
||||
|
||||
# Sorting
|
||||
sort_map = {'date': 'date', 'worker': 'worker__name',
|
||||
'amount': 'amount', 'status': 'payroll_record'}
|
||||
sort_field = sort_map.get(sort_col, 'date')
|
||||
if sort_order == 'desc':
|
||||
sort_field = '-' + sort_field
|
||||
adjustments = adjustments.order_by(sort_field, '-id')
|
||||
|
||||
# Stats (scoped to filtered set)
|
||||
adj_total_count = adjustments.count()
|
||||
unpaid_qs = adjustments.filter(payroll_record__isnull=True)
|
||||
adj_unpaid_count = unpaid_qs.count()
|
||||
adj_unpaid_sum = unpaid_qs.aggregate(total=Sum('amount'))['total'] or Decimal('0.00')
|
||||
additive_sum = adjustments.filter(type__in=ADDITIVE_TYPES).aggregate(
|
||||
total=Sum('amount'))['total'] or Decimal('0.00')
|
||||
deductive_sum = adjustments.filter(type__in=DEDUCTIVE_TYPES).aggregate(
|
||||
total=Sum('amount'))['total'] or Decimal('0.00')
|
||||
|
||||
# Pagination
|
||||
paginator = Paginator(adjustments, 50)
|
||||
adj_page = paginator.get_page(request.GET.get('page', 1))
|
||||
|
||||
# Group-by rendering
|
||||
adj_groups = None
|
||||
if group_by in ('type', 'worker'):
|
||||
adj_groups = _group_adjustments(adj_page.object_list, group_by)
|
||||
|
||||
# Cross-filter data for JS
|
||||
team_worker_pairs_json = json.dumps(list(
|
||||
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
|
||||
))
|
||||
|
||||
context.update({
|
||||
'adj_page': adj_page,
|
||||
'adj_groups': adj_groups,
|
||||
'adj_total_count': adj_total_count,
|
||||
'adj_unpaid_count': adj_unpaid_count,
|
||||
'adj_unpaid_sum': adj_unpaid_sum,
|
||||
'adj_additive_sum': additive_sum,
|
||||
'adj_deductive_sum': deductive_sum,
|
||||
'adj_filter_values': {
|
||||
'type': type_filter, 'worker': worker_filter, 'team': team_filter,
|
||||
'adj_status': adj_status, 'adj_date_from': adj_date_from,
|
||||
'adj_date_to': adj_date_to, 'group_by': group_by,
|
||||
'sort': sort_col, 'order': sort_order,
|
||||
},
|
||||
'adjustment_types': ADDITIVE_TYPES + DEDUCTIVE_TYPES,
|
||||
'all_workers_for_filter': Worker.objects.filter(active=True).order_by('name'),
|
||||
'all_teams_for_filter': Team.objects.filter(active=True).order_by('name'),
|
||||
'team_worker_pairs_json': team_worker_pairs_json,
|
||||
})
|
||||
```
|
||||
|
||||
### New helper `_group_adjustments`
|
||||
|
||||
```python
|
||||
def _group_adjustments(adjustments, group_by):
|
||||
"""Regroup a flat list of adjustments by type or by worker.
|
||||
|
||||
Returns list of dicts: [{'label', 'slug', 'rows', 'count', 'net_sum'}, ...]
|
||||
Groups ordered by descending net_sum magnitude (biggest impact first).
|
||||
"""
|
||||
from collections import defaultdict
|
||||
buckets = defaultdict(list)
|
||||
for adj in adjustments:
|
||||
key = adj.type if group_by == 'type' else adj.worker_id
|
||||
buckets[key].append(adj)
|
||||
|
||||
groups = []
|
||||
for key, rows in buckets.items():
|
||||
if group_by == 'type':
|
||||
label = key
|
||||
slug = key.lower().replace(' ', '-')
|
||||
else: # worker
|
||||
label = rows[0].worker.name
|
||||
slug = f'worker-{key}'
|
||||
net_sum = sum(
|
||||
(r.amount if r.type in ADDITIVE_TYPES else -r.amount)
|
||||
for r in rows
|
||||
)
|
||||
groups.append({
|
||||
'label': label, 'slug': slug, 'rows': rows,
|
||||
'count': len(rows), 'net_sum': net_sum,
|
||||
})
|
||||
groups.sort(key=lambda g: -abs(g['net_sum']))
|
||||
return groups
|
||||
```
|
||||
|
||||
### New endpoint `bulk_delete_adjustments`
|
||||
|
||||
(Shown in section 6.)
|
||||
|
||||
### New template filter `|type_slug`
|
||||
|
||||
In `core/templatetags/format_tags.py`:
|
||||
|
||||
```python
|
||||
@register.filter
|
||||
def type_slug(value):
|
||||
"""Convert 'Advance Payment' -> 'advance-payment' for CSS class naming."""
|
||||
if not value:
|
||||
return ''
|
||||
return value.lower().replace(' ', '-')
|
||||
```
|
||||
|
||||
## 12. Testing
|
||||
|
||||
New `AdjustmentsTabTests` class in `core/tests.py` (~8 tests):
|
||||
|
||||
- `test_admin_sees_adjustments_tab` — 200 for admin at `?status=adjustments`
|
||||
- `test_supervisor_forbidden` — 403 for supervisor at `/payroll/?status=adjustments`
|
||||
- `test_type_multi_filter` — `?type=Bonus&type=Overtime` returns union (not intersection)
|
||||
- `test_worker_multi_filter` — `?worker=1&worker=2` returns union
|
||||
- `test_team_cross_filter` — team filter uses subquery pattern (no inflation on linked counts)
|
||||
- `test_status_filter_unpaid` — only unpaid rows returned
|
||||
- `test_date_filter_single_vs_range` — single date = exact match; range = inclusive
|
||||
- `test_group_by_type` — response context has `adj_groups` with correct keys
|
||||
- `test_group_by_worker` — same with worker grouping
|
||||
- `test_bulk_delete_only_affects_unpaid` — POST with mixed paid+unpaid IDs deletes only unpaid
|
||||
- `test_bulk_delete_requires_admin` — 403 for supervisor
|
||||
|
||||
(~11 tests; aim for ~130 lines of test code.)
|
||||
|
||||
## 13. Scope estimate
|
||||
|
||||
| Change | Lines |
|
||||
|---|---|
|
||||
| `core/views.py` — new `payroll_dashboard` branch + `_group_adjustments` helper + `bulk_delete_adjustments` endpoint | ~180 |
|
||||
| `core/templates/core/payroll_dashboard.html` — tab `<li>` + filter bar + sticky stats row + group-by toggle + table with grouping + row actions + bulk action bar | ~260 |
|
||||
| `core/templates/core/_adjustment_row.html` — shared row partial (used by both flat + grouped views) | ~40 |
|
||||
| `core/templatetags/format_tags.py` — `type_slug` filter | ~10 |
|
||||
| `static/css/custom.css` — 14 badge colour tokens × 2 themes + 7 badge classes + sticky filter bar + group header + bulk action bar | ~170 |
|
||||
| JS (inline in template) — Choices.js init × 3 multi-selects + Team→Worker cross-filter + bulk checkbox logic + group-header collapse + sort header clicks + date single/range toggle | ~170 |
|
||||
| `core/urls.py` — 1 new path (bulk-delete) | ~3 |
|
||||
| Tests in `core/tests.py` | ~130 |
|
||||
| **Total** | **~960 lines** |
|
||||
|
||||
~12 tasks, 2 checkpoints.
|
||||
|
||||
**Suggested checkpoints:**
|
||||
1. After core filter logic + Choices.js multi-selects + stats + sort + flat table render
|
||||
2. After group-by + bulk delete + date single/range + cross-filter + row actions + full QA
|
||||
|
||||
## 14. Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|---|---|
|
||||
| Filter returns 0 rows | Empty-state card with "Clear filters" + "Add new adjustment" CTAs |
|
||||
| Paid row selected via bulk checkbox (shouldn't happen via UI; defensive) | Backend silently skips paid rows in bulk-delete |
|
||||
| Worker with no teams (orphan) | Hidden from Workers dropdown when any Team filter is active |
|
||||
| Sort by column with missing values (e.g. sorting by worker name when worker deleted) | NULLs sort last; fallback to date-desc within same key |
|
||||
| Group-by on an empty filter result | Empty state message (no group headers shown) |
|
||||
| 500+ adjustments in DB | Pagination handles it — 50/page, URL `?page=2` |
|
||||
| User clicks Edit on a paid row (shouldn't have button; defensive) | Edit modal wouldn't allow edit; existing `edit_adjustment` view blocks paid |
|
||||
| Bulk-delete hits a race condition (another admin pays one of the selected) | Endpoint filters `payroll_record__isnull=True` at DELETE time; stale ones silently excluded |
|
||||
|
||||
## 15. Dependencies on Feature 1 (inline filters)
|
||||
|
||||
Feature 1's Choices.js infrastructure (CDN loading, SRI hashes, dark/light theme overrides) is already shipped. This feature reuses it directly — no duplicate CDN loads.
|
||||
|
||||
Feature 1 retires the report-config modal; this feature is on a different page and doesn't interact with that change.
|
||||
|
||||
**Order of implementation: either can ship first.** Feature 1 is smaller (~5-6 tasks) and may inform JS patterns we lift into Feature 2. Feature 2 is larger but self-contained. Suggested order: Feature 1 first, then Feature 2, separate plans.
|
||||
|
||||
## 16. Out of scope (YAGNI)
|
||||
|
||||
- **Bulk Mark Paid** — entangled with Pay Now flow; use existing per-worker `process_payment` via Preview modal
|
||||
- **CSV export** — easy to add later if requested; not in original ask
|
||||
- **Keyboard shortcuts beyond Esc** — browser Tab is fine
|
||||
- **Persistent sort / filter session state** — URL bookmark covers it
|
||||
- **Inline editing of description or amount** — Edit modal is sufficient
|
||||
- **Adjustment history / audit log** — would require a new model; not asked for
|
||||
- **Group by Project** — Type and Worker cover the two most useful axes
|
||||
|
||||
## 17. Rollback
|
||||
|
||||
Template + view additions only; one new endpoint; no schema changes. Rollback = revert the commits (or disable the tab via template edit if a panic fix is needed).
|
||||
|
||||
## 18. Next step
|
||||
|
||||
Hand off to `superpowers:writing-plans`. Two design docs exist today:
|
||||
- `docs/plans/2026-04-23-inline-filters-design.md` (Feature 1 — report page pills)
|
||||
- `docs/plans/2026-04-23-adjustments-tab-design.md` (this doc — Feature 2)
|
||||
|
||||
Recommended sequence: **Feature 1 first** (smaller, ~5-6 tasks; Choices.js patterns learned here can lift into Feature 2). Ship Feature 1, validate on production, then Feature 2's plan + implementation. Both design docs stay local until their respective implementations ship; then push everything together.
|
||||
|
||||
---
|
||||
|
||||
## 19. Shipped — 2026-04-23
|
||||
|
||||
Implementation complete. 11 tasks + 1 hard-pause checkpoint + 1 round of
|
||||
Konrad feedback fixes. 65 tests passing (up from 47 pre-feature).
|
||||
|
||||
### Commit map
|
||||
|
||||
| Task | Commits | Scope |
|
||||
|------|---------|-------|
|
||||
| 1 | `97d8a69` | `type_slug` template filter (+ tests) |
|
||||
| 2 | `a20a025` | CSS badge palette + foundational styles |
|
||||
| 3 | `10d381e`, `89f109a` | Backend filter branch + stats; strengthened subquery test |
|
||||
| 4 | `b450bd3`, `06b3315` | Tab markup + filter bar + flat table; pagination / a11y / N+1 fixes |
|
||||
| 4* | `e088192`, `4c1cdb6` | Two multi-line `{# #}` comment hotfixes — see Deviations #2 |
|
||||
| CP1 A | `b59eb31` | Row actions → modals + project link → History tab |
|
||||
| CP1 B | `4f15e4b` | **Replaced Choices.js chip-multiselect with popover-checkbox filter UX** — see Deviations #1 |
|
||||
| 5 | `0862805`, `e5d06f9` | Group-by type/worker + toggle + colour-accented headers; chevron + ordering polish |
|
||||
| 6 | `03f177e`, `5f2e6d8`, `4c3e90f` | Bulk-delete endpoint; id-collision fix; **cascade logic fix** — see Deviations #3 |
|
||||
| 7 | `6905703` | Team → Workers cross-filter |
|
||||
| 8 | `c851b49` | Date picker single/range toggle + preset buttons |
|
||||
| 9 | `7b71048` | Sortable column headers with URL state |
|
||||
| 10 | `9bb9ede` | Empty-state card with recovery CTAs |
|
||||
|
||||
### Deviations from the original design
|
||||
|
||||
1. **Choices.js chip-multiselect → popover-checkbox filters.** The original
|
||||
design (§3) specified Choices.js for Type/Workers/Teams multi-selects —
|
||||
the same pattern used in the report page's retired modal. At Checkpoint 1
|
||||
Konrad flagged that the chip-style rendering was intrusive once multiple
|
||||
options were selected, dominating the filter bar. We replaced the
|
||||
Choices.js widgets with pill-buttons that open popovers containing a
|
||||
scrollable checkbox list + search + Select All / Invert / Clear. Reuses
|
||||
Feature 1's `.filter-pill` / `.filter-popover` CSS vocabulary.
|
||||
Implemented in `4f15e4b`.
|
||||
|
||||
2. **Multi-line `{# ... #}` comment bug, twice.** Django's `{# #}` comment
|
||||
syntax is single-line only — multi-line blocks need
|
||||
`{% comment %}...{% endcomment %}`. We shipped the bug in the Task 4
|
||||
row partial (`e088192` fixed it) and then AGAIN in the Fix-A worker
|
||||
cell (`4c1cdb6` fixed it). Both shipped into production-looking
|
||||
renders, not caught by automated tests. Lesson: add a repo-wide
|
||||
grep guard or a Django linter for this class of template bug.
|
||||
|
||||
3. **Bulk-delete cascade gap.** The original Task 6 spec's reference
|
||||
implementation (`PayrollAdjustment.objects.filter(...).delete()`)
|
||||
silently orphaned linked `Loan` rows and `priced_workers` M2M entries
|
||||
when bulk-deleting adjustments of type "New Loan", "Advance Payment",
|
||||
or "Overtime". The single-row `delete_adjustment` view had 30+ lines
|
||||
of cascade logic the bulk view didn't use. Code review caught it.
|
||||
Fix: extracted `_delete_adjustment_with_cascade(adj)` helper and
|
||||
delegated both views to it — ensuring bulk and single-row have
|
||||
identical semantics. Also added a 'has_paid_repayments' skip reason
|
||||
in the JSON response so the UI can indicate why some rows were kept.
|
||||
Implemented in `4c3e90f`.
|
||||
|
||||
4. **Row actions → modals (CP1 Fix A).** The original design §4 said row
|
||||
actions "match the rest of the dashboard — NO expandable rows". We
|
||||
interpreted this as table-to-page navigation (Worker name → `/workers/<id>/`,
|
||||
View Payslip → `/payroll/payslip/<pk>/`). At CP1 Konrad clarified he
|
||||
wanted in-place MODALS matching the Pending tab: worker name opens
|
||||
`#workerLookupModal`, paid-row eye icon opens `#previewPayslipModal`,
|
||||
project name goes to `/projects/<id>/#history` (History tab active).
|
||||
Implemented in `b59eb31`; tiny tab-activation helper in
|
||||
`projects/detail.html` picks up the URL hash.
|
||||
|
||||
5. **id collision.** Task 4 added `id="adjSelectAll"` to the table
|
||||
header checkbox, but the Add Adjustment modal already used that id
|
||||
for its Select-All anchor. `document.getElementById` returns only
|
||||
the first match, so the modal's handler silently bound to the table
|
||||
checkbox. Renamed the table's to `#adjTableSelectAll` in `5f2e6d8`.
|
||||
|
||||
### Tests
|
||||
|
||||
Added 14 tests in `AdjustmentsTabTests`:
|
||||
|
||||
- `test_admin_sees_adjustments_tab` — 200 + active_tab set
|
||||
- `test_supervisor_forbidden` — non-admin redirected
|
||||
- `test_type_multi_filter` — union on multi-value param (uses adj_total_count)
|
||||
- `test_worker_multi_filter` — worker filter
|
||||
- `test_team_filter_uses_subquery_no_inflation` — proves the subquery
|
||||
pattern with 2 teams × 2 workers × 3 adjustments (naive would return 6)
|
||||
- `test_status_filter_unpaid` — payroll_record__isnull filter
|
||||
- `test_date_range_filter` — date__gte/lte
|
||||
- `test_stats_scoped_to_filtered_set` — counts + sums respect filter
|
||||
- `test_group_by_type` — buckets + net_sum + descending-magnitude ordering
|
||||
- `test_group_by_worker` — buckets by worker_id
|
||||
- `test_bulk_delete_only_affects_unpaid` — paid row survives
|
||||
- `test_bulk_delete_requires_admin` — 403 for supervisors
|
||||
- `test_bulk_delete_cascades_new_loan` — Loan + unpaid repayments gone too
|
||||
- `test_bulk_delete_skips_loan_with_paid_repayments` — refuses, reports reason
|
||||
- `test_team_worker_pairs_json_context_key` — raw Python list shape (not double-encoded)
|
||||
|
||||
Also extended existing tests:
|
||||
- `test_group_by_type` gained a descending-magnitude ordering assertion
|
||||
- `TypeSlugFilterTests` has 3 tests for the new template filter
|
||||
|
||||
### Net code churn
|
||||
|
||||
- `core/views.py`: ~+200 lines (filter branch + 2 helpers + bulk-delete view)
|
||||
- `core/templates/core/payroll_dashboard.html`: ~+450 lines (tab + filter bar + popover markup + table + JS modules)
|
||||
- `core/templates/core/_adjustment_row.html`: new file, ~120 lines
|
||||
- `core/templatetags/format_tags.py`: ~+35 lines (`type_slug`, `money_abs`, `url_replace`)
|
||||
- `static/css/custom.css`: ~+220 lines (badge palette + layout skeleton + popover extensions + colour-accented group headers + chevron rotation)
|
||||
- `core/tests.py`: ~+380 lines (14 new adjustments tests + 3 type_slug tests)
|
||||
- `core/urls.py`: +1 route
|
||||
- Total: ~+1,400 lines added, ~-100 replaced/removed.
|
||||
|
||||
(Original estimate: ~960 lines. Actual: +44% — mostly from the popover-
|
||||
checkbox filter rewrite, the bulk-delete cascade, and the cross-filter JS.)
|
||||
1884
docs/plans/2026-04-23-adjustments-tab-plan.md
Normal file
1884
docs/plans/2026-04-23-adjustments-tab-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
277
docs/plans/2026-04-23-inline-filters-design.md
Normal file
277
docs/plans/2026-04-23-inline-filters-design.md
Normal file
@ -0,0 +1,277 @@
|
||||
# Inline Filters on Report Page — Design (23 Apr 2026)
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the modal-based filter form on `/report/` with inline, interactive filter pills so Konrad can tweak project/team/date filters without opening a modal every time. Adds cross-filter awareness: selecting a project hides teams that never worked on it (and vice versa).
|
||||
|
||||
## Origin
|
||||
|
||||
Raised by Konrad at Checkpoint 3 of the Executive Report v2 work (just shipped):
|
||||
|
||||
> _"I wonder if the generate should not rather have static filters instead of the popup open up for every report? Easier to add or remove a project or team or change dates instead of having to start from scratch."_
|
||||
|
||||
And the cross-filter request that came later:
|
||||
|
||||
> _"Is it possible to filter out teams when selecting a project that has not worked on that project?"_
|
||||
|
||||
## Who it's for
|
||||
|
||||
**Admins** (`is_staff` or `is_superuser`). Supervisors keep no report access.
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
- Every filter pill on the report page becomes a **clickable dropdown**. Click → popover opens directly under that pill → edit → click **OK** inside the popover → popover closes + pill shows dirty state + Apply button appears at the right end of the pill strip.
|
||||
- **Apply** submits to `/report/?<reconstructed querystring>` — full page reload, same URL scheme as today. No AJAX.
|
||||
- The Generate Report modal is **deleted**. The dashboard's "Generate Report" button becomes a plain link to `/report/?from_month=<current>&to_month=<current>`.
|
||||
- Cross-filter: a serialised map of `(project_id, team_id)` pairs from `WorkLog` is passed as JSON to the page. Popovers filter their options based on the other pill's current selection.
|
||||
- **Backend code touched: minimal** — one new context key (`project_team_pairs_json`) and removal of the now-unused `selected_project_ids` / `selected_team_ids` keys the modal needed.
|
||||
|
||||
## 1. Filter trigger — explicit Apply with dirty-state indicator
|
||||
|
||||
Chosen over auto-apply-on-change because:
|
||||
- Admin users care about correctness; brief lag between "change filter" and "see numbers" is fine.
|
||||
- `_build_report_context` does ~10 aggregation queries; running it on every checkbox tick is wasteful.
|
||||
- Matches the rest of the codebase's pattern (Worker/Team/Project edit pages all use explicit Save).
|
||||
|
||||
Refinement: Apply button is **hidden when filters are in sync with URL**, so it can't be "forgotten" when there are no pending changes.
|
||||
|
||||
## 2. Three interactive pills, one Apply action
|
||||
|
||||
```
|
||||
┌──────────────────────────┬──────────────────────────────────┬──────────────────────┬──────────┐
|
||||
│ 📅 Mar 2026 – Apr 2026 ▾ │ 📁 Wilkot Boerdery + 1 more ▾ × │ 👥 All Teams ▾ │ [Apply] │
|
||||
└──────────────────────────┴──────────────────────────────────┴──────────────────────┴──────────┘
|
||||
```
|
||||
|
||||
**Pill states**:
|
||||
- **Closed**: current value + ▾ chevron; cursor pointer; tooltip "Click to edit".
|
||||
- **Dirty** (has uncommitted changes): accent-orange outline + small pulsing dot; Apply button appears.
|
||||
- **Open**: popover expanded; pill background darker to indicate focus.
|
||||
|
||||
**× button** on project and team pills: still clears that filter instantly (shortcut for "reset to All"). Only shown when a filter is active.
|
||||
|
||||
## 2.1 Date pill popover
|
||||
|
||||
Click `📅 Mar 2026 – Apr 2026 ▾` → popover opens with same fields as the current modal:
|
||||
|
||||
- Date Selection radio toggle: Month(s) / Custom Dates
|
||||
- From / To month pickers (when Month mode)
|
||||
- Start Date / End Date date pickers (when Custom mode)
|
||||
- Cancel / OK buttons
|
||||
|
||||
Click OK → popover closes, pill updates to new range in dirty state.
|
||||
|
||||
## 2.2 Projects / Teams pill popover
|
||||
|
||||
Click `📁 Wilkot Boerdery + 1 more ▾` → popover with a Choices.js multi-select (the same widget used in the just-retired modal):
|
||||
|
||||
- Chip-style selected items at top; typing filters options.
|
||||
- Option list below the input (cross-filtered — see section 2.5).
|
||||
- Cancel / OK at the bottom.
|
||||
|
||||
Click OK → popover closes, pill text updates. Pill display rules:
|
||||
- 0 selected: "All Projects" (or "All Teams")
|
||||
- 1 selected: show the name
|
||||
- 2 selected: show both comma-joined
|
||||
- 3+ selected: show first + "+ N more"
|
||||
|
||||
## 2.3 Apply button behaviour
|
||||
|
||||
- **Hidden** when filters match the URL (no dirty state).
|
||||
- **Appears** when any pill is dirty.
|
||||
- **Click Apply** → submits via `window.location = '/report/?' + querystring`. Full page reload.
|
||||
- **Cancel** button appears alongside Apply when dirty → reverts all pills to URL-current values without submitting.
|
||||
- Browser back button works normally (each Apply = real URL change).
|
||||
|
||||
## 2.4 Generate Report button → plain link
|
||||
|
||||
**Before (shipped)**:
|
||||
- Dashboard "Quick Actions" card → "Generate Report" button → modal → submit.
|
||||
- Report page "New Report" button → modal → submit.
|
||||
|
||||
**After**:
|
||||
- Dashboard "Generate Report" card → `<a>` link to `/report/?from_month={{ current_month }}&to_month={{ current_month }}`. One click → land on report with current month defaults → all filters are pills.
|
||||
- Report page "New Report" button → **deleted**. Pills are the new-report interface.
|
||||
|
||||
**Files affected by retirement**:
|
||||
- `core/templates/core/_report_config_modal.html` — **delete** (no other callers).
|
||||
- `core/templates/core/index.html` — remove `{% include 'core/_report_config_modal.html' %}`; change "Generate Report" button to plain link.
|
||||
- `core/templates/core/report.html` — remove `{% include %}`; remove "New Report" button.
|
||||
- `core/views.py` — remove `context['selected_project_ids']` / `selected_team_ids` from `index()` and `generate_report` (only existed for the modal's pre-selection).
|
||||
|
||||
## 2.5 Cross-filter (project ↔ team)
|
||||
|
||||
When a project is selected, the Teams popover shows only teams that have worked on at least one of the selected projects. Symmetric: selecting teams filters the Projects popover.
|
||||
|
||||
**Semantics**:
|
||||
- **Union across selections**: 2 projects selected → teams that have worked on EITHER project appear.
|
||||
- **All (no cross-filter)**: if no project is selected, all teams appear in the Teams popover. Same for projects.
|
||||
- **Scope = entire history**: "has worked on" means "has at least one `WorkLog` on that project, ever". NOT filtered by the report's date range — date is about data shown; cross-filter is about data possible.
|
||||
|
||||
**Auto-removal of invalid selections**:
|
||||
If you have Team Beta selected, then add Project Wilkot, and Beta has never worked on Wilkot → Beta is **auto-removed** from the team pill with a brief inline notice ("Team Beta removed — no logs on selected projects"). Toast-style, auto-dismisses after 4 seconds.
|
||||
|
||||
**Data source — one new context key**:
|
||||
```python
|
||||
# In generate_report view, serialise distinct (project_id, team_id) pairs as JSON.
|
||||
# The frontend uses this to filter dropdown options + auto-remove invalid selections.
|
||||
pairs = list(
|
||||
WorkLog.objects
|
||||
.filter(project__isnull=False, team__isnull=False)
|
||||
.values('project_id', 'team_id')
|
||||
.distinct()
|
||||
)
|
||||
context['project_team_pairs_json'] = json.dumps(pairs)
|
||||
```
|
||||
|
||||
Injected into the template via `{{ project_team_pairs_json|json_script:"projectTeamPairs" }}` (Django's safe-JSON pattern, already used for `team_workers_map_json` on the payroll dashboard per CLAUDE.md).
|
||||
|
||||
**Frontend JS** (inside the pill-popover module):
|
||||
- When a popover opens, determine which options to hide based on the OTHER pill's current selection.
|
||||
- When a popover "OK" fires, diff the new selection against pairs; remove invalid entries from the other pill; show a toast.
|
||||
- Choices.js supports programmatically setting/hiding options via its API.
|
||||
|
||||
**Edge cases**:
|
||||
- Team with zero work logs ever (e.g. just created) → invisible when any project is selected. Correct behaviour.
|
||||
- Deleted project/team references (`SET_NULL` in the DB) → already filtered out by the `filter(project__isnull=False, team__isnull=False)` clause.
|
||||
- If the URL specifies a team that isn't valid for the current project selection (shouldn't happen in practice, but if someone edits the URL) → the team still renders on the report's selected-period data (backend doesn't know about cross-filter); the pill shows it normally; next Apply will clean it up.
|
||||
|
||||
## 3. PDF stays identical
|
||||
|
||||
The "Download PDF" button uses `?{{ query_string }}` — whatever filters are in the URL flow into the PDF. No changes to `generate_report_pdf` or the PDF template.
|
||||
|
||||
## 4. CSS — new rules
|
||||
|
||||
In `static/css/custom.css`:
|
||||
- `.filter-pill--editable` — pointer cursor, hover tint, ▾ chevron alignment
|
||||
- `.filter-pill--dirty` — accent-orange outline, small pulsing dot (subtle)
|
||||
- `.filter-popover` — absolute-positioned below the pill, `--bg-card` + `--border-default` + shadow (same tokens as work log payroll modal)
|
||||
- `.filter-popover__footer` — right-aligned Cancel + OK buttons
|
||||
- `.apply-filters-btn` — primary button, slides in from right edge when dirty
|
||||
- `.filter-toast` — small accent-orange toast at the top of the report, auto-dismisses
|
||||
|
||||
~80 lines.
|
||||
|
||||
## 5. JS — one scoped module
|
||||
|
||||
Inline `<script>` in `report.html`. Structure (~150 lines):
|
||||
|
||||
```js
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Parse the pairs map from json_script
|
||||
const pairs = JSON.parse(document.getElementById('projectTeamPairs').textContent);
|
||||
|
||||
// Build lookup indices: project_id -> Set(team_id), team_id -> Set(project_id)
|
||||
const teamsByProject = buildIndex(pairs, 'project_id', 'team_id');
|
||||
const projectsByTeam = buildIndex(pairs, 'team_id', 'project_id');
|
||||
|
||||
// State
|
||||
const state = {
|
||||
urlProjects: [...], urlTeams: [...], urlDateRange: {...},
|
||||
pendingProjects: [...], pendingTeams: [...], pendingDateRange: {...},
|
||||
};
|
||||
|
||||
// Pill click handlers → open popovers
|
||||
// Popover OK handlers → update pending state + re-render pill text + trigger cross-filter + maybe show toast
|
||||
// Apply button → navigate to /report/?querystring
|
||||
// Cancel button → reset pending to url state
|
||||
// Esc key → close open popover
|
||||
});
|
||||
```
|
||||
|
||||
Uses `createElement` + `textContent` (XSS-safe; matches work log payroll modal pattern). No innerHTML.
|
||||
|
||||
## 6. Testing
|
||||
|
||||
No new backend tests — URL contract unchanged, context contract unchanged, PDF contract unchanged. All 42 existing tests keep passing.
|
||||
|
||||
**One new test** for the cross-filter context key:
|
||||
```python
|
||||
class ProjectTeamPairsTests(TestCase):
|
||||
def test_pairs_context_key_populated(self):
|
||||
"""Report view exposes (project_id, team_id) pairs for cross-filter JS."""
|
||||
# Assert generate_report renders with project_team_pairs_json containing
|
||||
# the (p, t) pairs from created work logs.
|
||||
```
|
||||
|
||||
Manual QA checklist (10 flows):
|
||||
1. `/report/` → pills show current filters; no Apply button (clean state)
|
||||
2. Click date pill → popover opens; change month; OK → pill dirty; Apply visible
|
||||
3. Click Apply → URL updates; re-render; clean state
|
||||
4. Click project pill → popover; pick 2 projects; OK → dirty
|
||||
5. Team pill options now show only teams that worked on those projects
|
||||
6. Select a team that wasn't in the project's history → warning toast, not selected
|
||||
7. Click × on project pill → filter clears; Apply button visible → click → URL drops projects
|
||||
8. Click pill, edit, click Cancel → no URL change; pill reverts
|
||||
9. Esc key closes popover
|
||||
10. Dashboard "Generate Report" → lands on `/report/?from_month=current&to_month=current` as plain link (no modal)
|
||||
|
||||
## 7. Scope estimate
|
||||
|
||||
- `core/views.py`: ~+5 lines (`project_team_pairs_json` in `generate_report`; remove `selected_*_ids` in both `index` and `generate_report`) — net roughly neutral
|
||||
- `core/templates/core/index.html`: -1 line (remove modal include), change one button to a link
|
||||
- `core/templates/core/report.html`: ~+30 lines (new pill-popover markup + json_script) / -5 lines (remove modal include + "New Report" button)
|
||||
- **Delete** `core/templates/core/_report_config_modal.html` (-140 lines)
|
||||
- `static/css/custom.css`: +80 lines
|
||||
- JS (inline in report.html): ~+150 lines
|
||||
- Tests: 1 new test class, ~20 lines
|
||||
- Net: ~+170 / -160 across 4 files + 1 deletion
|
||||
|
||||
About 5-6 focused tasks, 1 checkpoint after cross-filter behaviour works end-to-end.
|
||||
|
||||
## 8. Out of scope (YAGNI)
|
||||
|
||||
- **AJAX partial re-render** — Apply still triggers a full page reload. SSR pattern matches the rest of the app.
|
||||
- **"Save this filter set" feature** — URLs are bookmarkable already.
|
||||
- **Keyboard shortcuts for pills** — Tab/Enter work via native button/link behaviour.
|
||||
- **Undo stack** — browser back button is sufficient.
|
||||
- **Permissive cross-filter** (Option B — greyed-out but still selectable) — strict cross-filter is the clearest UX for FoxFitt's admin use case.
|
||||
- **Date-range scoped cross-filter** — "has worked on this project, ever" is simpler to explain than "within this report's date range".
|
||||
|
||||
## 9. Rollback plan
|
||||
|
||||
Feature is template-only (no backend behaviour change beyond one new serialised context key). Rollback = revert the commit. No data, schema, or migration impact.
|
||||
|
||||
## Next step
|
||||
|
||||
Hand off to `superpowers:writing-plans` after Feature 2 (Payroll Adjustments Browser) has been brainstormed and its design doc committed. Both features can then become either one combined plan or two separate plans — user's choice.
|
||||
|
||||
---
|
||||
|
||||
## 10. Shipped — 2026-04-23
|
||||
|
||||
Implementation and Checkpoint-1 UX approval complete. Everything on this branch before the final push. Sections below list what deviated from the design above, with the driving feedback + commit SHAs.
|
||||
|
||||
### Deviations from the original design
|
||||
|
||||
| # | Original | Shipped | Why |
|
||||
|---|----------|---------|-----|
|
||||
| 1 | Popover **OK** sets pending state; global **Apply** button commits when any pill is dirty (§2, §2.3) | No global Apply. Each popover's **OK** rebuilds the URL and navigates immediately. | Konrad on CP-1: Apply button was far-right + easy to miss, and the dirty-diff on multi-selects was unreliable. `ffb3ef6` |
|
||||
| 2 | Date pill uses **From / To** pickers, both required (§2.1) | **Until** is the always-filled anchor. **From (optional)** blank = single-month (JS submits `from_month = to_month`). Visual order: `From (optional)` left, `Until` right, English reading order. | Konrad on CP-1: "Until must be auto-filled, From optional." `71f8558`, `3fa3cdc` |
|
||||
| 3 | Cross-filter scope = **entire history** (§2.5 Semantics) | Cross-filter + picker lists scoped to the **currently-selected date range**. URL-selected IDs always unioned in so they never vanish. | Konrad on CP-1: "Filter out teams and projects that has no log for any of the dates chosen." `71f8558` |
|
||||
| 4 | Cross-filter **auto-removes** invalid selections and shows a toast (§2.5) | Read-time only: disable invalid options on popover open. No runtime removal, no toast (the next OK submits, so the server handles validation). | Side-effect of (1) — with auto-submit-on-OK there's no pending state to patch. `ffb3ef6` |
|
||||
| 5 | Dashboard "Generate Report" → `?from_month=current&to_month=current` (§2.4) | Same, implemented as `{% now 'Y-m' %}` template tag. | No deviation, just recording. `1d00a3a` |
|
||||
| 6 | Not in scope | **"Last Activity" column** added to All Time Projects table. | Konrad on CP-1 surprise ask. Extends `_build_report_context` with `Max(WorkLog.date)`; mirrored in PDF. `f6975bf` |
|
||||
|
||||
### Polish not in the original design
|
||||
|
||||
| Commit | What |
|
||||
|--------|------|
|
||||
| `5c4162d` | Fixed double-encoded `project_team_pairs_json` — the view was calling `json.dumps(pairs)` AND the template's `\|json_script` filter was re-serialising. Now passes raw list. Regression test added. |
|
||||
| `c1937cd` | Tooltip on **Until** "(ⓘ Single month select)" + shrink `(optional)` helper to 0.6rem |
|
||||
| `0bbf2ca` | Popover border → 2px accent-orange + three-layer shadow so it visually detaches from the report body. Separate light-theme shadow palette. |
|
||||
| `dcc0eeb` | Choices.js dropdown → `position: static` scoped to `.filter-popover` so it flows inline (dropdown was being clipped by `overflow: hidden` and not contributing to the body's scrollHeight). Specificity trick: mirrored Choices.js's own `[aria-expanded]` selector to win the source-order tiebreaker. |
|
||||
| `c26d2e0` | Auto-open Choices dropdown on pill click via `showDropdown(true)` (dropdown was opening hidden until user clicked the input). Helper text colour swapped from `opacity: 0.75` over Bootstrap's `.form-text` default to `var(--text-tertiary)` — was unreadable on the dark card. |
|
||||
|
||||
### Tests
|
||||
|
||||
- 42 → 47 passing, +5 locked-in behaviours:
|
||||
- `InlineFiltersPairsContextTests.test_pairs_context_key_populated`
|
||||
- `test_pairs_excludes_null_project_or_team`
|
||||
- `test_pairs_renders_as_valid_json_in_template` — end-to-end HTML check for the double-encoding bug
|
||||
- `test_pickers_and_pairs_are_date_scoped` — out-of-range entries absent from picker and pair map
|
||||
- `test_url_selected_projects_survive_even_out_of_range` — URL selection unioned into picker list
|
||||
|
||||
### Total churn
|
||||
|
||||
17 commits on `ai-dev` (prior to push). Across the feature: template -375 net, CSS -65 net (rewritten smaller), view +65 net, tests +130 net. Modal partial deleted (-160). JS module -200 after collapsing the pending/dirty/Apply model.
|
||||
1439
docs/plans/2026-04-23-inline-filters-plan.md
Normal file
1439
docs/plans/2026-04-23-inline-filters-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -77,6 +77,18 @@
|
||||
|
||||
/* Layout dimensions */
|
||||
--bottom-nav-height: 64px;
|
||||
|
||||
/* === ADJUSTMENTS TAB — badge palette (dark theme) === */
|
||||
/* Each adjustment type has its own colour family. Loan-Repayment and
|
||||
Advance-Repayment are +15% saturation siblings of their parent colour
|
||||
so "money coming back" reads as a hotter signal than "money going out". */
|
||||
--badge-bonus-bg: #5b8260; --badge-bonus-fg: #e8f3ea;
|
||||
--badge-overtime-bg: #a16881; --badge-overtime-fg: #fce4ec;
|
||||
--badge-deduction-bg: #5b4f8c; --badge-deduction-fg: #e0daf3;
|
||||
--badge-loan-bg: #9b7f39; --badge-loan-fg: #fef4d1;
|
||||
--badge-loan-rep-bg: #b48a1a; --badge-loan-rep-fg: #fef4d1;
|
||||
--badge-advance-bg: #3e5c7b; --badge-advance-fg: #d7e5f2;
|
||||
--badge-advance-rep-bg: #2f679a; --badge-advance-rep-fg: #d7e5f2;
|
||||
}
|
||||
|
||||
/* === LIGHT MODE === */
|
||||
@ -132,6 +144,15 @@
|
||||
--color-warning-bg: #fffbeb;
|
||||
--color-info: #2563eb;
|
||||
--color-info-bg: #eff6ff;
|
||||
|
||||
/* === ADJUSTMENTS TAB — badge palette (light theme) === */
|
||||
--badge-bonus-bg: #d7e8d9; --badge-bonus-fg: #385640;
|
||||
--badge-overtime-bg: #f3d1dd; --badge-overtime-fg: #703347;
|
||||
--badge-deduction-bg: #d8d0ef; --badge-deduction-fg: #3b2f6d;
|
||||
--badge-loan-bg: #f0dc9d; --badge-loan-fg: #6a5320;
|
||||
--badge-loan-rep-bg: #f7d873; --badge-loan-rep-fg: #5a4418;
|
||||
--badge-advance-bg: #bccee0; --badge-advance-fg: #243b56;
|
||||
--badge-advance-rep-bg: #9ec1dd; --badge-advance-rep-fg: #1d3550;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
@ -1733,3 +1754,357 @@ body, .card, .modal-content, .form-control, .form-select,
|
||||
border-top: 2px solid var(--border-default) !important;
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
/* === Inline Filters (pill-as-dropdown) on the report page === */
|
||||
/*
|
||||
Layered on top of the existing .filter-pill rules (lines ~1496–1524).
|
||||
Three components:
|
||||
1. .filter-pill--editable: pointer cursor, hover tint, rotating chevron
|
||||
2. .filter-popover: absolute-positioned dropdown anchored under the pill
|
||||
3. .filter-popover__footer: sticky bottom bar so the OK button stays
|
||||
visible even when Choices.js expands its dropdown list over the body
|
||||
|
||||
There is intentionally NO dirty-state indicator and NO global Apply button —
|
||||
each popover's OK commits and reloads the page immediately. Simpler model,
|
||||
less state to reason about. (Earlier revision had both; removed 2026-04-23
|
||||
after UX feedback.)
|
||||
*/
|
||||
|
||||
/* --- Wrapper keeps the popover anchored to its pill --- */
|
||||
.filter-pill-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* --- Editable pill: button, cursor, hover state, chevron --- */
|
||||
.filter-pill--editable {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-primary);
|
||||
transition: background-color 120ms, border-color 120ms, box-shadow 120ms;
|
||||
}
|
||||
.filter-pill--editable:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.filter-pill--editable[aria-expanded="true"] {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.filter-pill__chevron {
|
||||
opacity: 0.7;
|
||||
transition: transform 120ms;
|
||||
}
|
||||
.filter-pill--editable[aria-expanded="true"] .filter-pill__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* --- Popover positioned under the pill --- */
|
||||
/* Border + shadow beefed up 2026-04-23 so the popover visually detaches
|
||||
from the report body behind it — previous subtle shadow was getting
|
||||
lost against the amber-accented report cards.
|
||||
The popover uses a flex column so a sticky footer stays pinned at the
|
||||
bottom even when the body scrolls. We DO NOT set overflow: hidden on
|
||||
the popover itself — see the Choices.js override below for why. */
|
||||
.filter-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 1040; /* below Bootstrap modal (1055) but above everything else */
|
||||
min-width: 300px;
|
||||
max-width: 420px;
|
||||
max-height: min(70vh, 520px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
/* Two-layer border for depth: outer accent-tinted halo + inner crisp edge */
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(232, 133, 26, 0.08), /* soft accent halo */
|
||||
0 18px 44px rgba(0, 0, 0, 0.55), /* deep drop shadow */
|
||||
0 6px 12px rgba(0, 0, 0, 0.35); /* near shadow for edge crispness */
|
||||
padding: 0;
|
||||
}
|
||||
/* Light theme: shadow and halo need different opacity to read against white */
|
||||
:root.light .filter-popover {
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(217, 119, 6, 0.12),
|
||||
0 18px 44px rgba(15, 23, 42, 0.22),
|
||||
0 6px 12px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
.filter-popover[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.filter-popover__body {
|
||||
padding: 1rem;
|
||||
overflow-y: auto; /* body scrolls when content exceeds max-height */
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* Footer is sticky at the bottom of the popover so the OK button is always
|
||||
reachable — fixes the "Choices.js dropdown hides the OK button" complaint. */
|
||||
.filter-popover__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--bg-inset);
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* --- Choices.js dropdown override (scoped to filter popovers) ---
|
||||
Choices.js renders its option list as position: absolute beneath the
|
||||
input. Inside our popovers (a flex column with a max-height and a
|
||||
sticky footer) that's a problem:
|
||||
1. The absolute-positioned dropdown doesn't contribute to the body's
|
||||
scrollHeight, so the body's overflow-y: auto never creates a
|
||||
scrollbar — and the user's wheel scroll falls through to the page.
|
||||
2. The dropdown's rendered position overlaps / sits behind the sticky
|
||||
footer, so options aren't visible.
|
||||
Forcing the dropdown to position: static lets it flow inline: the body
|
||||
grows to contain it, the sticky footer pushes below, and for long
|
||||
option lists the dropdown's own max-height + overflow-y gives a clean
|
||||
internal scroll.
|
||||
|
||||
Specificity note: we mirror Choices.js's selector list
|
||||
(`.choices__list--dropdown, .choices__list[aria-expanded]`) because
|
||||
the second branch carries a class+attribute specificity (0,0,2,0)
|
||||
that ties with a naive two-class override. Source order then decides
|
||||
the winner and Choices.js's stylesheet loads AFTER ours. Mirroring
|
||||
the selector lifts our specificity one step on the aria-expanded
|
||||
branch and wins cleanly without needing !important.
|
||||
|
||||
Scoped to .filter-popover so other Choices.js usages in the app
|
||||
(worker/team pickers on edit pages, etc.) keep their default behaviour. */
|
||||
.filter-popover .choices__list--dropdown,
|
||||
.filter-popover .choices__list[aria-expanded] {
|
||||
position: static;
|
||||
margin-top: 0.35rem;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* --- Mobile: popovers stretch full-width below the pill strip --- */
|
||||
@media (max-width: 576px) {
|
||||
.filter-popover {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
z-index: 1050;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* ADJUSTMENTS TAB
|
||||
* Visual vocabulary for the Payroll → Adjustments tab.
|
||||
* - 7 badge classes, one per adjustment type
|
||||
* - Sticky filter bar that stays visible as the table scrolls
|
||||
* - Group-by header style (collapsible section divider)
|
||||
* - Floating bulk-action bar at the bottom of the viewport
|
||||
* ============================================================================= */
|
||||
|
||||
/* --- Type badges (one class per PayrollAdjustment type) --- */
|
||||
.badge-type-bonus,
|
||||
.badge-type-overtime,
|
||||
.badge-type-deduction,
|
||||
.badge-type-new-loan,
|
||||
.badge-type-loan-repayment,
|
||||
.badge-type-advance-payment,
|
||||
.badge-type-advance-repayment {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-type-bonus { background: var(--badge-bonus-bg); color: var(--badge-bonus-fg); }
|
||||
.badge-type-overtime { background: var(--badge-overtime-bg); color: var(--badge-overtime-fg); }
|
||||
.badge-type-deduction { background: var(--badge-deduction-bg); color: var(--badge-deduction-fg); }
|
||||
.badge-type-new-loan { background: var(--badge-loan-bg); color: var(--badge-loan-fg); }
|
||||
.badge-type-loan-repayment { background: var(--badge-loan-rep-bg); color: var(--badge-loan-rep-fg); }
|
||||
.badge-type-advance-payment { background: var(--badge-advance-bg); color: var(--badge-advance-fg); }
|
||||
.badge-type-advance-repayment { background: var(--badge-advance-rep-bg); color: var(--badge-advance-rep-fg); }
|
||||
|
||||
/* --- Sticky filter bar (keeps filters visible as the table scrolls) --- */
|
||||
.adjustments-filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-card);
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
/* All children are now same-height pills — center-align them */
|
||||
align-items: center;
|
||||
}
|
||||
/* Apply / Clear push to the right end of the bar */
|
||||
.adjustments-filter-bar > form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.adjustments-filter-bar .adj-apply-group {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* --- Group header (collapsible section divider for group-by mode) --- */
|
||||
.adj-group-header {
|
||||
cursor: pointer;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-inset);
|
||||
border-top: 1px solid var(--border-default);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
user-select: none;
|
||||
transition: background-color 120ms;
|
||||
}
|
||||
.adj-group-header:hover { background: var(--bg-card-hover); }
|
||||
.adj-group-header .fa-chevron-down,
|
||||
.adj-group-header .fa-chevron-right { opacity: 0.7; width: 0.8rem; }
|
||||
.adj-group-header .adj-group-label { font-weight: 600; }
|
||||
.adj-group-header .adj-group-meta { color: var(--text-secondary); font-size: 0.875rem; margin-left: auto; }
|
||||
|
||||
/* --- By-Type group headers: 4px left-accent picks up the type's signature
|
||||
badge colour so grouped rows visually echo the badges below.
|
||||
Uses a [data-type="..."] attribute on the <tr class="adj-group-header">
|
||||
so the selector is self-descriptive (no per-type class explosion). --- */
|
||||
.adj-group-header[data-type="Bonus"] { border-left: 4px solid var(--badge-bonus-bg); }
|
||||
.adj-group-header[data-type="Overtime"] { border-left: 4px solid var(--badge-overtime-bg); }
|
||||
.adj-group-header[data-type="Deduction"] { border-left: 4px solid var(--badge-deduction-bg); }
|
||||
.adj-group-header[data-type="New Loan"] { border-left: 4px solid var(--badge-loan-bg); }
|
||||
.adj-group-header[data-type="Loan Repayment"] { border-left: 4px solid var(--badge-loan-rep-bg); }
|
||||
.adj-group-header[data-type="Advance Payment"] { border-left: 4px solid var(--badge-advance-bg); }
|
||||
.adj-group-header[data-type="Advance Repayment"] { border-left: 4px solid var(--badge-advance-rep-bg); }
|
||||
|
||||
/* --- Chevron rotates to indicate collapsed / expanded state.
|
||||
Bootstrap sets aria-expanded="false" on the toggle when collapsed;
|
||||
we rotate the chevron 90deg counter-clockwise so it points right,
|
||||
a familiar "this is collapsed" signal. --- */
|
||||
.adj-group-header .fa-chevron-down { transition: transform 150ms ease; }
|
||||
.adj-group-header[aria-expanded="false"] .fa-chevron-down { transform: rotate(-90deg); }
|
||||
|
||||
/* --- Floating bulk action bar (appears when >=1 row selected) --- */
|
||||
.adj-bulk-bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1.5rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1050;
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 2rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
padding: 0.6rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
animation: adj-bulk-bar-in 180ms ease-out;
|
||||
}
|
||||
.adj-bulk-bar[hidden] { display: none; }
|
||||
@keyframes adj-bulk-bar-in {
|
||||
from { opacity: 0; transform: translate(-50%, 10px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
/* --- Empty state card --- */
|
||||
.adj-empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.adj-empty-state .adj-empty-icon { font-size: 2.5rem; opacity: 0.35; margin-bottom: 1rem; }
|
||||
|
||||
/* --- Group-by toggle pill buttons (Flat / By Type / By Worker) --- */
|
||||
/* margin-top: 1rem gives breathing room between the sticky filter bar and
|
||||
this row — they used to read as one cramped block. */
|
||||
.adj-groupby-toggle {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.adj-groupby-toggle .btn { font-size: 0.8rem; padding: 0.3rem 0.75rem; }
|
||||
|
||||
/* --- Sort header arrows --- */
|
||||
th.sortable { cursor: pointer; user-select: none; }
|
||||
th.sortable .sort-arrow {
|
||||
opacity: 0.4;
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
transition: opacity 120ms;
|
||||
}
|
||||
th.sortable:hover .sort-arrow,
|
||||
th.sortable.sorted .sort-arrow { opacity: 1; }
|
||||
|
||||
/* =============================================================================
|
||||
* ADJUSTMENTS TAB — pill-popover checkbox list
|
||||
* The Type / Workers / Teams filters each open a popover that reuses the
|
||||
* shared .filter-popover styles (see "Inline Filters" block above). This
|
||||
* section only adds the bits specific to the checkbox-list body — the rest
|
||||
* of the visual vocabulary (pill button, popover chrome, sticky footer)
|
||||
* is inherited.
|
||||
* ============================================================================= */
|
||||
|
||||
/* --- Adjustments popover — tighter density for the checkbox/radio list ---
|
||||
Font-size drop (~14px → 12.8px) + tighter row padding cuts the
|
||||
7-type Type popover from ~320px tall to ~240px without losing
|
||||
readability. Radios in the Status popover and date fields in the
|
||||
Date popover inherit the same size via .adj-radio-list. */
|
||||
.adj-checkbox-list,
|
||||
.adj-radio-list {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
/* Each row is a full-width <label> so clicking the text toggles the input */
|
||||
.adj-cb-row {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 120ms;
|
||||
}
|
||||
.adj-cb-row:hover { background: var(--bg-card-hover); }
|
||||
.adj-cb-label { user-select: none; }
|
||||
.adj-cb-row .form-check-input { width: 0.9em; height: 0.9em; }
|
||||
|
||||
/* Popover footer buttons (OK / Cancel / All / Invert / Clear) match the
|
||||
same compact typography — reads as one cohesive strip. */
|
||||
.filter-popover__footer .btn { font-size: 0.75rem; padding: 0.25rem 0.6rem; }
|
||||
|
||||
/* The Adjustments popover is also slightly narrower now (less visual weight
|
||||
once the filter bar is all pills of the same size). */
|
||||
.adj-filter-pill + .filter-popover,
|
||||
#adjustmentsFilters .filter-popover { max-width: 360px; }
|
||||
|
||||
/* --- Count badge shown on the pill when 2+ options are selected --- */
|
||||
/* (For 0 or 1 selected the label text carries the info; the badge stays hidden.) */
|
||||
.filter-pill__count {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.75;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user