Commit graph

31 commits

Author SHA1 Message Date
David
def147bc73 Income: hide stood-down machines, with an active/inactive filter
The field is `assets.is_active`. All 7 inactive machines have no income at all
— the three generators, two porta camps, AEB Plant Versekering and the Diesel
Truck — so on the income list they were a flat row of zeros and nothing else,
reading as "idle" when they are actually out of the fleet.

They are now hidden by default. The rows are not dropped from the data: the
filter lives in the view, so Active / All / Inactive is a client-side toggle
with no refetch, and each shows its count so a filter that silently hides rows
is obvious. A machine hidden by the filter also collapses if it was expanded.

The YTD income and profit tiles moved into the view so they sum only what is
visible — otherwise the headline would report the whole fleet while the list
below it showed a subset.

/charts is unchanged: it passes no filter and still sees everything.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-03 18:40:32 +02:00
David
299c20ae57 Mobile: the top tiles overflowed on /income, hugged text on /sites, and repeated on invoices
Three separate layout faults in the header rows, all reported from a phone.

/income scrolled sideways by 158px. Three causes stacked, none of them responsive:
the wrapper was p-8 (32px each side at 375px), the header row was
`flex items-end justify-between` with no breakpoint, and the KPI row was
`flex items-center gap-3` with no wrap, so the two tiles physically could not
stack. Measured: main sat at x=36 w=339 at 375px, and the YTD Profit tile began
at x=355 in a 375px viewport — about 20px of its 178px was on screen.

- Wrapper is p-4 sm:p-8, and the header row stacks until lg, not sm. This one
  matters: at 768px a side-by-side row leaves the tile grid only 317px of the
  704px available (the title block takes 371px), so two columns of 152px are too
  narrow for text-2xl money and the figures spilled out over their own tiles.
  Note the DOM check said pageHScroll:false at 768 — the page did not report
  overflow even though the text was visibly broken, and only the screenshot
  caught it. The tiles may not go 2-up until the title is above them.
- The KPI row is grid grid-cols-1 sm:grid-cols-2, so the two tiles fill the row
  instead of being sized by their content.

The machine card's P/L pill hung 32px outside its card (pill ended at x=374, the
card at x=342), jammed against the viewport edge. IncomeView's mobile row 2 is
sparkline + YTD + pill, and both ends are shrink-0 with nothing allowed to give:
Sparkline's root is a fixed 120px box and the pill is shrink-0. /sites already
passed width={72} for exactly this reason (see the note in Sparkline.tsx);
/income never did. It does now, with min-w-0 on the YTD block. The pill now sits
16px inside the card.

/sites was not broken — it never overflowed — it just looked wrong. `flex
flex-wrap items-stretch` wraps, so nothing broke, but flex-wrap sizes tiles by
CONTENT: "Revenue (ex VAT)" measured 197px inside a 343px row and the second
tile wrapped to a line narrower still, both left-aligned, leaving a ragged edge
and dead space. That is the same anti-pattern AGENTS.md already records as fixed
on /income/invoices, never carried over here. It is a grid now.

/income/invoices showed "Booked to income" and "Billed (incl VAT)" as two tiles
with the same figure, which read as a duplicate. Not a bug: one sums booked
lines, the other sums every invoice header, and all 10 invoices are currently
fully booked (0 unbooked, problem inbox empty), so they are legitimately equal.
They separate the moment a line is left unbooked — which is the whole point of
the screen. Merged into one tile that says what the pair actually means,
"R 780 003,61 of R 780 003,61 billed", with the VAT breakdown kept underneath
because AGENTS.md requires VAT to stay visible. The gradient moved onto the
merged tile so the money figure keeps its emphasis.

Verified in the browser at 375, 768 and 1280: no page overflow, no element past
the viewport edge outside the table's own scroller, nothing clipped inside the
merged tile, and both desktop layouts unchanged. Typecheck and build green.
2026-10-03 16:45:23 +02:00
David
aa1e75e399 Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.

Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.

Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.

The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.

- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
  never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
  width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
  on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
  backdrop-blur, and a backdrop-filter makes an element the containing block for
  its position:fixed descendants — inline, the drawer would have sized itself to
  the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
  cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
  from 247px to 215px at 375px, and the search is the only thing that yields.

Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 16:08:04 +02:00
David
fd2afeba4e Invoices: a machine chosen in the dropdown was never actually booked
Selecting a plant item in the Booked to dropdown silently did nothing. The menu
opened, the option highlighted, and clicking it produced no request at all — the
menu just closed. Reproduced with a real browser click: no POST to /book.

The menu is rendered with createPortal(..., document.body) because the line table
lives in an overflow-x-auto container that would clip an in-place dropdown. A
portal is not a DOM descendant of the component that made it, so the click-away
handler's `boxRef.contains(target)` reported every option as an outside click:
mousedown closed the menu, React unmounted the button, and the click that would
have booked it never landed. The menu now has its own ref and is treated as part
of the control. Verified with a real click — the booking posts, the site clears,
and the unbooked badge goes.

The site dropdown was never affected; it is a native <select>, outside the portal.

Also on this screen:

- The Booked to column needs min-w-[240px] with the two pickers stacked inside
  it. At 768px the column is only ~166px, so two 200px controls could not sit
  side by side; as flex children they compressed into each other by ~12px, and
  once they were not allowed to shrink they spilled past the edge of the table.
- Dropped the "Booked to income / VAT to SARS / Billed to customer / Still
  unbooked" money block, its component and the variables feeding it. The status
  line stays: "1 line not yet booked to a machine".

Typecheck and build green. Checked at 768x1024 and 375x812: no page overflow, no
control past the table edge, no tap target under 44px.
2026-10-03 15:43:46 +02:00
David
39633838e0 Plant: default the register's status filter to Active
The register is a working list of the machines currently in the fleet, so
retired rows were noise until someone asked for them. Rows with an unsaved
toggle staged stay visible, so an edit is never hidden mid-change.

(Change made in the working tree alongside the invoice layout work; committed
separately so it keeps its own message.)
2026-10-03 09:59:11 +02:00
David
8423878b4f Invoices: fix the tablet layout and make booking an unbooked line obvious
On /income/invoices at tablet width the expanded invoice detail was unreadable
and the booking control was effectively unreachable.

- The line-item table is rendered inside a <td> of the invoice list's own
  scrollable table, so its 1200px width stretched the OUTER table to 1234px and
  the whole list scrolled sideways whenever a row was open. The detail is now
  measured with a ResizeObserver and given that pixel width, so it can no longer
  influence the outer table.
- Both tables' first columns are sticky left-0, and two nested stickies at the
  same offset stack on top of one another: the invoice number, the site and the
  period printed over each other. The detail wrapper is now sticky left-0 z-20,
  above the outer cell's z-10, so the inner table scrolls in its own box.
