38686-vm/docs/plans/2026-04-23-adjustments-tab-design.md
Konrad du Plessis 12edafa441 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>
2026-04-23 09:26:01 +02:00

26 KiB
Raw Blame History

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:

/* 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:

.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:

<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:
    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:

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

@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:

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

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:

@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.pytype_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.