Compare commits

...

11 Commits

Author SHA1 Message Date
Konrad du Plessis
f159a9f6f2 ux(labels): close remaining Adjustments-tab display gaps
Final whole-impl review on bce2619 caught two user-facing surfaces
still showing DB values instead of display labels:

1. By-Type group headers - _group_adjustments() used adj.type as
   both the visible label AND the CSS data-type attribute. Split
   into group.label (short display, for visible text) and
   group.type_key (raw DB value, for the [data-type="X"] CSS
   border-left selector).

2. Type filter popover checkboxes - adj_type_choices was a flat
   list of DB values, so checkbox labels read "New Loan" /
   "Advance Payment" / "Advance Repayment". Replaced with
   PayrollAdjustment.TYPE_CHOICES (already a (db_value,
   display_label) tuple list), and updated the template loop to
   unpack both - label in <span>, DB value in the input value=.

Both surfaces now show Loan / Advance / Advance Repaid while
preserving the canonical DB values for CSS selectors + filter
form submissions.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:29:25 +02:00
Konrad du Plessis
bce2619a71 fix(css): move display:flex from <tr> to <td> on adj-group-header
Root cause of Konrad's narrow-wrap screenshot: display:flex was set
on .adj-group-header (a <tr>), which causes the browser to remove
the row from table layout. A flex-mode <tr> ignores colspan and
shrinks to intrinsic content width — which is why a row with
colspan=10 ended up rendering at ~80-100px and wrapping the meta
text into a 5-char column.

Moved display:flex, align-items, gap, and padding onto the single
<td> child. The td is a normal block box and flexes correctly,
putting icon + label + meta in a horizontal row with the meta
pushed to the right via margin-left:auto (now working since its
parent is a real flex container).

Also added white-space:nowrap on .adj-group-meta so the meta never
wraps mid-phrase even if a narrow viewport squeezes the cell.

Inline comment documents the <tr> vs <td> distinction so future
sessions don't re-introduce the bug.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:13:00 +02:00
Konrad du Plessis
e932b3c3a7 ux(colors): unify badge colours across all payroll tabs
Replaces the 4-branch Bootstrap-state conditional on the Pending
and History tabs with the semantic .badge-type-{{ adj.type|type_slug }}
palette that the Adjustments tab has been using. Now "Loan" badges
are the same colour in every tab instead of Pending=yellow /
Adjustments=amber.

Also recolours the Pending-tab "Loan" worker flag to the same amber
(.loan-flag-badge class). "Overdue" flag stays red - it's an urgency
signal, not a type signal, and we deliberately keep transactional
state colours (Bootstrap bg-success/bg-warning/bg-danger) separate
from the type palette so a green badge can only mean "Bonus" and
never ambiguously "Paid".

Threads 'additive_types' (list(ADDITIVE_TYPES)) into the base
payroll_dashboard context so the +/- sign logic works on Pending
and History too (was previously only set in the Adjustments-tab
branch).

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:09:10 +02:00
Konrad du Plessis
f70342f825 ux(admin): use display label in PayrollAdjustment admin list column
Code-review follow-up on 1cf1304. /admin/core/payrolladjustment/
was still showing raw DB values (New Loan / Advance Payment /
Advance Repayment) in the Type list column because list_display
was the bare field name 'type', which Django renders via
str(obj.type).

Added a @admin.display method that returns obj.get_type_display()
and referenced it in list_display instead. Column header stays
'Type' and the column is still sortable by the underlying field.

list_filter kept on 'type' (DB value) - filter sidebar correctness
doesn't require the display label, and filtering works off the
canonical stored value.

Closes the last known "users see shorter labels everywhere" gap
from the Path A rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:03:57 +02:00
Konrad du Plessis
1cf13048c2 ux(labels): extend display labels to AJAX-sourced modal renders
Closes the Task 3 design-goal gap: two user-facing modals (work-log
payroll preview in base.html, split-payslip preview in
payroll_dashboard.html) render adjustment types via JS reading AJAX
JSON. After Task 3's TYPE_CHOICES rename they were still showing
the old long labels because the backend endpoints
(work_log_payroll_ajax, preview_payslip) only emitted adj.type (DB
value), not the display label.

Added a 'type_label' field to the JSON payloads alongside the
existing 'type' field. JS at both render sites now reads
`adj.type_label || adj.type` — with the fallback so any stale
client-side JSON degrades gracefully to the DB value rather than
rendering blank.

Path A still holds: adj.type in JSON stays the DB value for any
identifier purposes; the new type_label is additive.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:58:05 +02:00
Konrad du Plessis
c1d9014fe1 ux(labels): shorter adjustment type labels (display-only rename)
Path A rename - DB values untouched, only TYPE_CHOICES display
labels change:
  'New Loan'          -> shown as 'Loan'
  'Advance Payment'   -> shown as 'Advance'
  'Advance Repayment' -> shown as 'Advance Repaid'

Templates that render the type as visible text switched from
{{ adj.type }} to {{ adj.get_type_display }}. Data attributes and
CSS class slugs keep the raw DB value (identifiers, not labels).

Zero data migration. Zero changes to ADDITIVE_TYPES / DEDUCTIVE_TYPES
constants, hardcoded string comparisons, CSS class names, test
fixtures, or any other code that references the canonical DB value.
Every historic PayrollAdjustment row keeps type='New Loan' /
'Advance Payment' / 'Advance Repayment' as stored.

Django's makemigrations generated a no-op AlterField migration to
record the choices-metadata change.

Tests: 69/69.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:49:26 +02:00
Konrad du Plessis
e51a2f6d1d docs(claude): UI-vs-DB naming drift note (pre-rename)
Adds a new CLAUDE.md section documenting the display/DB gap that
Path A of the UX Polish Pass creates: user sees 'Loan' / 'Advance'
/ 'Advance Repaid' while DB stores 'New Loan' / 'Advance Payment'
/ 'Advance Repayment'. Includes a lookup table, the rule for when
to use which (DB for logic, display for templates), and the failure
symptom so future Claude sessions don't chase ghost filters.

Ships BEFORE the rename so the doc is searchable from minute one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:43:04 +02:00
Konrad du Plessis
0a4b12108e docs(tokens): add canonical design-tokens reference
New doc covering the semantic colour palette: every badge token, its
hex values in both themes, its CSS class, and where it's used across
the app. Categorises tokens into "type-of-adjustment" (custom semantic
palette) vs "transactional state" (Bootstrap defaults) and explains
why the two must not share colours.

