Compare commits
11 Commits
8f495064c3
...
f159a9f6f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f159a9f6f2 | ||
|
|
bce2619a71 | ||
|
|
e932b3c3a7 | ||
|
|
f70342f825 | ||
|
|
1cf13048c2 | ||
|
|
c1d9014fe1 | ||
|
|
e51a2f6d1d | ||
|
|
0a4b12108e | ||
|
|
84e9d247be | ||
|
|
9aba9b8fb8 | ||
|
|
b43892f712 |
95
CLAUDE.md
95
CLAUDE.md
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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')
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;">
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
76
docs/design-tokens.md
Normal 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.
|
||||
356
docs/plans/2026-04-24-ux-polish-design.md
Normal file
356
docs/plans/2026-04-24-ux-polish-design.md
Normal 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.
|
||||
742
docs/plans/2026-04-24-ux-polish-plan.md
Normal file
742
docs/plans/2026-04-24-ux-polish-plan.md
Normal 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`.
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user