Compare commits
16 Commits
5d6446ae75
...
6d37d1ba9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d37d1ba9b | ||
|
|
39cbda11e5 | ||
|
|
6f4748f4ab | ||
|
|
b06c1a4949 | ||
|
|
c22b1f7ef4 | ||
|
|
8e1f634f8f | ||
|
|
2e60124b9f | ||
|
|
9ae75b45ad | ||
|
|
9276e588a0 | ||
|
|
5720ca95ad | ||
|
|
b0aa35661b | ||
|
|
385d654082 | ||
|
|
b4c3109c29 | ||
|
|
0ec3f66739 | ||
|
|
1c00ba2628 | ||
|
|
a8ef7bb341 |
114
CLAUDE.md
114
CLAUDE.md
@ -194,6 +194,52 @@ 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.
|
||||
|
||||
@ -503,23 +549,83 @@ python manage.py restore_data backup.json
|
||||
|
||||
## Environment Variables
|
||||
```
|
||||
DJANGO_SECRET_KEY, DJANGO_DEBUG, HOST_FQDN, CSRF_TRUSTED_ORIGIN
|
||||
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://)
|
||||
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
|
||||
EMAIL_HOST_USER, EMAIL_HOST_PASSWORD (Gmail App Password — 16 chars)
|
||||
DEFAULT_FROM_EMAIL, SPARK_RECEIPT_EMAIL
|
||||
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
|
||||
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`
|
||||
|
||||
@ -392,6 +392,260 @@
|
||||
})();
|
||||
</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>
|
||||
|
||||
@ -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>
|
||||
<tr {% if user.is_staff or user.is_superuser %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
|
||||
<td>{{ log.date|date:"d M Y" }}</td>
|
||||
<td>{{ log.team.name|default:'—' }}</td>
|
||||
<td class="text-end">{{ log.workers.count }}</td>
|
||||
|
||||
@ -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>
|
||||
<tr {% if user.is_staff or user.is_superuser %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
|
||||
<td>{{ log.date|date:"d M Y" }}</td>
|
||||
<td>{{ log.project.name }}</td>
|
||||
<td class="text-end">{{ log.workers.count }}</td>
|
||||
|
||||
@ -439,7 +439,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<tr {% if is_admin %}class="work-log-row" data-log-id="{{ log.id }}" style="cursor: pointer;"{% endif %}>
|
||||
<td class="ps-4 align-middle">{{ log.date }}</td>
|
||||
<td class="align-middle"><strong>{{ log.project.name }}</strong></td>
|
||||
<td class="align-middle">
|
||||
|
||||
156
core/templates/core/work_log_payroll.html
Normal file
156
core/templates/core/work_log_payroll.html
Normal file
@ -0,0 +1,156 @@
|
||||
{# === 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 %}
|
||||
315
core/tests.py
315
core/tests.py
@ -1,3 +1,314 @@
|
||||
from django.test import TestCase
|
||||
# === 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.
|
||||
|
||||
# Create your tests here.
|
||||
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)
|
||||
|
||||
@ -18,6 +18,13 @@ 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'),
|
||||
|
||||
|
||||
181
core/views.py
181
core/views.py
@ -17,6 +17,7 @@ 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
|
||||
@ -720,6 +721,186 @@ 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.
|
||||
|
||||
168
docs/plans/2026-04-22-work-log-payroll-crosslink-design.md
Normal file
168
docs/plans/2026-04-22-work-log-payroll-crosslink-design.md
Normal file
@ -0,0 +1,168 @@
|
||||
# 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.
|
||||
1347
docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md
Normal file
1347
docs/plans/2026-04-22-work-log-payroll-crosslink-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,9 @@
|
||||
@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
|
||||
|
||||
@ -1481,3 +1481,13 @@ 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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user