Compare commits

..

No commits in common. "6f66faf06a252d43b4f7a922ebb9a70c593db1ff" and "3dab09cea35b09a8f3d0065a81f32d5e950207a7" have entirely different histories.

18 changed files with 232 additions and 7499 deletions

View File

@ -179,8 +179,6 @@ 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 |

View File

@ -1,130 +0,0 @@
{# === _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);">&minus;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">&mdash;</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">&mdash;</span>{% endif %}
{% endwith %}
</td>
{# --- Description (truncated; full text shown in a hover tooltip) --- #}
<td>
{% if adj.description %}
<span title="{{ adj.description }}" data-bs-toggle="tooltip">
{{ adj.description|truncatechars:40 }}
</span>
{% else %}<span class="text-muted">&mdash;</span>{% endif %}
</td>
{# --- Status: Paid #N (links to the payslip) or Unpaid badge --- #}
<td>
{% if adj.payroll_record %}
<a href="{% url 'payslip_detail' adj.payroll_record.id %}" class="badge bg-success text-decoration-none">
Paid #{{ adj.payroll_record.id }}
</a>
{% else %}
<span class="badge bg-warning text-dark">Unpaid</span>
{% endif %}
</td>
{# --- Row actions (eye + pen + x for unpaid; eye only for paid) --- #}
<td class="text-end">
{% if adj.payroll_record %}
{# 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>

View File

@ -0,0 +1,160 @@
{% 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 %}

View File

@ -184,11 +184,7 @@
<i class="fas fa-receipt"></i>
<span>New Receipt</span>
</a>
{# === 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">
<a href="#" class="quick-action" data-bs-toggle="modal" data-bs-target="#reportConfigModal">
<i class="fas fa-file-alt"></i>
<span>Generate Report</span>
</a>
@ -603,4 +599,9 @@ 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

View File

@ -565,7 +565,6 @@
<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>
@ -574,7 +573,6 @@
<tr>
<td class="name">{{ item.project }}</td>
<td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>
<td>{% if item.last_activity %}{{ item.last_activity|date:"d M Y" }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>
<td class="r">{% if item.working_days %}{{ item.working_days }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>
<td class="total">R&nbsp;{{ item.total|money }}</td>
<td class="r">{% if item.working_days %}R&nbsp;{{ item.avg_per_working_day|money }}{% else %}<span style="color:#cbd5e1;">&mdash;</span>{% endif %}</td>

View File

@ -181,22 +181,4 @@
</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 %}

View File

@ -17,8 +17,10 @@
</p>
</div>
<div class="d-flex gap-2 mt-3 mt-md-0">
{# "New Report" removed — the inline filter pills below ARE the #}
{# new-report interface now. Date/project/team pills edit in-place. #}
<!-- 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>
<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>
@ -28,167 +30,25 @@
</div>
</div>
{# === 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" }} &ndash; {{ 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>
{# === 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" }} &ndash; {{ end_date|date:"d M Y" }}
</span>
<span class="filter-pill">
<i class="fas fa-folder me-1"></i>{{ project_name }}
{% if selected_project_ids %}
<a href="?{{ query_string_without_project|default:query_string }}"
class="filter-pill__x"
aria-label="Clear project filter"
title="Clear project filter">&times;</a>
<a href="?{{ query_string_without_project|default:query_string }}" class="filter-pill__x" aria-label="Clear project filter">&times;</a>
{% endif %}
<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>
</span>
<span class="filter-pill">
<i class="fas fa-users me-1"></i>{{ team_name }}
{% if selected_team_ids %}
<a href="?{{ query_string_without_team|default:query_string }}"
class="filter-pill__x"
aria-label="Clear team filter"
title="Clear team filter">&times;</a>
<a href="?{{ query_string_without_team|default:query_string }}" class="filter-pill__x" aria-label="Clear team filter">&times;</a>
{% endif %}
<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. #}
</span>
</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 &mdash; Payroll Report</h2>
@ -246,7 +106,6 @@
<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>
@ -257,7 +116,6 @@
<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">&mdash;</span>{% endif %}</td>
<td>{% if item.last_activity %}{{ item.last_activity|date:"d M Y" }}{% else %}<span class="text-muted">&mdash;</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">&mdash;</span>{% endif %}</td>
@ -526,326 +384,18 @@
<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>
{# === 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 %}
<!-- === 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' %}
{% endblock %}

View File

@ -27,22 +27,6 @@ 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.
@ -58,50 +42,3 @@ 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))

View File

@ -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, Loan
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment
from core.views import _build_work_log_payroll_context, _build_report_context
@ -692,12 +692,6 @@ 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'
)
# =============================================================================
@ -755,485 +749,3 @@ 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)

View File

@ -54,9 +54,6 @@ 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'),

View File

@ -17,7 +17,6 @@ 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
@ -2139,15 +2138,6 @@ 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']
@ -2159,7 +2149,6 @@ 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,
})
@ -2404,56 +2393,13 @@ def generate_report(request):
return qd.urlencode()
context['query_string_without_project'] = _qs_without('project')
context['query_string_without_team'] = _qs_without('team')
# === 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.
# 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).
context['selected_project_ids'] = [str(p) for p in (project_ids or [])]
context['selected_team_ids'] = [str(t) for t in (team_ids or [])]
@ -2534,54 +2480,6 @@ 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,
@ -2945,158 +2843,6 @@ 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)
@ -3901,63 +3647,6 @@ 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':
@ -3966,83 +3655,52 @@ 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
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':
# === 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():
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.

View File

@ -1,199 +0,0 @@
# 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` ~1416px 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.

View File

@ -1,669 +0,0 @@
# 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.)

File diff suppressed because it is too large Load Diff

View File

@ -1,277 +0,0 @@
# 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.

File diff suppressed because it is too large Load Diff

View File

@ -77,18 +77,6 @@
/* 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 === */
@ -144,15 +132,6 @@
--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;
}
/* ===================================================================
@ -1754,357 +1733,3 @@ 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 ~14961524).
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;
}