11 tasks + 1 hard-pause checkpoint after Task 4. Targets ~960 LOC across core/views.py, payroll_dashboard.html, _adjustment_row.html (new), format_tags.py, custom.css, and tests.py. Derived from docs/plans/2026-04-23-adjustments-tab-design.md (commit 12edafa) — execution plan for subagent-driven development. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1885 lines
76 KiB
Markdown
1885 lines
76 KiB
Markdown
# Payroll Adjustments Tab — Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or superpowers:subagent-driven-development) to implement this plan task-by-task.
|
||
|
||
**Goal:** Add a new **Adjustments** tab to the payroll dashboard so admins can browse every `PayrollAdjustment` across all workers with multi-select filters, semantic badges, group-by, bulk-delete, and inline actions that reuse the existing modal infrastructure.
|
||
|
||
**Architecture:** Extend the existing `payroll_dashboard` view with a new `?status=adjustments` branch (mirrors the pattern already used for `pending` / `paid` / `loans`). Template gets a new `<li>` in the nav-tabs strip plus a gated content block. Modals and JS handlers already on the payroll dashboard (`#addAdjustmentModal`, `#editAdjustmentModal`, `#previewPayslipModal`) are reused as-is. No new models, no migrations. One new endpoint (`bulk_delete_adjustments`), one new template filter (`type_slug`), one new shared partial (`_adjustment_row.html`).
|
||
|
||
**Tech Stack:** Django 5.2.7, Python 3.13, Bootstrap 5.3, Choices.js 10.2.0 (already on the payroll dashboard), Font Awesome 6, WeasyPrint (not touched). Local dev = SQLite + `run_dev.bat`; production = MySQL on Flatlogic.
|
||
|
||
**Design doc:** `docs/plans/2026-04-23-adjustments-tab-design.md` (commit `12edafa`, local-only). Read sections 1–13 for full rationale; this plan executes on that doc.
|
||
|
||
**CLAUDE.md — mandatory read-before-touch:**
|
||
- "Coding Style" section — section-header comment convention + plain-English comments for the non-programmer owner
|
||
- "PayrollAdjustment Type Handling" — ADDITIVE_TYPES / DEDUCTIVE_TYPES constants
|
||
- "Schema name-drifts to remember" — `PayrollAdjustment.description` (NOT `reason`); `log.adjustments_by_work_log` (NOT `payrolladjustment_set`)
|
||
- "Django ORM gotcha — M2M filter + aggregate inflation" — the subquery pattern `id__in=Model.objects.filter(m2m__field=X).values('id')` is mandatory when you filter on an M2M relation AND aggregate
|
||
|
||
---
|
||
|
||
## Who it's for
|
||
|
||
Admins only (`is_admin(user)` = `is_staff OR is_superuser`). Supervisors get 403.
|
||
|
||
## Files touched
|
||
|
||
| File | What |
|
||
|------|------|
|
||
| `core/views.py` | New `elif status_filter == 'adjustments'` branch in `payroll_dashboard`; new `_group_adjustments` helper; new `bulk_delete_adjustments` view |
|
||
| `core/urls.py` | One new path for bulk-delete |
|
||
| `core/templatetags/format_tags.py` | New `type_slug` filter |
|
||
| `core/templates/core/payroll_dashboard.html` | New `<li>` in tab strip; new `{% elif active_tab == 'adjustments' %}` block; reuse existing modals |
|
||
| `core/templates/core/_adjustment_row.html` | New shared partial used by flat AND grouped views |
|
||
| `static/css/custom.css` | Badge palette tokens (dark + light themes) + 7 badge classes + sticky filter bar + group header + bulk action bar styles |
|
||
| `core/tests.py` | New `AdjustmentsTabTests` class (~11 tests) |
|
||
|
||
## Task sequence + checkpoint
|
||
|
||
| # | Task | Type | Checkpoint gate? |
|
||
|---|------|------|:-:|
|
||
| 1 | `type_slug` template filter | TDD | |
|
||
| 2 | CSS badge palette + foundational styles | UI | |
|
||
| 3 | Backend filter branch + stats (no group/bulk/cross-filter yet) | TDD | |
|
||
| 4 | Tab markup + filter bar + flat table + row actions | UI + smoke test | |
|
||
| — | **CHECKPOINT 1** — Konrad demos the core experience | | ✅ hard-pause |
|
||
| 5 | Group-by helper + toggle + rendering | TDD | |
|
||
| 6 | Bulk-delete endpoint + floating action bar | TDD | |
|
||
| 7 | Cross-filter Team → Workers + auto-removal toast | Integration | |
|
||
| 8 | Date picker single vs. range + preset buttons | UI | |
|
||
| 9 | Sort header clicks + URL state | UI | |
|
||
| 10 | Empty state + sticky filter bar polish | UI | |
|
||
| 11 | Manual QA sweep + design-doc "Shipped" block | Docs | |
|
||
|
||
Target: ~960 LOC across all files, per design §13.
|
||
|
||
---
|
||
|
||
## Task 1: `type_slug` template filter
|
||
|
||
**Goal:** a tiny helper that turns `"Advance Payment"` → `"advance-payment"` so templates can build class names like `badge-type-advance-payment`.
|
||
|
||
**Files:**
|
||
- Modify: `core/templatetags/format_tags.py` (existing file — `money` filter lives here; add alongside)
|
||
- Test: `core/tests.py` (new `TypeSlugFilterTests` class)
|
||
|
||
**Step 1: Write the failing test**
|
||
|
||
Append to `core/tests.py` (near the bottom, after the last `Tests` class):
|
||
|
||
```python
|
||
# =============================================================================
|
||
# === 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')
|
||
```
|
||
|
||
**Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.TypeSlugFilterTests -v 2
|
||
```
|
||
|
||
Expected: `ImportError: cannot import name 'type_slug' from 'core.templatetags.format_tags'`.
|
||
|
||
**Step 3: Implement**
|
||
|
||
Edit `core/templatetags/format_tags.py` — append:
|
||
|
||
```python
|
||
# === 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(' ', '-')
|
||
```
|
||
|
||
**Step 4: Run to verify pass**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.TypeSlugFilterTests -v 2
|
||
```
|
||
|
||
Expected: 3 tests pass. Full suite: `python manage.py test core.tests` → 47 + 3 = 50 tests pass.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add core/templatetags/format_tags.py core/tests.py
|
||
git commit -m "feat(adjustments): add |type_slug template filter for badge class naming"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: CSS badge palette + foundational styles
|
||
|
||
**Goal:** Add the 14 badge colour tokens (7 types × 2 themes) plus 7 badge classes plus the sticky filter bar / group header / bulk-action-bar skeleton styles. No functional wiring yet — just the visual vocabulary.
|
||
|
||
No TDD here (CSS-only). Smoke test: render a throwaway `<span class="badge-type-bonus">Bonus</span>` in any template and eyeball.
|
||
|
||
**Files:**
|
||
- Modify: `static/css/custom.css` (append near end, after the inline-filters block — line ~1855+)
|
||
|
||
**Step 1: Add badge palette tokens to both themes**
|
||
|
||
Find the `:root {` block near the top of `custom.css` (the CSS-variables root). Append the 7 dark-theme tokens INSIDE the existing `:root` block — keep them near other colour tokens:
|
||
|
||
```css
|
||
/* === 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;
|
||
```
|
||
|
||
Then find the `:root.light {` block (light-theme overrides). Append the light-theme pairs INSIDE it:
|
||
|
||
```css
|
||
/* === 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;
|
||
```
|
||
|
||
**Step 2: Add badge classes + layout skeleton**
|
||
|
||
Append at the END of `custom.css`:
|
||
|
||
```css
|
||
/* =============================================================================
|
||
* 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.75rem 1rem;
|
||
border-bottom: 1px solid var(--border-default);
|
||
border-radius: 0.5rem 0.5rem 0 0;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem;
|
||
align-items: end;
|
||
}
|
||
|
||
/* --- 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; }
|
||
|
||
/* --- 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) --- */
|
||
.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; }
|
||
```
|
||
|
||
**Step 3: Smoke-test in the browser**
|
||
|
||
Start dev server (likely already running):
|
||
|
||
```bash
|
||
run_dev.bat # or: set USE_SQLITE=true && python manage.py runserver 0.0.0.0:8000
|
||
```
|
||
|
||
Temporarily edit `payroll_dashboard.html` to add a throwaway line right after the tab strip (we'll remove this before commit):
|
||
|
||
```html
|
||
<div class="mb-2">
|
||
<span class="badge-type-bonus">Bonus</span>
|
||
<span class="badge-type-overtime">Overtime</span>
|
||
<span class="badge-type-deduction">Deduction</span>
|
||
<span class="badge-type-new-loan">New Loan</span>
|
||
<span class="badge-type-loan-repayment">Loan Repayment</span>
|
||
<span class="badge-type-advance-payment">Advance Payment</span>
|
||
<span class="badge-type-advance-repayment">Advance Repayment</span>
|
||
</div>
|
||
```
|
||
|
||
Load `/payroll/` in the browser → all 7 badges should render with their colours. Toggle the theme (sun/moon topbar button) → badges should re-colour using light-theme tokens.
|
||
|
||
Then **remove that throwaway div** before committing.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add static/css/custom.css
|
||
git commit -m "feat(adjustments): add semantic badge palette + sticky filter bar / group-header / bulk-bar styles"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Backend filter branch + stats (the view work)
|
||
|
||
**Goal:** Add the new `elif status_filter == 'adjustments'` branch inside `payroll_dashboard`. Handles type / worker / team / status / date filters, sort, stats, pagination. **No group-by, no bulk-delete, no cross-filter in this task** — those are Tasks 5, 6, 7.
|
||
|
||
**Files:**
|
||
- Modify: `core/views.py` — `payroll_dashboard` function (starts at line 2544). The new branch goes inside it, before the final `context.update({...})` call (around line 2880).
|
||
- Test: `core/tests.py` — new `AdjustmentsTabTests` class
|
||
|
||
**Step 1: Write the failing tests (5 tests for this task)**
|
||
|
||
Append to `core/tests.py`:
|
||
|
||
```python
|
||
# =============================================================================
|
||
# === TESTS FOR ADJUSTMENTS TAB (payroll_dashboard ?status=adjustments) ===
|
||
# Groups shared setUp by fixture factory so each test starts fresh.
|
||
# =============================================================================
|
||
|
||
|
||
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')
|
||
)
|
||
self.team = Team.objects.create(name='Alpha', supervisor=self.admin)
|
||
self.team.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):
|
||
self._login_admin()
|
||
resp = self.client.get(self.url + '&type=Bonus')
|
||
ids = {a.id for a in resp.context['adj_page'].object_list}
|
||
self.assertIn(self.a1.id, ids)
|
||
self.assertIn(self.a2.id, ids)
|
||
self.assertNotIn(self.a3.id, ids)
|
||
|
||
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 (M2M JOIN inflation
|
||
would give 6 rows for 3 adjustments × 2 workers on team Alpha)."""
|
||
self._login_admin()
|
||
resp = self.client.get(self.url + f'&team={self.team.id}')
|
||
self.assertEqual(len(resp.context['adj_page'].object_list), 3)
|
||
|
||
def test_status_filter_unpaid(self):
|
||
self._login_admin()
|
||
# Mark a1 as paid
|
||
pr = PayrollRecord.objects.create(
|
||
worker=self.w1, date=datetime.date(2026, 4, 15),
|
||
days_worked=20, total_amount=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–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'))
|
||
```
|
||
|
||
**Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2
|
||
```
|
||
|
||
Expected: all 8 tests fail with various `KeyError` / `AttributeError` / 404 / 500 errors because the branch doesn't exist.
|
||
|
||
**Step 3: Implement the view branch**
|
||
|
||
Open `core/views.py`. Find `def payroll_dashboard(request):` at line 2544. Find the final `context.update({...})` call (around line ~2880). Inside `payroll_dashboard`, right AFTER `status_filter = request.GET.get('status', 'pending')` (line 2549) and BEFORE the existing `active_workers = ...` fetch at line 2553 — **gate the expensive Pending workflow** so the Adjustments tab doesn't pay the cost of the Pending tab's query:
|
||
|
||
Actually — to keep this minimally invasive (and because the existing view isn't structured as an `if / elif / elif` tree but a "compute everything, then render" pattern), add the Adjustments branch as a standalone block right BEFORE the final `context.update({...})` call. The `active_tab` context key already picks up `status_filter` so the template can gate on it.
|
||
|
||
Locate this area (line ~2880):
|
||
```python
|
||
context = {
|
||
# ... existing keys ...
|
||
'active_tab': status_filter,
|
||
# ... more existing keys ...
|
||
}
|
||
return render(request, 'core/payroll_dashboard.html', context)
|
||
```
|
||
|
||
Right BEFORE `return render(...)` — after the existing context is populated — insert:
|
||
|
||
```python
|
||
# =========================================================================
|
||
# === ADJUSTMENTS TAB CONTEXT ===
|
||
# Populated only when ?status=adjustments. Handles type/worker/team/status/
|
||
# date filters, sort, stats, and pagination. Group-by rendering, bulk-select
|
||
# selection state, and Team→Workers cross-filter are layered in later tasks.
|
||
# =========================================================================
|
||
if status_filter == 'adjustments':
|
||
from django.core.paginator import Paginator
|
||
from django.utils.dateparse import parse_date
|
||
|
||
# --- Parse query params ---
|
||
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 ---
|
||
adjustments = PayrollAdjustment.objects.select_related(
|
||
'worker', 'project', 'payroll_record'
|
||
).prefetch_related('worker__teams')
|
||
|
||
# --- Apply filters ---
|
||
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 ORM gotcha): keeps the outer
|
||
# queryset JOIN-free so aggregations don't inflate.
|
||
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 ---
|
||
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 for stable ordering
|
||
adjustments = adjustments.order_by(sort_field, '-id')
|
||
|
||
# --- Stats (all scoped to filtered set, BEFORE pagination) ---
|
||
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')
|
||
|
||
# --- Pagination ---
|
||
paginator = Paginator(adjustments, 50)
|
||
adj_page = paginator.get_page(request.GET.get('page', 1))
|
||
|
||
# --- Context for the template ---
|
||
context.update({
|
||
'adj_page': adj_page,
|
||
'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,
|
||
},
|
||
'adjustment_types': 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'),
|
||
})
|
||
```
|
||
|
||
**Step 4: Run the tests to verify pass**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2
|
||
```
|
||
|
||
Expected: 8 tests pass.
|
||
|
||
Full suite:
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 1
|
||
```
|
||
|
||
Expected: `Ran 58 tests` (50 from before + 8 new).
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add core/views.py core/tests.py
|
||
git commit -m "feat(adjustments): backend filter branch for ?status=adjustments
|
||
|
||
Type / worker / team / status / date filters, sort, stats, pagination.
|
||
Subquery pattern on the team filter avoids M2M JOIN inflation
|
||
(CLAUDE.md ORM gotcha). Group-by + bulk-delete + cross-filter
|
||
come later (Tasks 5/6/7)."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Tab markup + filter bar + flat table + row actions
|
||
|
||
**Goal:** Wire the view's context into the template — add the nav tab, the filter bar with Choices.js multi-selects, the flat table with badges and row actions, the stats header. This is the task that lets Konrad *see and use* the feature end-to-end.
|
||
|
||
**Files:**
|
||
- Create: `core/templates/core/_adjustment_row.html` (shared row partial)
|
||
- Modify: `core/templates/core/payroll_dashboard.html` (add tab + content block + inline JS)
|
||
|
||
**Step 1: Create the shared row partial**
|
||
|
||
Create `core/templates/core/_adjustment_row.html` with this content:
|
||
|
||
```django
|
||
{# === _adjustment_row.html ===
|
||
Single <tr> used by BOTH the flat Adjustments view and the grouped view.
|
||
Context: `adj` (PayrollAdjustment), `show_checkbox` (bool, default true).
|
||
Row actions differ by paid status:
|
||
- Paid → [View Payslip]
|
||
- Unpaid → [Preview][Edit][×] (reuses existing modals on the dashboard)
|
||
#}
|
||
{% 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 (disabled for paid rows; interactive for unpaid) --- #}
|
||
<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 (link to worker profile) --- #}
|
||
<td>
|
||
<a href="{% url 'worker_detail' adj.worker.id %}" class="text-decoration-none">
|
||
{{ adj.worker.name }}
|
||
</a>
|
||
</td>
|
||
|
||
{# --- Type badge --- #}
|
||
<td><span class="badge-type-{{ adj.type|type_slug }}">{{ adj.type }}</span></td>
|
||
|
||
{# --- Amount (sign reflects additive / deductive) --- #}
|
||
<td class="text-end" style="font-variant-numeric: tabular-nums;">
|
||
{% if adj.type in additive_types %}
|
||
<span style="color: var(--text-primary);">+R {{ adj.amount|money }}</span>
|
||
{% else %}
|
||
<span style="color: var(--text-primary);">−R {{ adj.amount|money }}</span>
|
||
{% endif %}
|
||
</td>
|
||
|
||
{# --- Project --- #}
|
||
<td>
|
||
{% if adj.project %}
|
||
<a href="{% url 'project_detail' adj.project.id %}" class="text-decoration-none">
|
||
{{ adj.project.name }}
|
||
</a>
|
||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||
</td>
|
||
|
||
{# --- Team (worker's first team; may be blank) --- #}
|
||
<td>
|
||
{% with team=adj.worker.teams.first %}
|
||
{% if team %}{{ team.name }}{% else %}<span class="text-muted">—</span>{% endif %}
|
||
{% endwith %}
|
||
</td>
|
||
|
||
{# --- Description (truncated with tooltip for full text) --- #}
|
||
<td>
|
||
{% if adj.description %}
|
||
<span title="{{ adj.description }}" data-bs-toggle="tooltip">
|
||
{{ adj.description|truncatechars:40 }}
|
||
</span>
|
||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||
</td>
|
||
|
||
{# --- Status: Paid #N / Unpaid --- #}
|
||
<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">Unpaid</span>
|
||
{% endif %}
|
||
</td>
|
||
|
||
{# --- Row actions --- #}
|
||
<td class="text-end">
|
||
{% if adj.payroll_record %}
|
||
<a href="{% url 'payslip_detail' adj.payroll_record.id %}"
|
||
class="btn btn-sm btn-outline-secondary" title="View payslip">
|
||
<i class="fas fa-eye"></i>
|
||
</a>
|
||
{% else %}
|
||
<button type="button"
|
||
class="btn btn-sm btn-outline-secondary preview-payslip-btn"
|
||
data-worker-id="{{ adj.worker.id }}"
|
||
title="Preview payslip">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
<button type="button"
|
||
class="btn btn-sm btn-outline-primary edit-adjustment-btn"
|
||
data-adj-id="{{ adj.id }}"
|
||
data-type="{{ adj.type }}"
|
||
data-amount="{{ adj.amount }}"
|
||
data-description="{{ adj.description|default:'' }}"
|
||
data-bs-toggle="modal"
|
||
data-bs-target="#editAdjustmentModal"
|
||
title="Edit">
|
||
<i class="fas fa-pen"></i>
|
||
</button>
|
||
<button type="button"
|
||
class="btn btn-sm btn-outline-danger delete-adjustment-btn"
|
||
data-adj-id="{{ adj.id }}"
|
||
title="Delete">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
{% endif %}
|
||
</td>
|
||
</tr>
|
||
```
|
||
|
||
**Step 2: Add the nav tab `<li>`**
|
||
|
||
Edit `core/templates/core/payroll_dashboard.html`. Find the tab strip at line 252. Add a new `<li>` inside `<ul class="nav nav-tabs">`, RIGHT AFTER the "Loans & Advances" tab (current last child, around line 267):
|
||
|
||
```html
|
||
<li class="nav-item" role="presentation">
|
||
<a class="nav-link {% if active_tab == 'adjustments' %}active{% endif %}" href="?status=adjustments">
|
||
<i class="fas fa-sliders-h me-1"></i> Adjustments
|
||
</a>
|
||
</li>
|
||
```
|
||
|
||
**Step 3: Add the new tab content block**
|
||
|
||
Still in `payroll_dashboard.html`. After the existing Loans tab's closing `{% endif %}` (the block that renders `{% if active_tab == 'loans' %}...{% endif %}`), add:
|
||
|
||
```django
|
||
{# =============================================== #}
|
||
{# === ADJUSTMENTS TAB === #}
|
||
{# =============================================== #}
|
||
{% elif active_tab == 'adjustments' %}
|
||
|
||
{# --- Sticky filter bar --- #}
|
||
<div class="adjustments-filter-bar" id="adjustmentsFilters">
|
||
<form method="get" action="{% url 'payroll_dashboard' %}" class="d-flex flex-wrap gap-3 align-items-end w-100" id="adjFilterForm">
|
||
<input type="hidden" name="status" value="adjustments">
|
||
|
||
{# --- Type multi-select --- #}
|
||
<div class="flex-grow-1" style="min-width: 180px;">
|
||
<label class="form-label small mb-1">Type</label>
|
||
<select name="type" class="form-select form-select-sm adj-multi" multiple data-placeholder="All types">
|
||
{% for t in adjustment_types %}
|
||
<option value="{{ t }}" {% if t in adj_filter_values.type %}selected{% endif %}>{{ t }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
{# --- Workers multi-select (cross-filtered by Teams in Task 7) --- #}
|
||
<div class="flex-grow-1" style="min-width: 180px;">
|
||
<label class="form-label small mb-1">Workers</label>
|
||
<select name="worker" id="adjWorkerSelect" class="form-select form-select-sm adj-multi" multiple data-placeholder="All workers">
|
||
{% for w in all_workers_for_filter %}
|
||
<option value="{{ w.id }}" {% if w.id in adj_filter_values.worker %}selected{% endif %}>{{ w.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
{# --- Teams multi-select --- #}
|
||
<div class="flex-grow-1" style="min-width: 160px;">
|
||
<label class="form-label small mb-1">Teams</label>
|
||
<select name="team" id="adjTeamSelect" class="form-select form-select-sm adj-multi" multiple data-placeholder="All teams">
|
||
{% for t in all_teams_for_filter %}
|
||
<option value="{{ t.id }}" {% if t.id in adj_filter_values.team %}selected{% endif %}>{{ t.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
|
||
{# --- Status single-select --- #}
|
||
<div style="min-width: 120px;">
|
||
<label class="form-label small mb-1">Status</label>
|
||
<select name="adj_status" class="form-select form-select-sm">
|
||
<option value="" {% if not adj_filter_values.adj_status %}selected{% endif %}>All</option>
|
||
<option value="unpaid" {% if adj_filter_values.adj_status == 'unpaid' %}selected{% endif %}>Unpaid</option>
|
||
<option value="paid" {% if adj_filter_values.adj_status == 'paid' %}selected{% endif %}>Paid</option>
|
||
</select>
|
||
</div>
|
||
|
||
{# --- Date range --- #}
|
||
<div style="min-width: 130px;">
|
||
<label class="form-label small mb-1">From</label>
|
||
<input type="date" name="adj_date_from" class="form-control form-control-sm" value="{{ adj_filter_values.adj_date_from }}">
|
||
</div>
|
||
<div style="min-width: 130px;">
|
||
<label class="form-label small mb-1">To</label>
|
||
<input type="date" name="adj_date_to" class="form-control form-control-sm" value="{{ adj_filter_values.adj_date_to }}">
|
||
</div>
|
||
|
||
{# --- Sort (hidden state — click on <th> sets these via JS in Task 9) --- #}
|
||
<input type="hidden" name="sort" value="{{ adj_filter_values.sort }}">
|
||
<input type="hidden" name="order" value="{{ adj_filter_values.order }}">
|
||
|
||
{# --- Apply / Clear --- #}
|
||
<div class="d-flex gap-2">
|
||
<button type="submit" class="btn btn-sm btn-accent">
|
||
<i class="fas fa-filter me-1"></i>Apply
|
||
</button>
|
||
<a href="?status=adjustments" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
{# --- Stats row (scoped to filtered set) --- #}
|
||
<div class="d-flex flex-wrap gap-3 mb-3 px-2 small text-muted">
|
||
<span><strong>{{ adj_total_count }}</strong> adjustment{{ adj_total_count|pluralize }}</span>
|
||
<span>·</span>
|
||
<span><strong>{{ adj_unpaid_count }}</strong> unpaid (R {{ adj_unpaid_sum|money }})</span>
|
||
<span>·</span>
|
||
<span>+R {{ adj_additive_sum|money }} net additive</span>
|
||
<span>·</span>
|
||
<span>−R {{ adj_deductive_sum|money }} net deductive</span>
|
||
</div>
|
||
|
||
{# --- Flat table --- #}
|
||
{% if adj_page.object_list %}
|
||
<div class="card">
|
||
<div class="card-body p-0">
|
||
<div class="table-responsive">
|
||
<table class="table table-sm mb-0 align-middle">
|
||
<thead>
|
||
<tr>
|
||
<th style="width: 40px;">
|
||
<input type="checkbox" class="form-check-input" id="adjSelectAll" title="Select all unpaid on this page">
|
||
</th>
|
||
<th class="sortable" data-sort="date">Date <i class="fas fa-sort sort-arrow"></i></th>
|
||
<th class="sortable" data-sort="worker">Worker <i class="fas fa-sort sort-arrow"></i></th>
|
||
<th>Type</th>
|
||
<th class="text-end sortable" data-sort="amount">Amount <i class="fas fa-sort sort-arrow"></i></th>
|
||
<th>Project</th>
|
||
<th>Team</th>
|
||
<th>Description</th>
|
||
<th class="sortable" data-sort="status">Status <i class="fas fa-sort sort-arrow"></i></th>
|
||
<th class="text-end">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for adj in adj_page.object_list %}
|
||
{% include 'core/_adjustment_row.html' with adj=adj additive_types=adjustment_types %}
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# --- Pagination --- #}
|
||
{% if adj_page.has_other_pages %}
|
||
<nav class="mt-3 d-flex justify-content-center">
|
||
<ul class="pagination pagination-sm">
|
||
{% if adj_page.has_previous %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="?{{ request.GET.urlencode }}&page={{ adj_page.previous_page_number }}">Previous</a>
|
||
</li>
|
||
{% endif %}
|
||
<li class="page-item active"><span class="page-link">{{ adj_page.number }} of {{ adj_page.paginator.num_pages }}</span></li>
|
||
{% if adj_page.has_next %}
|
||
<li class="page-item">
|
||
<a class="page-link" href="?{{ request.GET.urlencode }}&page={{ adj_page.next_page_number }}">Next</a>
|
||
</li>
|
||
{% endif %}
|
||
</ul>
|
||
</nav>
|
||
{% endif %}
|
||
|
||
{% else %}
|
||
{# Simple placeholder empty state — polish comes in Task 10 #}
|
||
<div class="adj-empty-state">
|
||
<div class="adj-empty-icon"><i class="fas fa-inbox"></i></div>
|
||
<p>No adjustments match these filters.</p>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# Need the additive-types list in the row partial for +/- sign rendering #}
|
||
{{ adjustment_types|json_script:"adjAdditiveTypes" }}
|
||
```
|
||
|
||
**Note:** the `additive_types` value passed into the row partial is really the list of ADDITIVE_TYPES names. Pass it explicitly so the row partial doesn't accidentally import the full `adjustment_types` list.
|
||
|
||
Refine: at the end of `views.py`'s adjustments branch, add a dedicated `'additive_types': list(ADDITIVE_TYPES)` context key and change the row include to `{% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}`.
|
||
|
||
**Step 4: Add the Choices.js init JS block**
|
||
|
||
Still in `payroll_dashboard.html`. Near the bottom of the file — find the existing big `<script>` block that inits the payroll dashboard JS. Inside that block (so it runs on DOMContentLoaded with everything else), add:
|
||
|
||
```javascript
|
||
// === ADJUSTMENTS TAB — Choices.js multi-selects ===
|
||
// Three multi-select filters (Type / Workers / Teams). Lazy-init only when
|
||
// the user is actually on the Adjustments tab (Choices.js is expensive).
|
||
if (document.getElementById('adjustmentsFilters')) {
|
||
document.querySelectorAll('#adjFilterForm .adj-multi').forEach(function(sel) {
|
||
new Choices(sel, {
|
||
removeItemButton: true,
|
||
shouldSort: false,
|
||
placeholder: true,
|
||
placeholderValue: sel.getAttribute('data-placeholder') || '',
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
**Step 5: Verify in the browser**
|
||
|
||
Start dev server (if not already). Navigate to `/payroll/?status=adjustments`. Expect:
|
||
1. Nav tab "Adjustments" active
|
||
2. Sticky filter bar across the top
|
||
3. 3 stat numbers in the small-text row below
|
||
4. Table with all existing PayrollAdjustment rows — each with a coloured badge per type
|
||
5. Row actions on unpaid rows: Preview + Edit + ×; on paid rows: View Payslip only
|
||
6. Clicking the Type/Workers/Teams filter + Apply submits a form GET and reloads with the filter active
|
||
|
||
**Step 6: Run the suite**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 1
|
||
```
|
||
|
||
Expected: 58 tests still pass (no new test here — Task 4 is UI-side, existing tests cover the backend).
|
||
|
||
**Step 7: Commit**
|
||
|
||
```bash
|
||
git add core/templates/core/payroll_dashboard.html core/templates/core/_adjustment_row.html core/views.py
|
||
git commit -m "feat(adjustments): Adjustments tab — nav + filter bar + flat table + row actions
|
||
|
||
Reuses existing modals (#editAdjustmentModal, #previewPayslipModal, delete
|
||
confirm flow) — zero new JS for row actions. Choices.js lazy-inits only
|
||
when the tab is active. Stats row scoped to filter set. Subquery pattern
|
||
on team filter (CLAUDE.md). Group-by + bulk-delete + cross-filter come
|
||
in Tasks 5/6/7."
|
||
```
|
||
|
||
---
|
||
|
||
## 🚧 CHECKPOINT 1 — demo-able midpoint
|
||
|
||
**Hard-pause here.** Do NOT proceed to Task 5 until Konrad approves.
|
||
|
||
The browser-testable surface at this point:
|
||
|
||
1. `/payroll/?status=adjustments` loads, tab is active
|
||
2. All `PayrollAdjustment` rows show in the table with the right coloured badges
|
||
3. Multi-select filters (Type / Workers / Teams) + Status + Date range work via Apply
|
||
4. Stats row updates when filters are applied
|
||
5. Row actions: Paid row → View Payslip link; Unpaid row → Preview / Edit / × buttons
|
||
6. Pagination works past 50 rows
|
||
7. Existing `/payroll/`, `/payroll/?status=paid`, `/payroll/?status=loans` tabs keep working
|
||
|
||
**Ask Konrad:**
|
||
- Is the palette legible? (he specified these colours in brainstorming; worth a gut-check on real data)
|
||
- Does clicking Edit/Preview/× on an unpaid row open the expected modal behaviour?
|
||
- Are the filters covering his daily use case ("see unpaid bonuses this month", "all of Alice's overtime", etc.)?
|
||
|
||
Red light → fix before proceeding. Green light → Task 5.
|
||
|
||
---
|
||
|
||
## Task 5: Group-by helper + toggle + template rendering
|
||
|
||
**Goal:** Add the `_group_adjustments` helper + the Flat / By Type / By Worker toggle UI + the grouped-table rendering path. Rows still use `_adjustment_row.html` (no markup duplication).
|
||
|
||
**Files:**
|
||
- Modify: `core/views.py` — add helper, wire into adjustments branch
|
||
- Modify: `core/templates/core/payroll_dashboard.html` — add toggle pills + grouped rendering
|
||
- Test: `core/tests.py` — extend `AdjustmentsTabTests`
|
||
|
||
**Step 1: Write the failing tests (2 tests)**
|
||
|
||
Append to `AdjustmentsTabTests`:
|
||
|
||
```python
|
||
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'))
|
||
|
||
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'))
|
||
```
|
||
|
||
**Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests.test_group_by_type core.tests.AdjustmentsTabTests.test_group_by_worker -v 2
|
||
```
|
||
|
||
Expected: `KeyError: 'adj_groups'` in context.
|
||
|
||
**Step 3: Implement the helper**
|
||
|
||
Add `_group_adjustments` to `core/views.py` near other helpers (e.g. just before `def payroll_dashboard`):
|
||
|
||
```python
|
||
# =============================================================================
|
||
# === ADJUSTMENT GROUPING HELPER ===
|
||
# Used by the Adjustments tab's By Type / By Worker render path.
|
||
# =============================================================================
|
||
|
||
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
|
||
```
|
||
|
||
Then inside the adjustments branch, right after `adj_page = paginator.get_page(...)`, wire it up:
|
||
|
||
```python
|
||
# --- Group-by rendering (passed to template as adj_groups; None = flat view) ---
|
||
group_by = request.GET.get('group_by', '').strip()
|
||
adj_groups = None
|
||
if group_by in ('type', 'worker'):
|
||
adj_groups = _group_adjustments(adj_page.object_list, group_by)
|
||
```
|
||
|
||
And add `'adj_groups': adj_groups` and `'group_by': group_by` keys to the `context.update({...})` at the end of the branch. Update `adj_filter_values` to include `'group_by': group_by`.
|
||
|
||
**Step 4: Add toggle + grouped render in template**
|
||
|
||
Edit `payroll_dashboard.html`. Inside the Adjustments tab block (after the filter bar, before the stats row), add the toggle pills:
|
||
|
||
```django
|
||
{# --- Group-by toggle --- #}
|
||
<div class="d-flex align-items-center gap-2 mb-3 adj-groupby-toggle">
|
||
<span class="text-muted small">Show as:</span>
|
||
<div class="btn-group" role="group">
|
||
<a href="?{% url_replace request 'group_by' '' %}"
|
||
class="btn {% if not adj_filter_values.group_by %}btn-accent{% else %}btn-outline-secondary{% endif %}">Flat</a>
|
||
<a href="?{% url_replace request 'group_by' 'type' %}"
|
||
class="btn {% if adj_filter_values.group_by == 'type' %}btn-accent{% else %}btn-outline-secondary{% endif %}">By Type</a>
|
||
<a href="?{% url_replace request 'group_by' 'worker' %}"
|
||
class="btn {% if adj_filter_values.group_by == 'worker' %}btn-accent{% else %}btn-outline-secondary{% endif %}">By Worker</a>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
The `url_replace` template tag doesn't exist yet — add it to `format_tags.py`:
|
||
|
||
```python
|
||
# === url_replace tag ===
|
||
# Returns the current request's querystring with one key replaced.
|
||
# Used by filter-toggle links like "?status=adjustments&group_by=type".
|
||
@register.simple_tag
|
||
def url_replace(request, key, value):
|
||
qd = request.GET.copy()
|
||
if value == '' or value is None:
|
||
qd.pop(key, None)
|
||
else:
|
||
qd[key] = value
|
||
return qd.urlencode()
|
||
```
|
||
|
||
Then replace the `<tbody>` flat loop with a conditional on `adj_groups`:
|
||
|
||
```django
|
||
{% if adj_groups %}
|
||
{# --- Grouped view: one collapsible section per bucket --- #}
|
||
{% for group in adj_groups %}
|
||
<tr class="adj-group-header" data-bs-toggle="collapse" data-bs-target="#adj-group-{{ group.slug }}">
|
||
<td colspan="10">
|
||
<i class="fas fa-chevron-down"></i>
|
||
<span class="adj-group-label">{{ group.label }}</span>
|
||
<span class="adj-group-meta">
|
||
{{ group.count }} row{{ group.count|pluralize }} ·
|
||
{% if group.net_sum >= 0 %}+{% else %}−{% endif %}R {{ group.net_sum|money_abs }} net
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
<tbody id="adj-group-{{ group.slug }}" class="collapse show">
|
||
{% for adj in group.rows %}
|
||
{% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
|
||
{% endfor %}
|
||
</tbody>
|
||
{% endfor %}
|
||
{% else %}
|
||
{# --- Flat view (Task 4) --- #}
|
||
<tbody>
|
||
{% for adj in adj_page.object_list %}
|
||
{% include 'core/_adjustment_row.html' with adj=adj additive_types=additive_types %}
|
||
{% endfor %}
|
||
</tbody>
|
||
{% endif %}
|
||
```
|
||
|
||
Note `|money_abs` — a new filter needed because `{{ -500|money }}` would render `R -500` but we want `−R 500.00`. Add to `format_tags.py`:
|
||
|
||
```python
|
||
# === money_abs filter ===
|
||
# Formats the absolute value of a Decimal in ZAR style (space-separated).
|
||
# Callers handle the sign explicitly. Pairs with the + / − prefix logic
|
||
# in the Adjustments group-header template.
|
||
@register.filter
|
||
def money_abs(value):
|
||
if value is None:
|
||
return ''
|
||
return money(abs(value))
|
||
```
|
||
|
||
**Step 5: Run the tests**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2
|
||
```
|
||
|
||
Expected: 10 tests pass.
|
||
|
||
**Step 6: Browser smoke test**
|
||
|
||
Navigate `/payroll/?status=adjustments&group_by=type` → headers "Bonus" / "Deduction" with row counts + net sums; rows under each. Click a header → section collapses. Click again → expands. Same for `?group_by=worker`. Back to Flat via the toggle pill.
|
||
|
||
**Step 7: Commit**
|
||
|
||
```bash
|
||
git add core/views.py core/templates/core/payroll_dashboard.html core/templatetags/format_tags.py core/tests.py
|
||
git commit -m "feat(adjustments): group-by type / worker + collapsible headers
|
||
|
||
Adds _group_adjustments helper, |money_abs + url_replace template
|
||
helpers. Groups ordered by |net_sum| desc so biggest impact is at
|
||
the top. Shares the _adjustment_row.html partial — no row markup
|
||
duplication."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Bulk-delete endpoint + floating action bar
|
||
|
||
**Goal:** Let admins select multiple unpaid rows and delete them in one shot via a new endpoint. Paid rows are silently skipped at the DB level (defensive).
|
||
|
||
**Files:**
|
||
- Modify: `core/views.py` — new `bulk_delete_adjustments` view
|
||
- Modify: `core/urls.py` — add URL pattern
|
||
- Modify: `core/templates/core/payroll_dashboard.html` — add floating action bar + JS
|
||
- Test: `core/tests.py` — extend `AdjustmentsTabTests`
|
||
|
||
**Step 1: Write the failing tests (2 tests)**
|
||
|
||
```python
|
||
def test_bulk_delete_only_affects_unpaid(self):
|
||
"""POST /payroll/adjustments/bulk-delete/ with mixed paid+unpaid IDs
|
||
deletes ONLY unpaid rows. Paid rows are untouched (payroll history
|
||
is immutable)."""
|
||
self._login_admin()
|
||
# Pay a1
|
||
pr = PayrollRecord.objects.create(
|
||
worker=self.w1, date=datetime.date(2026, 4, 15),
|
||
days_worked=20, total_amount=Decimal('4000'),
|
||
)
|
||
self.a1.payroll_record = pr
|
||
self.a1.save()
|
||
# Request delete on all three (a1 paid, a2 + a3 unpaid)
|
||
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):
|
||
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())
|
||
```
|
||
|
||
**Step 2: Run to verify failure**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests.test_bulk_delete_only_affects_unpaid core.tests.AdjustmentsTabTests.test_bulk_delete_requires_admin -v 2
|
||
```
|
||
|
||
Expected: `NoReverseMatch: 'bulk_delete_adjustments'`.
|
||
|
||
**Step 3: Implement the endpoint**
|
||
|
||
Add to `core/views.py`, near `delete_adjustment`:
|
||
|
||
```python
|
||
# =============================================================================
|
||
# === BULK DELETE ADJUSTMENTS (Adjustments tab) ===
|
||
# POST /payroll/adjustments/bulk-delete/ with adjustment_ids[] body.
|
||
# Only unpaid adjustments are deleted; paid rows survive (payroll
|
||
# history is immutable and the existing edit_adjustment view also
|
||
# blocks edits on paid rows).
|
||
# =============================================================================
|
||
|
||
@login_required
|
||
@require_POST
|
||
def bulk_delete_adjustments(request):
|
||
"""Delete multiple unpaid PayrollAdjustment rows in one DB call.
|
||
|
||
Body (form-encoded): `adjustment_ids` — one entry per ID.
|
||
Returns JSON: `{"deleted": N, "requested": M}`.
|
||
"""
|
||
if not is_admin(request.user):
|
||
return JsonResponse({'error': 'Admin access required'}, status=403)
|
||
|
||
ids = request.POST.getlist('adjustment_ids')
|
||
# Keep only unpaid — paid rows are silently skipped.
|
||
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),
|
||
})
|
||
```
|
||
|
||
Make sure the imports at the top of `views.py` include `from django.views.decorators.http import require_POST` (or `require_http_methods(['POST'])`). Grep to check.
|
||
|
||
**Step 4: Add URL**
|
||
|
||
In `core/urls.py`, in the payroll section around line 56 (just after `delete_adjustment`):
|
||
|
||
```python
|
||
# Bulk-delete multiple unpaid adjustments at once (Adjustments tab)
|
||
path('payroll/adjustments/bulk-delete/', views.bulk_delete_adjustments, name='bulk_delete_adjustments'),
|
||
```
|
||
|
||
**Step 5: Run the tests**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests.AdjustmentsTabTests -v 2
|
||
```
|
||
|
||
Expected: 12 tests pass.
|
||
|
||
**Step 6: Add floating action bar + selection JS**
|
||
|
||
In `payroll_dashboard.html`, immediately BEFORE the closing `</div>` of the adjustments tab block (or anywhere inside `{% elif active_tab == 'adjustments' %}` after the table), add:
|
||
|
||
```html
|
||
{# --- Floating bulk action bar (shown when >=1 unpaid row is selected) --- #}
|
||
<div class="adj-bulk-bar" id="adjBulkBar" hidden>
|
||
<span><strong id="adjBulkCount">0</strong> selected</span>
|
||
<button type="button" class="btn btn-sm btn-outline-danger" id="adjBulkDeleteBtn">
|
||
<i class="fas fa-trash me-1"></i>Delete
|
||
</button>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" id="adjBulkClearBtn">Clear</button>
|
||
</div>
|
||
```
|
||
|
||
JS (inside the same `<script>` DOMContentLoaded block as the Choices.js init):
|
||
|
||
```javascript
|
||
// === ADJUSTMENTS TAB — Bulk select + delete ===
|
||
if (document.getElementById('adjustmentsFilters')) {
|
||
var bulkBar = document.getElementById('adjBulkBar');
|
||
var bulkCount = document.getElementById('adjBulkCount');
|
||
var bulkDeleteBtn = document.getElementById('adjBulkDeleteBtn');
|
||
var bulkClearBtn = document.getElementById('adjBulkClearBtn');
|
||
var selectAll = document.getElementById('adjSelectAll');
|
||
|
||
function getCheckedIds() {
|
||
return Array.from(document.querySelectorAll('.adj-bulk-checkbox:checked'))
|
||
.map(function(cb) { return cb.value; });
|
||
}
|
||
function refreshBulkBar() {
|
||
var ids = getCheckedIds();
|
||
bulkCount.textContent = ids.length;
|
||
bulkBar.hidden = ids.length === 0;
|
||
}
|
||
|
||
// Per-row checkbox change
|
||
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
|
||
cb.addEventListener('change', refreshBulkBar);
|
||
});
|
||
|
||
// Select-all (only unpaid rows are interactive)
|
||
if (selectAll) {
|
||
selectAll.addEventListener('change', function() {
|
||
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
|
||
cb.checked = selectAll.checked;
|
||
});
|
||
refreshBulkBar();
|
||
});
|
||
}
|
||
|
||
// Clear selection
|
||
bulkClearBtn.addEventListener('click', function() {
|
||
document.querySelectorAll('.adj-bulk-checkbox').forEach(function(cb) {
|
||
cb.checked = false;
|
||
});
|
||
if (selectAll) selectAll.checked = false;
|
||
refreshBulkBar();
|
||
});
|
||
|
||
// Delete selected — confirm + POST
|
||
bulkDeleteBtn.addEventListener('click', function() {
|
||
var ids = getCheckedIds();
|
||
if (ids.length === 0) return;
|
||
if (!confirm('Delete ' + ids.length + ' adjustment(s)? This cannot be undone.')) return;
|
||
|
||
var form = new FormData();
|
||
ids.forEach(function(id) { form.append('adjustment_ids', id); });
|
||
form.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
|
||
|
||
fetch('{% url "bulk_delete_adjustments" %}', {
|
||
method: 'POST', body: form, credentials: 'same-origin',
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
// Simple refresh — server-rendered view reflects the deletion
|
||
window.location.reload();
|
||
})
|
||
.catch(function(err) {
|
||
alert('Bulk delete failed: ' + err);
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
**Step 7: Commit**
|
||
|
||
```bash
|
||
git add core/views.py core/urls.py core/templates/core/payroll_dashboard.html core/tests.py
|
||
git commit -m "feat(adjustments): bulk-delete unpaid rows + floating action bar
|
||
|
||
New POST endpoint silently skips paid rows at the DB level (defensive
|
||
against UI bugs). Floating bar slides up from the bottom when >=1 row
|
||
is selected — Clear / Delete / count. Confirm dialog before POST."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Cross-filter Team → Workers + auto-removal toast
|
||
|
||
**Goal:** When Team(s) are selected in the filter bar, the Workers dropdown should only show workers from those teams. If a previously-selected worker is no longer in any selected team, auto-remove them and show a toast.
|
||
|
||
Exactly the pattern from Feature 1 (Inline Filters) — lift the JS approach.
|
||
|
||
**Files:**
|
||
- Modify: `core/views.py` — add `team_worker_pairs_json` context key
|
||
- Modify: `core/templates/core/payroll_dashboard.html` — add `{% json_script %}` + JS
|
||
- Test: `core/tests.py` — 1 test that pairs are exposed
|
||
|
||
**Step 1: Add the failing test**
|
||
|
||
```python
|
||
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 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: team Alpha has w1 and w2
|
||
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)
|
||
```
|
||
|
||
**Step 2: Run to verify failure**
|
||
|
||
Expected: `KeyError: 'team_worker_pairs_json'`.
|
||
|
||
**Step 3: Implement**
|
||
|
||
In `core/views.py` adjustments branch, inside the `context.update({...})` block, add:
|
||
|
||
```python
|
||
# --- Cross-filter source: (team_id, worker_id) pairs ---
|
||
# Consumed by the frontend JS to filter the Workers dropdown when
|
||
# Team(s) are picked. Raw list — |json_script in the template
|
||
# handles safe serialisation (NOT json.dumps — see 2026-04-23
|
||
# inline-filters regression).
|
||
'team_worker_pairs_json': list(
|
||
Team.workers.through.objects.values('team_id', 'worker_id').distinct()
|
||
),
|
||
```
|
||
|
||
**Step 4: Template + JS**
|
||
|
||
In `payroll_dashboard.html`, inside the adjustments tab block (near the bottom), add:
|
||
|
||
```django
|
||
{# --- Cross-filter data for the Workers ↔ Teams JS --- #}
|
||
{{ team_worker_pairs_json|json_script:"teamWorkerPairsAdj" }}
|
||
```
|
||
|
||
Inside the DOMContentLoaded script for adjustments, add:
|
||
|
||
```javascript
|
||
// === ADJUSTMENTS TAB — Team → Workers cross-filter ===
|
||
if (document.getElementById('teamWorkerPairsAdj')) {
|
||
var pairsAdj = JSON.parse(document.getElementById('teamWorkerPairsAdj').textContent);
|
||
var workerByTeamAdj = {};
|
||
pairsAdj.forEach(function(p) {
|
||
if (!workerByTeamAdj[p.team_id]) workerByTeamAdj[p.team_id] = new Set();
|
||
workerByTeamAdj[p.team_id].add(p.worker_id);
|
||
});
|
||
|
||
var teamSel = document.getElementById('adjTeamSelect');
|
||
var workerSel = document.getElementById('adjWorkerSelect');
|
||
|
||
function selectedTeamIds() {
|
||
return Array.from(teamSel.selectedOptions).map(function(o) {
|
||
return parseInt(o.value, 10);
|
||
});
|
||
}
|
||
function applyWorkerCrossFilter() {
|
||
var teamIds = selectedTeamIds();
|
||
if (teamIds.length === 0) {
|
||
// No constraint — enable everything
|
||
Array.from(workerSel.options).forEach(function(o) { o.disabled = false; });
|
||
return;
|
||
}
|
||
var validIds = new Set();
|
||
teamIds.forEach(function(tid) {
|
||
if (workerByTeamAdj[tid]) {
|
||
workerByTeamAdj[tid].forEach(function(wid) { validIds.add(wid); });
|
||
}
|
||
});
|
||
Array.from(workerSel.options).forEach(function(o) {
|
||
var wid = parseInt(o.value, 10);
|
||
// Keep already-selected workers enabled (so user can see + deselect)
|
||
o.disabled = !validIds.has(wid) && !o.selected;
|
||
});
|
||
}
|
||
|
||
// Run once on load; re-run when Team selection changes
|
||
applyWorkerCrossFilter();
|
||
teamSel.addEventListener('change', applyWorkerCrossFilter);
|
||
}
|
||
```
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add core/views.py core/templates/core/payroll_dashboard.html core/tests.py
|
||
git commit -m "feat(adjustments): Team → Workers cross-filter via JSON pair map
|
||
|
||
Mirrors Feature 1's inline-filters cross-filter pattern. Workers
|
||
dropdown options disable when Team(s) are selected and the worker
|
||
isn't in any of them. Already-selected workers stay enabled so
|
||
the user can still deselect them."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Date picker single vs. range + preset buttons
|
||
|
||
**Goal:** The current filter bar has From / To date inputs (always both). Konrad wants a single date by default with a `…` button that expands to range mode, plus preset quick-buttons (Today / This week / This month / Clear).
|
||
|
||
**Files:**
|
||
- Modify: `core/templates/core/payroll_dashboard.html` — rework the date inputs
|
||
|
||
**Step 1: Replace the two date inputs**
|
||
|
||
In the filter bar, replace the two `<div>`s for "From" and "To" with:
|
||
|
||
```html
|
||
{# --- Date picker (single or range) --- #}
|
||
<div id="adjDateWrap" style="min-width: 140px;">
|
||
<label class="form-label small mb-1 d-flex align-items-center gap-2">
|
||
<span id="adjDateLabel">Date</span>
|
||
<button type="button" id="adjDateRangeToggle" class="btn btn-link btn-sm p-0 ms-auto"
|
||
title="Toggle range mode">
|
||
<i class="fas fa-ellipsis-h"></i>
|
||
</button>
|
||
</label>
|
||
<input type="date" name="adj_date_from" id="adjDateFrom"
|
||
class="form-control form-control-sm" value="{{ adj_filter_values.adj_date_from }}"
|
||
placeholder="Date">
|
||
<input type="date" name="adj_date_to" id="adjDateTo"
|
||
class="form-control form-control-sm mt-1" value="{{ adj_filter_values.adj_date_to }}"
|
||
hidden>
|
||
<div class="d-flex gap-1 mt-1" id="adjDatePresets">
|
||
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="today">Today</button>
|
||
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="week">This week</button>
|
||
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="month">This month</button>
|
||
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="clear">Clear</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**Step 2: Add the JS — single vs. range toggle + preset handlers**
|
||
|
||
Inside the same script block:
|
||
|
||
```javascript
|
||
// === ADJUSTMENTS TAB — Date single/range toggle + presets ===
|
||
if (document.getElementById('adjDateWrap')) {
|
||
var dateFrom = document.getElementById('adjDateFrom');
|
||
var dateTo = document.getElementById('adjDateTo');
|
||
var toggleBtn = document.getElementById('adjDateRangeToggle');
|
||
var dateLabel = document.getElementById('adjDateLabel');
|
||
|
||
// Single mode hides `to`; range mode shows it. Submit logic:
|
||
// - single: from == to (backend treats as exact match)
|
||
// - range : from <= date <= to
|
||
function applyMode(rangeMode) {
|
||
dateTo.hidden = !rangeMode;
|
||
dateLabel.textContent = rangeMode ? 'Date range' : 'Date';
|
||
// In single mode keep dateTo mirrored to dateFrom on form submit
|
||
if (!rangeMode) dateTo.value = dateFrom.value;
|
||
}
|
||
// Start in range mode if both fields pre-populated AND differ
|
||
var initialRange = dateFrom.value && dateTo.value && dateFrom.value !== dateTo.value;
|
||
applyMode(initialRange);
|
||
|
||
toggleBtn.addEventListener('click', function() {
|
||
applyMode(dateTo.hidden); // toggling hidden
|
||
});
|
||
dateFrom.addEventListener('change', function() {
|
||
if (dateTo.hidden) dateTo.value = dateFrom.value;
|
||
});
|
||
|
||
// Preset quick-buttons
|
||
document.querySelectorAll('#adjDatePresets [data-preset]').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
var preset = btn.getAttribute('data-preset');
|
||
var today = new Date();
|
||
var iso = function(d) { return d.toISOString().slice(0, 10); };
|
||
if (preset === 'today') {
|
||
dateFrom.value = iso(today);
|
||
dateTo.value = iso(today);
|
||
applyMode(false);
|
||
} else if (preset === 'week') {
|
||
var weekStart = new Date(today);
|
||
weekStart.setDate(today.getDate() - ((today.getDay() + 6) % 7)); // Monday
|
||
var weekEnd = new Date(weekStart);
|
||
weekEnd.setDate(weekStart.getDate() + 6);
|
||
dateFrom.value = iso(weekStart);
|
||
dateTo.value = iso(weekEnd);
|
||
applyMode(true);
|
||
} else if (preset === 'month') {
|
||
dateFrom.value = iso(new Date(today.getFullYear(), today.getMonth(), 1));
|
||
dateTo.value = iso(new Date(today.getFullYear(), today.getMonth() + 1, 0));
|
||
applyMode(true);
|
||
} else if (preset === 'clear') {
|
||
dateFrom.value = '';
|
||
dateTo.value = '';
|
||
applyMode(false);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
**Step 3: Browser smoke test**
|
||
|
||
Navigate the tab. Confirm:
|
||
- Single mode shows just the first date input + `…` button to toggle
|
||
- Click `…` → second date input appears; label changes to "Date range"
|
||
- Presets work: Today sets both same; This week / This month sets a range
|
||
- Submit (Apply) → URL includes `adj_date_from` and `adj_date_to` matching the inputs
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add core/templates/core/payroll_dashboard.html
|
||
git commit -m "feat(adjustments): date picker single/range toggle + preset quick-buttons
|
||
|
||
Single by default (one input); `…` button expands to range. Today /
|
||
This week / This month / Clear presets. Submit logic keeps from==to
|
||
for exact-match days."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Sort header clicks + URL state
|
||
|
||
**Goal:** Clicking a `<th class="sortable">` toggles sort column and direction via URL. Sort arrows reflect current state.
|
||
|
||
**Files:**
|
||
- Modify: `core/templates/core/payroll_dashboard.html` — JS + CSS class toggles
|
||
|
||
**Step 1: Update sort arrows to reflect current state**
|
||
|
||
In the `<thead>` block (Task 4 step 3), replace the `<i class="fas fa-sort sort-arrow"></i>` with conditional rendering that picks up `adj_filter_values.sort` and `.order`:
|
||
|
||
```django
|
||
<th class="sortable{% if adj_filter_values.sort == 'date' %} sorted{% endif %}" data-sort="date">
|
||
Date
|
||
<i class="fas fa-sort{% if adj_filter_values.sort == 'date' %}-{{ adj_filter_values.order }}{% endif %} sort-arrow"></i>
|
||
</th>
|
||
```
|
||
|
||
(Font Awesome: `fa-sort-asc` / `fa-sort-desc` are the directional variants.)
|
||
|
||
Do the same for Worker, Amount, Status.
|
||
|
||
**Step 2: Add the JS handler**
|
||
|
||
```javascript
|
||
// === ADJUSTMENTS TAB — Sort header clicks ===
|
||
if (document.getElementById('adjustmentsFilters')) {
|
||
document.querySelectorAll('th.sortable').forEach(function(th) {
|
||
th.addEventListener('click', function() {
|
||
var col = th.getAttribute('data-sort');
|
||
var currentSort = document.querySelector('input[name=sort]');
|
||
var currentOrder = document.querySelector('input[name=order]');
|
||
// Toggle direction if clicking the same column; else default desc
|
||
if (currentSort.value === col) {
|
||
currentOrder.value = currentOrder.value === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
currentSort.value = col;
|
||
currentOrder.value = 'desc';
|
||
}
|
||
// Submit the filter form so the sort persists in the URL
|
||
document.getElementById('adjFilterForm').submit();
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add core/templates/core/payroll_dashboard.html
|
||
git commit -m "feat(adjustments): sortable column headers — click to toggle direction
|
||
|
||
4 sortable columns (Date / Worker / Amount / Status). Click cycles
|
||
desc → asc → desc. URL state via hidden sort/order inputs so the
|
||
sort is bookmarkable and survives pagination."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Empty state + sticky filter bar polish + small fit-and-finish
|
||
|
||
**Goal:** Make the "no rows" case genuinely useful; polish any visual leftovers from earlier tasks.
|
||
|
||
**Files:**
|
||
- Modify: `core/templates/core/payroll_dashboard.html` — improve empty state
|
||
|
||
**Step 1: Replace the placeholder empty state with the full version**
|
||
|
||
Find the `<div class="adj-empty-state">` (from Task 4 step 3) and replace with:
|
||
|
||
```html
|
||
<div class="adj-empty-state card">
|
||
<div class="card-body text-center py-5">
|
||
<div class="adj-empty-icon"><i class="fas fa-inbox"></i></div>
|
||
<h6 class="mb-2">No adjustments match these filters.</h6>
|
||
<p class="text-muted small mb-3">Try clearing filters or adding a new adjustment.</p>
|
||
<div class="d-flex gap-2 justify-content-center">
|
||
<a href="?status=adjustments" class="btn btn-sm btn-outline-secondary">
|
||
<i class="fas fa-times me-1"></i>Clear filters
|
||
</a>
|
||
<button type="button" class="btn btn-sm btn-accent"
|
||
data-bs-toggle="modal" data-bs-target="#addAdjustmentModal">
|
||
<i class="fas fa-plus me-1"></i>Add adjustment
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**Step 2: Verify sticky behaviour**
|
||
|
||
Scroll the table long enough that the stats row + filter bar would normally scroll out of view. The filter bar should stay pinned to the top of the viewport. If not, adjust `.adjustments-filter-bar { position: sticky; top: 0; ... }` in custom.css — the sticky context ancestor may need a `position: relative` and no `overflow: hidden`.
|
||
|
||
**Step 3: Run the full suite**
|
||
|
||
```bash
|
||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 1
|
||
```
|
||
|
||
Expected: 60 tests pass (50 pre-existing + 10 Adjustments).
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add core/templates/core/payroll_dashboard.html static/css/custom.css
|
||
git commit -m "style(adjustments): empty-state card with recovery CTAs + sticky bar polish"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Manual QA + "Shipped" block on design doc
|
||
|
||
**Goal:** Run the QA matrix, record deviations on the design doc, update CLAUDE.md with one line about the new tab's URL.
|
||
|
||
**Files:**
|
||
- Modify: `docs/plans/2026-04-23-adjustments-tab-design.md` — append "Shipped" section
|
||
- Modify: `CLAUDE.md` — one line in URL Routes table
|
||
|
||
**Step 1: Manual QA matrix**
|
||
|
||
Run each flow in the browser with real data and note any bugs:
|
||
|
||
1. `/payroll/?status=adjustments` loads, "Adjustments" tab is active
|
||
2. All existing `PayrollAdjustment` rows show with correct coloured badges
|
||
3. Type filter: pick Bonus + Overtime → rows filter to union
|
||
4. Worker filter: pick Alice → rows filter to Alice's only
|
||
5. Team filter: pick a team → worker dropdown narrows to that team's workers (cross-filter)
|
||
6. Status = Unpaid → only unpaid rows
|
||
7. Date picker: single mode filters to exact day; `…` toggles range; presets fill correctly
|
||
8. Sort: click Date → desc by date; click again → asc; click Amount → sort by amount
|
||
9. Group-by: By Type → collapsible headers with counts and net sums; By Worker → same
|
||
10. Bulk select: tick 3 rows → floating bar shows "3 selected"; Delete → confirms → deletes → reloads; Clear unticks all
|
||
11. Paid row → View Payslip button only; bulk-select disabled
|
||
12. Unpaid row → Preview (opens `#previewPayslipModal`), Edit (opens `#editAdjustmentModal` pre-filled), × (opens existing delete confirm)
|
||
13. Empty state card with recovery CTAs when filters return 0 rows
|
||
14. Pagination: if 50+ rows, page 2 link works and keeps all filters in URL
|
||
15. Non-admin supervisor → 302/403 at `/payroll/?status=adjustments`
|
||
|
||
**Step 2: Append the Shipped block to the design doc**
|
||
|
||
At the bottom of `docs/plans/2026-04-23-adjustments-tab-design.md`:
|
||
|
||
```markdown
|
||
---
|
||
|
||
## 19. Shipped — <DATE-YOU-COMPLETE>
|
||
|
||
Implementation complete; all 11 QA flows pass. Commits on `ai-dev`:
|
||
|
||
| # | Commit | Scope |
|
||
|---|--------|-------|
|
||
| 1 | `<sha>` | `type_slug` filter + tests |
|
||
| 2 | `<sha>` | CSS badge palette + foundational styles |
|
||
| 3 | `<sha>` | Backend filter branch + stats |
|
||
| 4 | `<sha>` | Tab + filter bar + flat table + row actions |
|
||
| 5 | `<sha>` | Group-by helper + toggle |
|
||
| 6 | `<sha>` | Bulk-delete endpoint + floating bar |
|
||
| 7 | `<sha>` | Team → Workers cross-filter |
|
||
| 8 | `<sha>` | Date single/range + presets |
|
||
| 9 | `<sha>` | Sort header clicks + URL state |
|
||
| 10 | `<sha>` | Empty state + polish |
|
||
| 11 | `<sha>` | Docs + QA |
|
||
|
||
Deviations from the original design: <list any, or "none">.
|
||
|
||
Tests: 50 → <N> passing (+<delta> new).
|
||
```
|
||
|
||
**Step 3: Add one line to CLAUDE.md's URL Routes table**
|
||
|
||
Find the URL Routes table in `CLAUDE.md`. Add:
|
||
|
||
```markdown
|
||
| `/payroll/?status=adjustments` | `payroll_dashboard` | Admin: browse ALL payroll adjustments (filter by type, worker, team, status, date; group-by; bulk-delete unpaid) |
|
||
| `/payroll/adjustments/bulk-delete/` | `bulk_delete_adjustments` | Admin: POST-only; delete multiple unpaid adjustments in one DB call |
|
||
```
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add docs/plans/2026-04-23-adjustments-tab-design.md CLAUDE.md
|
||
git commit -m "docs(adjustments): Shipped block on design doc + CLAUDE.md URL route
|
||
|
||
Captures the 11-task implementation, deviations, and test delta.
|
||
CLAUDE.md URL Routes table updated so future sessions surface the
|
||
new ?status=adjustments branch + bulk-delete endpoint."
|
||
```
|
||
|
||
---
|
||
|
||
## Final checklist before push
|
||
|
||
After Task 11:
|
||
|
||
- [ ] `python manage.py test core.tests` → all pass (expect ~60+)
|
||
- [ ] `python manage.py check` → clean (pre-existing `staticfiles.W004` OK)
|
||
- [ ] `grep -rn "TODO\|FIXME" core/views.py core/templates/core/payroll_dashboard.html core/templates/core/_adjustment_row.html static/css/custom.css` → zero new markers
|
||
- [ ] Browser sweep (QA matrix above)
|
||
- [ ] Light-theme toggle check: filter bar, badges, group headers, floating bar all readable
|
||
- [ ] Cross-browser spot check: Chrome + at least one Firefox pane
|
||
|
||
Then dispatch `superpowers:requesting-code-review` for a full-feature review subagent pass. Address any issues. Then single batched push of **all** commits (17 inline-filters + 11 adjustments + 3 pre-existing design docs/plan + any fix-up commits) to `origin/ai-dev`.
|
||
|
||
## Out of scope reminders (per design §16)
|
||
|
||
- Bulk **Mark Paid** → use existing per-worker Pay Now flow via Preview modal
|
||
- CSV export → not in original ask; easy to add later
|
||
- Keyboard shortcuts beyond Esc → native Tab works
|
||
- Inline editing of amount/description → Edit modal already handles it
|
||
- Adjustment audit log → would require a new model
|
||
- Group by Project → Type and Worker cover the two most useful axes
|