- The unbooked row tint was bg-[#D1495B]/[0.04] — 96% transparent — so every
  column a sticky cell covered bled through it on scroll. The tint is now the
  solid #FDF3F4 (dark #292934), repeated exactly on the sticky cells.
- The KPI row was flex-wrap, which sizes tiles by CONTENT, so the wide "booked
  to income" tile pushed the third one onto a second row below ~644px. It is a
  grid-cols-3 now, with the figures sized to the column.
- The machine picker and the site dropdown in the Booked to cell were flex
  children and compressed into each other by ~12px instead of sitting side by
  side. Both are shrink-0 now.
- Booking a line always saved on selection with no Save button, which read as
  "nothing happened". Unmatched rows now say so.

The booking API was already correct: a machine booking clears the site and posts
income, a site booking posts none. Verified at 768x1024 and 375x812.
2026-10-03 09:59:05 +02:00
David
649e495f49 Housekeeping: the surface token, and ignore the verification screenshots
The machine dropdown was still hard-coding one of four dark surface hexes
that globals.css already defines as a token.

Also ignore .verify/, where the Round 4 mobile checklist leaves its
screenshots. They are regenerated on every pass, so they were never meant
to be committed.
2026-10-03 09:20:24 +02:00
David
3cd3025116 Map: dark tiles from Esri, because CARTO retired keyless access
CARTO now answers every keyless basemap request with HTTP 200 and a valid
256x256 PNG that is just a dark square watermarked "API KEY REQUIRED".
Leaflet accepts it as a tile and paints it, so the map silently filled with
the warning — no console error, nothing in the network tab to catch it.

Esri's World Dark Gray Canvas is a genuine dark basemap and needs no key.
Its ArcGIS REST service orders the tile path {z}/{y}/{x}, the reverse of
what Leaflet knows how to substitute, so a getTileUrl override is the whole
fix. react-leaflet always instantiates Leaflet's own TileLayer, so the
custom class is added to the map directly and torn down on unmount rather
than passed as a prop.
2026-10-03 09:20:24 +02:00
David
813c187272 Sites: a card list that reads like the income screen
The client likes the vehicles overview and asked for the site list to work
the same way, so /sites now defaults to Cards — same toolbar, same card
shape, same 12-month strip on expand, same three-up footer as IncomeView.
The old table is kept as a Grid toggle rather than deleted.

The detail link lives inside the expanded panel only: a card is a <button>,
so a nested <a> there would be invalid HTML.
2026-10-03 09:20:20 +02:00
David
681225e51d Charts: a profit line that changes colour, and a Series toggle
Profit was a single always-teal line, so a month that lost money looked
like a month that made a little. It is now drawn per sign: one <Line> per
same-sign run, plus a gradient <Line> across each crossing. Recharts draws
a Line as one <Curve> path, so <Cell> cannot recolour a segment — separate
series are the only way. Every variant is a key on the ONE shared dataset
rather than a sliced `data` prop, because Recharts builds a categorical axis
by concatenating every series' data and a short slice re-adds the month
labels ("Jan … Aug, Jan … Aug"). The tooltip needs a custom `content`
component to fold those per-run keys back into a single profit row.

Two honesty rules: profit stays null in a month with no income figure
(treating it as 0 drew a green dot for a month that was never billed), and
a month with no running-cost row gets a hollow dot, because its profit is
optimistic.

The toolbar's Series button toggles which lines a card draws, remembered in
localStorage. A hidden series takes its legend entry and its Y axis with it,
so there is never an axis for a line that is not drawn, and the last visible
series cannot be switched off.

Palette: Running moves off red to the project's existing brand purple,
because red is now reserved for a loss and a red Running line next to a red
loss segment was unreadable. `--color-chart-loss` and `--color-chart-profit`
are separate tokens on purpose — loss used to read the Running token, so
recolouring Running silently dragged the red with it.

/charts alone now reads profit including running cost, so the card badge,
the profit line and the Profit bars view cannot disagree. Scoped to the
page: lib/profit.ts keeps its workbook default for every other surface.
2026-10-03 09:20:16 +02:00
David
876619c3ac Invoices: keep the PDF on the invoice, and search the invoice's own words
Two things this question turned up.

The upload DID store the PDF, but on the intake record, never on the invoice
— so "Open invoice PDF" 404'd for every invoice imported through the app
while working for the older ones. The file now lives on invoices.source_pdf
and the intake record keeps only the provenance (who sent it, what was
parsed), rather than a second copy of the file. The document endpoint also
falls back to the intake record, which makes the seven already-stored
documents reachable again without a migration. All 10 now open.

The machine combobox searched only the register's own words, so an invoice
line reading "CB22 2.2 TON Roller Vibration" could never be matched to the
asset filed as "CAT 2,4TON RV" — the words simply were not in the list. The
line's own invoice code and description are now searchable too, so the two
sides of the same machine can actually be found from either direction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 19:09:16 +02:00
David
5b9425873b Fix: toast and machine dropdown were rendering behind the page chrome
Two different causes, both from the same root — the header was raised to
z-[1100] so the Plant Map's Leaflet controls (z-1000) could not scroll over
it, which quietly pushed my overlays underneath it.

The toast sat at z-60 and the Add Invoice dialog at z-50, both below the
header, so a confirmation appeared to vanish behind the nav bar. Rung out
against the existing ladder instead of picking a new number: header 1100,
nav drawer 1200, dialog 1250, toast 1300. The toast and the combobox list sit
at the same layer deliberately — they never open at the same time.

The machine dropdown was a separate and worse bug: it was not merely behind
anything, it was CLIPPED. It rendered inside the line table's overflow-x-auto
container, so the list was cut off by the table edge and the card around it —
it could never escape, which is why the search appeared to do nothing.

It now renders through a portal into <body>, positioned from the input's
measured viewport rect, so no ancestor can clip it. The rect is measured
before paint so it never flashes in the wrong place, kept in sync with
scroll and resize, and the list flips upward when there is not enough room
below — which is the common case on a phone with the keyboard open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 18:42:29 +02:00
David
d471dc2d88 Invoices: make the machine picker searchable
Booking a line meant scrolling a native <select> of 44 machines, where two
dozen near-identical "CASE TLB" entries sit next to each other. A native
select cannot be filtered, so finding TLB07 meant scrolling past most of the
list.

Replaced with a combobox that filters as you type, on the code, the name or
the category, and says how many of 44 machines matched. Still behaves like a
select rather than a text box: opening it, Up/Down to move, Enter to choose,
Escape to cancel, and a "Not booked" entry to clear.

The list stays grouped by category so the codes read the way the plant
register does, and every row is a 44px target with the list scrolling within
its own box, so it stays usable on a phone. It is an ARIA combobox —
aria-expanded, aria-autocomplete, aria-controls — and the highlighted row
scrolls into view as you arrow through it.

Booking behaviour is unchanged: still posts on selection, still rolls the
display back if the save fails, still refreshes on success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 18:37:54 +02:00
David
293276ccce Sites: auto-allocate container lines to the container the site hires
A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.

The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.

A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.

Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 18:32:12 +02:00
David
802f471047 Sites: give every site coordinates
`assets` already carried latitude/longitude, but `sites` had nowhere to put a
pin — so there was no way to locate a site. Both fields added, matching the
plant collection's shape so the same map component can plot either.

Coordinates are entered as two plain number fields with an "open in maps" link
to verify the point, rather than a drag-to-pin map: the in-app Leaflet map is
still being worked on, and a coordinate is usually copied off some other map
anyway. Clearing removes the pin rather than leaving half a coordinate, which
would plot in the Gulf of Guinea and read as "located".

Bounds are checked server-side, not just in the form: a point outside South Africa
is refused with the numbers echoed back, because a swapped pair or a dropped
digit is far likelier than a machine abroad. 0,0 is rejected by the same rule.

The sites list marks each row as pinned or "no pin", with a banner counting the
gaps, so an unlocated site is visible without opening it.

While checking this, found that all 44 assets carry the SAME coordinate
(-26.1016, 28.3487) — the Plant Map currently stacks every marker on one pin
while reporting "44 located on the map". Left alone, but worth knowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:33:43 +02:00
David
14ec899ca9 Sites: a real Sites list, with the unbooked lines bookable to a site
The container lines (MPLANT04/MPLANT05) have no matching plant item, so they
sat unbooked forever with no way to account for them. They can now be booked
to a SITE instead.

`sites` is a new PocketBase collection derived from the invoices — each
distinct invoice site becomes a site, created as invoices are imported. They
are never hand-typed, and never deleted: a site can carry notes and hold line
bookings, so removing one would orphan that history.

/sites lists every site with its revenue ex VAT, its VAT, the total billed,
how many invoices and machines it carries, and how many lines are still
outstanding. Each row links to a detail page listing the site's invoices and
every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a
group with Overview and Sites.

Booking a line to a site posts NO income — there is no machine to carry it —
it only marks the line accounted for, so it stops showing as unbooked. Exactly
one of booked_asset / booked_site is ever set, and a line with neither is what
surfaces as unbooked everywhere.

One container per site, as specified: once a machine-less line has taken a
site, that site is withheld from the next one's picker and shown disabled
("already has one") rather than hidden. Free sites sort to the top. The rule is
enforced server-side too, so it does not depend on the UI being the only way in —
re-posting a taken site is refused with 409. Verified: booking The Hills, then
trying again, then taking West Park, and the remaining line sees 8 free and 2
taken. Clearing a booking frees its site.

The site picker only appears on lines that need it — no machine, or one already
booked to a site — so fully-booked rows keep a single control.

Also fixed the Sidebar's group-active check, which derived its prefix from the
first child. For a group whose first child is "/" that yields "/" and matched
every route, so Dashboard would have been highlighted on every page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 16:24:06 +02:00
David
dc7024d98a Fix: the mobile table was mislabelled and clipping its own totals
A screenshot at phone width showed an "INVOICE | LINES | TOTAL | STATUS"
header row while the data under LINES was money. The header and its data cell
had drifted apart: the earlier pass hid the Lines <td> but not the matching
<th>, because the loop that added the hide classes only matched text-left
headers and Lines is text-right. So the Total values rendered under a heading
that named a column which no longer existed.

The same screenshot showed the totals clipped — "41,710.50" reading as
"710,50". With only three columns left, the 520px min-width was still forcing
a sideways scroll, and the opaque sticky first column then sat on top of the
left of the money values. The mobile floor is gone, so the three remaining
columns size to their content, and the sticky and status gutters are trimmed
at mobile widths to let them fit 375px.

Below sm the table is now exactly Invoice, Total, Status — and every header
matches a visible cell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 15:34:19 +02:00
David
999feb37fa Mobile: make the invoice tables readable at 375px
Audited the invoice UI against the Round 4 checklist and fixed four things.

The summary table carried eight columns at a fixed 820px min-width, which at
375px is a long sideways scroll through three figures before reaching the
status badge — the one thing that needs acting on. Below sm it now keeps only
Invoice, Site, Total and Status, and drops to a 520px min-width, so the useful
columns are readable without scrolling. Period, line count and the ex-VAT/VAT
split are one tap away in the expanded detail, and the full eight columns
return from sm up where there is room.

The summary table also had no right-edge fade. Rule 4 asks for a visible
affordance on any wide table and it was simply missing, so scrolling sideways
looked like the table ended. Same for the month grid on /income, which was also
missing -webkit-overflow-scrolling. Both now share components/ScrollFade.

Two 10px labels in the app header ("Overheads 2026", "FY") were invisible at
375px but visible from 640/768px — and 768x1024 is one of the two sizes the
checklist tests, so they were a rule-2 violation at tablet width. Bumped to
12px. PlantRegisterTable's 10px filter count was checked and left alone: it
sits inside hidden lg:flex, so it never renders on mobile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 15:29:00 +02:00
David
58c972c4c9 Invoices: a duplicate is a notice, not a broken invoice
Re-offering a PDF that is already in the register did the safe thing — nothing
was written, no income double-counted — but reported it as a failure, which
was wrong twice over. No toast fired, so if the dialog were not in front of
you the click looked unacknowledged; and it filed an entry in the retry inbox,
where a duplicate is a trap: re-offering it can never succeed unless the
original is deleted first, so it sat in a list of things to fix that could not
be fixed.

Duplicates now get their own status. Nothing is written, no inbox entry is
created, the dialog closes and a notice toast names the copy already held —
site, line count and total — so it is obvious which one it matched. Amber
rather than teal, because nothing was imported and a success-coloured toast
would misread.

Verified by re-uffering PHI 169: invoices 10->10, lines 31->31, inbox 7->7,
failed 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:59:08 +02:00
David
b0b31ad830 Invoices: toast to the top, solid teal
The confirmation now arrives at the top of the screen instead of the bottom,
and is a solid teal panel rather than a white card with a green icon — at a
glance it reads as a system success message rather than a quiet note.

Teal rather than a literal green: #00798C is already this app's success
colour (the "All booked" badge, the income tiles, the import button), so a new
hue would have been off-palette. Solid fill with white text reads unambiguously
as confirmation, where the previous white card could be missed.

Slides down from above rather than up from below. Honours the notch on mobile
via env(safe-area-inset-top) and keeps a 44px dismiss target on the new fill.

The unused tone prop went with it — failures stay in the dialog, so the toast
is only ever a success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:50:48 +02:00
David
fe4b1fc3dd Invoices: confirm a successful import with a toast
Closing the dialog on success left the OK click completely silent — the user
had to go hunting the table for the new row to find out whether anything had
happened. A toast says so plainly and then gets out of the way.

It carries the useful part: invoice number, line count, billed total, how many
lines booked, and — the bit that actually needs action — the machine codes
that could not be matched, named rather than left for the user to infer from
the badge. Auto-dismisses after six seconds, dismissible by hand, and clears
when the dialog is reopened.

Announced via role=status / aria-live=polite rather than assertively: a
confirmation is not urgent and should not cut into whatever a screen reader
is currently saying.

No toast library — the app needs exactly one, and a dependency would carry
far more surface than that. Sits above the dialog's z-50, full-width on
mobile and bottom-right from sm up, with a 44px dismiss target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:46:55 +02:00
David
10d257e592 Invoices: the dialog closes itself once an import succeeds
An import that worked left the modal sitting there with a green message,
blocking the table it had just added a row to. Success now resets the dialog
and closes it, and the refreshed table is the confirmation.

A failure deliberately keeps it open: the reason is the whole point, and the
problem inbox entry alone does not stop the user from wondering what the
modal was trying to tell them. Anything still needing attention after a
successful import (an unassigned line) is already badged on the invoice's
row, so nothing is lost by closing.

With success no longer rendering in the dialog, the result state was carrying
a boolean that could only ever be false, so it collapsed to the error itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:43:32 +02:00
David
9e6235af92 Invoices: Add Invoice becomes a real dialog; quieten the invoice table
The upload panel expanded inline above the table, which pushed the list down
the page every time it opened. It is now a standalone modal: backdrop,
Escape to close, body scroll locked, sheet on small screens and a centred
dialog from sm up. The file is chosen or dropped into the modal and nothing
is sent until OK — a mis-drop can be cleared without leaving a stray record
in the inbox. Cancel, a busy state and inline size/type errors round it out.

The status column carried two extra lines on every single row — a multi-site
note and a "booked R X · R Y VAT" figure — which is repetition: the figures
are already in the Revenue/VAT/Total columns, and the note restated something
the client ruled on. Both are gone, leaving just the booked/unbooked badge,
which is the only thing that needs acting on. The per-row booking figures
stay available in the expanded detail.

The plumbing those two lines used — the multi-site cross-invoice lookup and
the per-row booked/VAT totals — is now unused and removed with them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:35:27 +02:00
David
5caa923375 Invoices: the app ingests PDFs itself, with a problem-invoice inbox
Extraction was previously manual: an agent read each PDF, mapped the columns
by hand and wrote data/source/invoice-*.json. That was the only way anything
got in, and the upload button just filed the document without reading it.

That supplier's report is one fixed template — the columns sit at identical
x-positions on every invoice, and only the line count and page count vary —
so the figures are now read by coordinate instead. Deliberately not an AI
extractor: this input does not need one, and a deterministic parse that fails
loudly is worth more here than a flexible one that can hallucinate a total.

lib/invoice-parser.ts reads the header, the machine lines across all pages,
the "Plant Hire Total" row and the SUB TOTAL / Escalation / PLUS VAT /
INVOICE TOTAL block, then applies the same arithmetic gates the seed does:
lines must sum to the subtotal, each line must satisfy qty x rate = amount
and amount + VAT = total, and VAT must match the stated rate. Anything that
does not reconcile is refused with the reason — never partially imported.

Validated by parsing all five existing invoices and comparing every field
against the hand-extracted JSON: identical, including the R24,205.13-VAT
column that a plain text read gets wrong. PHI 169 was then deleted and
re-ingested through the HTTP endpoint alone and came back field-for-field
identical, with no agent involved.

- "Add Invoice" drops a PDF in and imports it; lines are matched to plant
  items by the code the invoice prints, and an unmatched line (the
  container codes MPLANT04/MPLANT05) is left unassigned rather than guessed.
- A "Problem invoices" inbox lists every PDF that failed, with the reason,
  and Retry re-reads the stored document and re-runs the parse. Retry
  re-reports rather than claiming success, and only files imported data.
- invoice_intake collection records each attempt.

pdfjs-dist is in serverExternalPackages: bundled, it rewrites its own lazy
worker import to a .next path absent from the standalone image and every
parse dies with "Setting up fake worker failed". The parser also copies the
buffer before handing it over, since pdfjs transfers and detaches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:30:17 +02:00
David
63016895b9 Plant Register: inline editing, active toggle and cascade-delete impact
WIP carried over from the working tree, committed separately so it does not
sit inside the invoice work.

- inline Edit cells for code / name / type and an active toggle
- GET /api/assets/:id/impact returns the cascade counts so the client can
  confirm before a delete
- DELETE /api/assets/:id hard delete; PocketBase cascade clears the dependent
  income / running / cost-line rows

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 15:59:51 +02:00
David
baf0bddde6 Invoices: line-item extraction, booking to plant items, VAT-inclusive income
Adds an Invoices sub-menu under /income with a summary table (one row per
invoice, click to expand the full detail) and the data model behind it.

Data:
- invoices + invoice_lines PocketBase collections, created idempotently by
  scripts/setup-invoice-collections.mjs
- line items transcribed from PHI 166/167/171 with per-invoice arithmetic
  validation in the seed; a bad invoice is refused without blocking the rest
- invoice-*.json in data/source are auto-imported, so adding an invoice is
  one file

Booking:
- manual book-in per line (Booked to), grouped by category
- POST /api/invoice-lines/:id/book moves the income row rather than adding a
  second one, so a booking can be changed freely
- scripts/book-invoice.mjs is idempotent: rows are stamped with
  monthly_income.invoice_line and replaced on re-run
- booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat),
  read by both the app and the CLI so they cannot disagree

