Design: Payroll Adjustments Tab on the payroll dashboard
Second brainstorm output of the day. New tab alongside Pending / History / Loans & Advances (the URL pattern already established at ?status=pending|paid|loans — this slots in at ?status=adjustments). Key decisions: - Semantic badge palette: 5 colour categories mapped across 7 types. Loan/advance repayments get +15% saturation — same family, hotter signal for "money coming back" vs "money going out". - Three multi-select filters (Type, Workers, Teams) via Choices.js. Teams cross-filter Workers using JSON pair map (mirrors Feature 1's project<->team pattern). Auto-remove invalid selections with toast. - Single-date default with optional range toggle; presets for Today / This week / This month. - Sticky filter bar; sortable columns (Date / Worker / Amount / Status). - Group-by toggle: Flat / By Type / By Worker. Collapsible group headers show count + net sum per group (+R additive / -R deductive). - Bulk action bar (floating) for multi-row delete on unpaid rows only. New endpoint POST /payroll/adjustments/bulk-delete/ filters payroll_record__isnull=True for safety. - Inline row actions reuse existing modals: Preview (unpaid) / View Payslip (paid) / Edit + Delete (unpaid). Zero new modal code. - Empty state, keyboard Esc, URL state for everything (bookmark-safe). Scope: ~960 lines, ~12 tasks, 2 checkpoints. Uses existing #addAdjustmentModal / #editAdjustmentModal / #payslipPreviewModal already on payroll_dashboard.html — zero duplication. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
30d0991956
commit
12edafa441
557
docs/plans/2026-04-23-adjustments-tab-design.md
Normal file
557
docs/plans/2026-04-23-adjustments-tab-design.md
Normal file
@ -0,0 +1,557 @@
|
||||
# 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.
|
||||
Loading…
x
Reference in New Issue
Block a user