Compare commits
21 Commits
88e68f5e36
...
3dab09cea3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dab09cea3 | ||
|
|
a27da90c58 | ||
|
|
fe85c9d7fd | ||
|
|
89c42d25a3 | ||
|
|
68c9afd939 | ||
|
|
9632214f99 | ||
|
|
8ea8955b30 | ||
|
|
b0d382987b | ||
|
|
ea481bfbf4 | ||
|
|
702bba10ed | ||
|
|
748c7c79d7 | ||
|
|
16d192d5fc | ||
|
|
ea1e4bdbcb | ||
|
|
e8ba2c6745 | ||
|
|
ccc44a8d51 | ||
|
|
82594faad7 | ||
|
|
e74f48f050 | ||
|
|
6be6a09056 | ||
|
|
e2eb889a29 | ||
|
|
27cdb46ec9 | ||
|
|
92036f7e4c |
42
CLAUDE.md
42
CLAUDE.md
@ -59,6 +59,14 @@ staticfiles/ — Collected static assets (Bootstrap, admin) — NOT in git (
|
||||
- **WorkerCertificate** — per-worker certifications (Skills, PDP, First Aid, Medical, Work at Height) with `valid_until` expiry and optional document upload. Unique per (worker, cert_type). Has `is_expired` and `expires_soon` (≤30 days) properties.
|
||||
- **WorkerWarning** — disciplinary records per worker with severity (verbal/written/final), reason, optional document. Ordered -date.
|
||||
|
||||
### Schema name-drifts to remember
|
||||
Fields / accessors that differ from what you'd guess. Each has bitten multiple
|
||||
sessions; grep `core/models.py` before using any field you haven't used before:
|
||||
|
||||
- `PayrollAdjustment.description` — NOT `reason`
|
||||
- `log.adjustments_by_work_log` (reverse accessor for PayrollAdjustment.work_log FK) — NOT `payrolladjustment_set` (the FK has `related_name` set)
|
||||
- `log.overtime_amount` (DecimalField, default 0.00) — NOT `log.overtime`
|
||||
|
||||
## Key Business Rules
|
||||
- All business logic lives in the `core/` app — do not create additional Django apps
|
||||
- Workers have a `daily_rate` property: `monthly_salary / Decimal('20.00')`
|
||||
@ -75,6 +83,17 @@ Defined at top of views.py — used in dashboard calculations and payment proces
|
||||
- **ADDITIVE_TYPES** = `['Bonus', 'Overtime', 'New Loan', 'Advance Payment']` — increase worker's net pay
|
||||
- **DEDUCTIVE_TYPES** = `['Deduction', 'Loan Repayment', 'Advance Repayment']` — decrease net pay
|
||||
|
||||
## Django ORM gotcha — M2M filter + aggregate inflation
|
||||
Chained `.filter(m2m__field=X).filter(m2m__other=Y)` creates **separate JOIN
|
||||
aliases**, producing a cartesian product of rows. `.aggregate(Sum(...))` dedupes
|
||||
via subquery when `distinct()` is present; `.values().annotate(Sum(...))` does
|
||||
NOT — it `GROUP BY`s the inflated rows and multiplies sums by N×M (where N and
|
||||
M are the counts of matching related rows). Fix pattern: use
|
||||
`.filter(id__in=Model.objects.filter(m2m__field=X).values('id'))` to keep the
|
||||
outer queryset JOIN-free. See `_build_report_context` in `core/views.py` and
|
||||
`ReportContextFilterInflationTests` in `core/tests.py` for the reference
|
||||
implementation (commit f1e246c, Apr 2026).
|
||||
|
||||
## PayrollAdjustment Type Handling
|
||||
- **Bonus / Deduction** — standalone, require a linked Project
|
||||
- **New Loan** — creates a `Loan` record (`loan_type='loan'`); has a "Pay Immediately" checkbox (checked by default) that auto-processes the loan (creates PayrollRecord, sends payslip to Spark, marks as paid). When unchecked, the loan sits in Pending Payments for the next pay cycle. Editing syncs loan amount/balance/reason; deleting cascades to Loan + unpaid repayments
|
||||
@ -102,6 +121,9 @@ python manage.py setup_test_data # Populate sample workers, projects,
|
||||
python manage.py import_production_data # Import real production data (14 workers)
|
||||
python manage.py collectstatic # Collect static files for production
|
||||
python manage.py check # System check
|
||||
|
||||
# Run the test suite (sets env vars inline — works in Git Bash; on cmd.exe use `set` first)
|
||||
USE_SQLITE=true DJANGO_DEBUG=true python manage.py test core.tests -v 2
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
@ -420,13 +442,16 @@ When editing a Team or Project via the friendly UI (`/teams/<id>/edit/` or
|
||||
`_supervisor_user_queryset()` in `core/forms.py`:
|
||||
|
||||
```python
|
||||
User.objects.filter(is_active=True).filter(
|
||||
Q(is_staff=True) | Q(is_superuser=True) | Q(groups__name='Work Logger')
|
||||
).distinct()
|
||||
User.objects.filter(is_active=True).order_by('username')
|
||||
```
|
||||
|
||||
So anyone who's either an admin OR a Work Logger shows up as an eligible
|
||||
supervisor. Deactivated accounts (`is_active=False`) are hidden.
|
||||
Any active user can be picked. The picker is deliberately NOT pre-filtered
|
||||
by group/staff flags because `is_supervisor(user)` (views.py) grants
|
||||
supervisor powers to anyone assigned to a team/project FK/M2M — so the
|
||||
picker shouldn't be stricter than the permission model. Pre-Apr 2026 the
|
||||
picker required Work Logger group membership, which hid valid supervisors
|
||||
(see commit 0ceceeb for the fix + regression tests). Deactivated accounts
|
||||
are still hidden.
|
||||
|
||||
### Typical user setups
|
||||
|
||||
@ -449,10 +474,13 @@ we'd have to add a separate flag or group check — not currently supported.
|
||||
1. Go to `/admin/auth/user/add/` and create the user with a username and
|
||||
password. **Uncheck "Staff status"** on the initial form (they don't need
|
||||
Django admin access).
|
||||
2. On the user's change page, add them to the **Work Logger** group.
|
||||
2. (Optional) Add them to the **Work Logger** group if you want
|
||||
`is_supervisor(user)` to return True even without a team/project
|
||||
assignment. Not required for the picker to show them — the picker
|
||||
shows any active user (see commit 0ceceeb, Apr 2026).
|
||||
3. (Optional) Assign them as the supervisor of one or more teams via
|
||||
`/teams/<id>/edit/` (Supervisor dropdown — they'll appear in the list
|
||||
because of their Work Logger group membership).
|
||||
because they're active).
|
||||
4. (Optional) Add them to one or more projects via `/projects/<id>/edit/`
|
||||
(Supervisors M2M checklist).
|
||||
5. They can now log in at `/accounts/login/` and will land on the Dashboard
|
||||
|
||||
@ -61,20 +61,18 @@
|
||||
<!-- Project Filter (optional) -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Project <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select name="project" class="form-select">
|
||||
<option value="">All Projects</option>
|
||||
<select name="project" class="form-select report-multi" multiple data-placeholder="All projects (leave empty for all)">
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
<option value="{{ p.id }}"{% if p.id|stringformat:"s" in selected_project_ids %} selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Team Filter (optional) -->
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Team <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<select name="team" class="form-select">
|
||||
<option value="">All Teams</option>
|
||||
<select name="team" class="form-select report-multi" multiple data-placeholder="All teams (leave empty for all)">
|
||||
{% for t in teams %}
|
||||
<option value="{{ t.id }}">{{ t.name }}</option>
|
||||
<option value="{{ t.id }}"{% if t.id|stringformat:"s" in selected_team_ids %} selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@ -132,3 +130,31 @@
|
||||
if (modeCustom) modeCustom.addEventListener('change', toggleMode);
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# === CHOICES.JS — multi-select enhancement (admin-only) === #}
|
||||
{# Loaded CDN-only; falls back to native <select multiple> if the CDN fails. #}
|
||||
{% if user.is_staff or user.is_superuser %}
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/styles/choices.min.css"
|
||||
integrity="sha384-9oHz8X4XgvL+WkhPjPTMHviP0FM/eWUHWFmAVXKJ3PnbIK8Vi2ranPMgb0LZhaeQ"
|
||||
crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/scripts/choices.min.js"
|
||||
integrity="sha384-9r5e85TmdjVjyjYzZAV3TG5A6tcrmD7JjNBGfT2r1wp9txUPttent/DMiMuOwRNG"
|
||||
crossorigin="anonymous"
|
||||
defer></script>
|
||||
<script>
|
||||
(function() {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof Choices === 'undefined') return; // graceful fallback
|
||||
document.querySelectorAll('.report-multi').forEach(function(el) {
|
||||
new Choices(el, {
|
||||
removeItemButton: true,
|
||||
shouldSort: false,
|
||||
placeholder: true,
|
||||
placeholderValue: el.getAttribute('data-placeholder') || '',
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@ -5,17 +5,14 @@
|
||||
<style>
|
||||
/* ==========================================================
|
||||
PAGE SETUP
|
||||
Standard A4 portrait with generous margins. WeasyPrint
|
||||
understands @page rules for headers/footers; we use a
|
||||
simple bottom-margin footer block instead of a running
|
||||
element so the footer can reference `now` from context.
|
||||
========================================================== */
|
||||
@page {
|
||||
size: a4 portrait;
|
||||
margin: 2cm 1.8cm 1.6cm 1.8cm;
|
||||
@frame footer_frame {
|
||||
-pdf-frame-content: footerContent;
|
||||
bottom: 0.6cm;
|
||||
margin-left: 1.8cm;
|
||||
margin-right: 1.8cm;
|
||||
height: 0.8cm;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
@ -30,12 +27,14 @@
|
||||
p { margin: 3pt 0; }
|
||||
|
||||
/* ==========================================================
|
||||
COVER
|
||||
COVER — brand eyebrow + orange title band + filter pills
|
||||
The accent line colour was updated to the brand amber
|
||||
(#e8851a) so the PDF matches the web app's orange accent.
|
||||
========================================================== */
|
||||
.brand-eyebrow {
|
||||
font-size: 7.5pt;
|
||||
font-weight: bold;
|
||||
color: #10b981;
|
||||
color: #e8851a;
|
||||
letter-spacing: 3pt;
|
||||
margin-bottom: 4pt;
|
||||
}
|
||||
@ -45,8 +44,8 @@
|
||||
margin: 0;
|
||||
}
|
||||
table.cover-band td {
|
||||
border-top: 1pt solid #10b981;
|
||||
border-bottom: 1pt solid #10b981;
|
||||
border-top: 1pt solid #e8851a;
|
||||
border-bottom: 1pt solid #e8851a;
|
||||
padding: 9pt 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@ -64,39 +63,53 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cover-filters {
|
||||
font-size: 10pt;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.3pt;
|
||||
margin: 4pt 0 14pt 0;
|
||||
font-size: 9.5pt;
|
||||
color: #475569;
|
||||
letter-spacing: 0.2pt;
|
||||
margin: 6pt 0 14pt 0;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
SECTION STRUCTURE
|
||||
CHAPTER HEADINGS
|
||||
Matches the HTML's "chapter-num I" style: a small filled
|
||||
square-ish marker with the Roman numeral next to the
|
||||
chapter title. No Font Awesome in PDFs — just text.
|
||||
========================================================== */
|
||||
.section {
|
||||
margin-top: 16pt;
|
||||
}
|
||||
h2.section-title {
|
||||
.chapter-heading {
|
||||
margin: 16pt 0 8pt 0;
|
||||
padding-bottom: 4pt;
|
||||
border-bottom: 0.8pt solid #e8851a;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.break-before {
|
||||
page-break-before: always;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 7pt;
|
||||
.chapter-num {
|
||||
display: inline-block;
|
||||
background-color: #e8851a;
|
||||
color: #ffffff;
|
||||
font-size: 9pt;
|
||||
font-weight: bold;
|
||||
color: #10b981;
|
||||
letter-spacing: 2.5pt;
|
||||
margin-bottom: 3pt;
|
||||
padding: 2pt 7pt;
|
||||
margin-right: 8pt;
|
||||
letter-spacing: 0.5pt;
|
||||
}
|
||||
h2.section-title {
|
||||
font-size: 13pt;
|
||||
.chapter-title {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
margin: 0 0 10pt 0;
|
||||
padding-bottom: 4pt;
|
||||
border-bottom: 0.5pt solid #cbd5e1;
|
||||
letter-spacing: 0.2pt;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
SECTION STRUCTURE — smaller sub-headings within a chapter
|
||||
========================================================== */
|
||||
.section { margin-top: 10pt; }
|
||||
h2.section-title {
|
||||
page-break-after: avoid;
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
margin: 10pt 0 6pt 0;
|
||||
padding-bottom: 3pt;
|
||||
border-bottom: 0.4pt solid #cbd5e1;
|
||||
}
|
||||
h3.sub-title {
|
||||
font-size: 9pt;
|
||||
@ -105,35 +118,121 @@
|
||||
letter-spacing: 1pt;
|
||||
margin: 8pt 0 3pt 0;
|
||||
}
|
||||
.break-before { page-break-before: always; }
|
||||
.eyebrow {
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
color: #e8851a;
|
||||
letter-spacing: 2.5pt;
|
||||
margin-bottom: 3pt;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
HERO CARD — 50% SMALLER
|
||||
Halved the overall visual weight per feedback:
|
||||
• padding dropped from 9pt → 4pt top/bottom
|
||||
• hero-value dropped from 22pt → 14pt
|
||||
• label/caption scaled down in proportion
|
||||
Result: card is roughly half the height it was before.
|
||||
HERO KPI BAND — 4 cards laid out 2x2 so they fit on the
|
||||
portrait page without shrinking the big numbers. Each cell
|
||||
is a mini stat card: small label, bold value, subline.
|
||||
========================================================== */
|
||||
table.kpi-band {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 6pt;
|
||||
margin: 2pt 0 10pt 0;
|
||||
}
|
||||
table.kpi-band td.kpi {
|
||||
width: 50%;
|
||||
background-color: #f8fafc;
|
||||
border-left: 3pt solid #e8851a;
|
||||
padding: 8pt 10pt;
|
||||
vertical-align: top;
|
||||
}
|
||||
/* Colour-vary left border to echo the HTML hero tiles */
|
||||
table.kpi-band td.kpi-danger { border-left-color: #dc2626; }
|
||||
table.kpi-band td.kpi-warning { border-left-color: #d97706; }
|
||||
table.kpi-band td.kpi-info { border-left-color: #0ea5e9; }
|
||||
.kpi-label {
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
color: #64748b;
|
||||
letter-spacing: 1.8pt;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 16pt;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
line-height: 1;
|
||||
margin: 4pt 0 2pt 0;
|
||||
}
|
||||
.kpi-subline {
|
||||
font-size: 7.5pt;
|
||||
color: #64748b;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
SMALLER STAT CARD GRID — 6 stats for Selected Period
|
||||
Rendered as a 3x2 grid so the labels + numbers all fit on
|
||||
the portrait page. Same border-colour trick as the hero.
|
||||
========================================================== */
|
||||
table.stat-grid {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 5pt;
|
||||
margin: 2pt 0 8pt 0;
|
||||
}
|
||||
table.stat-grid td.stat {
|
||||
width: 33.33%;
|
||||
background-color: #f8fafc;
|
||||
border-left: 2.5pt solid #e8851a;
|
||||
padding: 6pt 8pt;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.stat-grid td.stat-danger { border-left-color: #dc2626; }
|
||||
table.stat-grid td.stat-warning { border-left-color: #d97706; }
|
||||
table.stat-grid td.stat-success { border-left-color: #059669; }
|
||||
table.stat-grid td.stat-info { border-left-color: #0ea5e9; }
|
||||
.stat-label {
|
||||
font-size: 6.5pt;
|
||||
font-weight: bold;
|
||||
color: #64748b;
|
||||
letter-spacing: 1.2pt;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
line-height: 1;
|
||||
margin: 3pt 0 0 0;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
HERO — legacy single-card block used by the small
|
||||
"Total Paid Out" hero inside Chapter II (kept for the
|
||||
SELECTED PERIOD section's existing design).
|
||||
========================================================== */
|
||||
table.hero {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 4pt 0 14pt 0;
|
||||
margin: 4pt 0 10pt 0;
|
||||
}
|
||||
table.hero td {
|
||||
background-color: #f8fafc;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.hero td.hero-accent {
|
||||
background-color: #10b981;
|
||||
background-color: #e8851a;
|
||||
width: 3pt;
|
||||
padding: 0;
|
||||
}
|
||||
table.hero td.hero-body {
|
||||
padding: 4pt 14pt;
|
||||
}
|
||||
/* Hero spacing is dominated by line-height, not margin.
|
||||
line-height: 1 collapses the phantom "leading" above/below
|
||||
the value glyphs → ~50% tighter gaps around "R 64 939.00". */
|
||||
.hero-label {
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
@ -157,10 +256,10 @@
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
LEDGER LINES — with R-symbol aligned in its own column
|
||||
Splitting the value cell into two cells (rsym + rnum) means
|
||||
every "R" in a column appears at the same x-position, while
|
||||
the numbers right-align neatly on their own edge.
|
||||
LEDGER LINES — used in Chapter II for compact key/value
|
||||
tables (Labour Cost, Payments by Date, Adjustments). The
|
||||
split "rsym"/"rnum" trick keeps every R aligned in its
|
||||
own column so the numbers right-align cleanly.
|
||||
========================================================== */
|
||||
table.ledger {
|
||||
width: 100%;
|
||||
@ -191,9 +290,6 @@
|
||||
padding-right: 10pt;
|
||||
width: 55pt;
|
||||
}
|
||||
/* The two cells that together form a money value.
|
||||
rsym: left-aligned "R" anchored at a fixed x-position
|
||||
rnum: right-aligned number, bold black */
|
||||
table.ledger td.rsym {
|
||||
text-align: left;
|
||||
color: #0f172a;
|
||||
@ -213,57 +309,56 @@
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
TWO-COLUMN LAYOUT
|
||||
LIFETIME PROJECTS — wider table with Start / Working
|
||||
Days / Avg per Working Day columns. Not a ledger because
|
||||
we need column headers.
|
||||
========================================================== */
|
||||
table.cols {
|
||||
table.lifetime {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
margin-top: 4pt;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
table.cols td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
table.lifetime th {
|
||||
text-align: left;
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.8pt;
|
||||
padding: 4pt 5pt 5pt 5pt;
|
||||
border-bottom: 1pt solid #0f172a;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
table.cols td.colL { width: 45%; }
|
||||
table.cols td.gap { width: 10%; }
|
||||
table.cols td.colR { width: 45%; }
|
||||
|
||||
/* Extra breathing room between the two rows of the Period
|
||||
Breakdown section (Labour Cost row ⇢ Payments/Adjustments row) */
|
||||
table.cols-spaced {
|
||||
margin-top: 18pt;
|
||||
table.lifetime th.r { text-align: right; }
|
||||
table.lifetime td {
|
||||
padding: 5pt;
|
||||
border-bottom: 0.4pt solid #e2e8f0;
|
||||
color: #334155;
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.lifetime td.name {
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
}
|
||||
table.lifetime td.r {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.lifetime td.total {
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.lifetime td.dim {
|
||||
color: #cbd5e1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
PERIOD DETAIL — 15% smaller text in this section only
|
||||
Scoped via the .period-detail wrapper so other sections keep
|
||||
their normal size.
|
||||
========================================================== */
|
||||
.period-detail h3.sub-title {
|
||||
font-size: 8pt; /* was 9pt */
|
||||
}
|
||||
.period-detail table.ledger td.lbl {
|
||||
font-size: 8pt; /* was 9.5pt */
|
||||
}
|
||||
.period-detail table.ledger td.meta {
|
||||
font-size: 7.5pt; /* was 8.5pt */
|
||||
}
|
||||
.period-detail table.ledger td.rsym,
|
||||
.period-detail table.ledger td.rnum {
|
||||
font-size: 8.5pt; /* was 10pt */
|
||||
}
|
||||
/* Use split padding-top/bottom (NOT the shorthand) so horizontal
|
||||
padding defined on .meta and .rsym is preserved — otherwise the
|
||||
shorthand clobbers it and you get "130 daysR" with no gap. */
|
||||
.period-detail table.ledger td {
|
||||
padding-top: 3pt;
|
||||
padding-bottom: 3pt;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
WORKER BREAKDOWN TABLE
|
||||
Money values inside use a nested mini-table so R and number
|
||||
live in their own columns (same alignment trick as ledger).
|
||||
WORKER BREAKDOWN TABLE — unchanged layout, now used for
|
||||
Chapter III only.
|
||||
========================================================== */
|
||||
table.worker {
|
||||
width: 100%;
|
||||
@ -296,19 +391,88 @@
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Total Paid column: bolder, darker for emphasis */
|
||||
table.worker td.total {
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Empty-value variant (em-dash) */
|
||||
table.worker td.dim {
|
||||
color: #cbd5e1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
PIVOT TABLE — Chapter IV Team x Project activity
|
||||
========================================================== */
|
||||
table.pivot {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 4pt;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
table.pivot th {
|
||||
text-align: left;
|
||||
font-size: 7pt;
|
||||
font-weight: bold;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.8pt;
|
||||
padding: 4pt 5pt 5pt 5pt;
|
||||
border-bottom: 1pt solid #0f172a;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
table.pivot th.r { text-align: right; }
|
||||
table.pivot td {
|
||||
padding: 4pt 5pt;
|
||||
border-bottom: 0.4pt solid #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
table.pivot td.name {
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
}
|
||||
table.pivot td.r {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.pivot td.dim {
|
||||
color: #cbd5e1;
|
||||
text-align: right;
|
||||
}
|
||||
table.pivot td.total {
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.pivot tr.total-row td {
|
||||
border-top: 0.8pt solid #0f172a;
|
||||
border-bottom: none;
|
||||
font-weight: bold;
|
||||
color: #0f172a;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
TWO-COLUMN LAYOUT (kept for the Chapter II sub-blocks)
|
||||
========================================================== */
|
||||
table.cols {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
}
|
||||
table.cols td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
table.cols td.colL { width: 48%; }
|
||||
table.cols td.gap { width: 4%; }
|
||||
table.cols td.colR { width: 48%; }
|
||||
table.cols-spaced {
|
||||
margin-top: 12pt;
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
MISC
|
||||
========================================================== */
|
||||
@ -317,13 +481,14 @@
|
||||
font-size: 9pt;
|
||||
padding: 5pt 0;
|
||||
}
|
||||
#footerContent {
|
||||
.footer {
|
||||
margin-top: 20pt;
|
||||
padding-top: 6pt;
|
||||
border-top: 0.3pt solid #e2e8f0;
|
||||
font-size: 7pt;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
letter-spacing: 0.5pt;
|
||||
border-top: 0.3pt solid #e2e8f0;
|
||||
padding-top: 4pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@ -331,6 +496,11 @@
|
||||
|
||||
<!-- ==============================================================
|
||||
COVER
|
||||
Brand eyebrow + title band + static filter-pill line. The filter
|
||||
line uses plain text (no x-buttons) because this is a static PDF.
|
||||
project_name and team_name already arrive comma-joined from the
|
||||
report-context helper — "All Projects" / "All Teams" when unset,
|
||||
or "Wilkot Boerdery, Solar Farm Alpha" style for multi-select.
|
||||
============================================================== -->
|
||||
<div class="brand-eyebrow">FOXFITT CONSTRUCTION</div>
|
||||
<table class="cover-band">
|
||||
@ -339,168 +509,141 @@
|
||||
<td class="cover-date">{{ start_date|date:"d F Y" }} – {{ end_date|date:"d F Y" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cover-filters">{{ project_name }} • {{ team_name }}</div>
|
||||
|
||||
<!-- ==============================================================
|
||||
ALL TIME
|
||||
(All-time and year-to-date sections now appear FIRST, before the
|
||||
Selected Period block — the big-picture lifetime view sets context
|
||||
before we zoom in to the selected date range.)
|
||||
============================================================== -->
|
||||
|
||||
<div class="section">
|
||||
<div class="eyebrow">LIFETIME PERFORMANCE</div>
|
||||
<h2 class="section-title">All Time — Labour Cost</h2>
|
||||
|
||||
<table class="cols">
|
||||
<tr>
|
||||
<td class="colL">
|
||||
<h3 class="sub-title">BY PROJECT</h3>
|
||||
{% if alltime_projects %}
|
||||
<table class="ledger">
|
||||
{% for item in alltime_projects %}
|
||||
<tr>
|
||||
<td class="rank">{{ forloop.counter }}</td>
|
||||
<td class="lbl">{{ item.project }}</td>
|
||||
<td class="rsym">R</td>
|
||||
<td class="rnum">{{ item.total|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p class="empty">No project data yet.</p>{% endif %}
|
||||
</td>
|
||||
<td class="gap"></td>
|
||||
<td class="colR">
|
||||
<h3 class="sub-title">BY TEAM</h3>
|
||||
{% if alltime_teams %}
|
||||
<table class="ledger">
|
||||
{% for item in alltime_teams %}
|
||||
<tr>
|
||||
<td class="rank">{{ forloop.counter }}</td>
|
||||
<td class="lbl">{{ item.team }}</td>
|
||||
<td class="rsym">R</td>
|
||||
<td class="rnum">{{ item.total|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p class="empty">No team data yet.</p>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cover-filters">
|
||||
{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}
|
||||
· {{ project_name }}
|
||||
· {{ team_name }}
|
||||
</div>
|
||||
|
||||
<!-- ==============================================================
|
||||
THIS YEAR
|
||||
HERO KPI BAND — 4 cards in a 2x2 grid
|
||||
Shows the big-picture numbers up front:
|
||||
1. Paid This Period (total_paid_out for the selected range)
|
||||
2. Outstanding Now (what's currently unpaid across FoxFitt)
|
||||
3. FoxFitt Avg / Day (lifetime company average)
|
||||
4. FoxFitt Avg / Month (lifetime × ~30.44)
|
||||
============================================================== -->
|
||||
<div class="section">
|
||||
<div class="eyebrow">YEAR-TO-DATE</div>
|
||||
<h2 class="section-title">{{ current_year }} — Labour Cost</h2>
|
||||
|
||||
<table class="cols">
|
||||
<table class="kpi-band">
|
||||
<tr>
|
||||
<td class="colL">
|
||||
<h3 class="sub-title">BY PROJECT</h3>
|
||||
{% if year_projects %}
|
||||
<table class="ledger">
|
||||
{% for item in year_projects %}
|
||||
<tr>
|
||||
<td class="rank">{{ forloop.counter }}</td>
|
||||
<td class="lbl">{{ item.project }}</td>
|
||||
<td class="rsym">R</td>
|
||||
<td class="rnum">{{ item.total|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p class="empty">No project data for {{ current_year }}.</p>{% endif %}
|
||||
</td>
|
||||
<td class="gap"></td>
|
||||
<td class="colR">
|
||||
<h3 class="sub-title">BY TEAM</h3>
|
||||
{% if year_teams %}
|
||||
<table class="ledger">
|
||||
{% for item in year_teams %}
|
||||
<tr>
|
||||
<td class="rank">{{ forloop.counter }}</td>
|
||||
<td class="lbl">{{ item.team }}</td>
|
||||
<td class="rsym">R</td>
|
||||
<td class="rnum">{{ item.total|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p class="empty">No team data for {{ current_year }}.</p>{% endif %}
|
||||
</td>
|
||||
<td class="kpi kpi-danger">
|
||||
<div class="kpi-label">Paid This Period</div>
|
||||
<div class="kpi-value">R {{ total_paid_out|money }}</div>
|
||||
<div class="kpi-subline">{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}</div>
|
||||
</td>
|
||||
<td class="kpi kpi-warning">
|
||||
<div class="kpi-label">Outstanding Now</div>
|
||||
<div class="kpi-value">R {{ current_outstanding.total|money }}</div>
|
||||
<div class="kpi-subline">as of {{ current_as_of|date:"d M Y H:i" }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<tr>
|
||||
<td class="kpi kpi-info">
|
||||
<div class="kpi-label">FoxFitt Avg / Day</div>
|
||||
<div class="kpi-value">R {{ company_avg_daily|money }}</div>
|
||||
<div class="kpi-subline">lifetime avg · {{ company_working_days }} working days</div>
|
||||
</td>
|
||||
<td class="kpi kpi-info">
|
||||
<div class="kpi-label">FoxFitt Avg / Month</div>
|
||||
<div class="kpi-value">R {{ company_avg_monthly|money }}</div>
|
||||
<div class="kpi-subline">lifetime avg · ~30.44 days/month</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- ==============================================================
|
||||
SELECTED PERIOD (new page, compact text via .period-detail)
|
||||
All summary figures for the chosen date range live here:
|
||||
- Hero: Total Paid Out (headline KPI)
|
||||
- Loans pair (issued + outstanding)
|
||||
- Advances pair (issued + outstanding)
|
||||
- Labour Cost by project / team
|
||||
- Payments by date / Adjustments
|
||||
- Worker breakdown (next section, flows naturally)
|
||||
The .period-detail wrapper shrinks ledger text 15%; the hero
|
||||
card uses its own classes so its headline stays prominent.
|
||||
CHAPTER I — Lifetime Context
|
||||
All-time totals by project (with start date / working days /
|
||||
avg per working day) and by team (simpler: name + total).
|
||||
============================================================== -->
|
||||
<div class="section break-before period-detail">
|
||||
<div class="eyebrow">SELECTED PERIOD</div>
|
||||
<h2 class="section-title">Period Breakdown</h2>
|
||||
<div class="chapter-heading">
|
||||
<span class="chapter-num">I</span><span class="chapter-title">Lifetime Context</span>
|
||||
</div>
|
||||
|
||||
<!-- Hero: the headline KPI for this period -->
|
||||
<table class="hero">
|
||||
<h2 class="section-title">All Time — Projects</h2>
|
||||
{% if alltime_projects %}
|
||||
<table class="lifetime">
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Start</th>
|
||||
<th class="r">Working Days</th>
|
||||
<th class="r">Total Cost</th>
|
||||
<th class="r">Avg R / Working Day</th>
|
||||
</tr>
|
||||
{% for item in alltime_projects %}
|
||||
<tr>
|
||||
<td class="name">{{ item.project }}</td>
|
||||
<td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span style="color:#cbd5e1;">—</span>{% endif %}</td>
|
||||
<td class="r">{% if item.working_days %}{{ item.working_days }}{% else %}<span style="color:#cbd5e1;">—</span>{% endif %}</td>
|
||||
<td class="total">R {{ item.total|money }}</td>
|
||||
<td class="r">{% if item.working_days %}R {{ item.avg_per_working_day|money }}{% else %}<span style="color:#cbd5e1;">—</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p class="empty">No lifetime project data.</p>{% endif %}
|
||||
|
||||
<h2 class="section-title">All Time — Teams</h2>
|
||||
{% if alltime_teams %}
|
||||
<table class="lifetime">
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
<th class="r">Total Cost</th>
|
||||
</tr>
|
||||
{% for item in alltime_teams %}
|
||||
<tr>
|
||||
<td class="name">{{ item.team }}</td>
|
||||
<td class="total">R {{ item.total|money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p class="empty">No lifetime team data.</p>{% endif %}
|
||||
|
||||
<!-- ==============================================================
|
||||
CHAPTER II — Selected Period
|
||||
The detailed breakdown for the chosen date range. Starts on a
|
||||
fresh page so lifetime context (Chapter I) doesn't crowd it.
|
||||
Structure:
|
||||
- 6 stat cards (Paid · Worker-Days · Loans×2 · Advances×2)
|
||||
- Labour Cost by Project / by Team (side-by-side)
|
||||
- Payments by Date / Adjustments (side-by-side)
|
||||
============================================================== -->
|
||||
<div class="break-before">
|
||||
<div class="chapter-heading">
|
||||
<span class="chapter-num">II</span><span class="chapter-title">Selected Period: {{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 6 stat cards in a 3x2 grid -->
|
||||
<table class="stat-grid">
|
||||
<tr>
|
||||
<td class="hero-accent"></td>
|
||||
<td class="hero-body">
|
||||
<div class="hero-label">TOTAL PAID OUT</div>
|
||||
<div class="hero-value">R {{ total_paid_out|money }}</div>
|
||||
<div class="hero-caption">across {{ total_worker_days }} worker-days in this period</div>
|
||||
<td class="stat stat-danger">
|
||||
<div class="stat-label">Total Paid Out</div>
|
||||
<div class="stat-value">R {{ total_paid_out|money }}</div>
|
||||
</td>
|
||||
<td class="stat stat-info">
|
||||
<div class="stat-label">Worker-Days</div>
|
||||
<div class="stat-value">{{ total_worker_days }}</div>
|
||||
</td>
|
||||
<td class="stat stat-success">
|
||||
<div class="stat-label">Loans Issued</div>
|
||||
<div class="stat-value">R {{ loans_issued|money }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="stat stat-warning">
|
||||
<div class="stat-label">Loans Outstanding</div>
|
||||
<div class="stat-value">R {{ loans_outstanding|money }}</div>
|
||||
</td>
|
||||
<td class="stat stat-success">
|
||||
<div class="stat-label">Advances Issued</div>
|
||||
<div class="stat-value">R {{ advances_issued|money }}</div>
|
||||
</td>
|
||||
<td class="stat stat-warning">
|
||||
<div class="stat-label">Advances Outstanding</div>
|
||||
<div class="stat-value">R {{ advances_outstanding|money }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Loans pair (left) + Advances pair (right) -->
|
||||
<!-- Each column shows issued first, then outstanding — grouped
|
||||
by instrument type for easier scanning. -->
|
||||
<table class="cols">
|
||||
<tr>
|
||||
<td class="colL">
|
||||
<h3 class="sub-title">LOANS</h3>
|
||||
<table class="ledger">
|
||||
<tr>
|
||||
<td class="lbl">Loans issued</td>
|
||||
<td class="rsym">R</td>
|
||||
<td class="rnum">{{ loans_issued|money }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">Loans outstanding</td>
|
||||
<td class="rsym">R</td>
|
||||
<td class="rnum">{{ loans_outstanding|money }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td class="gap"></td>
|
||||
<td class="colR">
|
||||
<h3 class="sub-title">ADVANCES</h3>
|
||||
<table class="ledger">
|
||||
<tr>
|
||||
<td class="lbl">Advances issued</td>
|
||||
<td class="rsym">R</td>
|
||||
<td class="rnum">{{ advances_issued|money }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="lbl">Advances outstanding</td>
|
||||
<td class="rsym">R</td>
|
||||
<td class="rnum">{{ advances_outstanding|money }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- First row: Labour Cost by project / team -->
|
||||
<!-- Labour Cost by Project / by Team (side-by-side) -->
|
||||
<table class="cols">
|
||||
<tr>
|
||||
<td class="colL">
|
||||
@ -537,7 +680,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Second row: extra top margin creates clear visual gap -->
|
||||
<!-- Payments by Date / Adjustments (side-by-side) -->
|
||||
<table class="cols cols-spaced">
|
||||
<tr>
|
||||
<td class="colL">
|
||||
@ -574,13 +717,14 @@
|
||||
</div>
|
||||
|
||||
<!-- ==============================================================
|
||||
WORKER BREAKDOWN
|
||||
Uses nested mini-tables inside each money cell so the R and the
|
||||
number line up column-wise across every row.
|
||||
CHAPTER III — Worker Breakdown
|
||||
Per-worker detail: days worked, total paid, plus one column
|
||||
per active adjustment type. Same table as before.
|
||||
============================================================== -->
|
||||
<div class="section">
|
||||
<div class="eyebrow">PER-WORKER DETAIL</div>
|
||||
<h2 class="section-title">Worker Breakdown</h2>
|
||||
<div class="chapter-heading">
|
||||
<span class="chapter-num">III</span><span class="chapter-title">Worker Breakdown</span>
|
||||
</div>
|
||||
|
||||
{% if worker_breakdown %}
|
||||
<table class="worker">
|
||||
@ -612,10 +756,59 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ==============================================================
|
||||
CHAPTER IV — Team × Project Activity
|
||||
Pivot table: rows = teams, columns = projects, cells = distinct
|
||||
work days. The `dictlookup` filter (from core/templatetags/
|
||||
format_tags.py) lets the template fetch cells by project id
|
||||
from the `row.cells_by_project_id` dict.
|
||||
============================================================== -->
|
||||
<div class="section">
|
||||
<div class="chapter-heading">
|
||||
<span class="chapter-num">IV</span><span class="chapter-title">Team × Project Activity</span>
|
||||
</div>
|
||||
|
||||
{% if team_project_activity.rows %}
|
||||
<table class="pivot">
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
{% for col in team_project_activity.columns %}
|
||||
<th class="r">{{ col.name }}</th>
|
||||
{% endfor %}
|
||||
<th class="r">Total</th>
|
||||
</tr>
|
||||
{% for row in team_project_activity.rows %}
|
||||
<tr>
|
||||
<td class="name">{{ row.team_name }}</td>
|
||||
{% for col in team_project_activity.columns %}
|
||||
{% with days=row.cells_by_project_id|dictlookup:col.id %}
|
||||
{% if days %}
|
||||
<td class="r">{{ days }}</td>
|
||||
{% else %}
|
||||
<td class="dim">—</td>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
<td class="total">{{ row.row_total }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td>Total</td>
|
||||
{% for col in team_project_activity.columns %}
|
||||
<td class="r">{{ team_project_activity.col_totals|dictlookup:col.id }}</td>
|
||||
{% endfor %}
|
||||
<td class="r">{{ team_project_activity.grand_total }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">No team × project activity in this period.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ==============================================================
|
||||
FOOTER
|
||||
============================================================== -->
|
||||
<div id="footerContent">
|
||||
<div class="footer">
|
||||
GENERATED {{ now|date:"d M Y H:i" }} • FOXFITT CONSTRUCTION • CONFIDENTIAL
|
||||
</div>
|
||||
|
||||
|
||||
@ -30,6 +30,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === FILTER PILLS === #}
|
||||
<div class="filter-pill-strip d-flex flex-wrap gap-2 mb-4 d-print-none">
|
||||
<span class="filter-pill">
|
||||
<i class="fas fa-calendar me-1"></i>{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}
|
||||
</span>
|
||||
<span class="filter-pill">
|
||||
<i class="fas fa-folder me-1"></i>{{ project_name }}
|
||||
{% if selected_project_ids %}
|
||||
<a href="?{{ query_string_without_project|default:query_string }}" class="filter-pill__x" aria-label="Clear project filter">×</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="filter-pill">
|
||||
<i class="fas fa-users me-1"></i>{{ team_name }}
|
||||
{% if selected_team_ids %}
|
||||
<a href="?{{ query_string_without_team|default:query_string }}" class="filter-pill__x" aria-label="Clear team filter">×</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- === PRINT HEADER === -->
|
||||
<div class="d-none d-print-block mb-4">
|
||||
<h2 class="text-center fw-bold mb-1">FoxFitt Construction — Payroll Report</h2>
|
||||
@ -39,88 +58,94 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ===================================================================
|
||||
ALL TIME & THIS YEAR — big-picture context, now shown FIRST so
|
||||
readers see the lifetime/YTD picture before the selected period.
|
||||
=================================================================== -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- All Time by Project -->
|
||||
{# === HERO KPI BAND === #}
|
||||
<div class="row g-3 mb-4 hero-kpi-row">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="stat-card stat-card--danger stat-card--hero h-100">
|
||||
<div class="stat-label">Paid This Period</div>
|
||||
<div class="stat-value">R {{ total_paid_out|money }}</div>
|
||||
<div class="stat-subline">{{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="stat-card stat-card--warning stat-card--hero h-100">
|
||||
<div class="stat-label">Outstanding Now</div>
|
||||
<div class="stat-value">R {{ current_outstanding.total|money }}</div>
|
||||
<div class="stat-subline">as of {{ current_as_of|date:"H:i" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="stat-card stat-card--info stat-card--hero h-100">
|
||||
<div class="stat-label">FoxFitt Avg / Day</div>
|
||||
<div class="stat-value">R {{ company_avg_daily|money }}</div>
|
||||
<div class="stat-subline">lifetime avg · {{ company_working_days }} working days</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="stat-card stat-card--info stat-card--hero h-100">
|
||||
<div class="stat-label">FoxFitt Avg / Month</div>
|
||||
<div class="stat-value">R {{ company_avg_monthly|money }}</div>
|
||||
<div class="stat-subline">lifetime avg · ~30.44 days/month</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === CHAPTER I — Lifetime Context === #}
|
||||
<h5 class="chapter-heading mb-3"><span class="chapter-num">I</span>Lifetime Context</h5>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-globe me-1" style="color: var(--accent);"></i>All Time — Projects</h6>
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>All Time — Projects</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if alltime_projects %}
|
||||
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
|
||||
<thead><tr><th>Project</th><th class="text-end">Cost</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in alltime_projects %}
|
||||
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.project }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 report-numeric">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Start</th>
|
||||
<th class="text-end">Working Days</th>
|
||||
<th class="text-end">Total Cost</th>
|
||||
<th class="text-end">Avg R / Working Day</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in alltime_projects %}
|
||||
<tr>
|
||||
<td class="fw-medium">{{ item.project }}</td>
|
||||
<td>{% if item.start_date %}{{ item.start_date|date:"d M Y" }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td class="text-end">{{ item.working_days|default:"—" }}</td>
|
||||
<td class="text-end fw-semibold">R {{ item.total|money }}</td>
|
||||
<td class="text-end">{% if item.working_days %}R {{ item.avg_per_working_day|money }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}<p class="text-muted text-center py-3 mb-0">No lifetime project data.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- All Time by Team -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-globe me-1" style="color: var(--accent);"></i>All Time — Teams</h6>
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold"><i class="fas fa-users me-2" style="color: var(--accent);"></i>All Time — Teams</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if alltime_teams %}
|
||||
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
|
||||
<thead><tr><th>Team</th><th class="text-end">Cost</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in alltime_teams %}
|
||||
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.team }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- This Year by Project -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-calendar me-1" style="color: var(--accent);"></i>{{ current_year }} — Projects</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if year_projects %}
|
||||
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
|
||||
<thead><tr><th>Project</th><th class="text-end">Cost</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in year_projects %}
|
||||
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.project }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- This Year by Team -->
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="m-0 fw-bold" style="font-size: 0.8rem;"><i class="fas fa-calendar me-1" style="color: var(--accent);"></i>{{ current_year }} — Teams</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if year_teams %}
|
||||
<table class="table table-sm mb-0" style="font-size: 0.8rem;">
|
||||
<thead><tr><th>Team</th><th class="text-end">Cost</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in year_teams %}
|
||||
<tr><td class="text-truncate" style="max-width: 120px;">{{ item.team }}</td><td class="text-end fw-semibold text-nowrap">R {{ item.total|money }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class="text-muted text-center py-2 mb-0" style="font-size: 0.8rem;">No data</p>{% endif %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 report-numeric">
|
||||
<thead><tr><th>Team</th><th class="text-end">Total Cost</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in alltime_teams %}
|
||||
<tr><td>{{ item.team }}</td><td class="text-end fw-semibold">R {{ item.total|money }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}<p class="text-muted text-center py-3 mb-0">No lifetime team data.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -132,9 +157,7 @@
|
||||
under the Selected Period heading, grouped as Loans pair and
|
||||
Advances pair for quick scanning.
|
||||
=================================================================== -->
|
||||
<h5 class="fw-bold mb-3" style="color: var(--text-primary);">
|
||||
<i class="fas fa-filter me-2" style="color: var(--accent);"></i>Selected Period: {{ start_date|date:"d M Y" }} — {{ end_date|date:"d M Y" }}
|
||||
</h5>
|
||||
<h5 class="chapter-heading mb-3"><span class="chapter-num">II</span>Selected Period: {{ start_date|date:"d M Y" }} – {{ end_date|date:"d M Y" }}</h5>
|
||||
|
||||
<!-- === SUMMARY CARDS — scoped to the selected period === -->
|
||||
<!-- Order: Total Paid Out, Worker-Days, Loans pair, Advances pair -->
|
||||
@ -271,6 +294,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === CHAPTER III — Worker Breakdown === #}
|
||||
<h5 class="chapter-heading mb-3"><span class="chapter-num">III</span>Worker Breakdown</h5>
|
||||
<!-- Worker Breakdown -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3">
|
||||
@ -279,7 +304,7 @@
|
||||
<div class="card-body p-0">
|
||||
{% if worker_breakdown %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<table class="table table-sm mb-0 report-numeric">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
@ -308,6 +333,53 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === CHAPTER IV — Team × Project Activity === #}
|
||||
<h5 class="chapter-heading mb-3"><span class="chapter-num">IV</span>Team × Project Activity</h5>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold"><i class="fas fa-th me-2" style="color: var(--accent);"></i>Distinct Work Days per Team × Project</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if team_project_activity.rows %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 report-numeric">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team</th>
|
||||
{% for col in team_project_activity.columns %}
|
||||
<th class="text-end">{{ col.name }}</th>
|
||||
{% endfor %}
|
||||
<th class="text-end fw-bold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in team_project_activity.rows %}
|
||||
<tr>
|
||||
<td class="fw-medium">{{ row.team_name }}</td>
|
||||
{% for col in team_project_activity.columns %}
|
||||
<td class="text-end">
|
||||
{% with days=row.cells_by_project_id|dictlookup:col.id %}
|
||||
{% if days %}{{ days }}{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td class="text-end fw-semibold">{{ row.row_total }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="table-total-row">
|
||||
<td class="fw-bold">Total</td>
|
||||
{% for col in team_project_activity.columns %}
|
||||
<td class="text-end fw-bold">{{ team_project_activity.col_totals|dictlookup:col.id }}</td>
|
||||
{% endfor %}
|
||||
<td class="text-end fw-bold">{{ team_project_activity.grand_total }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}<p class="text-muted text-center py-3 mb-0">No team × project activity in this period.</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Action Bar -->
|
||||
<div class="d-flex justify-content-between align-items-center d-print-none">
|
||||
<a href="{% url 'home' %}" class="btn btn-outline-secondary"><i class="fas fa-arrow-left me-1"></i>Back to Dashboard</a>
|
||||
|
||||
@ -25,3 +25,20 @@ def money(value):
|
||||
# Python's :, format gives comma separators — swap commas for spaces
|
||||
formatted = f"{num:,.2f}"
|
||||
return formatted.replace(",", " ")
|
||||
|
||||
|
||||
@register.filter
|
||||
def dictlookup(d, key):
|
||||
"""Look up a dict value by a dynamic key.
|
||||
|
||||
Plain-English: in Django templates, `{{ mydict.foo }}` only works when
|
||||
`foo` is a literal key. For `mydict[col.id]` with a variable key you need
|
||||
a filter — this one. Returns None if the key is missing or the input
|
||||
isn't a dict.
|
||||
"""
|
||||
if d is None:
|
||||
return None
|
||||
try:
|
||||
return d.get(key)
|
||||
except (AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
289
core/tests.py
289
core/tests.py
@ -368,28 +368,28 @@ class ReportContextFilterInflationTests(TestCase):
|
||||
)
|
||||
self.record.work_logs.add(*self.logs)
|
||||
|
||||
def _ctx(self, project_id=None, team_id=None):
|
||||
def _ctx(self, project_ids=None, team_ids=None):
|
||||
return _build_report_context(
|
||||
datetime.date(2026, 3, 1),
|
||||
datetime.date(2026, 3, 31),
|
||||
project_id=project_id,
|
||||
team_id=team_id,
|
||||
project_ids=project_ids,
|
||||
team_ids=team_ids,
|
||||
)
|
||||
|
||||
def test_worker_breakdown_not_inflated_with_project_filter_only(self):
|
||||
ctx = self._ctx(project_id=self.project.id)
|
||||
ctx = self._ctx(project_ids=[self.project.id])
|
||||
self.assertEqual(len(ctx['worker_breakdown']), 1)
|
||||
# Pre-fix: this was 600 × 3 = 1800 (one JOIN, 3-way inflation).
|
||||
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('600.00'))
|
||||
|
||||
def test_worker_breakdown_not_inflated_with_both_filters(self):
|
||||
ctx = self._ctx(project_id=self.project.id, team_id=self.team.id)
|
||||
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
|
||||
self.assertEqual(len(ctx['worker_breakdown']), 1)
|
||||
# Pre-fix: this was 600 × 3 × 3 = 5400 (two JOINs, 9-way inflation).
|
||||
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('600.00'))
|
||||
|
||||
def test_payments_by_date_not_inflated_with_both_filters(self):
|
||||
ctx = self._ctx(project_id=self.project.id, team_id=self.team.id)
|
||||
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
|
||||
payments = list(ctx['payments_by_date'])
|
||||
self.assertEqual(len(payments), 1)
|
||||
self.assertEqual(payments[0]['total'], Decimal('600.00'))
|
||||
@ -398,7 +398,7 @@ class ReportContextFilterInflationTests(TestCase):
|
||||
"""Regression guard: total_paid_out was ALREADY correct pre-fix
|
||||
because .aggregate() handles distinct() via a subquery. Lock it in
|
||||
so a future refactor doesn't accidentally reintroduce inflation here."""
|
||||
ctx = self._ctx(project_id=self.project.id, team_id=self.team.id)
|
||||
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
|
||||
self.assertEqual(ctx['total_paid_out'], Decimal('600.00'))
|
||||
|
||||
def test_adjustment_summary_not_inflated_with_team_filter(self):
|
||||
@ -413,7 +413,7 @@ class ReportContextFilterInflationTests(TestCase):
|
||||
date=datetime.date(2026, 3, 10),
|
||||
description='Test bonus',
|
||||
)
|
||||
ctx = self._ctx(project_id=self.project.id, team_id=self.team.id)
|
||||
ctx = self._ctx(project_ids=[self.project.id], team_ids=[self.team.id])
|
||||
totals = {item['type']: item['total'] for item in ctx['adjustment_totals']}
|
||||
self.assertEqual(totals.get('Bonus'), Decimal('100.00'))
|
||||
|
||||
@ -476,3 +476,276 @@ class SupervisorPickerQuerysetTests(TestCase):
|
||||
username='an_admin', password='pass', is_staff=True
|
||||
)
|
||||
self.assertIn(admin, _supervisor_user_queryset())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === TESTS FOR EXECUTIVE REPORT v2 ===
|
||||
# Covers the new helpers introduced in the report rebuild (Apr 2026):
|
||||
# _company_cost_velocity, _current_outstanding_in_scope, _team_project_activity,
|
||||
# and the multi-filter extension of _build_report_context.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CompanyCostVelocityTests(TestCase):
|
||||
"""Company-wide avg daily and monthly labour cost (hero KPI card 3 & 4)."""
|
||||
|
||||
def test_empty_db_returns_zero(self):
|
||||
from core.views import _company_cost_velocity
|
||||
result = _company_cost_velocity()
|
||||
self.assertEqual(result['avg_daily'], Decimal('0.00'))
|
||||
self.assertEqual(result['avg_monthly'], Decimal('0.00'))
|
||||
self.assertEqual(result['working_days'], 0)
|
||||
|
||||
def test_known_values(self):
|
||||
from core.views import _company_cost_velocity
|
||||
# Setup: 2 workers (daily_rate = 4000/20 = R 200 each), each works 5 distinct dates.
|
||||
# Lifetime cost = 2 workers * 5 days * R 200 = R 2000. Working days = 5.
|
||||
# Avg daily = 2000 / 5 = R 400.
|
||||
# Avg monthly = 400 * 30.44 = R 12,176.
|
||||
admin = User.objects.create_user(username='admin-cv', is_staff=True)
|
||||
project = Project.objects.create(name='P')
|
||||
w1 = Worker.objects.create(name='W1', id_number='W1', monthly_salary=Decimal('4000'))
|
||||
w2 = Worker.objects.create(name='W2', id_number='W2', monthly_salary=Decimal('4000'))
|
||||
for d in range(1, 6): # 5 distinct dates
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, d),
|
||||
project=project, supervisor=admin,
|
||||
)
|
||||
log.workers.add(w1, w2)
|
||||
|
||||
result = _company_cost_velocity()
|
||||
self.assertEqual(result['working_days'], 5)
|
||||
self.assertEqual(result['avg_daily'], Decimal('400.00'))
|
||||
# Tolerance: ±1 cent for the 30.44 multiplication
|
||||
self.assertAlmostEqual(
|
||||
float(result['avg_monthly']), 12176.00, delta=0.01
|
||||
)
|
||||
|
||||
def test_duplicate_dates_not_double_counted(self):
|
||||
"""Two workers working the same date = 1 distinct date, not 2."""
|
||||
from core.views import _company_cost_velocity
|
||||
admin = User.objects.create_user(username='admin-cv2', is_staff=True)
|
||||
project = Project.objects.create(name='P2')
|
||||
w1 = Worker.objects.create(name='X', id_number='X1', monthly_salary=Decimal('4000'))
|
||||
w2 = Worker.objects.create(name='Y', id_number='Y1', monthly_salary=Decimal('4000'))
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 1), project=project, supervisor=admin,
|
||||
)
|
||||
log.workers.add(w1, w2)
|
||||
result = _company_cost_velocity()
|
||||
self.assertEqual(result['working_days'], 1) # not 2
|
||||
|
||||
|
||||
class CurrentOutstandingInScopeTests(TestCase):
|
||||
"""Hero card 2 — 'Outstanding NOW' with optional filter scope."""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(username='a-out', is_staff=True)
|
||||
self.p1 = Project.objects.create(name='ProjA')
|
||||
self.p2 = Project.objects.create(name='ProjB')
|
||||
self.t1 = Team.objects.create(name='TeamA', supervisor=self.admin)
|
||||
self.w = Worker.objects.create(
|
||||
name='Wkr', id_number='W1', monthly_salary=Decimal('4000')
|
||||
)
|
||||
self.t1.workers.add(self.w)
|
||||
# Unpaid log on project 1
|
||||
log1 = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 1),
|
||||
project=self.p1, team=self.t1, supervisor=self.admin,
|
||||
)
|
||||
log1.workers.add(self.w)
|
||||
# Unpaid log on project 2
|
||||
log2 = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 2),
|
||||
project=self.p2, team=self.t1, supervisor=self.admin,
|
||||
)
|
||||
log2.workers.add(self.w)
|
||||
|
||||
def test_no_filters_includes_all_projects(self):
|
||||
from core.views import _current_outstanding_in_scope
|
||||
result = _current_outstanding_in_scope()
|
||||
# daily_rate = 4000/20 = 200; 2 unpaid logs * 200 = 400
|
||||
self.assertEqual(result['total'], Decimal('400.00'))
|
||||
self.assertEqual(len(result['by_project']), 2)
|
||||
|
||||
def test_project_filter_scopes_total(self):
|
||||
from core.views import _current_outstanding_in_scope
|
||||
result = _current_outstanding_in_scope(project_ids=[self.p1.id])
|
||||
self.assertEqual(result['total'], Decimal('200.00'))
|
||||
self.assertEqual(len(result['by_project']), 1)
|
||||
self.assertEqual(result['by_project'][0]['name'], 'ProjA')
|
||||
|
||||
def test_team_filter_scopes_total(self):
|
||||
"""Team filter on work logs + worker__teams on adjustments."""
|
||||
from core.views import _current_outstanding_in_scope
|
||||
# Adjustment on a worker not in t1
|
||||
other_worker = Worker.objects.create(
|
||||
name='Other', id_number='O1', monthly_salary=Decimal('4000')
|
||||
)
|
||||
PayrollAdjustment.objects.create(
|
||||
worker=other_worker, project=self.p1, type='Bonus',
|
||||
amount=Decimal('500.00'), date=datetime.date(2026, 3, 3),
|
||||
)
|
||||
# With team filter, only self.w's logs appear — R 400 total
|
||||
result = _current_outstanding_in_scope(team_ids=[self.t1.id])
|
||||
self.assertEqual(result['total'], Decimal('400.00'))
|
||||
# The R500 bonus on other_worker must NOT appear in by_project because
|
||||
# that worker isn't in t1 — the team scope excludes them entirely.
|
||||
self.assertEqual(len(result['by_project']), 2)
|
||||
amounts = [row['amount'] for row in result['by_project']]
|
||||
self.assertNotIn(Decimal('500.00'), amounts)
|
||||
|
||||
|
||||
class TeamProjectActivityTests(TestCase):
|
||||
"""Chapter IV pivot: rows=team, columns=project, cell=distinct log dates."""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(username='a-tpa', is_staff=True)
|
||||
self.p1 = Project.objects.create(name='P1')
|
||||
self.p2 = Project.objects.create(name='P2')
|
||||
self.t1 = Team.objects.create(name='T1', supervisor=self.admin)
|
||||
self.t2 = Team.objects.create(name='T2', supervisor=self.admin)
|
||||
w = Worker.objects.create(name='W', id_number='W1', monthly_salary=Decimal('4000'))
|
||||
|
||||
# T1 works 3 distinct dates on P1
|
||||
for d in (1, 2, 3):
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, d), project=self.p1, team=self.t1,
|
||||
supervisor=self.admin,
|
||||
)
|
||||
log.workers.add(w)
|
||||
|
||||
# T2 works 2 distinct dates on P1 and 1 on P2
|
||||
for d in (4, 5):
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, d), project=self.p1, team=self.t2,
|
||||
supervisor=self.admin,
|
||||
)
|
||||
log.workers.add(w)
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 6), project=self.p2, team=self.t2,
|
||||
supervisor=self.admin,
|
||||
)
|
||||
log.workers.add(w)
|
||||
|
||||
self.logs_qs = WorkLog.objects.filter(
|
||||
date__gte=datetime.date(2026, 3, 1),
|
||||
date__lte=datetime.date(2026, 3, 31),
|
||||
)
|
||||
|
||||
def test_pivot_shape(self):
|
||||
from core.views import _team_project_activity
|
||||
r = _team_project_activity(self.logs_qs)
|
||||
# 2 columns (P1, P2), 2 rows (T1, T2)
|
||||
self.assertEqual(len(r['columns']), 2)
|
||||
self.assertEqual(len(r['rows']), 2)
|
||||
|
||||
def test_cell_counts(self):
|
||||
from core.views import _team_project_activity
|
||||
r = _team_project_activity(self.logs_qs)
|
||||
rows = {row['team_name']: row for row in r['rows']}
|
||||
# T1 has 3 days on P1, 0 on P2
|
||||
self.assertEqual(rows['T1']['cells_by_project_id'][self.p1.id], 3)
|
||||
self.assertEqual(rows['T1']['cells_by_project_id'].get(self.p2.id, 0), 0)
|
||||
# T2 has 2 days on P1, 1 on P2
|
||||
self.assertEqual(rows['T2']['cells_by_project_id'][self.p1.id], 2)
|
||||
self.assertEqual(rows['T2']['cells_by_project_id'][self.p2.id], 1)
|
||||
|
||||
def test_row_and_column_totals(self):
|
||||
from core.views import _team_project_activity
|
||||
r = _team_project_activity(self.logs_qs)
|
||||
rows = {row['team_name']: row for row in r['rows']}
|
||||
self.assertEqual(rows['T1']['row_total'], 3)
|
||||
self.assertEqual(rows['T2']['row_total'], 3)
|
||||
self.assertEqual(r['col_totals'][self.p1.id], 5)
|
||||
self.assertEqual(r['col_totals'][self.p2.id], 1)
|
||||
self.assertEqual(r['grand_total'], 6)
|
||||
|
||||
def test_team_with_no_logs_omitted(self):
|
||||
"""Team with zero logs in the period should not appear as a row."""
|
||||
from core.views import _team_project_activity
|
||||
Team.objects.create(name='GhostTeam', supervisor=self.admin)
|
||||
r = _team_project_activity(self.logs_qs)
|
||||
team_names = [row['team_name'] for row in r['rows']]
|
||||
self.assertNotIn('GhostTeam', team_names)
|
||||
|
||||
|
||||
class ChapterOneEnrichmentTests(TestCase):
|
||||
"""Chapter I — All Time Projects gains working_days and avg_per_working_day."""
|
||||
|
||||
def test_alltime_projects_includes_working_days_and_avg(self):
|
||||
from core.views import _build_report_context
|
||||
admin = User.objects.create_user(username='c1', is_staff=True)
|
||||
proj = Project.objects.create(name='C1', start_date=datetime.date(2026, 1, 1))
|
||||
w = Worker.objects.create(name='W', id_number='W1', monthly_salary=Decimal('4000'))
|
||||
# 4 distinct dates, 1 worker each; daily_rate=200; total = R 800; working_days=4; avg=200
|
||||
for d in (1, 2, 3, 4):
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, d), project=proj, supervisor=admin,
|
||||
)
|
||||
log.workers.add(w)
|
||||
ctx = _build_report_context(
|
||||
datetime.date(2026, 1, 1), datetime.date(2026, 12, 31),
|
||||
)
|
||||
by_name = {p['project']: p for p in ctx['alltime_projects']}
|
||||
self.assertIn('C1', by_name)
|
||||
self.assertEqual(by_name['C1']['working_days'], 4)
|
||||
self.assertEqual(by_name['C1']['avg_per_working_day'], Decimal('200.00'))
|
||||
self.assertEqual(by_name['C1']['start_date'], datetime.date(2026, 1, 1))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === TESTS FOR MULTI-VALUE FILTER SUPPORT (Task 6) ===
|
||||
# _build_report_context now accepts project_ids and team_ids lists.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ReportMultiFilterTests(TestCase):
|
||||
"""Task 6 — multi-value project_ids / team_ids filters."""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(username='mf', is_staff=True)
|
||||
self.p1 = Project.objects.create(name='P1')
|
||||
self.p2 = Project.objects.create(name='P2')
|
||||
self.p3 = Project.objects.create(name='P3')
|
||||
self.team = Team.objects.create(name='T', supervisor=self.admin)
|
||||
self.w = Worker.objects.create(
|
||||
name='W', id_number='W1', monthly_salary=Decimal('4000')
|
||||
)
|
||||
self.team.workers.add(self.w)
|
||||
# One log + one paid record per project
|
||||
for proj in (self.p1, self.p2, self.p3):
|
||||
log = WorkLog.objects.create(
|
||||
date=datetime.date(2026, 3, 1),
|
||||
project=proj, team=self.team, supervisor=self.admin,
|
||||
)
|
||||
log.workers.add(self.w)
|
||||
rec = PayrollRecord.objects.create(
|
||||
worker=self.w, amount_paid=Decimal('100.00'),
|
||||
date=datetime.date(2026, 3, 5),
|
||||
)
|
||||
rec.work_logs.add(log)
|
||||
|
||||
def _ctx(self, project_ids=None, team_ids=None):
|
||||
from core.views import _build_report_context
|
||||
return _build_report_context(
|
||||
datetime.date(2026, 3, 1), datetime.date(2026, 3, 31),
|
||||
project_ids=project_ids, team_ids=team_ids,
|
||||
)
|
||||
|
||||
def test_multi_project_union(self):
|
||||
ctx = self._ctx(project_ids=[self.p1.id, self.p2.id])
|
||||
# Two projects paid R 100 each = R 200; third excluded
|
||||
self.assertEqual(ctx['total_paid_out'], Decimal('200.00'))
|
||||
|
||||
def test_empty_list_equals_none(self):
|
||||
ctx_none = self._ctx(project_ids=None)
|
||||
ctx_empty = self._ctx(project_ids=[])
|
||||
self.assertEqual(ctx_none['total_paid_out'], ctx_empty['total_paid_out'])
|
||||
|
||||
def test_no_inflation_with_multi_project(self):
|
||||
"""Worker breakdown must not inflate when multiple projects are selected."""
|
||||
ctx = self._ctx(project_ids=[self.p1.id, self.p2.id, self.p3.id])
|
||||
self.assertEqual(len(ctx['worker_breakdown']), 1)
|
||||
# All three records are for the same worker, R 100 each = R 300
|
||||
self.assertEqual(ctx['worker_breakdown'][0]['total_paid'], Decimal('300.00'))
|
||||
|
||||
434
core/views.py
434
core/views.py
@ -140,6 +140,210 @@ def get_pay_period(team, reference_date=None):
|
||||
return (None, None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === OUTSTANDING PAYMENTS — SHARED HELPER ===
|
||||
# Used by the home dashboard AND the payroll report. Computes:
|
||||
# - outstanding_payments: Decimal total (unpaid wages + net unpaid adjustments)
|
||||
# - unpaid_wages: Decimal (pure daily rates for unpaid workers)
|
||||
# - pending_adj_add: Decimal (unpaid additive adjustments, e.g. bonuses)
|
||||
# - pending_adj_sub: Decimal (unpaid deductive adjustments, e.g. loan repayments)
|
||||
# - outstanding_by_project: dict[str project_name -> Decimal amount]
|
||||
#
|
||||
# Accepts optional project_ids / team_ids filters. Empty list or None = no filter.
|
||||
# =============================================================================
|
||||
|
||||
def _compute_outstanding(project_ids=None, team_ids=None):
|
||||
"""Return current-moment outstanding payment breakdown.
|
||||
|
||||
Plain-English: for each work log that hasn't been fully paid, adds up
|
||||
each unpaid worker's daily rate. Then adds unpaid additive adjustments
|
||||
(bonuses, overtime, new loans, advances) and subtracts unpaid deductive
|
||||
adjustments (deductions, loan/advance repayments). Results are the
|
||||
"as of right now" snapshot shown on the home dashboard's Outstanding
|
||||
Payments card. Optional filters scope the answer to specific projects
|
||||
and/or teams.
|
||||
"""
|
||||
# --- Work logs in scope ---
|
||||
work_logs = WorkLog.objects.select_related('project').prefetch_related('workers', 'payroll_records')
|
||||
if project_ids:
|
||||
work_logs = work_logs.filter(project_id__in=project_ids)
|
||||
if team_ids:
|
||||
work_logs = work_logs.filter(team_id__in=team_ids)
|
||||
|
||||
unpaid_wages = Decimal('0.00')
|
||||
outstanding_by_project = {}
|
||||
|
||||
for wl in work_logs:
|
||||
paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()}
|
||||
project_name = wl.project.name if wl.project else 'No Project'
|
||||
for worker in wl.workers.all():
|
||||
if worker.id not in paid_worker_ids:
|
||||
cost = worker.daily_rate
|
||||
unpaid_wages += cost
|
||||
outstanding_by_project.setdefault(project_name, Decimal('0.00'))
|
||||
outstanding_by_project[project_name] += cost
|
||||
|
||||
# --- Unpaid adjustments in scope ---
|
||||
adj_qs = PayrollAdjustment.objects.filter(payroll_record__isnull=True).select_related('project')
|
||||
if project_ids:
|
||||
adj_qs = adj_qs.filter(project_id__in=project_ids)
|
||||
if team_ids:
|
||||
# worker__teams is M2M — use subquery pattern (see CLAUDE.md Django ORM gotcha)
|
||||
adj_qs = adj_qs.filter(
|
||||
worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
|
||||
)
|
||||
|
||||
pending_adj_add = Decimal('0.00')
|
||||
pending_adj_sub = Decimal('0.00')
|
||||
for adj in adj_qs:
|
||||
project_name = adj.project.name if adj.project else 'No Project'
|
||||
outstanding_by_project.setdefault(project_name, Decimal('0.00'))
|
||||
if adj.type in ADDITIVE_TYPES:
|
||||
pending_adj_add += adj.amount
|
||||
outstanding_by_project[project_name] += adj.amount
|
||||
elif adj.type in DEDUCTIVE_TYPES:
|
||||
pending_adj_sub += adj.amount
|
||||
outstanding_by_project[project_name] -= adj.amount
|
||||
|
||||
outstanding_payments = unpaid_wages + pending_adj_add - pending_adj_sub
|
||||
|
||||
return {
|
||||
'outstanding_payments': outstanding_payments,
|
||||
'unpaid_wages': unpaid_wages,
|
||||
'pending_adj_add': pending_adj_add,
|
||||
'pending_adj_sub': pending_adj_sub,
|
||||
'outstanding_by_project': outstanding_by_project,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === COMPANY COST VELOCITY ===
|
||||
# Lifetime "what does a typical FoxFitt working day cost us?" metric.
|
||||
# Denominator = COUNT(DISTINCT work_log.date) — true working days, not
|
||||
# calendar days (rain days, weekends, permit delays don't dilute the rate).
|
||||
# Used by the hero KPI band on the payroll report.
|
||||
# =============================================================================
|
||||
|
||||
def _company_cost_velocity():
|
||||
"""Return company-wide avg daily and monthly labour cost (lifetime)."""
|
||||
# Total lifetime labour cost: sum of (worker.daily_rate) over every
|
||||
# (log, worker) pair that has ever been logged.
|
||||
total_cost = Decimal('0.00')
|
||||
for wl in WorkLog.objects.prefetch_related('workers').all():
|
||||
for worker in wl.workers.all():
|
||||
total_cost += worker.daily_rate
|
||||
|
||||
# Distinct work-log dates = working days
|
||||
working_days = WorkLog.objects.values('date').distinct().count()
|
||||
|
||||
if working_days == 0:
|
||||
avg_daily = Decimal('0.00')
|
||||
else:
|
||||
avg_daily = (total_cost / working_days).quantize(Decimal('0.01'))
|
||||
|
||||
# 30.44 = 365.25 / 12 — standard month-length approximation.
|
||||
# Keeps annualised totals correct on average.
|
||||
avg_monthly = (avg_daily * Decimal('30.44')).quantize(Decimal('0.01'))
|
||||
|
||||
return {
|
||||
'avg_daily': avg_daily,
|
||||
'avg_monthly': avg_monthly,
|
||||
'working_days': working_days,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === CURRENT OUTSTANDING — SCOPED FOR THE REPORT ===
|
||||
# Thin wrapper around _compute_outstanding that shapes the output for
|
||||
# the executive report's hero card 2. Includes a 'by_project' list
|
||||
# sorted by amount desc, ready for direct template rendering.
|
||||
# =============================================================================
|
||||
|
||||
def _current_outstanding_in_scope(project_ids=None, team_ids=None):
|
||||
"""Return current outstanding payments, optionally scoped by project/team.
|
||||
|
||||
Calls _compute_outstanding and reshapes the by_project dict into a
|
||||
list sorted by amount descending (for display). The 'total' field
|
||||
is the net outstanding (unpaid wages + additive adjustments minus
|
||||
deductive adjustments), matching the home dashboard card.
|
||||
"""
|
||||
raw = _compute_outstanding(project_ids=project_ids, team_ids=team_ids)
|
||||
by_project_list = sorted(
|
||||
[{'name': name, 'amount': amt} for name, amt in raw['outstanding_by_project'].items()],
|
||||
key=lambda r: r['amount'],
|
||||
reverse=True,
|
||||
)
|
||||
return {
|
||||
'total': raw['outstanding_payments'],
|
||||
'by_project': by_project_list,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === TEAM × PROJECT ACTIVITY PIVOT ===
|
||||
# Chapter IV of the executive report: "how many days did each team work
|
||||
# on each project in this period?" Cell value = COUNT(DISTINCT work_log.date).
|
||||
# Logs with no team (team IS NULL) are excluded — the pivot is meaningless
|
||||
# without a team axis.
|
||||
# =============================================================================
|
||||
|
||||
def _team_project_activity(work_logs_qs):
|
||||
"""Return pivot data for team × project activity within a work-logs queryset.
|
||||
|
||||
Plain-English: for each team-project pair represented in the given
|
||||
queryset, counts the number of distinct calendar dates the team worked
|
||||
on that project. Rows and columns include only teams/projects that
|
||||
actually appeared (zero-activity teams/projects aren't shown).
|
||||
"""
|
||||
# Narrow to logs that have both a team and a project (we can't pivot
|
||||
# on NULL axes; also filters out the "No Project" ghost rows).
|
||||
qs = work_logs_qs.filter(team__isnull=False, project__isnull=False)
|
||||
|
||||
# Aggregate: (team_id, project_id) -> distinct dates
|
||||
rows_data = qs.values(
|
||||
'team_id', 'team__name', 'project_id', 'project__name'
|
||||
).annotate(days=Count('date', distinct=True)).order_by('team__name')
|
||||
|
||||
# Build column list (unique projects, ordered by name)
|
||||
columns_seen = {}
|
||||
for r in rows_data:
|
||||
columns_seen.setdefault(r['project_id'], r['project__name'])
|
||||
columns = [
|
||||
{'id': pid, 'name': pname}
|
||||
for pid, pname in sorted(columns_seen.items(), key=lambda kv: kv[1])
|
||||
]
|
||||
|
||||
# Build rows: team_id -> cells_by_project_id dict
|
||||
rows_by_team = {} # team_id -> {'team_id', 'team_name', 'cells_by_project_id', 'row_total'}
|
||||
col_totals = {col['id']: 0 for col in columns}
|
||||
grand_total = 0
|
||||
|
||||
for r in rows_data:
|
||||
tid = r['team_id']
|
||||
pid = r['project_id']
|
||||
days = r['days']
|
||||
row = rows_by_team.setdefault(tid, {
|
||||
'team_id': tid,
|
||||
'team_name': r['team__name'],
|
||||
'cells_by_project_id': {},
|
||||
'row_total': 0,
|
||||
})
|
||||
row['cells_by_project_id'][pid] = days
|
||||
row['row_total'] += days
|
||||
col_totals[pid] += days
|
||||
grand_total += days
|
||||
|
||||
# Ordered rows list (by team name)
|
||||
rows = sorted(rows_by_team.values(), key=lambda r: r['team_name'])
|
||||
|
||||
return {
|
||||
'columns': columns,
|
||||
'rows': rows,
|
||||
'col_totals': col_totals,
|
||||
'grand_total': grand_total,
|
||||
}
|
||||
|
||||
|
||||
# === HOME DASHBOARD ===
|
||||
# The main page users see after logging in. Shows different content
|
||||
# depending on whether the user is an admin or supervisor.
|
||||
@ -151,56 +355,15 @@ def index(request):
|
||||
if is_admin(user):
|
||||
# --- ADMIN DASHBOARD ---
|
||||
|
||||
# Calculate total value of unpaid work and break it down by project.
|
||||
# A WorkLog is "unpaid for worker X" if no PayrollRecord links BOTH
|
||||
# that log AND that worker. This handles partially-paid logs where
|
||||
# some workers have been paid but others haven't.
|
||||
all_worklogs = WorkLog.objects.select_related(
|
||||
'project'
|
||||
).prefetch_related('workers', 'payroll_records')
|
||||
|
||||
# === OUTSTANDING BREAKDOWN ===
|
||||
# Track unpaid wages and adjustments separately so the dashboard
|
||||
# can show a clear breakdown of what makes up the total.
|
||||
unpaid_wages = Decimal('0.00') # Pure daily rates for unpaid workers
|
||||
pending_adjustments_add = Decimal('0.00') # Unpaid additive adjustments (bonuses, overtime, etc.)
|
||||
pending_adjustments_sub = Decimal('0.00') # Unpaid deductive adjustments (deductions, repayments)
|
||||
outstanding_by_project = {}
|
||||
|
||||
for wl in all_worklogs:
|
||||
# Get the set of worker IDs that have been paid for this log
|
||||
paid_worker_ids = {pr.worker_id for pr in wl.payroll_records.all()}
|
||||
project_name = wl.project.name
|
||||
|
||||
for worker in wl.workers.all():
|
||||
if worker.id not in paid_worker_ids:
|
||||
cost = worker.daily_rate
|
||||
unpaid_wages += cost
|
||||
if project_name not in outstanding_by_project:
|
||||
outstanding_by_project[project_name] = Decimal('0.00')
|
||||
outstanding_by_project[project_name] += cost
|
||||
|
||||
# Also include unpaid payroll adjustments (bonuses, deductions, etc.)
|
||||
# Additive types (Bonus, Overtime, New Loan) increase outstanding.
|
||||
# Deductive types (Deduction, Loan Repayment, Advance Repayment) decrease it.
|
||||
unpaid_adjustments = PayrollAdjustment.objects.filter(
|
||||
payroll_record__isnull=True
|
||||
).select_related('project')
|
||||
|
||||
for adj in unpaid_adjustments:
|
||||
project_name = adj.project.name if adj.project else 'No Project'
|
||||
if project_name not in outstanding_by_project:
|
||||
outstanding_by_project[project_name] = Decimal('0.00')
|
||||
|
||||
if adj.type in ADDITIVE_TYPES:
|
||||
pending_adjustments_add += adj.amount
|
||||
outstanding_by_project[project_name] += adj.amount
|
||||
elif adj.type in DEDUCTIVE_TYPES:
|
||||
pending_adjustments_sub += adj.amount
|
||||
outstanding_by_project[project_name] -= adj.amount
|
||||
|
||||
# Net total = wages + additions - deductions (same result as before, just tracked separately)
|
||||
outstanding_payments = unpaid_wages + pending_adjustments_add - pending_adjustments_sub
|
||||
# Uses the shared _compute_outstanding helper so the dashboard and the
|
||||
# payroll report can't drift. Unscoped (no filters) = whole company.
|
||||
_o = _compute_outstanding()
|
||||
outstanding_payments = _o['outstanding_payments']
|
||||
unpaid_wages = _o['unpaid_wages']
|
||||
pending_adjustments_add = _o['pending_adj_add']
|
||||
pending_adjustments_sub = _o['pending_adj_sub']
|
||||
outstanding_by_project = _o['outstanding_by_project']
|
||||
|
||||
# Sum total paid out in the last 60 days
|
||||
sixty_days_ago = timezone.now().date() - timezone.timedelta(days=60)
|
||||
@ -267,6 +430,9 @@ def index(request):
|
||||
'certs_expired_count': certs_expired_count,
|
||||
'certs_expiring_count': certs_expiring_count,
|
||||
'certs_alert_total': certs_alert_total,
|
||||
# Empty on the home dashboard — modal opens clean (no pre-selected filters)
|
||||
'selected_project_ids': [],
|
||||
'selected_team_ids': [],
|
||||
}
|
||||
return render(request, 'core/index.html', context)
|
||||
|
||||
@ -1864,11 +2030,16 @@ def _get_labour_costs(work_logs_qs, group_by_field, name_key):
|
||||
]
|
||||
|
||||
|
||||
def _build_report_context(start_date, end_date, project_id=None, team_id=None):
|
||||
def _build_report_context(start_date, end_date, project_ids=None, team_ids=None):
|
||||
"""
|
||||
Compute all report data for the given date range and filters.
|
||||
Returns a dictionary of totals, breakdowns, and worker-level data.
|
||||
|
||||
project_ids / team_ids are lists of ints (from request.GET.getlist).
|
||||
None or [] are treated as "no filter" — returning data for every project
|
||||
or every team respectively. A single-element list like [3] reproduces
|
||||
the old single-id behaviour (so old URLs like ?project=3 still work).
|
||||
|
||||
Key design decision: "Worker-Days" counts total worker×log entries
|
||||
(not distinct calendar dates). This correlates correctly with cost —
|
||||
if 5 workers work 22 days, that's 110 worker-days, and
|
||||
@ -1880,7 +2051,7 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
|
||||
# --- PayrollRecords in range ---
|
||||
#
|
||||
# IMPORTANT — avoid M2M double-JOIN inflation:
|
||||
# Chaining `.filter(work_logs__project_id=X).distinct().filter(work_logs__team_id=Y)`
|
||||
# Chaining `.filter(work_logs__project_id__in=X).distinct().filter(work_logs__team_id__in=Y)`
|
||||
# creates TWO separate JOIN aliases on core_payrollrecord_work_logs. Any
|
||||
# later `.values().annotate(Sum())` then aggregates across the cartesian
|
||||
# product of matching rows, inflating per-worker and per-date totals by
|
||||
@ -1890,16 +2061,16 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
|
||||
# use id__in subqueries to keep the outer queryset JOIN-free.
|
||||
# See ReportContextFilterInflationTests for regression coverage.
|
||||
records = PayrollRecord.objects.filter(date_filter)
|
||||
if project_id:
|
||||
if project_ids:
|
||||
records = records.filter(
|
||||
id__in=PayrollRecord.objects.filter(
|
||||
work_logs__project_id=project_id
|
||||
work_logs__project_id__in=project_ids
|
||||
).values('id')
|
||||
)
|
||||
if team_id:
|
||||
if team_ids:
|
||||
records = records.filter(
|
||||
id__in=PayrollRecord.objects.filter(
|
||||
work_logs__team_id=team_id
|
||||
work_logs__team_id__in=team_ids
|
||||
).values('id')
|
||||
)
|
||||
|
||||
@ -1914,23 +2085,23 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
|
||||
)
|
||||
|
||||
# --- Adjustments in range ---
|
||||
# project_id filters via an FK column (no JOIN inflation risk), but
|
||||
# team_id goes through worker__teams M2M — apply the same subquery
|
||||
# project_ids filters via an FK column (no JOIN inflation risk), but
|
||||
# team_ids goes through worker__teams M2M — apply the same subquery
|
||||
# pattern as above to keep adj_by_type's values().annotate(Sum()) safe.
|
||||
adjustments = PayrollAdjustment.objects.filter(date_filter)
|
||||
if project_id:
|
||||
adjustments = adjustments.filter(project_id=project_id)
|
||||
if team_id:
|
||||
if project_ids:
|
||||
adjustments = adjustments.filter(project_id__in=project_ids)
|
||||
if team_ids:
|
||||
adjustments = adjustments.filter(
|
||||
worker__in=Worker.objects.filter(teams__id=team_id).values('id')
|
||||
worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
|
||||
)
|
||||
|
||||
# --- Work Logs in range (for calculating actual labour cost) ---
|
||||
work_logs_qs = WorkLog.objects.filter(date__gte=start_date, date__lte=end_date)
|
||||
if project_id:
|
||||
work_logs_qs = work_logs_qs.filter(project_id=project_id)
|
||||
if team_id:
|
||||
work_logs_qs = work_logs_qs.filter(team_id=team_id)
|
||||
if project_ids:
|
||||
work_logs_qs = work_logs_qs.filter(project_id__in=project_ids)
|
||||
if team_ids:
|
||||
work_logs_qs = work_logs_qs.filter(team_id__in=team_ids)
|
||||
|
||||
# Total worker-days across all work logs (counts M2M worker entries)
|
||||
total_worker_days = work_logs_qs.aggregate(
|
||||
@ -1947,11 +2118,40 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
|
||||
|
||||
# --- ALL TIME: project and team costs since the very first work log ---
|
||||
all_time_logs = WorkLog.objects.all()
|
||||
if project_id:
|
||||
all_time_logs = all_time_logs.filter(project_id=project_id)
|
||||
if team_id:
|
||||
all_time_logs = all_time_logs.filter(team_id=team_id)
|
||||
alltime_projects = _get_labour_costs(all_time_logs, 'project__name', 'project')
|
||||
if project_ids:
|
||||
all_time_logs = all_time_logs.filter(project_id__in=project_ids)
|
||||
if team_ids:
|
||||
all_time_logs = all_time_logs.filter(team_id__in=team_ids)
|
||||
# === CHAPTER I — All Time Projects (enriched) ===
|
||||
# Adds working_days and avg_per_working_day (the 2026-04-23 design).
|
||||
# Can't just extend _get_labour_costs because that helper is used by
|
||||
# other sections with different columns. Wrap it here instead.
|
||||
alltime_projects_raw = _get_labour_costs(all_time_logs, 'project__name', 'project')
|
||||
# Build a lookup of working_days per project (distinct work-log dates)
|
||||
project_working_days = dict(
|
||||
all_time_logs.filter(project__isnull=False)
|
||||
.values('project_id', 'project__name')
|
||||
.annotate(days=Count('date', distinct=True))
|
||||
.values_list('project__name', 'days')
|
||||
)
|
||||
# Lookup project start_date from the Project model (authoritative source)
|
||||
start_dates = dict(
|
||||
Project.objects.values_list('name', 'start_date')
|
||||
)
|
||||
alltime_projects = []
|
||||
for row in alltime_projects_raw:
|
||||
name = row['project']
|
||||
wdays = project_working_days.get(name, 0)
|
||||
total = row['total'] or Decimal('0.00')
|
||||
avg = (total / wdays).quantize(Decimal('0.01')) if wdays else Decimal('0.00')
|
||||
alltime_projects.append({
|
||||
'project': name,
|
||||
'worker_days': row['worker_days'],
|
||||
'total': total,
|
||||
'start_date': start_dates.get(name), # may be None
|
||||
'working_days': wdays,
|
||||
'avg_per_working_day': avg,
|
||||
})
|
||||
alltime_teams = _get_labour_costs(
|
||||
all_time_logs.filter(team__isnull=False), 'team__name', 'team'
|
||||
)
|
||||
@ -1961,19 +2161,24 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
|
||||
year_start = datetime.date(current_year, 1, 1)
|
||||
year_end = datetime.date(current_year, 12, 31)
|
||||
year_logs = WorkLog.objects.filter(date__gte=year_start, date__lte=year_end)
|
||||
if project_id:
|
||||
year_logs = year_logs.filter(project_id=project_id)
|
||||
if team_id:
|
||||
year_logs = year_logs.filter(team_id=team_id)
|
||||
if project_ids:
|
||||
year_logs = year_logs.filter(project_id__in=project_ids)
|
||||
if team_ids:
|
||||
year_logs = year_logs.filter(team_id__in=team_ids)
|
||||
year_projects = _get_labour_costs(year_logs, 'project__name', 'project')
|
||||
year_teams = _get_labour_costs(
|
||||
year_logs.filter(team__isnull=False), 'team__name', 'team'
|
||||
)
|
||||
|
||||
# --- Loans & Advances Outstanding (current balances) ---
|
||||
# team filter goes through worker__teams (M2M). Use the subquery pattern
|
||||
# (CLAUDE.md Django ORM gotcha) so we don't pick up JOIN inflation on the
|
||||
# aggregate.
|
||||
active_loans = Loan.objects.filter(active=True, date__lte=end_date)
|
||||
if team_id:
|
||||
active_loans = active_loans.filter(worker__teams__id=team_id)
|
||||
if team_ids:
|
||||
active_loans = active_loans.filter(
|
||||
worker__in=Worker.objects.filter(teams__id__in=team_ids).values('id')
|
||||
)
|
||||
loans_outstanding = active_loans.filter(loan_type='loan').aggregate(
|
||||
total=Sum('remaining_balance'))['total'] or Decimal('0.00')
|
||||
advances_outstanding = active_loans.filter(loan_type='advance').aggregate(
|
||||
@ -1982,9 +2187,10 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
|
||||
# --- Loans & Advances Issued This Period ---
|
||||
loans_issued_qs = Loan.objects.filter(date_filter, loan_type='loan')
|
||||
advances_issued_qs = Loan.objects.filter(date_filter, loan_type='advance')
|
||||
if team_id:
|
||||
loans_issued_qs = loans_issued_qs.filter(worker__teams__id=team_id)
|
||||
advances_issued_qs = advances_issued_qs.filter(worker__teams__id=team_id)
|
||||
if team_ids:
|
||||
team_worker_ids = Worker.objects.filter(teams__id__in=team_ids).values('id')
|
||||
loans_issued_qs = loans_issued_qs.filter(worker__in=team_worker_ids)
|
||||
advances_issued_qs = advances_issued_qs.filter(worker__in=team_worker_ids)
|
||||
loans_issued = loans_issued_qs.aggregate(
|
||||
total=Sum('principal_amount'))['total'] or Decimal('0.00')
|
||||
advances_issued = advances_issued_qs.aggregate(
|
||||
@ -2046,11 +2252,26 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
|
||||
'adj_values': adj_values,
|
||||
})
|
||||
|
||||
# === Hero KPI band data (executive report v2) ===
|
||||
# Small helpers that power the new hero band at the top of the report.
|
||||
# Kept separate so the big return dict stays easy to scan.
|
||||
_cv = _company_cost_velocity()
|
||||
|
||||
return {
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'project_name': Project.objects.get(id=project_id).name if project_id else 'All Projects',
|
||||
'team_name': Team.objects.get(id=team_id).name if team_id else 'All Teams',
|
||||
'project_name': (
|
||||
', '.join(
|
||||
Project.objects.filter(id__in=project_ids).values_list('name', flat=True)
|
||||
)
|
||||
if project_ids else 'All Projects'
|
||||
),
|
||||
'team_name': (
|
||||
', '.join(
|
||||
Team.objects.filter(id__in=team_ids).values_list('name', flat=True)
|
||||
)
|
||||
if team_ids else 'All Teams'
|
||||
),
|
||||
# --- Summary ---
|
||||
'total_paid_out': total_paid_out,
|
||||
'total_worker_days': total_worker_days,
|
||||
@ -2072,6 +2293,15 @@ def _build_report_context(start_date, end_date, project_id=None, team_id=None):
|
||||
'active_adj_types': active_adj_types,
|
||||
'active_adj_labels': active_adj_labels,
|
||||
'worker_breakdown': worker_breakdown,
|
||||
# --- Hero KPI band (executive report v2) ---
|
||||
'current_outstanding': _current_outstanding_in_scope(
|
||||
project_ids=project_ids, team_ids=team_ids
|
||||
),
|
||||
'current_as_of': timezone.now(),
|
||||
'company_avg_daily': _cv['avg_daily'],
|
||||
'company_avg_monthly': _cv['avg_monthly'],
|
||||
'company_working_days': _cv['working_days'],
|
||||
'team_project_activity': _team_project_activity(work_logs_qs),
|
||||
}
|
||||
|
||||
|
||||
@ -2134,21 +2364,44 @@ def generate_report(request):
|
||||
|
||||
# Parse dates — supports both "month" and "start_date/end_date" params
|
||||
start_date, end_date = _parse_report_dates(request)
|
||||
project_id = request.GET.get('project') or None
|
||||
team_id = request.GET.get('team') or None
|
||||
# Multi-value: ?project=1&project=2 comes in as getlist ['1','2'].
|
||||
# Cast to ints; drop empties. None if list is empty (= "no filter").
|
||||
def _ids(name):
|
||||
return [int(v) for v in request.GET.getlist(name) if v.strip().isdigit()]
|
||||
project_ids = _ids('project') or None
|
||||
team_ids = _ids('team') or None
|
||||
|
||||
if not start_date or not end_date:
|
||||
messages.error(request, "Please select a month or provide start and end dates.")
|
||||
return redirect('home')
|
||||
|
||||
# Build report data using shared helper
|
||||
context = _build_report_context(start_date, end_date, project_id, team_id)
|
||||
context = _build_report_context(
|
||||
start_date, end_date,
|
||||
project_ids=project_ids, team_ids=team_ids,
|
||||
)
|
||||
# Pass the raw query params so the "Download PDF" button can use them
|
||||
context['query_string'] = request.GET.urlencode()
|
||||
# === FILTER PILL CLEAR LINKS ===
|
||||
# For the filter-pill x buttons: rebuild the querystring with one filter removed.
|
||||
# QueryDict.pop() only removes the first occurrence, so for multi-value keys
|
||||
# (e.g. project=1&project=2) we follow up with setlist(key, []) to strip them all.
|
||||
def _qs_without(key):
|
||||
qd = request.GET.copy()
|
||||
qd.pop(key, None)
|
||||
qd.setlist(key, [])
|
||||
return qd.urlencode()
|
||||
context['query_string_without_project'] = _qs_without('project')
|
||||
context['query_string_without_team'] = _qs_without('team')
|
||||
# Pass projects and teams so the "New Report" modal's dropdowns can
|
||||
# populate (same lists the Dashboard modal uses)
|
||||
context['projects'] = Project.objects.all().order_by('name')
|
||||
context['teams'] = Team.objects.all().order_by('name')
|
||||
# For the modal's <select multiple> pre-selection: stringify the IDs so
|
||||
# the template's `{% if p.id|stringformat:"s" in selected_project_ids %}`
|
||||
# comparison works (Django templates compare strings to strings).
|
||||
context['selected_project_ids'] = [str(p) for p in (project_ids or [])]
|
||||
context['selected_team_ids'] = [str(t) for t in (team_ids or [])]
|
||||
|
||||
return render(request, 'core/report.html', context)
|
||||
|
||||
@ -2163,14 +2416,21 @@ def generate_report_pdf(request):
|
||||
|
||||
# Parse dates — same logic as the HTML view
|
||||
start_date, end_date = _parse_report_dates(request)
|
||||
project_id = request.GET.get('project') or None
|
||||
team_id = request.GET.get('team') or None
|
||||
# Multi-value: ?project=1&project=2 comes in as getlist ['1','2'].
|
||||
# Cast to ints; drop empties. None if list is empty (= "no filter").
|
||||
def _ids(name):
|
||||
return [int(v) for v in request.GET.getlist(name) if v.strip().isdigit()]
|
||||
project_ids = _ids('project') or None
|
||||
team_ids = _ids('team') or None
|
||||
|
||||
if not start_date or not end_date:
|
||||
messages.error(request, "Please select a month or provide start and end dates.")
|
||||
return redirect('home')
|
||||
|
||||
context = _build_report_context(start_date, end_date, project_id, team_id)
|
||||
context = _build_report_context(
|
||||
start_date, end_date,
|
||||
project_ids=project_ids, team_ids=team_ids,
|
||||
)
|
||||
context['now'] = timezone.now()
|
||||
|
||||
pdf = render_to_pdf('core/pdf/report_pdf.html', context)
|
||||
|
||||
295
docs/plans/2026-04-23-executive-report-v2-design.md
Normal file
295
docs/plans/2026-04-23-executive-report-v2-design.md
Normal file
@ -0,0 +1,295 @@
|
||||
# Executive Payroll Report v2 — Design (23 Apr 2026)
|
||||
|
||||
## Goal
|
||||
|
||||
Rebuild the payroll report (`/report/`) as an **executive-grade dashboard**:
|
||||
multi-select project and team filters, a live "Current Outstanding" KPI card,
|
||||
company-wide cost-velocity metrics, a new team × project activity pivot,
|
||||
and a re-organised layout that leads with the numbers a business owner reads
|
||||
first. No model changes; no new dependencies beyond the Choices.js CDN.
|
||||
|
||||
## Who it's for
|
||||
|
||||
**Admins** (`is_staff` or `is_superuser`). Supervisors keep no report access.
|
||||
|
||||
## Design shape at a glance
|
||||
|
||||
```
|
||||
┌─ Header (title + filter pills) ──────────────────────────────────────┐
|
||||
├─ HERO KPI BAND (4 big cards) ────────────────────────────────────────┤
|
||||
│ Paid this period │ Outstanding NOW │ Avg R/day │ Avg R/month │
|
||||
├─ Chapter I — Lifetime Context ───────────────────────────────────────┤
|
||||
│ All Time Projects (name, start, working days, total, avg/wday) │
|
||||
│ All Time Teams (name, working days, total) │
|
||||
├─ Chapter II — Selected Period ───────────────────────────────────────┤
|
||||
│ Summary stat cards (6: Paid, Worker-Days, Loans×2, Advances×2) │
|
||||
│ Payments by Date | Adjustment Summary │
|
||||
│ Labour Cost by Project | Labour Cost by Team │
|
||||
├─ Chapter III — Worker Breakdown ─────────────────────────────────────┤
|
||||
│ Wide table: worker, days, total paid, dynamic adjustment columns │
|
||||
├─ Chapter IV — Team × Project Activity (NEW) ────────────────────────┤
|
||||
│ Pivot: rows=team, columns=project, cell=distinct work-log dates │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 1. Filters — multi-select
|
||||
|
||||
### UI
|
||||
- `<select multiple>` elements for Project and Team, enhanced with [Choices.js](https://choices-js.github.io/Choices/) (CDN: `cdn.jsdelivr.net/npm/choices.js@10.2.0/...`).
|
||||
- Removes native ugly multi-select, adds chip-style display, search-as-you-type.
|
||||
- Empty selection = "all" (no explicit "All Projects" option row).
|
||||
- Graceful fallback: if Choices.js fails to load, the native `<select multiple>` still works — degraded but functional.
|
||||
|
||||
### Semantics
|
||||
- **Empty selection** → treated as "all"
|
||||
- **Multiple values within one filter** → OR (`project_id IN (1, 2, 3)`)
|
||||
- **Across Project × Team** → AND (`project_id IN (...) AND team_id IN (...)`)
|
||||
|
||||
### Backend signature change
|
||||
```python
|
||||
# Before
|
||||
_build_report_context(start, end, project_id=None, team_id=None)
|
||||
|
||||
# After
|
||||
_build_report_context(start, end, project_ids=None, team_ids=None)
|
||||
# both accept list[int] | None; [] treated same as None (= "all")
|
||||
```
|
||||
|
||||
### URL / QS compatibility
|
||||
- `generate_report` and `generate_report_pdf` use `request.GET.getlist('project')` / `getlist('team')`.
|
||||
- Multiple values in the querystring: `?project=1&project=2&team=3`.
|
||||
- Old single-value URLs (`?project=1`) still resolve — `getlist` returns a one-element list.
|
||||
|
||||
### Filter-pill strip
|
||||
Directly under the page header, a horizontal row of summary pills:
|
||||
- `📅 Mar 2026 – Apr 2026`
|
||||
- `📁 Wilkot Boerdery, Solar Farm Alpha` (one pill lists all selected projects)
|
||||
- `👥 Civils One` (same for teams)
|
||||
Clickable × on each pill to remove that filter. In PDFs, pills render as static labels.
|
||||
|
||||
## 2. Hero KPI band (new)
|
||||
|
||||
Four large cards in one row, sitting directly under the header / filter strip.
|
||||
|
||||
| Card | Label | Value | Sub-line |
|
||||
|---|---|---|---|
|
||||
| 1 | PAID THIS PERIOD | `total_paid_out` (already computed) | `{start_date} – {end_date}` |
|
||||
| 2 | OUTSTANDING NOW | live dashboard math | `as of {now:HH:MM}` |
|
||||
| 3 | FOXFITT AVG / DAY | `total_lifetime_cost / total_working_days` | `lifetime avg` |
|
||||
| 4 | FOXFITT AVG / MONTH | daily × 30.44 | `lifetime avg` |
|
||||
|
||||
Typography: Poppins 32pt semibold for the number; Inter 10pt uppercase tracked +0.08em for the label; Inter 9pt `--text-tertiary` for the sub-line. `--accent` orange on the left vertical bar (existing `.stat-card` style, scaled up).
|
||||
|
||||
### Backend — new computed values
|
||||
- **`current_outstanding`** — dict `{'total': Decimal, 'by_project': [{'name': str, 'amount': Decimal}, ...]}`. Reuses the dashboard math from `index()` (`unpaid_wages + pending_adj_add - pending_adj_sub`) but respects the report's project/team filters. Stamped with `current_as_of = timezone.now()`.
|
||||
- **`company_avg_daily`** — `Decimal`. `total_lifetime_cost / company_working_days`. `company_working_days = WorkLog.objects.values('date').distinct().count()`.
|
||||
- **`company_avg_monthly`** — `company_avg_daily * Decimal('30.44')`. The 30.44 is `365.25/12` — the standard month-length approximation (keeps annual totals correct on average).
|
||||
|
||||
These come from a new helper `_company_cost_velocity()` called from `_build_report_context`.
|
||||
|
||||
## 3. Chapter I — Lifetime Context
|
||||
|
||||
Two cards side-by-side, replacing the current 4 cramped cards.
|
||||
|
||||
### All Time — Projects (~60% width)
|
||||
Columns: **Project** · **Start** · **Working Days** · **Total Cost** · **Avg R / Working Day**
|
||||
- `Working Days` = `COUNT(DISTINCT work_log.date)` where `project_id = P` (this is your answer to 3a — working days, not calendar days, is the denominator)
|
||||
- `Avg R / Working Day` = `total_cost / working_days` (null-safe; shows `—` if 0 working days)
|
||||
- Ordered by total cost desc
|
||||
- Honours the filter (empty filters = all projects; otherwise only selected projects)
|
||||
|
||||
### All Time — Teams (~40% width)
|
||||
Columns: **Team** · **Working Days** · **Total Cost**
|
||||
- `Working Days` = `COUNT(DISTINCT work_log.date)` where `team_id = T`
|
||||
- Ordered by total cost desc
|
||||
|
||||
### Year context — removed
|
||||
The current "This Year — Projects / Teams" pair of cards is dropped. The lifetime + selected-period pair already covers the two timeframes that matter; the YTD cards have always been redundant with either. YAGNI.
|
||||
|
||||
## 4. Chapter II — Selected Period
|
||||
|
||||
Keeps all existing content, restructured visually.
|
||||
|
||||
### Row A — Summary stat cards (6)
|
||||
Same six cards as today (Total Paid Out · Worker-Days · Loans Issued · Loans Outstanding · Advances Issued · Advances Outstanding). Restyled to match the hero band aesthetic: slightly larger Poppins numbers, thin dividers. Laid out as a single row (6 columns on desktop; 3×2 on tablet; 2×3 on mobile).
|
||||
|
||||
### Row B — Payments by Date | Adjustment Summary
|
||||
Two cards side by side, existing content.
|
||||
|
||||
### Row C — Labour Cost by Project | Labour Cost by Team
|
||||
Two cards side by side, existing content.
|
||||
|
||||
No data changes here — just visual polish. Inter tabular-nums for all number columns (perfect right-alignment).
|
||||
|
||||
## 5. Chapter III — Worker Breakdown
|
||||
|
||||
Existing wide table, restyled. No structural change:
|
||||
- One row per worker with `total_paid > 0`
|
||||
- Columns: Worker · Days · Total Paid · {dynamic columns for each non-zero adjustment type}
|
||||
- Ordered by total paid desc
|
||||
- Typography: Inter tabular-nums for right-aligned number columns
|
||||
|
||||
## 6. Chapter IV — Team × Project Activity (NEW)
|
||||
|
||||
A pivot table showing how many days each team worked on each project **in the report's date range + filter scope**.
|
||||
|
||||
```
|
||||
│ Wilkot │ Solar Alpha │ Solar Beta │ Total │
|
||||
────────────────┼────────┼─────────────┼────────────┼───────┤
|
||||
Civils One │ 87 │ — │ — │ 87 │
|
||||
Team Alpha │ — │ 45 │ 12 │ 57 │
|
||||
Team Bravo │ 3 │ 18 │ 41 │ 62 │
|
||||
────────────────┼────────┼─────────────┼────────────┼───────┤
|
||||
Total │ 90 │ 63 │ 53 │ 206 │
|
||||
```
|
||||
|
||||
- **Rows**: teams with ≥1 work log in the period (honours team filter)
|
||||
- **Columns**: projects with ≥1 work log in the period (honours project filter)
|
||||
- **Cell**: `COUNT(DISTINCT work_log.date)` for that (team, project) pair
|
||||
- **Row totals + column totals + grand total** rendered in bold
|
||||
- Zero cells render as em-dash in `--text-tertiary` (not `0` — makes non-zero values stand out)
|
||||
- Horizontally scrollable if > 6 project columns
|
||||
|
||||
### Backend
|
||||
New context key `team_project_activity`:
|
||||
```python
|
||||
{
|
||||
'columns': [{'id': 1, 'name': 'Wilkot Boerdery'}, ...], # projects in the period
|
||||
'rows': [
|
||||
{
|
||||
'team_id': 3, 'team_name': 'Civils One',
|
||||
'cells_by_project_id': {1: 87, 2: 0, 3: 0}, # count or 0
|
||||
'row_total': 87,
|
||||
},
|
||||
...
|
||||
],
|
||||
'col_totals': {1: 90, 2: 63, 3: 53},
|
||||
'grand_total': 206,
|
||||
}
|
||||
```
|
||||
Computed via a dedicated `_team_project_activity(work_logs_qs)` helper.
|
||||
|
||||
## 7. Print / PDF layout
|
||||
|
||||
Mirror the HTML structure in `core/templates/core/pdf/report_pdf.html`:
|
||||
- **Hero KPI band at the top** (cover-block style)
|
||||
- **Filter pills as static labels** (no × buttons)
|
||||
- **Single-column** for the body — PDFs look better sequential than side-by-side
|
||||
- Same `@page` config: `A4 portrait; margin: 1.8cm`
|
||||
- Same `_build_report_context` helper — HTML and PDF can't drift
|
||||
|
||||
## 8. Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|---|---|
|
||||
| No filters | Pills show "All Projects · All Teams"; all chapters show everything |
|
||||
| Filter returns zero records | Zero-state banners per section; no 500 |
|
||||
| Choices.js fails to load (CDN blocked) | Native `<select multiple>` still works, visually degraded |
|
||||
| Project has no `start_date` | Start column shows `—`; working days still counted |
|
||||
| Team with no work logs in the period | Row omitted from Chapter IV |
|
||||
| Company has no work logs yet (fresh install) | `company_avg_daily` = 0; sub-line says "no data yet" |
|
||||
| Phone viewport | Hero band stacks 2×2; side-by-side card rows stack vertically; tables horizontally scroll |
|
||||
|
||||
## 9. Testing
|
||||
|
||||
New test classes in `core/tests.py`:
|
||||
|
||||
### `ReportMultiFilterTests`
|
||||
- `project_ids=[1, 2]` returns union of logs from both projects
|
||||
- `project_ids=[]` equivalent to `None` (= all)
|
||||
- `project_ids=[X] & team_ids=[Y]` intersects correctly
|
||||
- Backward compat: old single-value URL (`?project=1`) parses fine via `getlist`
|
||||
|
||||
### `CurrentOutstandingInReportTests`
|
||||
- No filters → report's `current_outstanding.total` == dashboard's outstanding total
|
||||
- Project filter → only that project's outstanding amount appears
|
||||
- Deactivated worker with unpaid logs → counted (matches dashboard)
|
||||
|
||||
### `TeamProjectActivityTests`
|
||||
- Simple 2×2 pivot with known data
|
||||
- Cell with no activity → key absent or 0
|
||||
- Row totals and column totals match cell sums
|
||||
- Grand total matches sum of cell values
|
||||
- Filter honouring: project filter drops columns; team filter drops rows
|
||||
|
||||
### `CompanyCostVelocityTests`
|
||||
- Avg daily = total lifetime cost / distinct work-log dates
|
||||
- Monthly = daily × 30.44 (tolerance ±1 cent)
|
||||
- Empty DB → avg daily = 0, no exception
|
||||
|
||||
### Existing `ReportContextFilterInflationTests`
|
||||
Extended to cover multi-value filters — `project_ids=[A, B]` doesn't inflate the worker breakdown or payments_by_date. Locks in the subquery-filter pattern with `__in`.
|
||||
|
||||
**Expected test count after this feature**: 28 → 28 + ~10 = ~38.
|
||||
|
||||
## 10. Implementation shape (no code yet)
|
||||
|
||||
### Files to touch
|
||||
- `core/views.py` — `_build_report_context` signature + new helpers (`_current_outstanding_in_scope`, `_company_cost_velocity`, `_team_project_activity`), `generate_report` + `generate_report_pdf` switch to `getlist`.
|
||||
- `core/templates/core/_report_config_modal.html` — `multiple` attribute on both selects; Choices.js init.
|
||||
- `core/templates/base.html` — Choices.js CDN `<script>` + `<link>` (admin-only gated).
|
||||
- `core/templates/core/report.html` — chapter restructure, hero band, filter pills.
|
||||
- `core/templates/core/pdf/report_pdf.html` — same structure, single-column.
|
||||
- `static/css/custom.css` — hero-band styles; pill styles; Choices.js theme overrides (dark + light); tabular-nums on number columns.
|
||||
- `core/tests.py` — new test classes.
|
||||
|
||||
### No changes
|
||||
- Models / migrations — zero
|
||||
- Existing `_get_labour_costs` helper — unchanged
|
||||
- Existing `index()` / `payroll_dashboard` views — unchanged (we only READ their math, via a new extracted helper)
|
||||
|
||||
### Approximate size
|
||||
- Backend: ~120 new lines across 3 helpers + minor refactor
|
||||
- Templates: ~200 lines restructure + ~100 new for pivot + hero
|
||||
- CSS: ~80 lines (hero, pills, Choices.js theme)
|
||||
- JS: ~30 lines (Choices.js init + filter pill remove)
|
||||
- Tests: ~120 lines across 4 new test classes
|
||||
|
||||
Total: ~650 lines, no new dependencies beyond Choices.js (CDN).
|
||||
|
||||
## 11. Out of scope (YAGNI)
|
||||
|
||||
Explicitly NOT in this pass. Revisit only if users ask:
|
||||
|
||||
- **No charts or sparklines** — text-and-table first. Chart.js is already loaded for the payroll dashboard so the door is open for future additions, but not now.
|
||||
- **No "save report template" feature** — reports are URLs you can bookmark.
|
||||
- **No email-this-report-to-X** — PDF download is sufficient.
|
||||
- **No period-over-period comparison** (e.g. "vs. last month") — single-period only.
|
||||
- **No chart-based team/project activity** — the pivot table covers it.
|
||||
- **The YTD cards** (current "This Year — Projects / Teams") are dropped as redundant; if you miss them we can add back cheaply.
|
||||
|
||||
## 12. Next step
|
||||
|
||||
Hand off to `superpowers:writing-plans` to produce the task-by-task implementation plan with review checkpoints. Proposed checkpoint placement:
|
||||
|
||||
1. After backend helpers + tests (Chapter I + Hero numbers work)
|
||||
2. After multi-select modal (Choices.js integrated, filter pills render)
|
||||
3. After Chapter IV pivot + full HTML layout
|
||||
4. After PDF template mirrors HTML
|
||||
|
||||
Four natural demo-able pauses. Similar cadence to the 2026-04-22 work-log-payroll-crosslink plan.
|
||||
|
||||
---
|
||||
|
||||
## Shipped — 23 Apr 2026
|
||||
|
||||
**Commits:** 27cdb46 (design) → Task 14 shipped commit.
|
||||
**Plan:** `docs/plans/2026-04-23-executive-report-v2-plan.md`
|
||||
**Tests:** 28 → 42 (14 new — 3 company-cost-velocity, 3 current-outstanding-in-scope, 4 team-project-activity, 1 chapter-one-enrichment, 3 multi-filter).
|
||||
|
||||
**QA outcome:** 42/42 tests pass. `manage.py check` clean. `makemigrations --dry-run` reports no changes. Multi-value filter URLs (`?project=1&project=2`) resolve correctly. PDF rendering verified for populated and empty date ranges.
|
||||
|
||||
**Deferred / out of scope (revisit if requested):**
|
||||
- Charts / sparklines in any chapter (text-and-table only)
|
||||
- Save-as-template feature
|
||||
- Period-over-period comparison
|
||||
- Dead `.hero-*` CSS block in PDF template (~40 lines, not referenced by new body)
|
||||
- Consolidate near-duplicate PDF table classes (`.worker`, `.lifetime`, `.pivot`)
|
||||
- Inline filters on report page (vs modal-popup) — **Konrad flagged this during Checkpoint 3 as the next UX improvement; slated for a separate brainstorm + design + plan after this ships**
|
||||
|
||||
**Notable design decisions made during implementation:**
|
||||
- Task 1 extracted `_compute_outstanding` from `index()` as a pure refactor before any new work — zero behaviour change.
|
||||
- M2M filter pattern from commit `f1e246c` (Apr 22 bug fix) extended cleanly to multi-value via `__in` lookups + `id__in` subqueries.
|
||||
- Choices.js integrated via CDN with SRI hashes, graceful fallback to native `<select multiple>` on CDN failure, and custom CSS theme overrides matching the app's dark/light tokens.
|
||||
- `dictlookup` template filter added to `format_tags.py` — general-purpose utility for dict[var-key] lookups in Django templates.
|
||||
- PDF template swapped xhtml2pdf-era `@frame footer_frame` / `-pdf-*` rules for WeasyPrint-idiomatic plain `.footer` div.
|
||||
1778
docs/plans/2026-04-23-executive-report-v2-plan.md
Normal file
1778
docs/plans/2026-04-23-executive-report-v2-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -1491,3 +1491,245 @@ body, .card, .modal-content, .form-control, .form-select,
|
||||
.work-log-row:hover td {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
/* === Report filter pills === */
|
||||
.filter-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.825rem;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 999px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.filter-pill i {
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.filter-pill__x {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0 0.35rem;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
border-radius: 50%;
|
||||
transition: color 120ms, background-color 120ms;
|
||||
}
|
||||
.filter-pill__x:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* === Choices.js theme overrides (dark + light, executive report modal) === */
|
||||
/*
|
||||
Choices.js ships with a white-bg, light-grey-text default that clashes with
|
||||
the app's dark theme. These overrides replace those defaults with the app's
|
||||
own design tokens so the multi-select picker matches every other card and
|
||||
input on the page. All tokens auto-switch between dark (:root) and light
|
||||
(:root.light) themes — no duplicate blocks needed.
|
||||
|
||||
Specificity note: the Choices.js CDN CSS loads AFTER custom.css (inside the
|
||||
modal partial, near </body>). Every rule below chains the root `.choices`
|
||||
class to beat the CDN's same-class selectors, and uses !important on the
|
||||
two properties Choices.js hardcodes most aggressively (color + background)
|
||||
so dark/light theme tokens always win.
|
||||
*/
|
||||
|
||||
/* Container — the outer wrapper that replaces the native <select> */
|
||||
.choices.choices {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Closed-state input area (where chips and the placeholder/search sit) */
|
||||
.choices .choices__inner {
|
||||
background: var(--bg-inset) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
min-height: 2.55rem;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
.choices.is-focused .choices__inner,
|
||||
.choices.is-open .choices__inner {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 0.15rem rgba(232, 133, 26, 0.18);
|
||||
}
|
||||
|
||||
/* The cloned search input typed into when the dropdown is open */
|
||||
.choices .choices__input {
|
||||
background: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
.choices .choices__input::placeholder {
|
||||
color: var(--text-tertiary) !important;
|
||||
}
|
||||
|
||||
/* Dropdown popup — the list of choices */
|
||||
.choices .choices__list--dropdown,
|
||||
.choices .choices__list[aria-expanded] {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||
margin-top: 4px;
|
||||
z-index: 2000;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
/* Individual option rows in the dropdown — default state */
|
||||
.choices .choices__list--dropdown .choices__item,
|
||||
.choices .choices__list[aria-expanded] .choices__item {
|
||||
color: var(--text-primary) !important;
|
||||
background: transparent !important;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Hovered / keyboard-highlighted option — matches the "Month button selected" look */
|
||||
.choices .choices__list--dropdown .choices__item--selectable.is-highlighted,
|
||||
.choices .choices__list[aria-expanded] .choices__item--selectable.is-highlighted {
|
||||
background: var(--bg-card-hover) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
/* The trailing "Press to select" hint */
|
||||
.choices .choices__list--dropdown .choices__item--selectable.is-highlighted::after,
|
||||
.choices .choices__list[aria-expanded] .choices__item--selectable.is-highlighted::after {
|
||||
color: var(--accent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Disabled / placeholder-style rows (e.g. "No matches found") */
|
||||
.choices .choices__list--dropdown .choices__item--disabled,
|
||||
.choices .choices__list[aria-expanded] .choices__item--disabled {
|
||||
color: var(--text-tertiary) !important;
|
||||
}
|
||||
|
||||
/* Placeholder text in the input area when nothing is selected */
|
||||
.choices .choices__placeholder {
|
||||
color: var(--text-tertiary) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Selected chips in multi-select mode (visible when items are chosen) */
|
||||
.choices .choices__list--multiple .choices__item {
|
||||
background: var(--accent) !important;
|
||||
border: 1px solid var(--accent) !important;
|
||||
color: #fff !important;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
padding: 0.2rem 0.6rem;
|
||||
margin: 0.15rem 0.25rem 0.15rem 0;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.choices .choices__list--multiple .choices__item.is-highlighted {
|
||||
background: var(--accent-hover) !important;
|
||||
border-color: var(--accent-hover) !important;
|
||||
}
|
||||
|
||||
/* The × button on each selected chip */
|
||||
.choices .choices__list--multiple .choices__button {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.4);
|
||||
margin: 0 0 0 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.choices .choices__list--multiple .choices__button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* No-results / no-choices message */
|
||||
.choices .choices__list .choices__item--no-results,
|
||||
.choices .choices__list .choices__item--no-choices {
|
||||
color: var(--text-tertiary) !important;
|
||||
font-style: italic;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* === Hero KPI card variant (executive report) === */
|
||||
/*
|
||||
A larger, more typographic version of the existing .stat-card,
|
||||
used for the top-of-report KPI band. Keeps the same --accent-based
|
||||
colour stripes (stat-card--danger, --warning, --info) but scales
|
||||
the number, flattens the label to uppercase tracked caps, and adds
|
||||
a subtle tertiary sub-line for context like "as of 15:42" or the
|
||||
date range.
|
||||
*/
|
||||
.stat-card--hero {
|
||||
padding: 1.25rem 1.4rem;
|
||||
min-height: 130px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.stat-card--hero .stat-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.stat-card--hero .stat-value {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.85rem;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat-card--hero .stat-subline {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
/* === Report chapter headings === */
|
||||
/*
|
||||
Numbered chapter markers (I, II, III, IV) on the executive report.
|
||||
Each heading has an orange filled circle with the Roman numeral
|
||||
followed by the chapter title. Used on Chapter I (Lifetime Context),
|
||||
II (Selected Period), III (Worker Breakdown), IV (Team x Project).
|
||||
*/
|
||||
.chapter-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chapter-heading .chapter-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.85rem;
|
||||
height: 1.85rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* tabular-nums for all numeric report tables */
|
||||
.report-numeric td,
|
||||
.report-numeric th {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* === Pivot-table footer totals (Chapter IV) === */
|
||||
/*
|
||||
Bold, slightly-lifted row at the bottom of the Team × Project pivot
|
||||
that holds the column totals + grand total. The 2px top border
|
||||
visually separates totals from the data rows; the inset background
|
||||
is the same --bg-inset used by other "slightly raised" surfaces.
|
||||
*/
|
||||
.table-total-row td {
|
||||
border-top: 2px solid var(--border-default) !important;
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user