Money:
- the R24,205.13 column on these invoices is PLUS VAT, not escalation. The
  seed now rejects VAT that disagrees with the stated rate.
- booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the
  VAT stays visible rather than hidden.

Uploads:
- drag-and-drop PDF attached to invoices.source_pdf
- GET /api/invoices/:id/document proxies it — the collection is rule-locked,
  so a direct PocketBase /api/files link 404s in a browser

Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into
months[i], so a machine with two income rows in one month (one per invoice,
which is normal multi-site use) showed only the last. SS01 September read
R20,700 instead of R35,305 while its own YTD included both. Months now
accumulate, per-month profit is computed once after accumulation, and
lib/rows.test.ts pins it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 15:59:30 +02:00
Mega Plant
2f57a0c25f Brand logo: 3D cube/map icon replaces the M gradient mark
- public/brand/logo.png: 117×115 transparent PNG (source)
- public/icons/icon-192.png, icon-512.png, icon-maskable-512.png: PWA icons derived from logo
- public/icons/favicon.png (64), favicon-32.png (32): favicon sizes
- icon-maskable-512: logo on #003D5B indigo background, 80% safe zone
- layout.tsx: header logo + mobile drawer logo both use the new image
- Sidebar.tsx: drawer header logo uses the new image
- metadata.icons updated to include favicon sizes
- <link rel='icon'> added for 32×32 and 64×64 favicons
2026-09-12 12:12:30 +02:00
Mega Plant
0516220110 Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'

Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance

AGENTS.md created with the standing mobile checklist rule.
2026-09-12 12:05:43 +02:00
Mega Plant
7194e78079 Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 11:24:18 +02:00
Mega Plant
b70fc8c5c7 Round 2 (Parts B-D): asset register restructure, type field, sequential codes, flat sortable table, cost_lines, removed synthetic code, fail loudly
- B.1: removed running_cost from PB monthly_income schema
- B.2: 40 of 40 machines now have income (was 32/40)
- B.3: removed syntheticCode() + auto-create branch; deleted 2 ghost Z* assets; seed fails loudly on unmatched
- C.1: collapsed 13 cost-component rows into 5 machines + new cost_lines collection; Finance/Insurance/Tracking categories removed from assets
- C.2: type field on assets derived from code prefix
- C.3: sequential codes SC01-07, OC01-02, PC01-02; fixed SITE CONTANER spelling
- C.4: flat sortable plant register table with type filter pills
- C.5: wired filter input on plant register
- D: verified term 2 (insurance) reconciles for all 5 financed machines
2026-09-12 10:24:26 +02:00
Mega Plant
acf9d52e5f Mega Plant dashboard — fix & harden (Parts 0-5) 2026-09-12 09:35:48 +02:00