Compare commits

..

No commits in common. "6d37d1ba9b4efd7c0299d0419dbfac8d02f96bb1" and "5d6446ae75291f69bb39c6f4ab6bc6a31ecc008e" have entirely different histories.

13 changed files with 8 additions and 2554 deletions

114
CLAUDE.md
View File

@ -194,52 +194,6 @@ python manage.py check # System check
pair of body vars for dark mode).
- **Email/PDF payslips**: Worker name dominant — do NOT add prominent FoxFitt branding
## Static Assets & Cache-Busting (Cloudflare is in front)
Production traffic reaches the Flatlogic VM through **Cloudflare** (response headers
include `cf-ray`, `cf-cache-status`, and a `cache-control: max-age=14400`). Static
assets — including `custom.css` — are cached at Cloudflare's edge for up to 4 hours
per unique URL. This is great for performance and bad for deploys if the URL doesn't
change when the file does.
### How cache-busting works now
`base.html` loads CSS as:
```html
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp|default:'1' }}">
```
`deployment_timestamp` comes from `core/context_processors.py::project_context` as
`int(time.time())` — meaning every Django request generates a new query-string value.
Cloudflare treats each new `?v=...` value as a new URL → `cf-cache-status: MISS`
fresh fetch from the VM. Users always see the latest CSS as soon as the Django
process restarts.
**Trade-off**: because the timestamp changes every second, CDN cache-hit rate on
CSS is effectively zero. For a low-traffic app this is fine. If traffic grows,
consider switching to a file-mtime-based token so the URL only changes when the
CSS actually changes.
### The pitfall this replaced
Pre-Apr 2026, the template used `{{ request.timestamp|default:'1.0' }}`. But
`request.timestamp` is **not** a Django request attribute — the variable always
fell back to the literal `'1.0'`. Every deploy's CSS URL resolved to the same
`custom.css?v=1.0`, so Cloudflare held onto a pre-redesign copy for hours while
the VM served the new one. Symptom was "the deploy worked but the page looks wrong"
that only a hard refresh in incognito temporarily fixed. Never use `request.timestamp`
in templates — it doesn't exist.
### When CSS changes don't appear on production
1. Confirm Django is rendering the new URL: `curl -s https://foxlog.flatlogic.app/ | grep -oE 'custom\.css\?v=[^"]+'` — the `v=` number should change per request (or at least per restart)
2. Confirm the CDN honours it: `curl -sI "https://foxlog.flatlogic.app/static/css/custom.css?cb=test$(date +%s)" | grep -i cache` — expect `cf-cache-status: MISS` then `HIT` on repeat
3. If the Django URL still looks like `?v=1.0` (constant), `deployment_timestamp` isn't being injected — check that `core.context_processors.project_context` is listed in `TEMPLATES[0]['OPTIONS']['context_processors']` in `config/settings.py`
### `collectstatic` is required after CSS/JS changes on production
Flatlogic's rebuild does NOT automatically run `collectstatic`. If new CSS is on
disk but the VM's `staticfiles/` hasn't been refreshed, Apache serves the old
collected copy. Have Gemini run `python3 manage.py collectstatic --noinput`
after any PR that touches `static/`.
## PDF Generation (WeasyPrint)
Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade HTML→PDF renderer — it supports real CSS (flexbox, grid, `@font-face`, shadows, `border-radius`, proper cascade) that xhtml2pdf could not handle.
@ -549,83 +503,23 @@ python manage.py restore_data backup.json
## Environment Variables
```
DJANGO_SECRET_KEY # required in prod — startup fails without it
DJANGO_DEBUG # "true"/"false"; defaults to false; keep false in prod
HOST_FQDN, CSRF_TRUSTED_ORIGIN # trusted hostnames (scheme-less ok, auto-prefixed https://)
DJANGO_SECRET_KEY, DJANGO_DEBUG, HOST_FQDN, CSRF_TRUSTED_ORIGIN
DB_NAME, DB_USER, DB_PASS, DB_HOST (default: 127.0.0.1), DB_PORT (default: 3306)
USE_SQLITE # "true" → use SQLite instead of MySQL (local dev only)
EMAIL_HOST_USER # Gmail address — required for any outbound email
EMAIL_HOST_PASSWORD # Gmail App Password (16 chars, no spaces/non-breaking-space)
DEFAULT_FROM_EMAIL # Optional — falls back to EMAIL_HOST_USER if unset
SPARK_RECEIPT_EMAIL # Optional — defaults to FoxFitt's Spark Receipt address
USE_SQLITE # "true" → use SQLite instead of MySQL
EMAIL_HOST_USER, EMAIL_HOST_PASSWORD (Gmail App Password — 16 chars)
DEFAULT_FROM_EMAIL, SPARK_RECEIPT_EMAIL
PROJECT_DESCRIPTION, PROJECT_IMAGE_URL # Flatlogic branding
```
### Email fallback behaviour
`DEFAULT_FROM_EMAIL` is not strictly required — `config/settings.py` sets it as:
```python
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") or EMAIL_HOST_USER
```
…so if the env var is unset or empty, the "From" address on every outbound email
falls back to the authenticated Gmail address (which is always valid since we
send AS that account). Without this fallback, receipt and payslip emails fail
with `Invalid address ""`. If you want to send FROM a different display address
than the authenticated one (e.g. "FoxFitt Payroll <payroll@foxfitt.co.za>"),
set `DEFAULT_FROM_EMAIL` explicitly — but Gmail will likely rewrite it to the
authenticated user anyway unless you've configured a "Send mail as" alias.
### Where env vars live on Flatlogic
Flatlogic's platform has no env-var UI. Values are set in a `.env` file at
`BASE_DIR.parent / ".env"` on the VM (one level up from the repo). Edit via
Gemini/shell — the user cannot modify via Flatlogic's web editor because
`.env` is outside the project tree. The file is loaded by
`python-dotenv` in `config/settings.py` before any `os.getenv()` calls.
## Flatlogic/AppWizzy Deployment
- **Branches**: `ai-dev` = development (Flatlogic AI + Claude Code). `master` = deploy target.
- **Workflow**: Push to `ai-dev` → Flatlogic auto-detects → "Pull Latest" → app rebuilds (~5 min)
- **Deploy from Git** (Settings): Full rebuild from `master` — use for production
- **Migrations**: Sometimes run automatically during rebuild, but NOT always reliable. If you get "Unknown column" errors after pulling latest, visit `/run-migrate/` in the browser to apply pending migrations manually. This endpoint runs `python manage.py migrate` on the production MySQL database.
- **Static files**: Flatlogic's rebuild does NOT auto-run `collectstatic`. After CSS/JS changes have Gemini run `python3 manage.py collectstatic --noinput` + restart the service, otherwise Apache keeps serving the previously-collected copy.
- **Service**: The Django app runs as `django-dev.service` (systemd). Gemini restarts it via `sudo systemctl restart django-dev.service`. It runs `python manage.py runserver 0.0.0.0:8000` — a **development server**, not gunicorn/uwsgi (Flatlogic default, works fine at this scale).
- **CDN**: All production traffic goes through Cloudflare. Response headers show `cf-ray`/`cf-cache-status`. Static assets are cached at the edge for 4h — see "Static Assets & Cache-Busting" section for how the `deployment_timestamp` token breaks stale caches.
- **Never edit `ai-dev` directly on GitHub** — Flatlogic pushes overwrite it
- **Gemini gotcha**: Flatlogic's Gemini AI reads `__pycache__/*.pyc` and gets confused. Tell it: "Do NOT read .pyc files. Only work with .py source files."
- **Sequential workflow**: Don't edit in Flatlogic and Claude Code at the same time
### Git remotes on the VM
The Flatlogic VM has TWO git remotes, both kept in sync:
- `github``https://github.com/Konradzar/LabourPay_v5.git` (our canonical source)
- `gitea``https://gitea.flatlogic.app/admin/<id>-vm.git` (Flatlogic's internal mirror — the one the platform UI watches)
Any push the VM makes must go to BOTH: `git push github ai-dev && git push gitea ai-dev`.
If the two diverge, Flatlogic's dashboard can show a different commit than GitHub,
which silently confuses deploys. Flatlogic's UI occasionally commits as
`Flatlogic Bot <support@flatlogic.com>` (autosaves from the in-browser file editor) —
those commits land on gitea but don't propagate to GitHub unless someone pushes.
### VM-local safety branches
When doing risky deploys (model migrations, branch resets, history rewrites), we
create a safety branch on the VM at the pre-deploy HEAD so Gemini can
`git reset --hard <safety-branch>` + service-restart to roll back in ~60 seconds:
```
git branch pre-<purpose>-YYYYMMDD HEAD
git branch --list "pre-*"
```
Safety branches are VM-local — not pushed to GitHub by default. They're
single-use rollback anchors; delete after 7 days of confirmed stability via
`git branch -D pre-<purpose>-YYYYMMDD`.
### Workflow options going forward
Either works — pick one and stick to it per change to avoid divergence:
1. **Claude → GitHub → Flatlogic pulls**: Claude pushes to origin/ai-dev; you click "Pull Latest" in the Flatlogic UI (or ask Gemini to `git pull + push gitea + restart`).
2. **Flatlogic UI → GitHub**: edit in Flatlogic's file editor; click "Push to GitHub" in their UI; Claude pulls locally with `git pull origin ai-dev`.
**Don't mix** paths in the same change — that's how divergence (and the "Ver XX.YY screeeewup" commits) happen.
## Security Notes
- Production: `SESSION_COOKIE_SECURE=True`, `CSRF_COOKIE_SECURE=True`, `SameSite=None` (cross-origin for Flatlogic iframe)
- Local dev: Secure cookies disabled when `USE_SQLITE=true`

View File

@ -392,260 +392,6 @@
})();
</script>
{# === WORK LOG PAYROLL MODAL — click handler + safe DOM builder === #}
{# Builds the modal body from JSON via createElement + textContent. #}
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
<script>
document.addEventListener('DOMContentLoaded', function() {
var modalEl = document.getElementById('workLogPayrollModal');
if (!modalEl) return;
var bodyEl = document.getElementById('workLogPayrollBody');
var fullLinkEl = document.getElementById('workLogPayrollFullLink');
var bsModal = new bootstrap.Modal(modalEl);
// --- Safe element creator (copied from the worker lookup pattern) ---
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined && text !== null) node.textContent = text;
return node;
}
function link(href, text, className) {
var a = document.createElement('a');
a.setAttribute('href', href);
a.className = className || 'text-decoration-none';
a.textContent = text;
return a;
}
function formatRand(amount) {
return 'R ' + Number(amount).toLocaleString('en-ZA', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
}
function statusBadge(status) {
var span = document.createElement('span');
if (status === 'Paid') {
span.className = 'badge bg-success';
span.textContent = 'Paid';
} else if (status === 'Priced, not paid') {
span.className = 'badge bg-info text-dark';
span.textContent = 'Priced, not paid';
} else {
span.className = 'badge bg-warning text-dark';
span.textContent = 'Unpaid';
}
return span;
}
// --- Reset body to a spinner ---
function showSpinner() {
while (bodyEl.firstChild) bodyEl.removeChild(bodyEl.firstChild);
var wrap = el('div', 'text-center py-4 text-muted');
var spin = el('div', 'spinner-border');
spin.setAttribute('role', 'status');
wrap.appendChild(spin);
wrap.appendChild(el('p', 'mt-2 small', 'Loading…'));
bodyEl.appendChild(wrap);
}
// --- Replace body content with a built DOM fragment ---
function render(data) {
while (bodyEl.firstChild) bodyEl.removeChild(bodyEl.firstChild);
// Header strip (date + project/team/supervisor)
var header = el('div', 'mb-3');
var dateLine = el('div', 'fs-5 fw-semibold', data.date || '');
header.appendChild(dateLine);
var subLine = el('div', 'text-muted small');
if (data.project) subLine.appendChild(link('/projects/' + data.project.id + '/', data.project.name));
else subLine.appendChild(document.createTextNode('—'));
subLine.appendChild(document.createTextNode(' · '));
if (data.team) subLine.appendChild(link('/teams/' + data.team.id + '/', data.team.name));
else subLine.appendChild(document.createTextNode('—'));
subLine.appendChild(document.createTextNode(' · ' + data.worker_rows.length + ' worker' +
(data.worker_rows.length === 1 ? '' : 's')));
if (data.supervisor) subLine.appendChild(document.createTextNode(' · Logged by ' + data.supervisor));
header.appendChild(subLine);
bodyEl.appendChild(header);
// Unpriced OT banner (if needed)
if (data.overtime_needs_pricing) {
var banner = el('div', 'alert alert-warning py-2 px-3 mb-3 small');
banner.appendChild(document.createTextNode('Overtime on this log hasn\u2019t been priced yet. '));
banner.appendChild(link('/payroll/', 'Price now', 'alert-link'));
banner.appendChild(document.createTextNode('.'));
bodyEl.appendChild(banner);
}
// Workers table
var wrap = el('div', 'table-responsive mb-3');
var table = el('table', 'table table-sm align-middle mb-0');
var thead = document.createElement('thead');
var headRow = document.createElement('tr');
['Worker', 'Status', 'Earned', 'Payslip', 'Paid on'].forEach(function(h, i) {
var th = el('th', i === 2 ? 'text-end' : null, h);
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
var tbody = document.createElement('tbody');
data.worker_rows.forEach(function(row) {
var tr = document.createElement('tr');
// Worker cell with link + optional Inactive badge
var tdW = document.createElement('td');
var wLink = link('/workers/' + row.worker_id + '/', row.worker_name);
if (!row.worker_active) wLink.className += ' text-decoration-line-through';
tdW.appendChild(wLink);
if (!row.worker_active) {
var badge = el('span', 'badge bg-secondary ms-1', 'Inactive');
tdW.appendChild(badge);
}
tr.appendChild(tdW);
// Status
var tdS = document.createElement('td');
tdS.appendChild(statusBadge(row.status));
tr.appendChild(tdS);
// Earned
tr.appendChild(el('td', 'text-end', formatRand(row.earned)));
// Payslip link or em-dash
var tdP = document.createElement('td');
if (row.payroll_record_id) {
tdP.appendChild(link('/payroll/payslip/' + row.payroll_record_id + '/', '#' + row.payroll_record_id));
} else {
tdP.textContent = '\u2014';
}
tr.appendChild(tdP);
// Paid on
tr.appendChild(el('td', null, row.paid_date || '\u2014'));
tbody.appendChild(tr);
});
table.appendChild(tbody);
wrap.appendChild(table);
bodyEl.appendChild(wrap);
// Adjustments (optional)
if (data.adjustments && data.adjustments.length) {
var adjWrap = el('div', 'mb-3');
adjWrap.appendChild(el('h6', 'fw-semibold small text-uppercase text-muted mb-2', 'Adjustments on this log'));
var adjTable = el('table', 'table table-sm align-middle mb-0');
var adjHead = document.createElement('thead');
var adjHeadRow = document.createElement('tr');
['Type', 'Worker', 'Amount', 'Payslip'].forEach(function(h, i) {
adjHeadRow.appendChild(el('th', i === 2 ? 'text-end' : null, h));
});
adjHead.appendChild(adjHeadRow);
adjTable.appendChild(adjHead);
var adjBody = document.createElement('tbody');
data.adjustments.forEach(function(adj) {
var tr = document.createElement('tr');
tr.appendChild(el('td', null, adj.type));
var wTd = document.createElement('td');
wTd.appendChild(link('/workers/' + adj.worker_id + '/', adj.worker_name));
tr.appendChild(wTd);
tr.appendChild(el('td', 'text-end', formatRand(adj.amount)));
var pTd = document.createElement('td');
if (adj.payroll_record_id) {
pTd.appendChild(link('/payroll/payslip/' + adj.payroll_record_id + '/', '#' + adj.payroll_record_id));
} else {
pTd.appendChild(el('span', 'text-muted', 'unpaid'));
}
tr.appendChild(pTd);
adjBody.appendChild(tr);
});
adjTable.appendChild(adjBody);
adjWrap.appendChild(adjTable);
bodyEl.appendChild(adjWrap);
}
// Totals footer
var totals = el('div', 'd-flex gap-4 pt-2 border-top small');
function totalPair(label, value) {
var wrap = document.createElement('div');
wrap.appendChild(el('span', 'text-muted', label + ' '));
wrap.appendChild(el('strong', null, formatRand(value)));
return wrap;
}
totals.appendChild(totalPair('Total earned:', data.total_earned));
totals.appendChild(totalPair('Paid:', data.total_paid));
totals.appendChild(totalPair('Outstanding:', data.total_outstanding));
bodyEl.appendChild(totals);
// Footer "Open full page" link target
fullLinkEl.setAttribute('href', data.full_page_url);
}
function renderError() {
while (bodyEl.firstChild) bodyEl.removeChild(bodyEl.firstChild);
bodyEl.appendChild(el('div', 'alert alert-danger', 'Failed to load payroll info for this log.'));
}
// --- Open the modal and fetch data ---
function openForLog(logId) {
showSpinner();
fullLinkEl.setAttribute('href', '/history/' + logId + '/');
bsModal.show();
fetch('/history/' + logId + '/payroll/ajax/')
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
})
.then(render)
.catch(renderError);
}
// --- Delegated click listener: any [data-log-id] triggers the modal ---
document.addEventListener('click', function(ev) {
var target = ev.target.closest('[data-log-id]');
if (!target) return;
// Let real links/buttons inside the row do their own thing.
if (ev.target.closest('a, button')) return;
ev.preventDefault();
openForLog(target.getAttribute('data-log-id'));
});
});
</script>
{% endif %}
{% block extra_js %}{% endblock %}
{# === WORK LOG PAYROLL MODAL (admin-only) === #}
{# Hidden by default. Any element with data-log-id anywhere in the app #}
{# triggers this modal. Fetches JSON and builds the DOM safely. #}
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
<div class="modal fade" id="workLogPayrollModal" tabindex="-1" aria-labelledby="workLogPayrollModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="workLogPayrollModalLabel"><i class="fas fa-calendar-day me-2"></i>Work Log Payroll</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="workLogPayrollBody">
<div class="text-center py-4 text-muted">
<div class="spinner-border" role="status"></div>
<p class="mt-2 small">Loading…</p>
</div>
</div>
<div class="modal-footer">
<a href="#" id="workLogPayrollFullLink" class="btn btn-sm btn-accent">
<i class="fas fa-external-link-alt me-1"></i>Open full page
</a>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endif %}
</body>
</html>

View File

@ -164,7 +164,7 @@
<thead><tr><th>Date</th><th>Team</th><th class="text-end">Workers</th></tr></thead>
<tbody>
{% for log in recent_logs %}
<tr {% if user.is_staff or user.is_superuser %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
<tr>
<td>{{ log.date|date:"d M Y" }}</td>
<td>{{ log.team.name|default:'—' }}</td>
<td class="text-end">{{ log.workers.count }}</td>

View File

@ -155,7 +155,7 @@
<thead><tr><th>Date</th><th>Project</th><th class="text-end">Workers</th></tr></thead>
<tbody>
{% for log in recent_logs %}
<tr {% if user.is_staff or user.is_superuser %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
<tr>
<td>{{ log.date|date:"d M Y" }}</td>
<td>{{ log.project.name }}</td>
<td class="text-end">{{ log.workers.count }}</td>

View File

@ -439,7 +439,7 @@
</thead>
<tbody>
{% for log in logs %}
<tr {% if is_admin %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
<tr>
<td class="ps-4 align-middle">{{ log.date }}</td>
<td class="align-middle"><strong>{{ log.project.name }}</strong></td>
<td class="align-middle">

View File

@ -1,156 +0,0 @@
{# === WORK LOG PAYROLL — FULL PAGE === #}
{# Shareable, bookmark-able view for one work log's payroll status. #}
{# Same data source as the modal; different presentation. #}
{% extends "base.html" %}
{% load format_tags %}
{% block title %}Work Log {{ log.date|date:"d M Y" }} | FoxFitt{% endblock %}
{% block content %}
<div class="container py-4" style="max-width: 960px;">
{# === Breadcrumb === #}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small mb-0">
<li class="breadcrumb-item"><a href="{% url 'work_history' %}" class="text-decoration-none">History</a></li>
<li class="breadcrumb-item active" aria-current="page">
{{ log.date|date:"d M Y" }}
{% if log.project %} · <a href="{% url 'project_detail' log.project.id %}" class="text-decoration-none">{{ log.project.name }}</a>{% endif %}
{% if log.team %} · <a href="{% url 'team_detail' log.team.id %}" class="text-decoration-none">{{ log.team.name }}</a>{% endif %}
</li>
</ol>
</nav>
{# === Page header === #}
<div class="d-flex align-items-start justify-content-between mb-4">
<div>
<h3 class="mb-1"><i class="fas fa-calendar-day me-2"></i>Work Log Payroll</h3>
<p class="text-muted mb-0 small">Who was paid for this day's work and who is still outstanding.</p>
</div>
<a href="{% url 'work_history' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to history
</a>
</div>
{# === Attendance card === #}
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold small text-uppercase text-muted mb-3">Attendance</h6>
<div class="row g-3 small">
<div class="col-md-6">
<div><span class="text-muted">Workers present:</span> <strong>{{ worker_rows|length }}</strong></div>
<div><span class="text-muted">Overtime hours:</span> <strong>{{ log.overtime_amount|default:0 }}</strong></div>
</div>
<div class="col-md-6">
<div><span class="text-muted">Supervisor:</span> <strong>
{% if log.supervisor %}{{ log.supervisor.get_full_name|default:log.supervisor.username }}{% else %}—{% endif %}
</strong></div>
{% if pay_period.0 %}
<div><span class="text-muted">Pay period:</span> <strong>{{ pay_period.0|date:"d M" }} {{ pay_period.1|date:"d M Y" }}</strong></div>
{% else %}
<div><span class="text-muted">Pay period:</span>
<span class="text-muted fst-italic">no schedule</span>
{% if log.team %}<a href="{% url 'team_edit' log.team.id %}" class="small ms-1">configure</a>{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
{# === Unpriced OT banner === #}
{% if overtime_needs_pricing %}
<div class="alert alert-warning py-2 px-3 mb-3 small">
<i class="fas fa-triangle-exclamation me-1"></i>
Overtime on this log hasn't been priced yet.
<a href="{% url 'payroll_dashboard' %}" class="alert-link">Price now</a>.
</div>
{% endif %}
{# === Workers table === #}
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold small text-uppercase text-muted mb-3">Workers on this log</h6>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Worker</th>
<th>Status</th>
<th class="text-end">Earned</th>
<th>Payslip</th>
<th>Paid on</th>
</tr>
</thead>
<tbody>
{% for row in worker_rows %}
<tr>
<td>
<a href="{% url 'worker_detail' row.worker.id %}"
class="text-decoration-none {% if not row.worker.active %}text-decoration-line-through{% endif %}">
{{ row.worker.name }}
</a>
{% if not row.worker.active %}<span class="badge bg-secondary ms-1">Inactive</span>{% endif %}
</td>
<td>
{% if row.status == 'Paid' %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i>Paid</span>
{% elif row.status == 'Priced, not paid' %}
<span class="badge bg-info text-dark">Priced, not paid</span>
{% else %}
<span class="badge bg-warning text-dark"><i class="fas fa-clock me-1"></i>Unpaid</span>
{% endif %}
</td>
<td class="text-end">R {{ row.earned|money }}</td>
<td>
{% if row.payroll_record %}
<a href="{% url 'payslip_detail' row.payroll_record.pk %}" class="text-decoration-none">#{{ row.payroll_record.pk }}</a>
{% else %}—{% endif %}
</td>
<td>{{ row.paid_date|date:"d M Y"|default:"—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# === Adjustments card (only when present) === #}
{% if adjustments %}
<div class="card mb-3">
<div class="card-body">
<h6 class="fw-semibold small text-uppercase text-muted mb-3">Adjustments on this log</h6>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Type</th><th>Worker</th><th class="text-end">Amount</th><th>Payslip</th></tr></thead>
<tbody>
{% for adj in adjustments %}
<tr>
<td>{{ adj.type }}</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>
{% if adj.payroll_record %}
<a href="{% url 'payslip_detail' adj.payroll_record.pk %}" class="text-decoration-none">#{{ adj.payroll_record.pk }}</a>
{% else %}<span class="text-muted">unpaid</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# === Totals footer === #}
<div class="d-flex gap-4 pt-2 small">
<div><span class="text-muted">Total earned:</span> <strong>R {{ total_earned|money }}</strong></div>
<div><span class="text-muted">Paid:</span> <strong>R {{ total_paid|money }}</strong></div>
<div><span class="text-muted">Outstanding:</span> <strong>R {{ total_outstanding|money }}</strong></div>
</div>
</div>
{% endblock %}

View File

@ -1,314 +1,3 @@
# === TESTS FOR WORK LOG PAYROLL CROSS-LINK ===
# Covers the _build_work_log_payroll_context helper — the core logic that
# determines, for each worker on a log, whether they were paid for it.
import datetime
from decimal import Decimal
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from core.models import Project, Team, Worker, WorkLog, PayrollRecord, PayrollAdjustment
from core.views import _build_work_log_payroll_context
class WorkLogPayrollContextTests(TestCase):
"""Tests for the helper that builds the payroll-status view of a work log."""
def setUp(self):
# Minimal scenario: 1 admin, 1 project, 1 team, 3 workers, 1 log.
# Worker A has been paid for the log; Worker B is priced-not-paid;
# Worker C is unpaid.
self.admin = User.objects.create_user(username='admin', is_staff=True)
self.project = Project.objects.create(name='Test Project')
self.team = Team.objects.create(name='Team X', supervisor=self.admin)
self.worker_a = Worker.objects.create(name='Alice', id_number='A1', monthly_salary=Decimal('4000'))
self.worker_b = Worker.objects.create(name='Bob', id_number='B1', monthly_salary=Decimal('4000'))
self.worker_c = Worker.objects.create(name='Carol', id_number='C1', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=self.project,
team=self.team,
supervisor=self.admin,
)
self.log.workers.add(self.worker_a, self.worker_b, self.worker_c)
# Worker A has a PayrollRecord linking them and this log — "Paid".
self.record_a = PayrollRecord.objects.create(
worker=self.worker_a,
amount_paid=Decimal('200.00'),
date=datetime.date(2026, 4, 15),
)
self.record_a.work_logs.add(self.log)
# Worker B appears in priced_workers but has no PayrollRecord — "Priced, not paid".
self.log.priced_workers.add(self.worker_b)
# Worker C has neither — "Unpaid".
def test_returns_log_and_worker_rows(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['log'], self.log)
self.assertEqual(len(ctx['worker_rows']), 3)
def test_paid_worker_has_payslip_link(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_a.id)
self.assertEqual(row['status'], 'Paid')
self.assertEqual(row['payroll_record'], self.record_a)
self.assertGreater(row['earned'], 0)
def test_priced_but_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_b.id)
self.assertEqual(row['status'], 'Priced, not paid')
self.assertIsNone(row['payroll_record'])
def test_totally_unpaid_worker(self):
ctx = _build_work_log_payroll_context(self.log)
row = next(r for r in ctx['worker_rows'] if r['worker'].id == self.worker_c.id)
self.assertEqual(row['status'], 'Unpaid')
self.assertIsNone(row['payroll_record'])
def test_totals(self):
ctx = _build_work_log_payroll_context(self.log)
# Paid = Alice's daily_rate (one record exists for this log+worker).
self.assertEqual(ctx['total_paid'], self.worker_a.daily_rate)
# Outstanding = Bob + Carol each at their daily_rate.
expected = self.worker_b.daily_rate + self.worker_c.daily_rate
self.assertEqual(ctx['total_outstanding'], expected)
def test_adjustments_linked_to_log(self):
adj = PayrollAdjustment.objects.create(
worker=self.worker_a,
project=self.project,
type='Overtime',
amount=Decimal('50.00'),
date=datetime.date(2026, 4, 10),
description='Extra hour',
work_log=self.log,
)
ctx = _build_work_log_payroll_context(self.log)
self.assertIn(adj, ctx['adjustments'])
def test_pay_period_absent_if_no_schedule(self):
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['pay_period'], (None, None))
def test_pay_period_present_when_schedule_configured(self):
self.team.pay_frequency = 'weekly'
self.team.pay_start_date = datetime.date(2026, 1, 5) # A Monday
self.team.save()
ctx = _build_work_log_payroll_context(self.log)
start, end = ctx['pay_period']
self.assertIsNotNone(start)
self.assertIsNotNone(end)
self.assertLessEqual(start, self.log.date)
self.assertGreaterEqual(end, self.log.date)
def test_overtime_needs_pricing_flag(self):
"""Flag fires when log has OT hours but no priced_workers yet."""
# Start: no OT, no priced workers -> flag False
self.assertFalse(_build_work_log_payroll_context(self.log)['overtime_needs_pricing'])
# Add OT hours but keep priced_workers empty -> flag True
self.log.overtime_amount = Decimal('0.50')
self.log.priced_workers.clear()
self.log.save()
self.assertTrue(_build_work_log_payroll_context(self.log)['overtime_needs_pricing'])
# Price the OT -> flag False again
self.log.priced_workers.add(self.worker_a)
self.assertFalse(_build_work_log_payroll_context(self.log)['overtime_needs_pricing'])
def test_query_count_is_bounded(self):
"""Helper should not issue per-worker queries — guards against N+1."""
# 4 queries: payroll_records, priced_workers, workers.all, adjustments
# (plus whichever assertNumQueries overhead Django's test client adds).
# We assert a tight upper bound; regressions that add per-worker queries
# will push this well above the bound and fail the test.
with self.assertNumQueries(4):
_build_work_log_payroll_context(self.log)
def test_empty_log_returns_zero_totals(self):
"""Log with no workers: helper returns empty rows and zero totals."""
empty_log = WorkLog.objects.create(
date=datetime.date(2026, 4, 11),
project=self.project,
team=self.team,
supervisor=self.admin,
)
ctx = _build_work_log_payroll_context(empty_log)
self.assertEqual(ctx['worker_rows'], [])
self.assertEqual(ctx['total_earned'], Decimal('0.00'))
self.assertEqual(ctx['total_paid'], Decimal('0.00'))
self.assertEqual(ctx['total_outstanding'], Decimal('0.00'))
self.assertEqual(ctx['adjustments'], [])
def test_log_without_team_has_no_pay_period(self):
"""Log whose team was later soft-deleted to NULL still works."""
self.log.team = None
self.log.save()
ctx = _build_work_log_payroll_context(self.log)
self.assertEqual(ctx['pay_period'], (None, None))
# The rest of the context should still populate correctly.
self.assertEqual(len(ctx['worker_rows']), 3)
# === TESTS FOR THE WORK LOG PAYROLL AJAX ENDPOINT ===
# These cover the JSON endpoint that the Task-5 modal will consume.
# The endpoint must: return JSON to admins, forbid supervisors/anons,
# and 404 on unknown logs.
class WorkLogPayrollAjaxTests(TestCase):
"""Tests for the JSON AJAX endpoint that powers the modal."""
def setUp(self):
# One admin, one non-admin supervisor, and a simple log with one worker.
self.admin = User.objects.create_user(
username='admin', password='pass', is_staff=True
)
self.supervisor = User.objects.create_user(
username='sup', password='pass', is_staff=False
)
project = Project.objects.create(name='P')
team = Team.objects.create(name='T', supervisor=self.admin)
worker = Worker.objects.create(name='W', id_number='1', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=project, team=team, supervisor=self.admin,
)
self.log.workers.add(worker)
def test_admin_sees_200_json(self):
# Admin hits the endpoint and gets a well-formed JSON body.
self.client.login(username='admin', password='pass')
url = reverse('work_log_payroll_ajax', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data['log_id'], self.log.id)
self.assertEqual(len(data['worker_rows']), 1)
self.assertEqual(data['worker_rows'][0]['status'], 'Unpaid')
def test_supervisor_forbidden(self):
# A non-admin user (even if authenticated) gets 403 JSON.
self.client.login(username='sup', password='pass')
url = reverse('work_log_payroll_ajax', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
def test_anonymous_redirected_to_login(self):
# @login_required intercepts before our view ever runs — 302 to /accounts/login/.
url = reverse('work_log_payroll_ajax', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 302)
def test_missing_log_is_404(self):
# get_object_or_404 returns a 404 if the log_id doesn't exist.
self.client.login(username='admin', password='pass')
resp = self.client.get('/history/99999/payroll/ajax/')
self.assertEqual(resp.status_code, 404)
def test_full_payload_with_paid_worker_null_team_and_ot(self):
"""One scenario exercising: paid worker, null team, OT flag, full URL."""
# Create a paid worker on a separate log with no team (null team edge case)
# and with overtime hours but no priced_workers (unpriced OT flag).
project = Project.objects.create(name='P-full')
paid_worker = Worker.objects.create(
name='Paula', id_number='P1', monthly_salary=Decimal('4000')
)
log = WorkLog.objects.create(
date=datetime.date(2026, 4, 11),
project=project,
team=None, # null team
supervisor=self.admin,
overtime_amount=Decimal('1.50'), # > 0 and no priced_workers -> flag fires
)
log.workers.add(paid_worker)
# Link a PayrollRecord so Paula shows as Paid
record = PayrollRecord.objects.create(
worker=paid_worker,
amount_paid=Decimal('250.00'),
date=datetime.date(2026, 4, 16),
)
record.work_logs.add(log)
# Link an adjustment to the same log
adj = PayrollAdjustment.objects.create(
worker=paid_worker,
project=project,
type='Overtime',
amount=Decimal('75.00'),
date=datetime.date(2026, 4, 11),
description='OT pricing',
work_log=log,
payroll_record=record,
)
self.client.login(username='admin', password='pass')
resp = self.client.get(reverse('work_log_payroll_ajax', args=[log.id]))
self.assertEqual(resp.status_code, 200)
data = resp.json()
# Null team branch
self.assertIsNone(data['team'])
# Project is still present
self.assertEqual(data['project']['name'], 'P-full')
# Paid worker serialization shape
paula_row = next(r for r in data['worker_rows'] if r['worker_name'] == 'Paula')
self.assertEqual(paula_row['status'], 'Paid')
self.assertEqual(paula_row['payroll_record_id'], record.pk)
self.assertEqual(paula_row['paid_date'], '2026-04-16')
# OT flag fires (overtime > 0, no priced_workers)
self.assertTrue(data['overtime_needs_pricing'])
# full_page_url is the reverse of work_log_payroll_detail
self.assertEqual(data['full_page_url'], f'/history/{log.id}/')
# Adjustment serialized with payslip link
self.assertEqual(len(data['adjustments']), 1)
self.assertEqual(data['adjustments'][0]['type'], 'Overtime')
self.assertEqual(data['adjustments'][0]['payroll_record_id'], record.pk)
# === TESTS FOR THE WORK LOG PAYROLL FULL-PAGE VIEW ===
# These cover the HTML page at /history/<id>/ that shares the same context
# builder as the AJAX endpoint. Admin sees a 200 HTML page; supervisor 403.
class WorkLogPayrollDetailTests(TestCase):
"""Tests for the full-page /history/<id>/ view."""
def setUp(self):
self.admin = User.objects.create_user(
username='admin', password='pass', is_staff=True
)
self.supervisor = User.objects.create_user(
username='sup', password='pass', is_staff=False
)
project = Project.objects.create(name='P2')
team = Team.objects.create(name='T2', supervisor=self.admin)
worker = Worker.objects.create(name='Wanda', id_number='X', monthly_salary=Decimal('4000'))
self.log = WorkLog.objects.create(
date=datetime.date(2026, 4, 10),
project=project, team=team, supervisor=self.admin,
)
self.log.workers.add(worker)
def test_admin_gets_full_page(self):
self.client.login(username='admin', password='pass')
url = reverse('work_log_payroll_detail', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'FoxFitt')
self.assertContains(resp, 'History')
self.assertContains(resp, 'Wanda')
def test_supervisor_forbidden(self):
self.client.login(username='sup', password='pass')
url = reverse('work_log_payroll_detail', args=[self.log.id])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 403)
# Create your tests here.

View File

@ -18,13 +18,6 @@ urlpatterns = [
# CSV export — downloads filtered work logs as a spreadsheet
path('history/export/', views.export_work_log_csv, name='export_work_log_csv'),
# === WORK LOG PAYROLL CROSS-LINK (admin-only) ===
# Click a historic work log -> see who got paid and who didn't.
# AJAX endpoint returns JSON (the modal builds its own DOM safely);
# detail view renders the same data as a shareable full page.
path('history/<int:log_id>/', views.work_log_payroll_detail, name='work_log_payroll_detail'),
path('history/<int:log_id>/payroll/ajax/', views.work_log_payroll_ajax, name='work_log_payroll_ajax'),
# CSV export — downloads all worker data (admin only)
path('workers/export/', views.export_workers_csv, name='export_workers_csv'),

View File

@ -17,7 +17,6 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse, HttpResponseForbidden, HttpResponse
from django.middleware.csrf import get_token
from django.urls import reverse
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
@ -721,186 +720,6 @@ def work_history(request):
return render(request, 'core/work_history.html', context)
# =============================================================================
# === WORK LOG PAYROLL CROSS-LINK ===
# From any historic work log, see which workers got paid, which didn't, and
# (for paid ones) which payslip it was. Admin-only; supervisors never see
# payroll data. Two endpoints share one helper so the modal and the full
# page can never drift apart.
# =============================================================================
def _build_work_log_payroll_context(log):
"""Return a context dict describing the payroll status of a work log.
Plain-English summary for future-you:
For the given work log, loop over each worker on it and decide which of
three buckets they fall into:
- "Paid" -> a PayrollRecord links this worker + this log
- "Priced, not paid" -> worker is in log.priced_workers but no record yet
- "Unpaid" -> neither
Also collects any PayrollAdjustments tied to this log (e.g. overtime).
Used by the AJAX endpoint AND the full detail page keep them sharing
this helper so they can never show different data.
"""
# Prefetch payroll records once, rather than re-querying per worker.
payroll_records = list(
PayrollRecord.objects.filter(work_logs=log).select_related('worker')
)
# Lookup: worker_id -> first PayrollRecord found.
record_by_worker = {r.worker_id: r for r in payroll_records}
# IDs of workers who've been priced on this log but aren't necessarily paid yet.
priced_worker_ids = set(log.priced_workers.values_list('id', flat=True))
worker_rows = []
total_earned = Decimal('0.00')
total_paid = Decimal('0.00')
total_outstanding = Decimal('0.00')
# Loop each worker on the log and classify them into one of three buckets.
for worker in log.workers.all():
record = record_by_worker.get(worker.id)
if record:
status = 'Paid'
earned = worker.daily_rate
total_paid += earned
elif worker.id in priced_worker_ids:
status = 'Priced, not paid'
earned = worker.daily_rate
total_outstanding += earned
else:
status = 'Unpaid'
earned = worker.daily_rate
total_outstanding += earned
total_earned += earned
worker_rows.append({
'worker': worker,
'status': status,
'earned': earned,
'payroll_record': record,
'paid_date': record.date if record else None,
})
# Adjustments tied directly to this log (mostly overtime pricing).
# Reverse accessor is adjustments_by_work_log (see PayrollAdjustment.work_log related_name).
adjustments = list(
log.adjustments_by_work_log
.select_related('worker', 'payroll_record')
.order_by('type', 'id')
)
# Pay-period info (only if the team has a schedule configured).
# Use the log's own date as the reference so we report the period the
# log falls into — not whichever period happens to contain "today".
pay_period = get_pay_period(log.team, reference_date=log.date) if log.team else (None, None)
# Overtime "needs pricing" flag: log has OT hours but no priced_workers yet.
# log.overtime_amount is a Decimal with default=0.00 — always present on saved
# instances, so no defensive getattr needed. Compare via Decimal arithmetic.
log_overtime = log.overtime_amount or Decimal('0.00')
overtime_needs_pricing = log_overtime > 0 and not priced_worker_ids
return {
'log': log,
'worker_rows': worker_rows,
'adjustments': adjustments,
'total_earned': total_earned,
'total_paid': total_paid,
'total_outstanding': total_outstanding,
'pay_period': pay_period,
'overtime_needs_pricing': overtime_needs_pricing,
}
@login_required
def work_log_payroll_ajax(request, log_id):
"""Return JSON describing the payroll status of a work log.
Admin-only. The modal's JS builds its DOM from this JSON using
textContent/createElement (matches the worker_lookup_ajax pattern).
"""
# Only admins can see this data (salaries, adjustments, etc.)
if not is_admin(request.user):
return JsonResponse({'error': 'Not authorized'}, status=403)
# Fetch the log with related objects pre-loaded to avoid extra queries
log = get_object_or_404(
WorkLog.objects.select_related('project', 'team', 'supervisor'),
id=log_id,
)
# Shared helper also used by the full-page view (Task 4) — keeps the
# JSON payload and the HTML view in perfect sync.
ctx = _build_work_log_payroll_context(log)
# === SERIALIZE FOR JSON ===
# JSON can't represent Decimals or dates natively, so we convert:
# - Decimal -> float (JS does math in floats anyway)
# - date -> ISO 8601 string ("2026-04-10")
def _date_iso(d):
return d.strftime('%Y-%m-%d') if d else None
# One dict per worker row — small, hand-picked fields the modal needs.
worker_rows = [{
'worker_id': row['worker'].id,
'worker_name': row['worker'].name,
'worker_active': row['worker'].active,
'status': row['status'],
'earned': float(row['earned']),
'payroll_record_id': row['payroll_record'].pk if row['payroll_record'] else None,
'paid_date': _date_iso(row['paid_date']),
} for row in ctx['worker_rows']]
# Adjustments linked directly to this work_log (Overtime, etc.).
adjustments = [{
'type': adj.type,
'amount': float(adj.amount),
'worker_id': adj.worker.id,
'worker_name': adj.worker.name,
'payroll_record_id': adj.payroll_record.pk if adj.payroll_record else None,
} for adj in ctx['adjustments']]
return JsonResponse({
'log_id': log.id,
'date': _date_iso(log.date),
'project': {'id': log.project.id, 'name': log.project.name} if log.project else None,
'team': {'id': log.team.id, 'name': log.team.name} if log.team else None,
# get_full_name() returns "" if no first/last, so fall back to username.
'supervisor': (log.supervisor.get_full_name() or log.supervisor.username) if log.supervisor else None,
'worker_rows': worker_rows,
'adjustments': adjustments,
'total_earned': float(ctx['total_earned']),
'total_paid': float(ctx['total_paid']),
'total_outstanding': float(ctx['total_outstanding']),
'pay_period_start': _date_iso(ctx['pay_period'][0]),
'pay_period_end': _date_iso(ctx['pay_period'][1]),
'overtime_needs_pricing': ctx['overtime_needs_pricing'],
# Link to the full-page view (Task 4) for the "Open full page" button.
'full_page_url': reverse('work_log_payroll_detail', args=[log.id]),
})
@login_required
def work_log_payroll_detail(request, log_id):
"""Full-page payroll-status view for a single work log. Admin-only.
Shares the exact same context builder as the AJAX endpoint, so the
full page and the modal can never drift out of sync.
"""
# Admin-only: this page shows salary-level data.
if not is_admin(request.user):
return HttpResponseForbidden("Admin access required.")
# Fetch the log with related objects pre-loaded to avoid extra queries.
log = get_object_or_404(
WorkLog.objects.select_related('project', 'team', 'supervisor'),
id=log_id,
)
context = _build_work_log_payroll_context(log)
return render(request, 'core/work_log_payroll.html', context)
# === CSV EXPORT ===
# Downloads the filtered work log history as a CSV file.
# Uses the same filters as the work_history page.

View File

@ -1,168 +0,0 @@
# Work Log → Payroll Cross-Link — Design (22 Apr 2026)
## Goal
Let admins click a historic work log (anywhere it appears) and instantly see **which workers on that log have been paid, which haven't, and for paid ones, which payslip paid them** — with hyperlinks through to the existing Worker and Payslip detail pages.
Today the data exists (`PayrollRecord.work_logs` M2M, `PayrollAdjustment.work_log` FK) but is only reachable from the payroll dashboard by working backwards from a payment to its logs. This closes the loop: from a log you can reach its payments, not just the other way round.
## Who it's for
- **Admins** (`is_staff=True` or `is_superuser=True`) — full click-through to payroll data
- **Supervisors** (Work Logger group) — unchanged behaviour. Payroll data stays hidden per the existing permission rule.
## Entry points (three places, same modal)
| Page | Row source |
|---|---|
| `/history/` — Work History | each `WorkLog` in the paginated table |
| `/teams/<id>/` — Recent Work Logs card | same logs, team-filtered |
| `/projects/<id>/` — Recent Work Logs card | same logs, project-filtered |
On each of these, the work-log row becomes clickable for admins: `cursor: pointer`, `--bg-card-hover` on hover, a small chevron icon at the right edge. For supervisors the rows keep their current non-interactive styling.
## Interaction model — hybrid modal + full page
- **Row click** → Bootstrap modal fetches JSON and renders inline. Fast path for "did X get paid for this?". Same pattern as the existing Worker Lookup modal.
- **`[Open full page]` button** inside the modal → navigates to `/history/<log_id>/`, a dedicated bookmark-able page. Unlimited room for detail.
Both views share the same underlying data; the modal is a compact subset of the page.
## What each view shows
### Modal (compact)
1. **Header strip** — date, project (link), team (link), supervisor, worker count, OT-priced flag
2. **Workers table** — one row per `log.workers.all()`:
- Worker name → `/workers/<id>/`
- Status: `Paid` · `Priced, not paid` · `Unpaid`
- Earned from this log (net of adjustments on the same payroll record)
- Payslip reference → `/payroll/payslip/<pk>/` (or `—`)
- Paid on date (or `—`)
3. **Related adjustments** — every `PayrollAdjustment` with `adj.work_log == log`. Worker and payslip as links.
4. **Footer** — total earned, total paid, total outstanding · `[Open full page]` · `[Close]`
### Full page (`/history/<log_id>/`)
Everything the modal has, plus:
- **Breadcrumb**`History {date} · {project} · {team}`, each segment a link
- **Attendance detail block** — OT hours per worker (from `log.overtime`), supervisor, notes
- **Pay-period context** — uses `get_pay_period(log.team)` to show which period this log falls in and expected paydate if unpaid. Graceful "no schedule configured" fallback if the team has no `pay_frequency`.
Layout matches `/workers/<id>/` — single-column card-paneled page, same typography and spacing.
## Status logic (three states)
For each worker on the log:
```
record = PayrollRecord.objects.filter(work_logs=log, worker=worker).first()
if record:
status = "Paid" # show payslip + date
elif worker in log.priced_workers.all():
status = "Priced, not paid" # OT priced, no payroll cycle yet
else:
status = "Unpaid"
```
Same per-worker checking CLAUDE.md documents for the dashboard's outstanding calculation. Handles partially-paid logs correctly.
## Edge cases
- **Brand-new log, never paid** → all workers `Unpaid`, zero totals, no error
- **Partially paid** → mixed statuses per row (the whole reason per-worker checking exists)
- **Overtime not yet priced** (`log.overtime > 0` and `log.priced_workers` empty) → amber banner at top of modal: "Overtime not yet priced · [Price now]" linking to the existing `price_overtime` flow
- **Worker deactivated since** → name struck through, grey `Inactive` pill, still shown
- **Team/project deleted** (SET_NULL) → header shows `—` for the missing reference, no crash
- **Supervisor hits URL directly** → 403 via `is_admin()`, same as every `/payroll/*` view
- **Log with many workers** → modal scrolls internally (`max-height: 70vh`); full page handles it natively
## Implementation shape (no code yet)
### New URLs
```
/history/<int:log_id>/ → work_log_payroll_detail (HTML full page)
/history/<int:log_id>/payroll/ajax/ → work_log_payroll_ajax (JSON for modal)
```
### New views in `core/views.py` (both `@login_required`, both admin-gated)
- `_build_work_log_payroll_context(log)` — private helper. Returns a context dict with workers+status, adjustments, totals, pay-period info. One function, used by both endpoints so the JSON payload and the HTML page can never drift.
- `work_log_payroll_ajax(request, log_id)` — calls the helper, returns JSON for the modal
- `work_log_payroll_detail(request, log_id)` — calls the helper, renders the full-page template
### New template
- `core/templates/core/work_log_payroll.html` — full-page view. Structure mirrors `/workers/<id>/` so it feels like a sibling.
### Small edits to existing templates
- `core/templates/core/work_history.html` — admin-gated `data-log-id` attribute on each row + JS click handler
- `core/templates/core/teams/detail.html` — same on the Recent Work Logs card rows
- `core/templates/core/projects/detail.html` — same
### New JS (inline in `base.html` or `work_log_modal.js`)
- Single click handler, attached to `[data-log-id]` elements across any page
- `fetch` → inject HTML into a shared `#work-log-payroll-modal` defined once in `base.html`
- Same pattern as the existing Worker Lookup modal
### What we DON'T need
- No model changes
- No migrations
- No new fields
- No changes to `process_payment`, `add_adjustment`, or any mutation path
## Cross-links this adds to the app's graph
Before:
```
Payroll Dashboard → Payslip → [dead end]
Work History → [dead end]
```
After:
```
Work History ↘ Payslip ↗ Worker detail
Team detail → Work Log Payroll →
Project detail ↗ Worker name ↘ Worker detail
```
Every work log becomes a first-class navigable node, reachable from three places, linking out to the two existing detail pages.
## Rough scope
- ~150 lines new view code (helper + two endpoints)
- ~120 lines new full-page template
- ~40 lines JS for the modal
- ~30 lines CSS for hover and chevron states
- ~20 lines of edits across three existing templates
No third-party dependencies added.
## Out of scope (deliberately)
- **"Pay these workers now" action** inside the modal/page — kept read-only for this pass; admins still use the payroll dashboard's Pay flow. Can add later if useful.
- **"Add adjustment pre-filled for this log" shortcut** — tempting but expands scope; revisit after the read-only version ships.
- **Supervisor-visible attendance-only view** of the same click-through — supervisors get no click affordance for now; if they later need a non-payroll drill-down, that's a separate design.
## Next step
Hand off to `superpowers:writing-plans` to produce a task-by-task implementation plan with review checkpoints.
---
## Shipped — 22 Apr 2026
**Commits:** 1c00ba2 (design) through the Task 10 "shipped" commit (this one).
**Plan:** `docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md`.
**QA summary:**
- 19 tests pass (`python manage.py test core.tests`)
- `python manage.py check` — no new issues
- No model changes, no migrations, no pushed-to-prod artefacts
- Three entry points clickable for admins; supervisors unchanged
**Deferred for future passes (non-blocking):**
- Admin-gate consistency: work_history uses `{% if is_admin %}`; teams/projects detail templates use `{% if user.is_staff or user.is_superuser %}`. Both semantically identical but stylistically inconsistent.
- Task 4 template branch tests: OT banner, adjustments table, Paid badge, Inactive worker — covered end-to-end via modal tests now, not via Django test client.
- Task 4 template: redundant `|default:0` on `log.overtime_amount` (harmless, renders "0" instead of "0.00").
- Task 5 admin gate: `user.is_authenticated and user.is_staff or user.is_superuser` could be simplified to `user.is_staff or user.is_superuser`.
- `background` shorthand in Task 9 hover rule could be `background-color` for precision (pedantic).
**"Pay these workers now" modal action** and **"Add adjustment pre-filled for this log" shortcut** — explicitly out-of-scope per the design; revisit if users ask.

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,3 @@
@echo off
REM === Local dev launcher ===
REM USE_SQLITE=true -> use SQLite, skip MySQL requirement, relax prod-only checks
REM DJANGO_DEBUG=true -> make Django's dev server auto-serve /static/ files so
REM CSS/JS/images load without needing collectstatic (prod
REM on Flatlogic keeps DEBUG=false; this only affects local)
set USE_SQLITE=true
set DJANGO_DEBUG=true
python manage.py runserver 0.0.0.0:8000

View File

@ -1481,13 +1481,3 @@ body, .card, .modal-content, .form-control, .form-select,
border-color var(--transition-normal),
box-shadow var(--transition-normal);
}
/* === Work log payroll: clickable row hover === */
/* Applied only by base.html / templates that add class="work-log-row" */
/* (admin-only; supervisors never get the class so hover doesn't apply). */
.work-log-row {
transition: background-color 120ms ease-in-out;
}
.work-log-row:hover td {
background: var(--bg-card-hover);
}