Problem: every time collectstatic ran on the VM, Flatlogic's web UI
detected the modified files in staticfiles/ and auto-committed them
with a generic "Ver XX.YY" message (e.g. "Ver 30.04 Fix reports and
add Supervisor"), pushing the result to gitea but not GitHub. Every
push of CSS/JS changes triggered a reconciliation dance. See the
"Ver 30.04" divergence resolved by commit e0d2c74 for the most recent
example — that was the 3rd or 4th recurrence of this exact pattern.
Fix:
1. Add staticfiles/ to .gitignore
2. Untrack all 627 currently-tracked files via `git rm -r --cached`
3. Document the change in CLAUDE.md (Project Structure, Static Assets,
and a new "NOT tracked in git" subsection)
Deploy consequence: the NEXT pull on the VM will delete
staticfiles/ from the working tree (because git sees those files
removed from the tree). Gemini MUST run `collectstatic --noinput`
IMMEDIATELY after `git pull` to repopulate from source, then
restart the service. Brief window of 404s on static assets is
acceptable at this scale (seconds).
After this change: collectstatic output lives on the VM's filesystem
but outside git's view, so Flatlogic's UI has nothing to auto-commit.
The recurring divergence pattern is permanently eliminated.
No runtime code changes — all 28 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores the .work-log-row hover rule into the collected CSS.
Replaces the Flatlogic-auto-noise commit (683e2b0) which had the misleading message 'Ver 30.04 Fix reports and add Supervisor' but only contained this same collectstatic output.
Reported: when creating a new team or project from the friendly UI
(/teams/new/ or /projects/new/), the Supervisor dropdown only lists
is_staff / is_superuser accounts. Users who should be eligible to
supervise (e.g. eendman, supervisor_smoke) are invisible in the
picker even though they are active.
Root cause:
core.forms._supervisor_user_queryset filtered to
is_active=True AND (is_staff OR is_superuser OR groups__name='Work Logger')
That was strictly more restrictive than the app's own permission
helper is_supervisor(user) in views.py, which grants supervisor
powers to ANYONE assigned to a team/project (via the team.supervisor
FK or project.supervisors M2M), regardless of group membership.
On Konrad's dev DB that excluded 2 of 6 active users from the picker
(one in a custom group, one in no group) even though both were valid
supervisor candidates by the permission model.
Fix:
Queryset now returns every active user. The act of assigning a user
to a team/project is what confers supervisor-ness downstream, so
the picker no longer needs a pre-registered allow-list. Inactive
users (is_active=False) remain excluded — the one hard guardrail.
Docstring rewritten to explain the new behavior and why. Stale comment
in TeamForm.__init__ updated to match (the old comment still described
the pre-fix Work-Logger-group requirement).
Tests: 4 new regression tests in SupervisorPickerQuerysetTests:
- regular active user is selectable (the core bug)
- user in an unrelated group is selectable
- inactive user is still excluded (guardrail)
- admin is still selectable (no regression for prior use case)
All 28 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported: when the generate-report page is filtered by BOTH project and
team, every amount in the "Worker Breakdown" and "Payments by Date"
tables blew up by ~100x. Example: Billy Baloyi R 5,400 (correct)
became R 604,800 (wrong, 112x) after selecting Wilkot + Civils One.
Root cause:
_build_report_context chained `records.filter(work_logs__project_id=X)
.distinct().filter(work_logs__team_id=Y).distinct()`. In Django's ORM
each chained M2M filter creates a SEPARATE JOIN alias on
core_payrollrecord_work_logs, so the SQL produces the cartesian product
of (matching-logs-for-project) x (matching-logs-for-team) rows per
PayrollRecord. A downstream `.values().annotate(Sum('amount_paid'))`
then summed across those duplicated rows - inflating every total by
N * M where N and M are the log counts per record.
Why total_paid_out looked correct: `.aggregate(Sum(...))` wraps the
query in a subquery when distinct() is in play, so it dedupes before
summing. `.values().annotate(Sum(...))` uses GROUP BY on the raw
joined rows and doesn't get that help.
Fix:
Replace chained M2M filters with id__in subquery filters:
records.filter(id__in=PayrollRecord.objects.filter(
work_logs__project_id=X).values('id'))
This keeps the outer queryset JOIN-free, so values().annotate(Sum())
aggregates over distinct records. Same pattern applied to the
adjustments team-filter (worker__teams M2M) for the adjustment
summary.
Tests: 5 new regression tests in ReportContextFilterInflationTests
covering project-only, team-only, both-filters, total_paid_out
invariant, and the adjustment summary path. All 24 tests pass
(19 existing + 5 new).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a consolidated regression test to WorkLogPayrollAjaxTests that
exercises: paid worker serialization shape, null team branch, OT flag
in JSON, full_page_url value, and adjustment payslip-link serialization.
Closes the 'Important' coverage gap flagged in Task 3's quality review.
Also appends a 'Shipped' block to the design doc summarising QA
status and capturing all five deferred nits (admin-gate consistency,
template branch tests, |default:0 redundancy, admin-gate expression
readability, background vs background-color) so they survive the
merge into project history.
All 19 tests pass. manage.py check clean. No migrations needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subtle background tint on hover to cue that the row is clickable.
Applied via .work-log-row class which Tasks 6-8 added to admin-only
rows in work_history.html, teams/detail.html, and projects/detail.html.
Supervisors never get the class, so hover never applies for them.
Deployment_timestamp cache-bust in base.html will beat Cloudflare's
edge cache (per CLAUDE.md Static Assets section).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Admins see cursor:pointer + data-log-id on each row. Click opens the
shared modal from base.html. Supervisors unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Admins see cursor:pointer + data-log-id on each row. Click opens the
shared modal from base.html. Supervisors unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Admin users get cursor:pointer + data-log-id on each row. Click
opens the shared modal from base.html. Supervisors unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues caught by code quality review on commit 2e60124:
1. C1 (critical): the <script> at line ~398 runs during HTML parsing,
BEFORE the modal markup at line ~627 has been parsed. getElementById
returned null, the `if (!modalEl) return;` guard silently exited the
IIFE, and the delegated click listener was never attached — so the
modal was completely dormant. Wrapped the IIFE body in a
DOMContentLoaded handler so the DOM is fully parsed before lookups.
2. I1 (a11y): added aria-labelledby on the modal root + a matching id on
the modal-title h5 so screen readers announce the title correctly
(Bootstrap 5 a11y convention).
No behavioural changes to the JS logic itself — only the wrapping and
two aria attributes on the markup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modal shell + JS click handler live in base.html so any page opts in
by adding data-log-id to a row. JS uses createElement + textContent
(matches worker_lookup_ajax pattern) to build the modal body from
JSON — no innerHTML. Supervisors never receive the markup.
Footer 'Open full page' links to /history/<id>/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Active breadcrumb item now has aria-current="page" so screen
readers correctly announce the current page (Bootstrap 5 convention).
- Template section comments changed from {# --- #} to {# === #} to
match the CLAUDE.md Python convention used elsewhere in the project.
No logic or rendering changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
work_log_payroll_ajax serializes the helper's output to JSON with
floats (not Decimals), ISO dates, and payroll_record/worker IDs for
client-side link construction. Admin-only; supervisor = 403, anon =
302, unknown log = 404. Matches the worker_lookup_ajax pattern.
Added 4 view-level tests (total 16 passing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The helper used log.overtime (which doesn't exist on WorkLog); the
correct field is overtime_amount. Combined with a defensive
`getattr(..., None) or 0`, the bug made the flag permanently False,
which would have silently hidden the 'Price now' banner in Tasks 3
and 4. Now reads overtime_amount directly (it's non-nullable with a
0.00 default, so no defensive shim is needed).
Adds 4 regression tests:
- test_overtime_needs_pricing_flag: the bug that just got fixed
- test_query_count_is_bounded: N+1 guard (4 queries regardless of worker count)
- test_empty_log_returns_zero_totals: log with no workers attached
- test_log_without_team_has_no_pay_period: log whose team became NULL
Also removes unused `reverse` import from tests.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure-function helper that classifies each worker on a work log as
Paid / Priced-not-paid / Unpaid, collects log-linked adjustments,
and computes totals + pay-period context. Used by both the AJAX
endpoint and the full-page view so they can't drift.
Bootstraps core/tests.py (was empty); 8 tests cover the three
statuses, totals, log-linked adjustments, and the pay-period branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes /history/<id>/ and /history/<id>/payroll/ajax/ to stub views.
Both admin-gated; no data yet. Sets up the surface for Tasks 2-4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task-by-task plan for implementing the modal + /history/<id>/ page
designed in the companion design doc. 10 tasks, 4 hard-pause review
checkpoints (after tasks 2, 4, 6, 10). TDD for the pure helper
function (bootstraps the currently-empty core/tests.py), view-level
tests for the AJAX + detail endpoints, manual smoke tests for the
template/JS work.
Uses the existing worker_lookup_ajax JSON+DOM pattern for the modal
(createElement + textContent, not innerHTML) to match the codebase's
XSS-safe convention. Full page is server-side rendered via a Django
template.
No model changes. No migrations. Admin-only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brainstorm output for the next UI refinement. Adds a click-through from
any historic work log (Work History, team detail Recent Work Logs, project
detail Recent Work Logs) to a compact modal showing paid/unpaid status per
worker, with links out to /workers/<id>/ and /payroll/payslip/<pk>/. The
modal has a "Open full page" button that navigates to a new
/history/<log_id>/ route for bookmark-able detail + pay-period context
(via get_pay_period). Admin-only; supervisors unchanged.
Read-only pass; no model changes, no migrations. Uses existing data:
PayrollRecord.work_logs (M2M) and PayrollAdjustment.work_log (FK).
Also fixes local dev: run_dev.bat now sets DJANGO_DEBUG=true so runserver
auto-serves /static/ (prior behaviour: CSS 404 on localhost because
Django's dev server only serves static files when DEBUG=True; production
keeps DEBUG=false and is served by Apache, so unaffected).
Design doc: docs/plans/2026-04-22-work-log-payroll-crosslink-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents three things that came out of today's Phase 2 deploy session
and weren't previously written down:
1. Static Assets & Cache-Busting (new section): explains that production
traffic goes through Cloudflare with 4h edge cache; the
`deployment_timestamp` template variable is what breaks stale caches;
and why `request.timestamp` must never be used (the silent-default-to-1.0
bug that ate a couple of hours).
2. Environment Variables: inline notes for each var. Most important new
fact is that DEFAULT_FROM_EMAIL is now optional — falls back to
EMAIL_HOST_USER if unset (prevents the "Invalid address ''" failure
mode on outbound mail). Also documents that .env lives at BASE_DIR.parent
on Flatlogic and can only be edited via Gemini/shell.
3. Flatlogic Deployment: collectstatic isn't auto-run, django-dev.service
runs manage.py runserver (dev server in prod — known but works at this
scale), Cloudflare sits in front, VM has two git remotes (github +
gitea) that must stay in sync, VM-local safety branches for rollback,
and the "pick one write path" workflow rule to avoid divergence.
No code changes — documentation only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When DEFAULT_FROM_EMAIL env var isn't set, it defaulted to an empty
string, causing every outbound email (receipts, payslips) to fail
with: Invalid address "".
Phase 1 removed the hardcoded Gmail fallback for security. The
cleanest restore — without reintroducing a secret default — is to
fall back to EMAIL_HOST_USER, which is already the authenticated
Gmail address we send AS. That address is always valid when SMTP
auth works, and it's already set on the VM (otherwise sending
would fail with an auth error instead).
Now:
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") or EMAIL_HOST_USER
Verified locally: when DEFAULT_FROM_EMAIL is unset and EMAIL_HOST_USER
is 'test@example.com', DEFAULT_FROM_EMAIL resolves to the same address.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
My previous commit (fb1a8a2) added a multi-line explanatory comment
using Django's {# ... #} syntax, which is single-line only. The comment
therefore rendered as literal text at the top of every page.
This is the second time this session I've made this exact mistake —
lesson for next time: always render a page on the dev server and grep
the response body for '{#' after template changes, even one-liners.
Verified locally this time: leak count = 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The static asset cache-buster in base.html was using
{{ request.timestamp|default:'1.0' }} — but `request.timestamp` is
not a Django request attribute, so the template always fell back to
the literal '1.0'. Every deploy's CSS URL resolved to the same
`custom.css?v=1.0`, so any CDN or browser cache in front of the app
held onto the pre-redesign CSS forever — even hard refreshes in
incognito couldn't bust it.
Symptom: after deploying the redesigned app, the browser continued
to receive a 1,734-byte pre-redesign custom.css while the VM's
/static/css/custom.css was the full 39,078-byte Premium Orange Theme.
.topbar-nav rules were missing, so the topbar rendered as stacked
block links.
Fix: use `deployment_timestamp` (already provided by
core.context_processors.project_context as int(time.time()) at
render time). Every restart gets a fresh URL, CDNs refetch from
origin, stale caches break.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Override Bootstrap's --bs-table-color to use theme text color so table
numbers (days, amounts, totals) are readable on dark backgrounds. Fix
Loan badge by removing text-dark class and using CSS to force black text
on bg-warning. Add dark mode overrides for disabled form controls, select
option dropdowns, btn-close filter, btn-secondary colors, and Bootstrap
text utility classes (.text-dark, .text-primary, .text-muted, etc.).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move decorative gradient glows from ::before/::after pseudo-elements on
.app-main to a separate .app-glow div. The pseudo-elements were creating
a stacking context that trapped Bootstrap modals (z-index 1055) inside
.app-main, while the backdrop (z-index 1050) was appended to <body> —
causing the backdrop to render on top of the modal content.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the green accent with a warm orange/amber palette and switch to a
dark-first design. Add a fixed sidebar for desktop navigation and a bottom
tab bar for mobile, replacing the top navbar. Cards now use glass-morphism
with left accent bars, buttons use orange gradients, and decorative glow
effects add depth. All 8 page templates updated, both light and dark modes
tested across desktop and mobile viewports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New docs/FEATURES.md covers all 15 feature areas: dashboard, attendance,
work history, payroll, payments, adjustments, loans, worker lookup, worker
management, team schedules, receipts, emails/PDFs, auth, exports, and
deployment tools. CLAUDE.md updated with accurate line count.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New AJAX endpoint (worker_lookup_ajax) returns a comprehensive financial
report card for any active worker. Modal shows: amount payable, outstanding
loans, paid this month/year, loans this year, recent activity, active loans
table, current project + days, PPE sizing, drivers license, and notes.
Worker names across all dashboard tabs are now clickable links that open
the modal. Header button with searchable dropdown for quick access.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New fields: shoe_size, overall_top_size, pants_size, tshirt_size,
has_drivers_license (boolean), drivers_license (file upload).
Admin organised into 3 fieldsets. CSV export updated with new columns.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- views.py now has 27 functions (~2470 lines)
- Document the Quick Adjust button on pending payments rows
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each worker row now has an Adjust button (slider icon) that opens the
Add Adjustment modal with that worker pre-checked and their most recent
project pre-selected. Header Add Adjustment button resets the modal
to a clean state (no workers pre-checked).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When creating a New Loan, a "Pay Immediately" checkbox (checked by
default) processes the loan right away — creates PayrollRecord, sends
payslip to Spark, and records the loan as paid. Unchecking it keeps
the old behavior where the loan sits in Pending Payments.
Also adds loan-only payslip detection (like advance-only) across all
payslip views: email template, PDF template, and browser detail page
show a clean "Loan Payslip" layout instead of "0 days worked".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace "Exclude workers with loans" checkbox with dropdown
(All Workers / With loans only / Without loans) in batch pay modal,
matching the pending payments table filter style
- Fix radio button visual state when switching between
"Until Last Paydate" and "Pay All" modes (set checked after DOM append)
- Update CLAUDE.md with pending table filter and overdue badge docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Loans filter now offers: All Workers / With loans only / Without loans.
Replaces the simpler exclude-only checkbox for more flexibility.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Red 'Overdue' badge on workers with unpaid work from completed pay periods
- Yellow 'Loan' badge on workers with active loans/advances
- Filter bar above table: team dropdown, overdue-only toggle, exclude loans
- All three filters combine (team + overdue + loan) for flexible views
- Overdue detection uses team pay schedule cutoff from get_pay_period()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Backend adds has_loan flag per worker (checks active Loans).
Frontend shows checkbox only when any eligible worker has a loan.
Combined with team filter in a shared applyBatchFilters() function
that shows/hides rows based on both filters simultaneously.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Client-side filter lets admin narrow batch payment list by team.
Selecting a team hides other workers, unchecks them (so they won't
be paid), and updates the summary total. Select All respects the
filter — only toggles visible rows. Filter resets when switching
between schedule/pay-all modes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The radio group was being removed from DOM then accessed via getElementById
which returned null for detached elements, silently breaking the toggle.
Now uses a persistent JS variable reference that survives DOM removal.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Radio buttons in the Batch Pay modal let admin choose between:
- "Until Last Paydate" (default): splits at last completed pay period
- "Pay All": includes all unpaid work regardless of pay schedule
Preview re-fetches when mode changes. Workers without teams are
included in Pay All mode (skipped in schedule mode as before).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Batch Pay: new button on payroll dashboard lets admins pay multiple
workers at once using team pay schedules. Shows preview modal with
eligible workers, then processes all payments in one click.
Fix: "Split at Pay Date" now uses cutoff_date (end of last completed
period) instead of current period end. This includes ALL overdue work
across completed periods, not just one period.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Flatlogic doesn't always run migrations on Pull Latest. Added note
about using /run-migrate/ to fix "Unknown column" errors after deploy.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Flatlogic's "Pull Latest" doesn't always run migrations automatically.
This endpoint lets you visit /run-migrate/ to apply pending migrations
to the production MySQL database from the browser.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Document the new split payslip feature, team pay schedule fields,
pay period calculation helpers, and backward-compatible process_payment.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Enable selective payment of work logs and adjustments instead of
all-or-nothing. The preview modal now shows checkboxes on every item
(all checked by default) with dynamic net pay recalculation.
Teams can be configured with a pay frequency (weekly/fortnightly/monthly)
and anchor start date. When set, a "Split at Pay Date" button appears
that auto-unchecks items outside the current pay period.
Key changes:
- Team model: add pay_frequency and pay_start_date fields
- preview_payslip: return IDs, dates, and pay period info in JSON
- process_payment: accept optional selected_log_ids/selected_adj_ids
- Preview modal JS: checkboxes, recalcNetPay(), Split button, Pay Selected
- Backward compatible: existing Pay button still processes everything
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When hovering over a bar in the Cost by Project chart, the tooltip
now shows the total for that month across all projects at the bottom.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Same wages/additions/deductions breakdown as the home dashboard,
now also shown on the Payroll Dashboard stat card.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>