2026-09-12 07:35:35 +00:00
|
|
|
{
|
|
|
|
|
"name": "mega-plant",
|
|
|
|
|
"version": "0.1.0",
|
|
|
|
|
"private": true,
|
|
|
|
|
"scripts": {
|
|
|
|
|
"dev": "next dev --webpack -H 0.0.0.0 -p 6007",
|
|
|
|
|
"build": "next build --webpack",
|
|
|
|
|
"start": "next start -H 0.0.0.0 -p 6007",
|
|
|
|
|
"lint": "next lint",
|
|
|
|
|
"typecheck": "tsc --noEmit",
|
2026-10-06 02:02:08 +00:00
|
|
|
"test": "node --import tsx --test lib/*.test.ts",
|
|
|
|
|
"setup:auth": "node --env-file=.env.local scripts/setup-auth.mjs",
|
2026-09-12 07:35:35 +00:00
|
|
|
"seed": "node scripts/seed.mjs"
|
|
|
|
|
},
|
|
|
|
|
"dependencies": {
|
|
|
|
|
"@ducanh2912/next-pwa": "^10.2.9",
|
|
|
|
|
"@radix-ui/react-dialog": "^1.1.4",
|
|
|
|
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
|
|
|
|
"@radix-ui/react-label": "^2.1.1",
|
|
|
|
|
"@radix-ui/react-select": "^2.1.4",
|
|
|
|
|
"@radix-ui/react-slot": "^1.1.1",
|
|
|
|
|
"@radix-ui/react-tabs": "^1.1.2",
|
|
|
|
|
"@radix-ui/react-tooltip": "^1.1.6",
|
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 14:24:06 +00:00
|
|
|
"@types/leaflet": "^1.9.22",
|
2026-09-12 07:35:35 +00:00
|
|
|
"class-variance-authority": "^0.7.1",
|
|
|
|
|
"clsx": "^2.1.1",
|
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 14:24:06 +00:00
|
|
|
"leaflet": "^1.9.4",
|
2026-09-12 07:35:35 +00:00
|
|
|
"lucide-react": "^0.469.0",
|
|
|
|
|
"next": "^16",
|
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
|
|
|
"pdf-lib": "^1.17.1",
|
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 12:30:17 +00:00
|
|
|
"pdfjs-dist": "^4.10.38",
|
2026-09-12 07:35:35 +00:00
|
|
|
"pocketbase": "^0.27.3",
|
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
|
|
|
"qrcode": "^1.5.4",
|
2026-09-12 07:35:35 +00:00
|
|
|
"react": "^19.0.0",
|
|
|
|
|
"react-dom": "^19.0.0",
|
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 14:24:06 +00:00
|
|
|
"react-leaflet": "^5.0.0",
|
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 14:33:43 +00:00
|
|
|
"react-leaflet-cluster": "^4.1.3",
|
2026-09-12 07:35:35 +00:00
|
|
|
"recharts": "^2.15.0",
|
|
|
|
|
"tailwind-merge": "^2.6.0",
|
|
|
|
|
"xlsx": "^0.18.5"
|
|
|
|
|
},
|
|
|
|
|
"devDependencies": {
|
|
|
|
|
"@tailwindcss/postcss": "^4.0.0",
|
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 14:33:43 +00:00
|
|
|
"@types/leaflet.markercluster": "^1.5.6",
|
2026-09-12 07:35:35 +00:00
|
|
|
"@types/node": "^22.10.0",
|
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.
Insurance (R503 330/yr, 20 vehicles)
A premium can move every month or three, so it is stored as a RATE PERIOD in
insurance_rates and each month's cost is resolved against the rate in force
that month. cost_lines insurance rows are derived from those periods. An absent
rate is null, never 0 — nobody has entered the date, which is not the same as
being insured for free.
Financing (20 agreements, R428 585/mo)
Instalment from the sheet; start date, paid-off date and the settled toggle are
human-entered and preserved across re-seeds. Months-left and outstanding are
null until a date is entered, never R0 on a live debt. The two paid-off
agreements (WB02, DT01) show as settled and are excluded from the run-rate.
Purchases + Oil Stock
Uploads PO PDFs the way /income/invoices does. The parser reads columns by
x-position, because plain text extraction reorders them, and refuses any PO
whose lines do not sum to the sub-total. A cost code either names a machine
("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
stock is the sum of movements so the first usage is a subtraction from history.
Operators (19) and Mechanics (2)
Both are people with an employee number, separately reconcilable by the seed so
someone added in the app survives. Mechanics get a badge QR; a mechanic with no
employee number cannot be printed, because the code is what the QR encodes.
Cross-cutting
- The seed no longer deletes operators, mechanics or oil items: people are added
in the app, and those rows exist in no file.
- Re-pointing allocations that PocketBase clears because `assets` is rebuilt
with new ids each seed (operators, purchase lines).
- One QR encoder for screen and PDF, so a printed code cannot drift from the one
on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
- Every figure is null-safe: unknown is never rendered as zero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
|
|
|
"@types/qrcode": "^1.5.6",
|
2026-09-12 07:35:35 +00:00
|
|
|
"@types/react": "^19.0.0",
|
|
|
|
|
"@types/react-dom": "^19.0.0",
|
|
|
|
|
"postcss": "^8.4.49",
|
|
|
|
|
"tailwindcss": "^4.0.0",
|
|
|
|
|
"tsx": "^4.19.2",
|
|
|
|
|
"typescript": "^5.7.2"
|
|
|
|
|
}
|
|
|
|
|
}
|