Intended to be the single source of truth for UI colour decisions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:38:53 +02:00
Konrad du Plessis
84e9d247be docs(ux): task-by-task plan for UX Polish Pass
Five tasks: (1) docs/design-tokens.md as the canonical colour
reference; (2) CLAUDE.md UI-vs-DB naming-drift note (ships BEFORE
the rename so it's searchable from minute one); (3) display-only
TYPE_CHOICES rename + auto-migration + template visible-text swap
to get_type_display; (4) badge colour unification on Pending +
History tabs + loan-flag recolor; (5) CSS root-cause fix for the
group-summary narrow-wrap bug (move display:flex from <tr> to <td>).

Execute via subagent-driven-development. Auto mode — no mid-execution
checkpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:36:51 +02:00
Konrad du Plessis
9aba9b8fb8 docs(ux): design for UX Polish Pass
Four UX asks bundled in one pass:

1+2. Display-only rename of adjustment types: 'New Loan' (DB) →
     'Loan' (UI), 'Advance Payment' → 'Advance', 'Advance Repayment'
     → 'Advance Repaid'. DB values preserved forever — zero data
     migration, zero formula / constant / CSS / test changes.
3. Unify badge colours across all payroll tabs using the existing
   .badge-type-* semantic palette. Recolour Pending "With loans"
   flag to match the Loan type colour.
4. Fix CSS bug in .adj-group-meta (margin-left:auto doesn't work
   in a <td> — make the td a flex container).

Plus: new docs/design-tokens.md as the canonical colour reference,
and a crucial CLAUDE.md section documenting the UI-vs-DB naming
drift so future Claude sessions don't chase ghosts when writing
formulas / filters / ORM queries that reference the display label
instead of the DB value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:32:23 +02:00
Konrad du Plessis
b43892f712 docs(claude): capture session's new patterns + gotchas
Three additions from this session's work:

1. Django ORM gotcha — PayrollAdjustment project double-attribution.
   Documents the Coalesce pattern that solved the Apr 2026 perf-pass
   double-count bug on Overtime adjustments.

2. Payroll dashboard query-count baselines — target ranges for /
   and the four /payroll/ tabs after the perf pass, plus the
   "spotting a regression" heuristic (>50% jump = N+1 reintroduced).

3. Profiling locally — Django Debug Toolbar — what it is, how it's
   triple-gated, how to use it for N+1 hunting. Flags that the
   package is already in requirements.txt so future sessions don't
   need to install it.

Net: +35 lines, three new sections, no deletions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:51:23 +02:00
13 changed files with 1385 additions and 38 deletions

View File

@ -72,6 +72,50 @@ sessions; grep `core/models.py` before using any field you haven't used before:
- `PayrollRecord.amount_paid` (DecimalField) + `PayrollRecord.work_logs` (M2M reverse) — NOT `total_amount` / `days_worked` (easy to guess wrong when writing test fixtures)
- `Loan.principal_amount` — NOT `principal`. `Loan.save()` auto-sets `remaining_balance = principal_amount` on create, so tests rarely need to pass both.
## UI-vs-DB naming drift (Apr 2026) — READ BEFORE WRITING FORMULAS
`PayrollAdjustment.type` is DISPLAYED to users with short labels,
but the raw string stored in the database is always the long
legacy value:
| What the user SEES | What the DATABASE stores |
|---|---|
| Bonus | `'Bonus'` |
| Overtime | `'Overtime'` |
| Deduction | `'Deduction'` |
| Loan Repayment | `'Loan Repayment'` |
| Loan | `'New Loan'` ← mismatch |
| Advance | `'Advance Payment'` ← mismatch |
| Advance Repaid | `'Advance Repayment'` ← mismatch |
When writing ANY formula, filter, comparison, ORM query, test
fixture, CSS class name, or `data-type=` attribute: use the
DATABASE value (left column of the model).
- `ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan', 'Advance Payment']`
in `views.py` uses DB values.
- `if adj.type == 'New Loan':` checks the DB value.
- `<span class="badge-type-{{ adj.type|type_slug }}">` produces
`.badge-type-new-loan` from the DB value.
- `<tr data-type="{{ adj.type }}">` emits the DB value.
- Tests use `PayrollAdjustment.objects.create(type='New Loan', ...)`.
Only user-facing template TEXT uses the short label — via
`{{ adj.get_type_display }}`, Django's built-in choices lookup.
The label mapping lives in `PayrollAdjustment.TYPE_CHOICES`
(`core/models.py`).
**How this happened:** originally the adjustment-creation dropdown
said "New Loan" because that's what the action meant (_"log a new
loan"_). That label then propagated into every other view — tables,
badges, reports. On 24 Apr 2026 we renamed the user-visible labels
to be shorter and cleaner BUT deliberately kept the database values
untouched — to avoid breaking historic rows, tests, and hardcoded
string comparisons across ~30 source locations.
**Symptom of getting this wrong:** code that filters for
`type='Loan'` returns zero rows. Fix: use `type='New Loan'`.
## Key Business Rules
- All business logic lives in the `core/` app — do not create additional Django apps
- Workers have a `daily_rate` property: `monthly_salary / Decimal('20.00')`
@ -99,6 +143,25 @@ outer queryset JOIN-free. See `_build_report_context` in `core/views.py` and
`ReportContextFilterInflationTests` in `core/tests.py` for the reference
implementation (commit f1e246c, Apr 2026).
## Django ORM gotcha — PayrollAdjustment project double-attribution
`PayrollAdjustment` has TWO project FKs: a direct `adj.project` and an
indirect `adj.work_log.project`. For every **Overtime** adjustment these
always point at the same project (see `price_overtime()` — it sets
BOTH). When rolling up "costs per project" you typically want the
OR-union — "adjustments where either FK points to project P".
- **Correct**: `Q(project_id__in=ids) | Q(work_log__project_id__in=ids)` filter
+ `.annotate(effective_project_id=Coalesce('project_id', 'work_log__project_id'))`
+ `.values('effective_project_id', ...).annotate(total=Sum('amount'))`.
Each row contributes to exactly ONE project.
- **WRONG**: two separate filtered querysets (one per FK) summed in
Python. Any row with BOTH FKs set (every Overtime) gets counted twice.
Bit us during the Apr 2026 perf pass — Coalesce fix is commit
`167c821`. Regression test: `PayrollDashboardAdjustmentAggregationTests`
in `core/tests.py`. See `payroll_dashboard()` in `core/views.py` for
the reference implementation on both the unpaid-outstanding card and
the paid-monthly stacked chart.
## PayrollAdjustment Type Handling
- **Bonus / Deduction** — standalone, require a linked Project
- **New Loan** — creates a `Loan` record (`loan_type='loan'`); has a "Pay Immediately" checkbox (checked by default) that auto-processes the loan (creates PayrollRecord, sends payslip to Spark, marks as paid). When unchecked, the loan sits in Pending Payments for the next pay cycle. Editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments
@ -114,6 +177,22 @@ The dashboard's outstanding amount uses **per-worker** checking, not per-log:
- This correctly handles partially-paid logs (e.g., one worker paid, another not)
- Unpaid adjustments: additive types increase outstanding, deductive types decrease it
## Payroll dashboard query-count baselines (post Apr 2026 perf pass)
Target ranges after `payroll_dashboard()` was optimized with batched
aggregates + `Prefetch(to_attr='active_workers_cached')` + Coalesce-based
project attribution (commits `61c485f` + `167c821`):
- `/` (admin dashboard) — ~15 queries
- `/payroll/?status=pending` — ~24
- `/payroll/?status=history` — ~24
- `/payroll/?status=loans` — ~25
- `/payroll/?status=adjustments` — ~32
If any of these jumps meaningfully (>50%) after a future change, an N+1
was reintroduced. Profile with Django Debug Toolbar (see Profiling
section below) to find it. The test suite does NOT have `assertNumQueries`
guards on these views — deliberate YAGNI for now, worth adding if
regressions become a pattern.
## Commands
```bash
# Local development (SQLite)
@ -131,6 +210,22 @@ python manage.py check # System check
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
```
## Profiling locally — Django Debug Toolbar
Installed as a dev-only dependency in `requirements.txt` since Apr 2026.
Triple-gated in `config/settings.py`: only loads when **DEBUG=true AND
USE_SQLITE=true AND NOT running tests**. Never loads in production —
prod has neither flag, and the test-run gate exists because the toolbar
emits an E001 system-check error + breaks template rendering when
DEBUG=false (which Django forces during `manage.py test`).
To profile a page: start the dev server normally (`run_dev.bat` or
inline `USE_SQLITE=true DJANGO_DEBUG=true python manage.py runserver`),
log in as admin, navigate to any URL, click the toolbar tab on the
right edge. The **SQL panel** shows query count + highlights any
duplicate-query groups — the go-to tool for N+1 hunting. See the
"Payroll dashboard query-count baselines" section for expected
numbers on hot pages.
## Development Workflow
- Active development branch: `ai-dev` (PR target: `master`)
- Local dev uses SQLite: set `USE_SQLITE=true` environment variable

View File

@ -114,10 +114,19 @@ class LoanAdmin(admin.ModelAdmin):
@admin.register(PayrollAdjustment)
class PayrollAdjustmentAdmin(admin.ModelAdmin):
list_display = ('worker', 'type', 'amount', 'date')
list_display = ('worker', 'type_display', 'amount', 'date')
list_filter = ('type', 'date', 'worker')
search_fields = ('worker__name', 'description')
# === Type column uses the short user-facing label ===
@admin.display(description='Type', ordering='type')
def type_display(self, obj):
"""Show the short user-facing label (e.g. "Loan", "Advance")
instead of the raw DB value ("New Loan", "Advance Payment").
Sorting and filtering still work off the underlying `type`
field this only changes what's printed in the column."""
return obj.get_type_display()
class ExpenseLineItemInline(admin.TabularInline):
model = ExpenseLineItem
extra = 1

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-04-24 07:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_worker_tax_number'),
]
operations = [
migrations.AlterField(
model_name='payrolladjustment',
name='type',
field=models.CharField(choices=[('Bonus', 'Bonus'), ('Overtime', 'Overtime'), ('Deduction', 'Deduction'), ('Loan Repayment', 'Loan Repayment'), ('New Loan', 'Loan'), ('Advance Payment', 'Advance'), ('Advance Repayment', 'Advance Repaid')], max_length=50),
),
]

View File

@ -187,14 +187,21 @@ class Loan(models.Model):
return f"{self.worker.name} - {label} - {self.date}"
class PayrollAdjustment(models.Model):
# === PayrollAdjustment TYPE_CHOICES - canonical DB value | display label ===
# Path A rename (24 Apr 2026): DB values are PRESERVED as-is. Only the
# second tuple element (the human label) changes for three types, so
# users see shorter labels in tables while every historic row, formula,
# constant, test fixture, CSS class, and data-attribute KEEP WORKING
# UNCHANGED because they all key off the DB value on the left.
# See CLAUDE.md "UI-vs-DB naming drift" section for the full rule.
TYPE_CHOICES = [
('Bonus', 'Bonus'),
('Overtime', 'Overtime'),
('Deduction', 'Deduction'),
('Loan Repayment', 'Loan Repayment'),
('New Loan', 'New Loan'),
('Advance Payment', 'Advance Payment'),
('Advance Repayment', 'Advance Repayment'),
('Bonus', 'Bonus'),
('Overtime', 'Overtime'),
('Deduction', 'Deduction'),
('Loan Repayment', 'Loan Repayment'),
('New Loan', 'Loan'),
('Advance Payment', 'Advance'),
('Advance Repayment', 'Advance Repaid'),
]
worker = models.ForeignKey(Worker, on_delete=models.CASCADE, related_name='adjustments')

View File

@ -549,7 +549,10 @@ document.addEventListener('DOMContentLoaded', function() {
var adjBody = document.createElement('tbody');
data.adjustments.forEach(function(adj) {
var tr = document.createElement('tr');
tr.appendChild(el('td', null, adj.type));
// Prefer the short display label from the server; fall back to the
// raw DB value if a stale JSON response doesn't include type_label
// (graceful degradation — never render a blank cell).
tr.appendChild(el('td', null, adj.type_label || adj.type));
var wTd = document.createElement('td');
wTd.appendChild(link('/workers/' + adj.worker_id + '/', adj.worker_name));
tr.appendChild(wTd);

View File

@ -34,7 +34,7 @@ Row actions differ by paid status:
</td>
{# --- Type badge (colour comes from the .badge-type-<slug> CSS class) --- #}
<td><span class="badge-type-{{ adj.type|type_slug }}">{{ adj.type }}</span></td>
<td><span class="badge-type-{{ adj.type|type_slug }}">{{ adj.get_type_display }}</span></td>
{# --- Amount (sign reflects additive vs deductive) --- #}
<td class="text-end" style="font-variant-numeric: tabular-nums;">

View File

@ -342,7 +342,7 @@
<span class="badge bg-danger" style="font-size: 0.6rem;" title="Has unpaid work from a completed pay period (since {{ wd.earliest_unpaid|date:'d M Y' }})">Overdue</span>
{% endif %}
{% if wd.has_loan %}
<span class="badge bg-warning" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>
<span class="badge loan-flag-badge" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>
{% endif %}
</div>
{% endif %}
@ -353,11 +353,9 @@
<td class="align-middle d-none d-lg-table-cell">
{# Show each pending adjustment as a badge #}
{% for adj in wd.adjustments %}
{# Badge colour logic: #}
{# GREEN = earned money (Bonus, Overtime) or debt recovery (Loan/Advance Repayment) #}
{# YELLOW = loan-related outflow (New Loan, Advance Payment) — matches the Loan tag #}
{# RED = deductions (Deduction) #}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
{# Type badge uses the semantic palette: colour = type (Bonus, Loan, etc.). #}
{# Sign + / - reflects additive-vs-deductive (orthogonal to the colour). #}
<span class="badge badge-type-{{ adj.type|type_slug }} mb-1 me-1 adjustment-badge"
style="cursor: pointer;"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
@ -366,8 +364,8 @@
data-adj-description="{{ adj.description }}"
data-adj-project="{{ adj.project_id|default:'' }}"
data-adj-worker="{{ adj.worker.name }}">
{% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
{{ adj.type }}
{% if adj.type in additive_types %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
{{ adj.get_type_display }}
{% if adj.project %}({{ adj.project.name }}){% endif %}
</span>
{% endfor %}
@ -450,8 +448,8 @@
</td>
<td class="align-middle d-none d-lg-table-cell">
{% for adj in record.adjustments.all %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} me-1">
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
<span class="badge badge-type-{{ adj.type|type_slug }} me-1">
{{ adj.get_type_display }}: R {{ adj.amount|floatformat:2 }}
</span>
{% empty %}
<span class="text-muted">-</span>
@ -587,12 +585,12 @@
<input type="search" class="form-control form-control-sm mb-2"
placeholder="Search types..." data-popover-search>
<div class="adj-checkbox-list" data-popover-list>
{% for t in adj_type_choices %}
{% for value, label in adj_type_choices %}
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="checkbox" class="form-check-input adj-filter-cb"
data-adj-filter="type" value="{{ t }}"
{% if t in adj_filter_values.type %}checked{% endif %}>
<span class="adj-cb-label">{{ t }}</span>
data-adj-filter="type" value="{{ value }}"
{% if value in adj_filter_values.type %}checked{% endif %}>
<span class="adj-cb-label">{{ label }}</span>
</label>
{% endfor %}
</div>
@ -917,7 +915,7 @@
{% for group in adj_groups %}
<tbody>
<tr class="adj-group-header"
{% if adj_filter_values.group_by == 'type' %}data-type="{{ group.label }}"{% endif %}
{% if adj_filter_values.group_by == 'type' %}data-type="{{ group.type_key|default:group.label }}"{% endif %}
data-bs-toggle="collapse"
data-bs-target="#adj-group-{{ group.slug }}"
aria-expanded="true" aria-controls="adj-group-{{ group.slug }}">
@ -2395,7 +2393,10 @@ document.addEventListener('DOMContentLoaded', function() {
labelWrap.className = 'd-flex justify-content-between flex-grow-1';
var label = document.createElement('span');
label.textContent = adj.type + (adj.project ? ' (' + adj.project + ')' : '');
// Prefer the short display label ('Loan' / 'Advance' / 'Advance Repaid')
// from the server; fall back to the raw DB value if a stale JSON
// response doesn't include type_label (graceful degradation).
label.textContent = (adj.type_label || adj.type) + (adj.project ? ' (' + adj.project + ')' : '');
if (adj.description) {
var descSmall = document.createElement('small');
descSmall.className = 'text-muted ms-1';

View File

@ -128,7 +128,7 @@
<tbody>
{% for adj in adjustments %}
<tr>
<td>{{ adj.type }}</td>
<td>{{ adj.get_type_display }}</td>
<td><a href="{% url 'worker_detail' adj.worker.id %}" class="text-decoration-none">{{ adj.worker.name }}</a></td>
<td class="text-end">R {{ adj.amount|money }}</td>
<td>

View File

@ -1020,8 +1020,11 @@ def work_log_payroll_ajax(request, log_id):
} for row in ctx['worker_rows']]
# Adjustments linked directly to this work_log (Overtime, etc.).
# We emit BOTH 'type' (raw DB value — stable identifier for any JS logic)
# AND 'type_label' (short display label from get_type_display()) — visible UI.
adjustments = [{
'type': adj.type,
'type_label': adj.get_type_display(),
'amount': float(adj.amount),
'worker_id': adj.worker.id,
'worker_name': adj.worker.name,
@ -2562,10 +2565,17 @@ def _group_adjustments(adjustments, group_by):
groups = []
for key, rows in buckets.items():
if group_by == 'type':
label = key
# Visible header text uses the short display label (e.g. "Loan",
# "Advance", "Advance Repaid") from the model's TYPE_CHOICES.
label = rows[0].get_type_display()
# type_key holds the raw DB value so the template can emit it as
# data-type="..." for the [data-type="X"] CSS border-left accent
# selectors that still key on the canonical DB value.
type_key = key
slug = key.lower().replace(' ', '-')
else: # worker
label = rows[0].worker.name
type_key = None
slug = f'worker-{key}'
net_sum = sum(
(r.amount if r.type in ADDITIVE_TYPES else -r.amount)
@ -2573,6 +2583,7 @@ def _group_adjustments(adjustments, group_by):
)
groups.append({
'label': label,
'type_key': type_key,
'slug': slug,
'rows': rows,
'count': len(rows),
@ -3077,6 +3088,10 @@ def payroll_dashboard(request):
'all_teams': all_teams,
'team_workers_map_json': team_workers_map,
'adjustment_types': PayrollAdjustment.TYPE_CHOICES,
# List of type labels that ADD to a worker's pay (Bonus, Overtime,
# New Loan, Advance Payment). Used by the Pending and History tabs'
# adjustment badges to show a + or - sign next to the amount.
'additive_types': list(ADDITIVE_TYPES),
'active_projects': active_projects,
'loans': loans,
'loan_filter': loan_filter,
@ -3225,11 +3240,14 @@ def payroll_dashboard(request):
'order': sort_order,
'group_by': group_by,
},
# Flat list of type labels for the Adjustments tab filter dropdown.
# Stored under a separate key so we don't clobber the existing
# 'adjustment_types' context var (which is TYPE_CHOICES tuples
# used by the Add/Edit adjustment modals).
'adj_type_choices': list(ADDITIVE_TYPES) + list(DEDUCTIVE_TYPES),
# (db_value, display_label) pairs for the Type filter popover on the
# Adjustments tab. Uses TYPE_CHOICES directly so the checkbox labels
# show the short display labels (Loan / Advance / Advance Repaid)
# while checkbox values stay on the DB value (which the view filters
# by). Stored under a separate key so we don't clobber the existing
# 'adjustment_types' context var (also TYPE_CHOICES tuples, used by
# the Add/Edit adjustment modals).
'adj_type_choices': PayrollAdjustment.TYPE_CHOICES,
# PERF: reuse `all_workers`/`all_teams` (already cached above for
# the Add-Adjustment modal) — same row-set, same ordering, so no
# need to re-query the database for the filter popovers.
@ -4236,7 +4254,11 @@ def preview_payslip(request, worker_id):
adj_total += float(adj.amount) if adj.type in ADDITIVE_TYPES else -float(adj.amount)
adjustments_list.append({
'id': adj.id,
# 'type' keeps the raw DB value so any JS that uses it as an
# identifier keeps working; 'type_label' is the short display
# label ('Loan' / 'Advance' / 'Advance Repaid' etc.) for visible UI.
'type': adj.type,
'type_label': adj.get_type_display(),
'amount': float(adj.amount),
'sign': sign,
'description': adj.description,

76
docs/design-tokens.md Normal file
View File

@ -0,0 +1,76 @@
# Design Tokens — Semantic Colour Palette
_Last reviewed: 24 Apr 2026._
## How colours are structured
The app has TWO colour categories — they MUST NOT share colours:
1. **Type-of-adjustment** — 7 types × 2 themes. Used wherever a
`PayrollAdjustment` is shown as a badge or a group-header accent.
Token naming: `--badge-<type>-bg` / `--badge-<type>-fg`.
2. **Transactional state** — Bootstrap's `bg-success` /
`bg-warning` / `bg-danger`. Used for Paid, Unpaid, Overdue —
the payment lifecycle, not the kind of adjustment.
Mixing the two would make a green badge mean both "this is a Bonus"
AND "this is Paid" — the user would lose the ability to read the
colour as a signal. Keep the categories separate.
## Type-of-adjustment tokens
| DB type (canonical) | Displayed as | Dark BG | Dark FG | Light BG | Light FG | CSS class |
|---|---|---|---|---|---|---|
| Bonus | Bonus | `#5b8260` | `#e8f3ea` | `#d7e8d9` | `#385640` | `.badge-type-bonus` |
| Overtime | Overtime | `#a16881` | `#fce4ec` | `#f3d1dd` | `#703347` | `.badge-type-overtime` |
| Deduction | Deduction | `#5b4f8c` | `#e0daf3` | `#d8d0ef` | `#3b2f6d` | `.badge-type-deduction` |
| New Loan | Loan | `#9b7f39` | `#fef4d1` | `#f0dc9d` | `#6a5320` | `.badge-type-new-loan` |
| Loan Repayment | Loan Repayment | `#b48a1a` | `#fef4d1` | `#f7d873` | `#5a4418` | `.badge-type-loan-repayment` |
| Advance Payment | Advance | `#3e5c7b` | `#d7e5f2` | `#bccee0` | `#243b56` | `.badge-type-advance-payment` |
| Advance Repayment | Advance Repaid | `#2f679a` | `#d7e5f2` | `#9ec1dd` | `#1d3550` | `.badge-type-advance-repayment` |
Token definitions live in `static/css/custom.css`:
- Dark theme: `:root { ... }` block around lines 85-91
- Light theme: `[data-theme="light"] { ... }` block around lines 149-155
## Where each colour appears
| Semantic | Used by |
|---|---|
| `--badge-bonus-*` (green) | Adjustments tab type badge; By-Type group-header left-border accent |
| `--badge-overtime-*` (mauve) | Adjustments tab type badge; By-Type group-header accent |
| `--badge-deduction-*` (purple) | Adjustments tab type badge; By-Type group-header accent |
| `--badge-loan-*` (amber/yellow) | Adjustments tab type badge; By-Type group-header accent; Pending tab "Loan" worker flag (`.loan-flag-badge`) |
| `--badge-loan-rep-*` (deeper amber, +15% saturation) | Adjustments tab type badge for Loan Repayment; By-Type group-header accent |
| `--badge-advance-*` (blue) | Adjustments tab type badge; By-Type group-header accent |
| `--badge-advance-rep-*` (deeper blue, +15% saturation) | Adjustments tab type badge for Advance Repayment; By-Type group-header accent |
## Transactional-state colours (Bootstrap — unchanged)
| Use | Class |
|---|---|
| Paid payslip badge | `bg-success` |
| Unpaid status badge | `bg-warning` |
| Overdue worker flag (Pending tab) | `bg-danger` |
## How to add a new colour token
1. Define in BOTH the `:root` and `[data-theme="light"]` blocks in
`static/css/custom.css`. Choose colours that retain enough contrast
against the card background in both themes.
2. Add a row to the mapping table in this doc.
3. Reference via `var(--badge-*-bg)` in CSS — never hard-code hex
anywhere else.
4. If it's a new adjustment type, add:
- A `.badge-type-<slug>` class in the `.badge-type-*` block
(around line 1935 of `custom.css`)
- An entry in the `.adj-group-header[data-type="..."]` block
(around line 1994)
- The new TYPE_CHOICES entry in `core/models.py::PayrollAdjustment`
(and run `makemigrations`)
## Maintenance
This doc is the single source of truth for app-wide colour semantics.
When CSS tokens are added / removed / renamed in `custom.css`, update
this doc in the SAME commit.

View File

@ -0,0 +1,356 @@
# UX Polish Pass — Design (24 Apr 2026)
## Origin
Konrad, after the Perf Quick-Wins Pass shipped:
> _1. I named Loans — "New Loan" because in the dropdown for adjustments,
> I wanted to log a new loan. But now throughout the app it says "New
> Loan". Can we change it everywhere to just "Loan". (How Will this
> handle historic data and formulas? Make sure we do not break the
> app)_
>
> _2. Is it Possible to change Advance Payment to Adv Pay, Advance
> Repayment to Adv Repay. It will take less space in the tables (all
> tables)_ — refined in the brainstorm to **"Advance" / "Advance
> Repaid"** for readability
>
> _3. Can we change the colors for flags/tags in Pending Payments,
> Payment History, Loans and Advances to the colors we decided on in
> Adjustments (colors for Loans, advances, bonusses etc). Uniform
> colors throughout the app. Please make a note in some file regarding
> the colors for easy reference in the future._
>
> _4. The group summary Column in Adjustments is a bit narrow, can we
> have it a bit wider?_
Four independent UX polish asks — no behavioural change, no schema
change, no formula change.
## Goal
Tighten the visual vocabulary of the payroll area:
1. Cleaner, shorter adjustment type labels in tables
2. Consistent colour semantics across every payroll tab (one colour
per concept, everywhere it appears)
3. Fix a CSS bug squashing the group-summary row
All four items ship in one pass. Collectively they change how payroll
LOOKS without touching how it WORKS.
## Who it's for
Konrad — who sees these labels and colours dozens of times a day — and
any future viewer of the payroll pages.
## Scope decision — Path A: display-only rename (chosen)
Konrad's own words: _"How will this handle historic data and formulas?
Make sure we do not break the app."_ That anxiety is the exact
scenario where **display-only** shines. Two paths were on the table:
- **A — display-only**: `TYPE_CHOICES` second tuple value (the "human
label") gets shortened; the first tuple value (the DB value) stays
forever identical to today. No data migration. No constants touched.
No test changes. Every historic row keeps `type='New Loan'` forever.
- **B — full rename w/ data migration**: rename canonical value
everywhere; add `UPDATE payrolladjustment SET type='Loan' WHERE
type='New Loan'` migration; touch ~30 source files.
Path A picked. Every Konrad-visible goal is satisfied; every risk
ground-sourced in historic data/formulas is eliminated.
## 1. Display-only rename — the mechanics
**`core/models.py`** — the only data-layer change:
```python
TYPE_CHOICES = [
('Bonus', 'Bonus'), # unchanged
('Overtime', 'Overtime'), # unchanged
('Deduction', 'Deduction'), # unchanged
('Loan Repayment', 'Loan Repayment'), # unchanged
('New Loan', 'Loan'), # DB='New Loan', shown='Loan'
('Advance Payment', 'Advance'), # DB='Advance Payment', shown='Advance'
('Advance Repayment', 'Advance Repaid'), # DB='Advance Repayment', shown='Advance Repaid'
]
```
Django's `makemigrations` will detect the changed `choices` metadata
and generate a one-op `AlterField` migration. This is a **no-op at
the database level** — the column type and data are untouched — but
Django requires the migration to keep its model state in sync with
what it thinks is on disk.
### Template switches
Wherever a template currently renders the type as VISIBLE text, swap
to the display method:
- `{{ adj.type }}``{{ adj.get_type_display }}`
- `{% if adj.type == 'New Loan' %}...{% endif %}` — these stay on the
raw type (DB value), because they're CONTROL FLOW, not display
- `{{ choice.0 }}` / `{{ choice.1 }}` in dropdown iterations — already
render the display value by Django convention (the `<option>` element
uses `choice.1`)
### CSS / data-attribute exceptions (MUST NOT change)
Two patterns keep the raw DB value to avoid breaking CSS selectors:
- `<span class="badge-type-{{ adj.type|type_slug }}">` — the
`type_slug` filter converts the DB value `"New Loan"` to the slug
`"new-loan"`, which matches the `.badge-type-new-loan` CSS class.
If we ran `get_type_display` here it would become `badge-type-loan`
and break every color token.
- `<tr data-type="{{ adj.type }}">` — CSS selectors like
`.adj-group-header[data-type="New Loan"]` and JS code like
`el.dataset.type === 'New Loan'` both read the DB value. The
data attribute is an identifier, not a label.
### What doesn't change
| Thing | Keeps current value |
|---|---|
| DB column `payrolladjustment.type` | ALL historic rows unchanged |
| `ADDITIVE_TYPES` / `DEDUCTIVE_TYPES` constants | `'New Loan'`, `'Advance Payment'`, `'Advance Repayment'` |
| Every `if adj.type == 'New Loan':` in `views.py` | Unchanged |
| Every `.badge-type-new-loan`, `.badge-type-advance-payment` CSS class | Unchanged |
| Every test fixture creating `type='New Loan'` | Unchanged |
| `type_slug` template filter | Unchanged |
| Dropdown on the "Add Adjustment" modal | User sees "Loan" / "Advance" / "Advance Repaid" automatically (Django auto-uses display values in `<select>`) |
### Files touched (display-only rename)
- `core/models.py` — the 3 display-label edits
- `core/migrations/0012_*.py` — auto-generated no-op AlterField
- `core/templates/core/payroll_dashboard.html` — swap visible
`{{ adj.type }}` to `get_type_display` in the type-column cells
- `core/templates/core/_adjustment_row.html` — same swap
- Potentially `core/templates/core/payslip_detail.html` — if it shows
the type (check during implementation)
- Any PDF templates (`report_pdf.html`, `payslip_pdf.html`) — same
visible-type check
No test changes — tests use DB value.
## 2. Badge colour unification
Current state: the Adjustments tab uses the semantic
`.badge-type-{bonus, overtime, deduction, new-loan, loan-repayment,
advance-payment, advance-repayment}` classes (14 CSS tokens × 7 types
× 2 themes). Pending / History / Loans tabs don't — they still use
Bootstrap `bg-success` / `bg-warning` / `bg-danger`.
### Type badges (the inconsistency)
Replace the 4-branch conditional at `payroll_dashboard.html:360`,
`:369`, `:453`:
```django
<!-- BEFORE -->
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} adjustment-badge">
{% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
</span>
```
With:
```django
<!-- AFTER -->
<span class="badge badge-type-{{ adj.type|type_slug }} adjustment-badge">
{% if adj.type in additive_types %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
</span>
```
The sign logic is preserved via `additive_types` context var (already
threaded into `_adjustment_row.html` for the Adjustments tab — we'll
extend the same pattern to Pending/History).
### Status flag badges
Pending tab has a "With loans" yellow flag per worker — semantically
"this worker HAS an active loan". Recolour to match the adjustment
loan colour for consistency:
```css
/* In custom.css — new, near the existing badge-type-* block */
.loan-flag-badge {
background: var(--badge-loan-bg);
color: var(--badge-loan-fg);
}
```
Replace the `bg-warning` class on the existing flag with `.loan-flag-badge`.
**Not recoloured** (deliberately):
- "Overdue" red flag — this is a WARNING/URGENCY semantic, not
loan-related. Stays on a warning red (can adopt `--color-danger` if
we define one, but that's beyond scope here)
- "Paid #N" green badge on row status — TRANSACTIONAL state, distinct
from adjustment type. Stays green
- "Unpaid" yellow badge on row status — same reasoning, stays yellow
The rule: **colour-by-semantic-category**, not by "looks nice":
- Type-of-adjustment (Bonus, Loan, etc.) → `--badge-*-bg` tokens
- Transactional state (Paid / Unpaid / Overdue) → Bootstrap state colours
- The two categories must NOT share colours; otherwise a user glancing
at a green badge can't tell if it means "this is a Bonus" or "this
is Paid"
## 3. Wider group summary row (screenshot bug)
**Root cause** — `custom.css:1988`:
```css
.adj-group-header .adj-group-meta {
margin-left: auto; /* <-- only works inside a flex/grid container */
...
}
```
Written as if the parent `<td>` were a flex container. It isn't.
Result: the meta text doesn't push right, AND when the table has a
narrow wrapper, the contents wrap into a 5-character column (per
Konrad's screenshot).
**Fix** — make the `<td>` an explicit flex container + prevent the
meta text from wrapping mid-phrase:
```css
.adj-group-header > td {
display: flex;
align-items: center;
gap: 0.5rem;
}
.adj-group-header .adj-group-meta {
margin-left: auto;
white-space: nowrap;
}
```
Two-line addition. The `white-space: nowrap` guards against any
future narrow-viewport or scroll-wrapper regressions — the meta will
either fit or overflow, never wrap into an ugly stub.
## 4. New file — `docs/design-tokens.md`
Dedicated reference for the colour palette and its intended usage.
Content outline:
```markdown
# Design Tokens — Semantic Colour Palette
Last reviewed: 24 Apr 2026
## How colours are structured
The app has TWO colour categories — they MUST NOT share colours:
1. **Type-of-adjustment** — 7 types × 2 themes. Used wherever a
PayrollAdjustment is shown as a badge or a group-header accent.
Token naming: `--badge-<type>-bg` / `--badge-<type>-fg`.
2. **Transactional state** — Bootstrap's `bg-success` / `bg-warning`
/ `bg-danger`. Used for Paid, Unpaid, Overdue — the payment
lifecycle, not the kind of adjustment.
## Type-of-adjustment tokens
| DB type (canonical) | Displayed as | Dark BG | Dark FG | Light BG | Light FG | CSS class |
|---|---|---|---|---|---|---|
| Bonus | Bonus | `#5b8260` | `#e8f3ea` | `#d7e8d9` | `#385640` | `.badge-type-bonus` |
| Overtime | Overtime | `#a16881` | `#fce4ec` | `#f3d1dd` | `#703347` | `.badge-type-overtime` |
| Deduction | Deduction | `#5b4f8c` | `#e0daf3` | `#d8d0ef` | `#3b2f6d` | `.badge-type-deduction` |
| New Loan | Loan | `#9b7f39` | `#fef4d1` | `#f0dc9d` | `#6a5320` | `.badge-type-new-loan` |
| Loan Repayment | Loan Repayment | `#b48a1a` | `#fef4d1` | `#f7d873` | `#5a4418` | `.badge-type-loan-repayment` |
| Advance Payment | Advance | `#3e5c7b` | `#d7e5f2` | `#bccee0` | `#243b56` | `.badge-type-advance-payment` |
| Advance Repayment | Advance Repaid | `#2f679a` | `#d7e5f2` | `#9ec1dd` | `#1d3550` | `.badge-type-advance-repayment` |
## Where each colour appears
| Semantic | Used by |
|---|---|
| `--badge-loan-*` (Loan yellow) | Adjustments type badge; Adjustments By-Type group header left-border; Pending "With loans" worker flag |
| `--badge-advance-*` (Advance blue) | Adjustments type badge; group header border; Pending/History advance chips |
| `--badge-bonus-*` (Bonus green) | Adjustments type badge; group header border |
| ... | ... |
## How to add a new colour token
1. Define in BOTH the `:root` and `[data-theme="light"]` blocks in
`static/css/custom.css`
2. Add a row to the mapping table in THIS doc
3. Reference via `var(--badge-*-bg)` in CSS, never hard-code hex
4. If there's a new adjustment type, add an entry to the `.badge-type-*`
block AND the `.adj-group-header[data-type="..."]` block in the
same file
## Maintenance
This doc is the single source of truth for app-wide colour semantics.
When CSS tokens are added/removed/renamed in `custom.css`, update
this doc in the SAME commit.
```
## 5. CLAUDE.md — the crucial naming-drift note
A new section near the existing "Schema name-drifts" block, titled
**"UI-vs-DB naming drift (Apr 2026) — READ ME BEFORE WRITING FORMULAS"**.
Content exactly as proposed during the brainstorm — a table mapping
each "what user sees" to "what DB stores", a rule for when to use
which, a one-paragraph history of how we got here, and the failure
symptom ("code that filters for `type='Loan'` returns zero rows").
## 6. Out of scope
- Bootstrap state colours (`bg-success`/`bg-warning`/`bg-danger`) —
we're not replacing those with a custom palette. Transactional
state badges stay Bootstrap-default (Paid=green, Overdue=red,
Unpaid=yellow). The uniformity we want is within the
TYPE-of-adjustment category, not across all badges everywhere.
- Renaming "Loan Repayment" — not requested; already short.
- Changing the DB layer / TYPE_CHOICES canonical values — explicitly
rejected in favour of Path A.
- Touching historic CSV exports, production data imports, or any
test fixture that references `'New Loan'` / `'Advance Payment'` /
`'Advance Repayment'`. All of those continue to work exactly as-is
because Path A preserves DB values.
## 7. Risks + rollback
| Risk | Mitigation |
|---|---|
| Template uses `adj.type` in a CONTEXT that should have been display, OR vice versa | Grep pass in implementation — every `{{ adj.type }}` gets reviewed case-by-case |
| CSS class / data-attribute touched accidentally | Two-step grep: before any edit, confirm the `|type_slug` filter pattern or `data-type=` attribute is preserved |
| Django migration file committed but not run on VM | Explicit reminder in PR body — `python3 manage.py migrate` on VM (same workflow as always, migration is a no-op but must be recorded in Django's migrations table) |
| Colour change breaks for users with custom browser colour overrides | YAGNI — not a platform we support |
Rollback: `git revert <sha>` on any single commit. No data, schema,
or URL contract impact on any change in this pass.
## 8. Implementation plan (short — 5 tasks)
1. **Design tokens doc** — create `docs/design-tokens.md` first. Do
this one first so the reference exists before any colour work
starts; self-documenting discipline.
2. **CLAUDE.md naming-drift note** — add the "UI-vs-DB naming drift"
section. Locks in the mental model before code changes so it's
searchable from minute one.
3. **Display-only rename** — edit `TYPE_CHOICES`, generate migration,
swap visible template references to `get_type_display`. Run full
test suite to confirm zero regressions (expected: 69/69).
4. **Badge colour unification** — swap the 4-branch `{% if %}` to
`badge-type-{{ adj.type|type_slug }}` at all 3 known sites; add
`.loan-flag-badge` class + apply to the "With loans" Pending
flag. Visual check (dev server) each tab.
5. **Widen group summary row** — 2-line CSS tweak. Visual check.
Expected net change: ~60-120 lines, ~4-5 commits.
## 9. Next step
Generate an implementation plan via the `writing-plans` skill
(task-by-task, bite-sized steps). Then execute via
`subagent-driven-development`. Auto mode active — proceed
continuously, no mid-execution checkpoints.

View File

@ -0,0 +1,742 @@
# UX Polish Pass — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Auto mode is active — execute continuously, no mid-execution checkpoints.
**Goal:** Shorter adjustment-type labels in tables (display-only, DB untouched), uniform semantic badge colours across all payroll tabs, a CSS bug fix on the group-summary row, and two new documentation artifacts to prevent future confusion.
**Architecture:** Path A — display-only rename. Every DB value stays exactly as it is today; only the human-readable labels change via the second tuple element of `TYPE_CHOICES`. Unifies badge colours by replacing ~3 branches of Bootstrap-state-class conditionals with the existing `.badge-type-*` semantic palette. Moves one CSS property (`display: flex`) from a `<tr>` (where it silently breaks table rendering) to a `<td>` (where it does what the original author intended).
**Tech Stack:** Django 5.2.7 (TYPE_CHOICES + get_type_display + auto-generated AlterField migration); custom.css semantic palette (already in place); Django template filter `type_slug` (already in place). No new libraries.
**Design doc:** `docs/plans/2026-04-24-ux-polish-design.md` (committed as `9aba9b8`).
**Starting HEAD:** `9aba9b8` on branch `ai-dev`.
**Expected net change:** ~120-180 lines across 6 files + 1 new doc + 1 auto-generated migration.
---
## Critical context for every task
**The naming drift:** After this pass, what the user SEES and what the DATABASE stores for `PayrollAdjustment.type` will diverge permanently. Learn this before touching anything:
| DB value (CANONICAL) | Display label (new) |
|---|---|
| `'New Loan'` | `Loan` |
| `'Advance Payment'` | `Advance` |
| `'Advance Repayment'` | `Advance Repaid` |
**Never change the left column.** All logic, constants (`ADDITIVE_TYPES`), tests, CSS class slugs (`.badge-type-new-loan`), and `data-type="..."` attributes use the left column.
**Two kinds of template usage of `adj.type`:**
1. **Visible text** (renders letters on the page): `{{ adj.type }}` → change to `{{ adj.get_type_display }}`
2. **Identifier** (feeds a CSS class via `|type_slug`, or a `data-type=` attribute, or a control-flow `{% if %}`): keep as `{{ adj.type }}` — the raw DB value is the right thing to emit
The implementer for Tasks 3 and 4 MUST grep both patterns before editing and make the call case-by-case.
---
## Task 1: Create `docs/design-tokens.md` (canonical colour reference)
**Goal:** A single doc that lists every semantic colour token in the app, where it's used, and how to add a new one. Do this BEFORE any colour code-changes so the reference exists first.
**Files:**
- Create: `docs/design-tokens.md`
**Step 1: Create the file with the full content**
Write the following to `docs/design-tokens.md` (verbatim):
````markdown
# Design Tokens — Semantic Colour Palette
_Last reviewed: 24 Apr 2026._
## How colours are structured
The app has TWO colour categories — they MUST NOT share colours:
1. **Type-of-adjustment** — 7 types × 2 themes. Used wherever a
`PayrollAdjustment` is shown as a badge or a group-header accent.
Token naming: `--badge-<type>-bg` / `--badge-<type>-fg`.
2. **Transactional state** — Bootstrap's `bg-success` /
`bg-warning` / `bg-danger`. Used for Paid, Unpaid, Overdue —
the payment lifecycle, not the kind of adjustment.
Mixing the two would make a green badge mean both "this is a Bonus"
AND "this is Paid" — the user would lose the ability to read the
colour as a signal. Keep the categories separate.
## Type-of-adjustment tokens
| DB type (canonical) | Displayed as | Dark BG | Dark FG | Light BG | Light FG | CSS class |
|---|---|---|---|---|---|---|
| Bonus | Bonus | `#5b8260` | `#e8f3ea` | `#d7e8d9` | `#385640` | `.badge-type-bonus` |
| Overtime | Overtime | `#a16881` | `#fce4ec` | `#f3d1dd` | `#703347` | `.badge-type-overtime` |
| Deduction | Deduction | `#5b4f8c` | `#e0daf3` | `#d8d0ef` | `#3b2f6d` | `.badge-type-deduction` |
| New Loan | Loan | `#9b7f39` | `#fef4d1` | `#f0dc9d` | `#6a5320` | `.badge-type-new-loan` |
| Loan Repayment | Loan Repayment | `#b48a1a` | `#fef4d1` | `#f7d873` | `#5a4418` | `.badge-type-loan-repayment` |
| Advance Payment | Advance | `#3e5c7b` | `#d7e5f2` | `#bccee0` | `#243b56` | `.badge-type-advance-payment` |
| Advance Repayment | Advance Repaid | `#2f679a` | `#d7e5f2` | `#9ec1dd` | `#1d3550` | `.badge-type-advance-repayment` |
Token definitions live in `static/css/custom.css`:
- Dark theme: `:root { ... }` block around lines 85-91
- Light theme: `[data-theme="light"] { ... }` block around lines 149-155
## Where each colour appears
| Semantic | Used by |
|---|---|
| `--badge-bonus-*` (green) | Adjustments tab type badge; By-Type group-header left-border accent |
| `--badge-overtime-*` (mauve) | Adjustments tab type badge; By-Type group-header accent |
| `--badge-deduction-*` (purple) | Adjustments tab type badge; By-Type group-header accent |
| `--badge-loan-*` (amber/yellow) | Adjustments tab type badge; By-Type group-header accent; Pending tab "Loan" worker flag (`.loan-flag-badge`) |
| `--badge-loan-rep-*` (deeper amber, +15% saturation) | Adjustments tab type badge for Loan Repayment; By-Type group-header accent |
| `--badge-advance-*` (blue) | Adjustments tab type badge; By-Type group-header accent |
| `--badge-advance-rep-*` (deeper blue, +15% saturation) | Adjustments tab type badge for Advance Repayment; By-Type group-header accent |
## Transactional-state colours (Bootstrap — unchanged)
| Use | Class |
|---|---|
| Paid payslip badge | `bg-success` |
| Unpaid status badge | `bg-warning` |
| Overdue worker flag (Pending tab) | `bg-danger` |
## How to add a new colour token
1. Define in BOTH the `:root` and `[data-theme="light"]` blocks in
`static/css/custom.css`. Choose colours that retain enough contrast
against the card background in both themes.
2. Add a row to the mapping table in this doc.
3. Reference via `var(--badge-*-bg)` in CSS — never hard-code hex
anywhere else.
4. If it's a new adjustment type, add:
- A `.badge-type-<slug>` class in the `.badge-type-*` block
(around line 1935 of `custom.css`)
- An entry in the `.adj-group-header[data-type="..."]` block
(around line 1994)
- The new TYPE_CHOICES entry in `core/models.py::PayrollAdjustment`
(and run `makemigrations`)
## Maintenance
This doc is the single source of truth for app-wide colour semantics.
When CSS tokens are added / removed / renamed in `custom.css`, update
this doc in the SAME commit.
````
**Step 2: Commit**
```bash
git add docs/design-tokens.md
git commit -m "$(cat <<'EOF'
docs(tokens): add canonical design-tokens reference
New doc covering the semantic colour palette: every badge token, its
hex values in both themes, its CSS class, and where it's used across
the app. Categorises tokens into "type-of-adjustment" (custom semantic
palette) vs "transactional state" (Bootstrap defaults) and explains
why the two must not share colours.
Intended to be the single source of truth for UI colour decisions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
No tests, no code change. File-only addition.
---
## Task 2: CLAUDE.md — the "UI-vs-DB naming drift" section
**Goal:** Document the permanent gap between display labels and DB values so future Claude (and Konrad, on a tired day) don't chase ghosts. This goes in BEFORE any rename work — so it's searchable the moment the rename ships.
**Files:**
- Modify: `CLAUDE.md` (insert a new section near the existing "Schema name-drifts to remember" section)
**Step 1: Find the insertion point**
Use Grep to find the exact line:
```
grep -n "Schema name-drifts to remember" CLAUDE.md
```
Expected: one match, around line 52 (give or take).
**Step 2: Read the next ~20 lines to see the end of that block**
Use the Read tool at that line, limit=30, to see where the "Schema name-drifts" section ends. Note the line of the last bullet + the blank line after it.
**Step 3: Insert the new section**
Insert the following block AFTER the last `- ` bullet of the "Schema name-drifts to remember" section (before the next `##` header):
```markdown
## UI-vs-DB naming drift (Apr 2026) — READ BEFORE WRITING FORMULAS
`PayrollAdjustment.type` is DISPLAYED to users with short labels,
but the raw string stored in the database is always the long
legacy value:
| What the user SEES | What the DATABASE stores |
|---|---|
| Bonus | `'Bonus'` |
| Overtime | `'Overtime'` |
| Deduction | `'Deduction'` |
| Loan Repayment | `'Loan Repayment'` |
| Loan | `'New Loan'` ← mismatch |
| Advance | `'Advance Payment'` ← mismatch |
| Advance Repaid | `'Advance Repayment'` ← mismatch |
When writing ANY formula, filter, comparison, ORM query, test
fixture, CSS class name, or `data-type=` attribute: use the
DATABASE value (left column of the model).
- `ADDITIVE_TYPES = ['Bonus', 'Overtime', 'New Loan', 'Advance Payment']`
in `views.py` uses DB values.
- `if adj.type == 'New Loan':` checks the DB value.
- `<span class="badge-type-{{ adj.type|type_slug }}">` produces
`.badge-type-new-loan` from the DB value.
- `<tr data-type="{{ adj.type }}">` emits the DB value.
- Tests use `PayrollAdjustment.objects.create(type='New Loan', ...)`.
Only user-facing template TEXT uses the short label — via
`{{ adj.get_type_display }}`, Django's built-in choices lookup.
The label mapping lives in `PayrollAdjustment.TYPE_CHOICES`
(`core/models.py`).
**How this happened:** originally the adjustment-creation dropdown
said "New Loan" because that's what the action meant (_"log a new
loan"_). That label then propagated into every other view — tables,
badges, reports. On 24 Apr 2026 we renamed the user-visible labels
to be shorter and cleaner BUT deliberately kept the database values
untouched — to avoid breaking historic rows, tests, and hardcoded
string comparisons across ~30 source locations.
**Symptom of getting this wrong:** code that filters for
`type='Loan'` returns zero rows. Fix: use `type='New Loan'`.
```
**Step 4: Verify the insert didn't break anything**
```
grep -c "^## " CLAUDE.md # section-count should have increased by exactly 1
```
Run the test suite as a sanity check (CLAUDE.md isn't code, but any accidental wholesale rewrite of the file would show up elsewhere):
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 0
```
Expected: 69/69 pass (no code changed).
**Step 5: Commit**
```bash
git add CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(claude): UI-vs-DB naming drift note (pre-rename)
Adds a new CLAUDE.md section documenting the display/DB gap that
Path A of the UX Polish Pass creates: user sees 'Loan' / 'Advance'
/ 'Advance Repaid' while DB stores 'New Loan' / 'Advance Payment'
/ 'Advance Repayment'. Includes a lookup table, the rule for when
to use which (DB for logic, display for templates), and the failure
symptom so future Claude sessions don't chase ghost filters.
Ships BEFORE the rename so the doc is searchable from minute one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Display-only rename (TYPE_CHOICES + visible templates + migration)
**Goal:** The three rename labels take effect everywhere a user sees a type. DB stays untouched. `makemigrations` generates the required no-op `AlterField`.
**Files:**
- Modify: `core/models.py` — the `TYPE_CHOICES` list in `PayrollAdjustment`
- Create: `core/migrations/0012_alter_payrolladjustment_type.py` (auto-generated — do NOT hand-write)
- Modify: `core/templates/core/payroll_dashboard.html` — lines 370, 454 (visible `{{ adj.type }}` emissions)
- Modify: `core/templates/core/_adjustment_row.html` — the type-cell render (uses `badge-type-{{ adj.type|type_slug }}` and also emits `{{ adj.type }}` as visible text — only the visible-text copy changes)
- Grep-audit: other templates (`payslip_detail.html`, PDF templates, any other `{{ adj.type }}` emission) — audit & fix where visible
**Step 1: Edit `core/models.py`**
Read the current `TYPE_CHOICES` block first:
```
grep -n "TYPE_CHOICES = \[" core/models.py
```
Expected: one match around line 190. Read lines 190-210 to confirm current state matches:
```python
TYPE_CHOICES = [
('Bonus', 'Bonus'),
('Overtime', 'Overtime'),
('Deduction', 'Deduction'),
('Loan Repayment', 'Loan Repayment'),
('New Loan', 'New Loan'),
('Advance Payment', 'Advance Payment'),
('Advance Repayment', 'Advance Repayment'),
]
```
Replace with:
```python
# === PayrollAdjustment TYPE_CHOICES — canonical DB value | display label ===
# Path A rename (24 Apr 2026): DB values are PRESERVED as-is. Only the
# second tuple element (the human label) changes for three types, so
# users see shorter labels in tables while every historic row, formula,
# constant, test fixture, CSS class, and data-attribute KEEP WORKING
# UNCHANGED because they all key off the DB value on the left.
# See CLAUDE.md "UI-vs-DB naming drift" section for the full rule.
TYPE_CHOICES = [
('Bonus', 'Bonus'),
('Overtime', 'Overtime'),
('Deduction', 'Deduction'),
('Loan Repayment', 'Loan Repayment'),
('New Loan', 'Loan'),
('Advance Payment', 'Advance'),
('Advance Repayment', 'Advance Repaid'),
]
```
**Step 2: Generate the migration**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py makemigrations core --name alter_payrolladjustment_type_display_labels
```
Expected output: creates `core/migrations/0012_alter_payrolladjustment_type_display_labels.py` with a single `AlterField` operation changing `choices`. The migration is a no-op at the database level — Django tracks choices in its model metadata, not in the DB schema.
Open the generated file and confirm the operation is EXACTLY `AlterField` with `choices=[...]` — no `RunPython`, no `RunSQL`, no schema-altering operation. If you see anything more, stop and ask — something is wrong.
**Step 3: Run the migration locally**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py migrate
```
Expected: `Applying core.0012_alter_payrolladjustment_type_display_labels... OK` — 1 operation, < 1 second.
**Step 4: Grep-audit visible `{{ adj.type }}` template usages**
```
grep -rn "{{ adj\.type }}" core/templates/
grep -rn "{{ adjustment\.type }}" core/templates/
grep -rn "{{ a\.type }}" core/templates/
```
For EACH match, decide:
- Is the surrounding context VISIBLE text (e.g., `>{{ adj.type }}<` inside a badge span, or in a table cell)? → change to `{{ adj.get_type_display }}` (or `{{ adjustment.get_type_display }}` / `{{ a.get_type_display }}`)
- Is it a DATA ATTRIBUTE (`data-adj-type="{{ adj.type }}"`) or a CSS CLASS slug (`badge-type-{{ adj.type|type_slug }}`)? → LEAVE AS-IS. This is an identifier feed, not a display text.
Known sites (from the brainstorm grep — verify each):
1. `core/templates/core/payroll_dashboard.html:370` — inside `<span class="badge ...">{{ adj.type }}</span>` → SWAP to `{{ adj.get_type_display }}`
2. `core/templates/core/payroll_dashboard.html:454``{{ adj.type }}: R {{ adj.amount }}` → SWAP
3. `core/templates/core/payroll_dashboard.html:363``data-adj-type="{{ adj.type }}"` → LEAVE (identifier, consumed by JS)
4. `core/templates/core/_adjustment_row.html` — grep in that file; type appears both as a CSS class slug AND potentially as visible text. Only the visible-text copy swaps.
Also audit PDF + other detail pages:
```
grep -rn "{{ adj\.type }}\|{{ adjustment\.type }}\|{{ a\.type }}" core/templates/core/pdf/ core/templates/core/payslip_detail.html core/templates/core/report.html
```
Apply the same visible-vs-identifier call.
**Step 5: Run the full test suite**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2
```
Expected: 69/69 pass. Tests use DB values in fixtures (`type='New Loan'`) so NONE should break. If any test fails, something in Step 4 went wrong — revert and re-audit.
**Step 6: Visual smoke test via `manage.py shell`**
Quick sanity check that `get_type_display` returns the new labels:
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py shell -c "from core.models import PayrollAdjustment; choices = dict(PayrollAdjustment.TYPE_CHOICES); print('New Loan displays as:', choices.get('New Loan')); print('Advance Payment displays as:', choices.get('Advance Payment')); print('Advance Repayment displays as:', choices.get('Advance Repayment'))"
```
Expected output:
```
New Loan displays as: Loan
Advance Payment displays as: Advance
Advance Repayment displays as: Advance Repaid
```
**Step 7: Commit**
```bash
git add core/models.py core/migrations/0012_*.py core/templates/
git commit -m "$(cat <<'EOF'
ux(labels): shorter adjustment type labels (display-only rename)
Path A rename — DB values untouched, only TYPE_CHOICES display
labels change:
'New Loan' → shown as 'Loan'
'Advance Payment' → shown as 'Advance'
'Advance Repayment' → shown as 'Advance Repaid'
Templates that render the type as visible text switched from
{{ adj.type }} to {{ adj.get_type_display }}. Data attributes and
CSS class slugs keep the raw DB value (identifiers, not labels).
Zero data migration. Zero changes to ADDITIVE_TYPES / DEDUCTIVE_TYPES
constants, hardcoded string comparisons, CSS class names, test
fixtures, or any other code that references the canonical DB value.
Every historic PayrollAdjustment row keeps type='New Loan' /
'Advance Payment' / 'Advance Repayment' as stored.
Django's makemigrations generated a no-op AlterField migration to
record the choices-metadata change.
Tests: 69/69.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Badge colour unification + loan-flag recolor
**Goal:** Replace the three occurrences of the 4-branch Bootstrap-state conditional with a one-liner `badge-type-{{ adj.type|type_slug }}` that reuses the existing semantic palette. Recolour the Pending-tab "Loan" worker flag to the loan type colour.
**Files:**
- Modify: `core/templates/core/payroll_dashboard.html` — lines 360, 453 (type-badge blocks) + line 345 (loan flag)
- Modify: `static/css/custom.css` — add `.loan-flag-badge` class near the `.badge-type-*` block
**Step 1: Preconditions**
Confirm the existing context-variable `additive_types` is threaded to the Pending + History tab contexts. The Adjustments tab already uses it (see `_adjustment_row.html` line 42). For Pending + History, grep:
```
grep -n "additive_types" core/views.py
```
If the Pending/History branch of `payroll_dashboard()` does NOT set `additive_types` in its context, we need to add it. The constant is already defined at `views.py:45` — it's just a matter of adding one key to the context dict. Look for the `context = { ... }` block in the `payroll_dashboard` view that handles the default/pending branch (grep for `'workers_data'` as a nearby key — the same context dict).
If `additive_types` is already set for those branches, no view change is needed. Only the templates change.
**Step 2: Replace the type-badge block at line 360 (Pending tab)**
Current (lines 356-372):
```django
{% for adj in wd.adjustments %}
{# Badge colour logic: #}
{# GREEN = earned money (Bonus, Overtime) or debt recovery (Loan/Advance Repayment) #}
{# YELLOW = loan-related outflow (New Loan, Advance Payment) — matches the Loan tag #}
{# RED = deductions (Deduction) #}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} mb-1 me-1 adjustment-badge"
style="cursor: pointer;"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-amount="{{ adj.amount }}"
data-adj-date="{{ adj.date|date:'Y-m-d' }}"
data-adj-description="{{ adj.description }}"
data-adj-project="{{ adj.project_id|default:'' }}"
data-adj-worker="{{ adj.worker.name }}">
{% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'New Loan' or adj.type == 'Advance Payment' %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
{{ adj.type }}
{% if adj.project %}({{ adj.project.name }}){% endif %}
</span>
{% endfor %}
```
Note: `{{ adj.type }}` at the old line 370 will already have been changed to `{{ adj.get_type_display }}` by Task 3. If Task 3 shipped correctly, the current text at line 370 is `{{ adj.get_type_display }}`. Don't revert it.
Replace the badge block with:
```django
{% for adj in wd.adjustments %}
{# Type badge uses the semantic palette: colour = type (Bonus, Loan, etc.). #}
{# Sign + / reflects additive-vs-deductive (orthogonal to the colour). #}
<span class="badge badge-type-{{ adj.type|type_slug }} mb-1 me-1 adjustment-badge"
style="cursor: pointer;"
data-adj-id="{{ adj.id }}"
data-adj-type="{{ adj.type }}"
data-adj-amount="{{ adj.amount }}"
data-adj-date="{{ adj.date|date:'Y-m-d' }}"
data-adj-description="{{ adj.description }}"
data-adj-project="{{ adj.project_id|default:'' }}"
data-adj-worker="{{ adj.worker.name }}">
{% if adj.type in additive_types %}+{% else %}-{% endif %}R{{ adj.amount|floatformat:2 }}
{{ adj.get_type_display }}
{% if adj.project %}({{ adj.project.name }}){% endif %}
</span>
{% endfor %}
```
Three things happened in this change:
- `class="badge ...multi-line conditional..."``class="badge badge-type-{{ adj.type|type_slug }}"`
- Sign logic refactored from a long `{% if %}` chain to `{% if adj.type in additive_types %}` (cleaner, single source of truth for additive set)
- Outdated comment block removed; new comment describes the current semantic scheme
**Step 3: Replace the type-badge block at line 453 (History tab)**
Current:
```django
{% for adj in record.adjustments.all %}
<span class="badge {% if adj.type == 'Bonus' or adj.type == 'Overtime' or adj.type == 'Loan Repayment' or adj.type == 'Advance Repayment' %}bg-success{% elif adj.type == 'New Loan' or adj.type == 'Advance Payment' %}bg-warning{% else %}bg-danger{% endif %} me-1">
{{ adj.type }}: R {{ adj.amount|floatformat:2 }}
</span>
{% empty %}
```
(The `{{ adj.type }}` on the inner line was already swapped to `{{ adj.get_type_display }}` by Task 3.)
Replace with:
```django
{% for adj in record.adjustments.all %}
<span class="badge badge-type-{{ adj.type|type_slug }} me-1">
{{ adj.get_type_display }}: R {{ adj.amount|floatformat:2 }}
</span>
{% empty %}
```
**Step 4: Recolour the loan flag at line 345 (Pending tab)**
Current line 345:
```django
<span class="badge bg-warning" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>
```
Replace with:
```django
<span class="badge loan-flag-badge" style="font-size: 0.6rem;" title="Has active loan or advance">Loan</span>
```
Line 342 (Overdue flag) stays `bg-danger` — it's transactional / urgency, not type. Don't touch it.
**Step 5: Add the `.loan-flag-badge` CSS class**
In `static/css/custom.css`, find the `.badge-type-advance-repayment` line (around line 1941) and append a new block right after the type-badge definitions:
```css
/* --- Status flags that borrow a type's colour for semantic consistency.
"Has an active loan or advance" → Loan-type amber/yellow, so the
worker flag on the Pending tab visually matches the Adjustments
type badge for Loan. Keeps the Loan colour family unified across
the app regardless of which tab you're looking at. --- */
.loan-flag-badge {
background: var(--badge-loan-bg);
color: var(--badge-loan-fg);
}
```
**Step 6: Run the full test suite**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2
```
Expected: 69/69. Template changes don't hit any test assertion.
**Step 7: Visual smoke-test checklist**
The implementer must mentally walk through these with the template open in an editor (no browser needed if Django check passes):
- [ ] Pending tab: `{% for adj in wd.adjustments %}` block — the badge has exactly one class besides `badge`: `badge-type-{{ adj.type|type_slug }}`. No `bg-success`/`bg-warning`/`bg-danger` left.
- [ ] Pending tab: the "Loan" worker flag uses `loan-flag-badge` class, NOT `bg-warning`.
- [ ] Pending tab: the "Overdue" worker flag still uses `bg-danger`. (Don't "helpfully" change this.)
- [ ] History tab: `{% for adj in record.adjustments.all %}` block — same check as Pending.
- [ ] Paid #N / Unpaid badges elsewhere — still use Bootstrap state classes (`bg-success`/`bg-warning`). NOT touched.
Run `python manage.py check` as a final sanity check:
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py check
```
Expected: no errors (the pre-existing `staticfiles.W004` warning is fine).
**Step 8: Commit**
```bash
git add core/templates/core/payroll_dashboard.html static/css/custom.css
git commit -m "$(cat <<'EOF'
ux(colors): unify badge colours across all payroll tabs
Replaces the 4-branch Bootstrap-state conditional on the Pending
and History tabs with the semantic .badge-type-{{ adj.type|type_slug }}
palette that the Adjustments tab has been using. Now "Loan" badges
are the same colour in every tab instead of Pending=yellow /
Adjustments=amber.
Also recolours the Pending-tab "Loan" worker flag to the same amber
(.loan-flag-badge class). "Overdue" flag stays red — it's an urgency
signal, not a type signal, and we deliberately keep transactional
state colours (Bootstrap bg-success/bg-warning/bg-danger) separate
from the type palette so a green badge can only mean "Bonus" and
never ambiguously "Paid".
Tests: 69/69.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Fix `.adj-group-header` CSS (narrow-wrap bug)
**Goal:** Make the group-summary row span the full table width and push the "N row · +R X net" meta to the right, as originally intended. Root cause: `display: flex` was applied to the `<tr>`, which breaks table rendering (a flexed `<tr>` ignores `colspan` and shrinks to intrinsic content).
**Files:**
- Modify: `static/css/custom.css` — the `.adj-group-header` block around lines 1972-1988
**Step 1: Read the current block**
```
grep -n "\.adj-group-header {" static/css/custom.css
```
Expected: one match at line 1972. Read lines 1972-2000 to see the full current state.
**Step 2: Rewrite the block**
Replace the CURRENT content of lines 1972-1988 (note: preserve line 1984's `:hover` rule and everything below it):
```css
/* --- Group header (collapsible section divider for group-by mode) ---
NOTE: display: flex MUST be on the <td>, NOT on the <tr>. Setting
display on a <tr> removes it from table row/column participation
(colspan is ignored, the row shrinks to intrinsic content width),
which caused the "narrow wrap" screenshot bug in Apr 2026. The td
is an ordinary block box and flexes fine. --- */
.adj-group-header {
cursor: pointer;
background: var(--bg-inset);
border-top: 1px solid var(--border-default);
border-bottom: 1px solid var(--border-default);
user-select: none;
transition: background-color 120ms;
}
.adj-group-header > td {
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.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; white-space: nowrap; }
```
Diff from before:
- REMOVED `display: flex; align-items: center; gap: 0.75rem;` from `.adj-group-header` (it was on the `<tr>`, which was the bug)
- REMOVED `padding: 0.75rem 1rem;` from `.adj-group-header` (moved to the `<td>` where it belongs)
- ADDED `.adj-group-header > td { ... }` with the flex stuff now applied to the `<td>` — where it actually works
- ADDED `white-space: nowrap;` on `.adj-group-meta` so the meta text can overflow or fit on one line, never wrap into an ugly stub even in narrow viewports
- ADDED the explanatory comment documenting why this looks slightly unusual
Leave lines 1994-2007 (the `[data-type="X"]` border-left rules + chevron-rotation rules) alone — they were already correctly scoped and don't need to change.
**Step 3: Run the test suite**
```
set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2
```
Expected: 69/69. CSS-only change; no test should react.
**Step 4: Visual confirmation** (walk the template + CSS mentally)
- [ ] `<tr class="adj-group-header">` no longer gets `display: flex`, so the `<tr>` participates in table layout again. `colspan="10"` on the `<td>` is now honoured — the row spans all 10 columns.
- [ ] `<td>` has `display: flex`, so the icon / label / meta are flex children. `align-items: center` vertically centres them. `gap: 0.75rem` puts space between them.
- [ ] `.adj-group-meta { margin-left: auto; }` now works (it's a flex child of a flex container).
- [ ] `.adj-group-meta { white-space: nowrap; }` prevents the "Bonus 1 / row · +R / 444" stutter wrap even if something downstream tries to squeeze the cell.
- [ ] The `[data-type="X"]` border-left accent still paints the left edge of the row (it targets `.adj-group-header`, which is the `<tr>`; the `<tr>` is once again a normal table row).
**Step 5: Commit**
```bash
git add static/css/custom.css
git commit -m "$(cat <<'EOF'
fix(css): move display:flex from <tr> to <td> on adj-group-header
Root cause of Konrad's narrow-wrap screenshot: display:flex was set
on .adj-group-header (a <tr>), which causes the browser to remove
the row from table layout. A flex-mode <tr> ignores colspan and
shrinks to intrinsic content width — which is why a row with
colspan=10 ended up rendering at ~80-100px and wrapping the meta
text into a 5-char column.
Moved display:flex, align-items, gap, and padding onto the single
<td> child. The td is a normal block box and flexes correctly,
putting icon + label + meta in a horizontal row with the meta
pushed to the right via margin-left:auto (now working since its
parent is a real flex container).
Also added white-space:nowrap on .adj-group-meta so the meta never
wraps mid-phrase even if a narrow viewport squeezes the cell.
Inline comment documents the <tr> vs <td> distinction so future
sessions don't re-introduce the bug.
Tests: 69/69.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Final acceptance checklist
Before declaring the pass complete, the controller verifies:
- [ ] Every commit's prefix is appropriate (`docs(...)`, `ux(...)`, `fix(css)`)
- [ ] `set USE_SQLITE=true && set DJANGO_DEBUG=true && python manage.py test core.tests -v 2` → 69/69 passing
- [ ] `python manage.py check` → only the pre-existing `staticfiles.W004` warning
- [ ] `python manage.py migrate` ran cleanly in local dev (one new migration 0012)
- [ ] `grep -n "New Loan" core/models.py` still finds the DB value in TYPE_CHOICES (left tuple element) → confirms Path A discipline held
- [ ] `grep -rn "{{ adj.type }}" core/templates/` → only identifier contexts remain (data-attrs, CSS class slugs), no visible-text usages
- [ ] `grep -n "bg-success\|bg-warning\|bg-danger" core/templates/core/payroll_dashboard.html` → still finds the "Paid"/"Unpaid"/"Overdue" occurrences (transactional state, correct), but NONE on adjustment-type badges
- [ ] Working tree clean, branch ready to push
---
## What's NOT in this plan (explicit non-goals)
- Any change to DB values of `PayrollAdjustment.type`
- Any edit to `ADDITIVE_TYPES` / `DEDUCTIVE_TYPES` constants
- Any hardcoded-string comparison in `core/views.py`
- Any test fixture or assertion
- Any badge class rename (`.badge-type-new-loan` stays)
- Any `data-type="..."` attribute value change
- Bootstrap state colours being replaced anywhere (we explicitly keep `bg-success`/`bg-warning`/`bg-danger` for transactional badges)
- Adjustments tab layout or filter changes (Pass A for that shipped earlier)
Rollback: `git revert <sha>` on any individual commit. No data, schema, or URL-contract impact in any task.
---
## Execution
Plan complete and saved to `docs/plans/2026-04-24-ux-polish-plan.md`.
Per the arguments this plan was generated with, auto mode is active and the execution path is:
**Sub-skill: `superpowers:subagent-driven-development`**
Controller stays in-session and dispatches fresh subagents per task with spec-compliance + code-quality review after each. Expected ~5 task commits + ~2 fix commits if reviewers find anything = ~7 commits on `ai-dev`.

View File

@ -1940,6 +1940,16 @@ body, .card, .modal-content, .form-control, .form-select,
.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); }
/* --- Status flags that borrow a type's colour for semantic consistency.
"Has an active loan or advance" -> Loan-type amber/yellow, so the
worker flag on the Pending tab visually matches the Adjustments
type badge for Loan. Keeps the Loan colour family unified across
the app regardless of which tab you're looking at. --- */
.loan-flag-badge {
background: var(--badge-loan-bg);
color: var(--badge-loan-fg);
}
/* --- Sticky filter bar (keeps filters visible as the table scrolls) --- */
.adjustments-filter-bar {
position: sticky;
@ -1968,24 +1978,32 @@ body, .card, .modal-content, .form-control, .form-select,
gap: 0.35rem;
}
/* --- Group header (collapsible section divider for group-by mode) --- */
/* --- Group header (collapsible section divider for group-by mode).
IMPORTANT: display: flex MUST be on the <td>, NOT on the <tr>.
Setting display on a <tr> removes it from table row/column
participation colspan is ignored, the row shrinks to intrinsic
content width, and the meta wraps into an ugly 5-char column.
(This was the root cause of the Apr 2026 narrow-wrap screenshot.)
The <td> is an ordinary block box and flexes fine. --- */
.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);
user-select: none;
transition: background-color 120ms;
}
.adj-group-header > td {
padding: 0.75rem 1rem;
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; }
.adj-group-header .adj-group-meta { color: var(--text-secondary); font-size: 0.875rem; margin-left: auto; white-space: nowrap; }
/* --- By-Type group headers: 4px left-accent picks up the type's signature
badge colour so grouped rows visually echo the badges below.