Compare commits

...

2 commits

Author SHA1 Message Date
DEls
de85bf897e feat(po-email): native Outlook node build + Mail.app read route
Rebuilds the PO email intake on n8n's native Microsoft Outlook node so the mailbox
can be connected with a single sign-in instead of an Azure app registration, and
documents reading the same mailbox from Mail.app via AppleScript for one-off
backfills. Records the real-world site-matching gap: the site often appears only
inside the PDF, not in the email.
2026-10-28 23:04:16 +02:00
DEls
2c2d493e12 feat(plant): actual vs bill charts, attention card under KPIs, sidebar mark for missing rates
- Plant overview: monthly Actual vs Bill hours and Total vs Bill value across
  all equipment, using the same rules as the equipment detail tiles
  (billed-day logic moved to lib/metrics/plant-billing).
- Attention Needed moves under the KPI cards and now holds the missing hire
  rate, readings-to-review and fuel price notices.
- Sidebar Plant mark also counts equipment with no hire rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:43:56 +02:00
11 changed files with 515 additions and 250 deletions

View file

@ -1,8 +1,8 @@
# PO email intake — ai@megainfra.co.za → MEGA
**Status: built and tested. INACTIVE — waiting on two things from your IT admin (steps 1–2 below).**
**Status: built and tested. INACTIVE — waiting on one thing from you (step 1 below).**
Workflow: [MEGA - PO Email Intake (Microsoft Graph)](https://wfdash.n8n.apphub.co.za/workflow/bQ5PioGTSKnsWVh0)
Workflow: [MEGA - PO Email Intake (Microsoft Outlook)](https://wfdash.n8n.apphub.co.za/workflow/ARfOsAOy2KDUm1rp)
Source: [`po-email-intake.workflow.js`](./po-email-intake.workflow.js) (rebuildable from this file)
---
@ -11,7 +11,7 @@ Source: [`po-email-intake.workflow.js`](./po-email-intake.workflow.js) (rebuilda
Every 5 minutes the workflow:
1. lists the inbox of `ai@megainfra.co.za` through Microsoft Graph,
1. lists the inbox of `ai@megainfra.co.za` through n8n's native **Microsoft Outlook** node,
2. finds emails carrying a purchase-order document (PDF / JPEG / PNG / WEBP),
3. works out which MEGA site the PO belongs to,
4. files the document into the **same PocketBase queue the browser upload button uses** — a
@ -45,53 +45,80 @@ fallback IMAP route: `mail.megainfra.co.za` does not resolve, and HostH's
---
## Step 1 — your IT admin creates the app registration (once)
## Reading the mailbox from the Mac instead (Mail.app)
In the Azure portal → **App registrations** → **New registration**:
`ai@megainfra.co.za` is also set up in **Mail.app** on this Mac, and Mail can be read without
touching Full Disk Access, through Mail's own AppleScript interface:
- Name: `MEGA PO Email Intake`
- Supported account types: **Accounts in this organizational directory only**
- Redirect URI: leave blank (this is a client-credentials app, no sign-in redirect)
```bash
# accounts Mail knows about
osascript -e 'tell application "Mail" to get name of every account'
Then:
# raw MIME of a message (attachments come back base64-encoded inside it)
osascript -e 'tell application "Mail" to get source of message 1 of mailbox "Inbox" of account "ai@megainfra.co.za"' > msg1.eml
```
- **Certificates & secrets** → **New client secret** → copy the secret value (shown once).
- **API permissions** → **Add a permission** → **Microsoft Graph** → **Application permissions** →
add **Mail.ReadWrite** → **Grant admin consent**.
- `Mail.Read` would only allow reading. We also move and tag messages, so **`Mail.ReadWrite`
(application)** is the one to grant.
- Copy **Tenant ID**, **Application (client) ID** and the **client secret value**.
Then parse `msg1.eml` with any MIME parser to pull out the PDFs (see
[`po-email-intake.workflow.js`](./po-email-intake.workflow.js) for the same attachment filter
the workflow uses).
Send those three values to the person wiring up n8n. The account password is not needed and is
not stored anywhere.
Two things to know:
## Step 2 — create the n8n credential (once)
- macOS blocks **direct** reads of Mail's private store (`~/Library/Mail` and the
`com.apple.mail` container) from a sandboxed shell — you get `Operation not permitted`.
AppleScript works because Mail itself hands the data over; it asks once for Automation
permission, which is far less than Full Disk Access.
- This route is **Mac-local**. It only works while the Mac is on and Mail is signed in, so it is
good for a one-off backfill or for checking what is arriving — not for unattended intake.
The n8n workflow below is the route that runs on its own.
In n8n → **Credentials** → **New** → **OAuth2 API**:
---
| Field | Value |
| --- | --- |
| Name | `Microsoft Graph (MEGA PO)` |
| Grant Type | **Client Credentials** |
| Token URL | `https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/token` |
| Client ID | Application (client) ID from the app registration |
| Client Secret | the client secret value |
| Scopes | `https://graph.microsoft.com/.default` |
| Auth Style | Header → `Bearer` |
## Step 1 — connect the mailbox in n8n (once, about a minute)
## Step 3 — bind the credential and switch it on
In n8n → **Credentials** → **New** → search for **Microsoft Outlook** → **Connect**.
The six Microsoft Graph nodes still need the credential selected:
n8n opens a Microsoft sign-in page. **Sign in as `ai@megainfra.co.za`** and approve the mail
permissions it asks for (read, move, tag). n8n stores the tokens and refreshes them for you —
there is no password, no client secret and no Azure app registration to create. Name the
credential `Microsoft Outlook (MEGA PO)` so it lines up with the workflow.
`List Inbox POs` · `Download Attachment` · `Archive Message` ·
`Archive Message (already filed)` · `Flag Needs Review` ·
`Flag Needs Review (no site)`
> If your browser is already signed in to that account, it is one click. If the account is
> already open in your mail app, the same Microsoft session usually carries over — which is
> exactly why this route works without an IT admin.
The three PocketBase nodes (`Read Sites`, `Duplicate Check`, `Create PO Row`) are already bound
to the existing **`MEGAINFRASTRUCTURE`** credential — no secret sits inline in the workflow.
## Step 2 — bind the credential (once)
Once the credential is selected on those six nodes, activate the workflow (the schedule trigger
starts polling every 5 minutes).
Select `Microsoft Outlook (MEGA PO)` on these six nodes:
`List Inbox POs` · `List Attachments` · `Download Attachment` · `Archive Message` ·
`Archive Message (already filed)` · `Flag Needs Review` · `Flag Needs Review (no site)`
(The three PocketBase nodes — `Read Sites`, `Duplicate Check`, `Create PO Row` — are already
bound to the existing **`MEGAINFRASTRUCTURE`** credential. No secret sits inline in the workflow.)
## Step 3 — switch it on
Activate the workflow. The schedule trigger starts polling every 5 minutes.
---
## Upgrading to an unattended service principal (optional, later)
The delegated sign-in above runs as a person. That is fine for getting started, but for a
production flow you may prefer an app that needs no human at all. n8n's Outlook node supports
three authentication modes, and the swap is a setting change on the same six nodes:
| Mode | Credential | Use when |
| --- | --- | --- |
| `microsoftOutlookOAuth2Api` | Microsoft Outlook (hosted OAuth) | **default here** — one sign-in, no admin |
| `microsoftOAuth2Api` | Microsoft OAuth2 API (client credentials) | you want an app registration and a client secret |
| `microsoftEntraServicePrincipalApi` | Entra service principal | you want app-only access, with the `mailbox` field set to `ai@megainfra.co.za` |
For the service-principal route your IT admin creates an Azure app registration with
**Mail.ReadWrite** (application) + admin consent, and hands over Tenant ID, Application
(client) ID and the client secret value. Then change the `authentication` dropdown on the six
nodes and set the mailbox on each. No rebuild is needed — the workflow logic is unchanged.
---
@ -111,15 +138,24 @@ So the site has to be known when the row is created, and the workflow reads it o
itself, in this order:
1. exact project code — `C26/09`, `C26/13`, `C26/14`, `001`, `HEAD`
2. project code appearing anywhere in subject + body preview + attachment filename
2. project code appearing anywhere in subject + body preview + sender
3. site name — *The Hills*, *Cedar Gate*, *Munyaka 2*, *Nellmapius Ext 25*, *Mega Head Office*
If none of those match, the email is tagged `PO Needs Review` and left in the inbox for a human.
It is never filed against a guessed site.
**Known limitation, seen in real mail:** the site often appears only *inside* the PDF, not in the
email. A real example from the mailbox — the subject was *"RE: Bushveld Joy x 12 Traffic Light
Quote."* with no site anywhere in the subject, body or filename, while the PDF itself said
*"MEGCREQ11911 The Hills Ext12"* and the PO was for **The Hills**. That email gets flagged for
review rather than filed. The other three POs that week named their site in the subject
(*"Nellmapius Ext 25 PO Tools/cement/Bricks"*, *"Cedar Gate Cement PO"*) and matched fine.
> If you would rather have the OCR decide the site, that needs a change to the
> `process-batch` route (or to the queue) so the site can be corrected after extraction. That is
> a small change to the app, not to this workflow — say the word and it can be done.
> a small change to the app, not to this workflow — say the word and it can be done. It would
> also close the gap above, because the PO's own `Subject` line (`MEGCREQ11913 (Nellmapius EXT25)`)
> is far more reliable than the email text.
## Duplicate protection
@ -155,7 +191,7 @@ behave exactly like browser uploads.
| Symptom | Where to look |
| --- | --- |
| No emails picked up | n8n execution log for `List Inbox POs` — a 401/403 means the Graph credential is wrong or admin consent was not granted |
| No emails picked up | n8n execution log for `List Inbox POs` — a 401/403 means the credential is not connected or the consent was refused |
| POs tagged `PO Needs Review` | the email did not name a site; add the site/project code to the subject or check the site table in `Read Sites` |
| Row created but never finalised | the existing `MEGA - Purchase Order Ingest` workflow — check its `Fetch Pending Batches` / `Escalate Failure` nodes |
| Same PO filed twice | `Duplicate Check` compares `file_hash` + `site`; a different site for the same document is deliberately not treated as a duplicate |

View file

@ -1,11 +1,11 @@
/**
* MEGA - PO Email Intake (Microsoft Graph)
* -----------------------------------------
* MEGA - PO Email Intake (Microsoft Outlook)
* ------------------------------------------
* n8n Workflow SDK source. Rebuild with the n8n MCP `create_workflow_from_code` tool
* (validate first), or rebuild by hand in the n8n UI using
* docs/n8n/po-email-intake.md as the wiring reference.
*
* Live workflow: https://wfdash.n8n.apphub.co.za/workflow/bQ5PioGTSKnsWVh0
* Live workflow: https://wfdash.n8n.apphub.co.za/workflow/ARfOsAOy2KDUm1rp
*
* What it does: polls ai@megainfra.co.za for purchase-order emails, downloads the PO
* attachment, and files it into the same PocketBase pending queue the browser upload
@ -14,21 +14,19 @@
* cannot be matched to a site is tagged "PO Needs Review" in Outlook instead.
*
* Credentials referenced (none of them contain an inline secret):
* - "Microsoft Graph (MEGA PO)" (oAuth2Api) -> created by the admin, see the runbook
* - "MEGAINFRASTRUCTURE" (pocketbaseHttpApi) -> already exists on this instance
* - "Microsoft Outlook (MEGA PO)" (microsoftOutlookOAuth2Api) -> n8n's hosted Microsoft
* OAuth: create it in n8n and sign in as ai@megainfra.co.za. No app registration.
* Swap to microsoftEntraServicePrincipalApi for unattended app-only access.
* - "MEGAINFRASTRUCTURE" (pocketbaseHttpApi) -> already exists on this instance.
*
* Set the Graph credential on these six nodes after creating it:
* List Inbox POs, Download Attachment, Archive Message,
* Set the Microsoft credential on these seven nodes after creating it:
* List Inbox POs, List Attachments, Download Attachment, Archive Message,
* Archive Message (already filed), Flag Needs Review, Flag Needs Review (no site)
*/
import { workflow, node, trigger, ifElse, splitInBatches, nextBatch, newCredential, expr } from '@n8n/workflow-sdk';
const MAILBOX = 'ai@megainfra.co.za';
const GRAPH = 'https://graph.microsoft.com/v1.0';
const MSG = GRAPH + '/users/' + MAILBOX + '/messages/';
const GRAPH_CRED = { oAuth2Api: newCredential('Microsoft Graph (MEGA PO)') };
const GRAPH_CRED = { microsoftOutlookOAuth2Api: newCredential('Microsoft Outlook (MEGA PO)') };
const PB_CRED = { pocketbaseHttpApi: newCredential('MEGAINFRASTRUCTURE') };
const schedule = trigger({
@ -73,42 +71,38 @@ return [{ json: { sites: sites } }];`
});
const listInbox = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
type: 'n8n-nodes-base.microsoftOutlook',
version: 2,
config: {
name: 'List Inbox POs',
parameters: {
method: 'GET',
url: GRAPH + '/users/' + MAILBOX + '/mailFolders/inbox/messages',
sendQuery: true,
queryParameters: {
parameters: [
{ name: '$select', value: 'id,subject,from,bodyPreview,receivedDateTime,hasAttachments,categories' },
{ name: '$expand', value: 'attachments' },
{ name: '$orderby', value: 'receivedDateTime desc' },
{ name: '$top', value: '25' }
]
},
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
resource: 'message',
operation: 'getAll',
authentication: 'microsoftOutlookOAuth2Api',
returnAll: false,
limit: 25,
output: 'simple',
filtersUI: {
values: {
filterBy: 'filters',
filters: { hasAttachments: true, readStatus: 'both', foldersToInclude: ['inbox'] }
}
}
},
credentials: GRAPH_CRED
}
});
const selectDocs = node({
const selectMsgs = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Select PO Documents',
name: 'Select PO Messages',
parameters: {
mode: 'runOnceForAllItems',
jsCode: `const body = $input.first() ? $input.first().json : {};
const messages = body && Array.isArray(body.value) ? body.value : [];
jsCode: `const items = $input.all();
let sites = [];
try { sites = ($('Collect Sites').item.json.sites) || []; } catch (e) { sites = []; }
const DOC_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
const POISH = /(\\bp\\/?o\\b|\\bpo\\b|purchase\\s*order|\\border\\b)/i;
function norm(s) { return String(s || '').toUpperCase().replace(/\\s+/g, ' ').trim(); }
function matchSite(text) {
@ -122,39 +116,29 @@ function matchSite(text) {
return null;
}
const out = [];
for (const m of messages) {
if (!m || !m.id || m.hasAttachments !== true) continue;
for (const it of items) {
const m = it && it.json ? it.json : {};
if (!m.id || m.hasAttachments !== true) continue;
const cats = Array.isArray(m.categories) ? m.categories : [];
if (cats.indexOf('PO Needs Review') >= 0 || cats.indexOf('PO Ingested') >= 0) continue;
const atts = Array.isArray(m.attachments) ? m.attachments : [];
const subject = m.subject || '';
const preview = m.bodyPreview || '';
const from = (m.from && m.from.emailAddress && m.from.emailAddress.address) || '';
for (const a of atts) {
if (!a || a.isInline === true) continue;
const ct = String(a.contentType || '').toLowerCase();
const name = String(a.name || '');
if (DOC_TYPES.indexOf(ct) < 0) continue;
const hay = subject + ' ' + preview + ' ' + name;
const site = matchSite(hay);
const poish = POISH.test(hay);
if (!site && !poish) continue;
out.push({ json: {
messageId: m.id,
attachmentId: a.id,
filename: name,
contentType: ct,
size: a.size || 0,
subject: subject,
bodyPreview: preview,
from: from,
received: m.receivedDateTime || '',
ready: !!site,
siteId: site ? site.id : '',
siteCode: site ? site.project_code : '',
siteName: site ? site.name : ''
} });
}
const hay = subject + ' ' + preview + ' ' + from;
const site = matchSite(hay);
const poish = POISH.test(hay);
if (!site && !poish) continue;
out.push({ json: {
messageId: m.id,
subject: subject,
bodyPreview: preview,
from: from,
received: m.receivedDateTime || '',
ready: !!site,
siteId: site ? site.id : '',
siteCode: site ? site.project_code : '',
siteName: site ? site.name : ''
} });
}
return out;`
}
@ -163,7 +147,7 @@ return out;`
const loop = splitInBatches({
version: 3,
config: { name: 'Loop Documents', parameters: { batchSize: 1 } }
config: { name: 'Loop Messages', parameters: { batchSize: 1 } }
});
const siteResolved = ifElse({
@ -180,17 +164,66 @@ const siteResolved = ifElse({
}
});
const listAtts = node({
type: 'n8n-nodes-base.microsoftOutlook',
version: 2,
config: {
name: 'List Attachments',
parameters: {
resource: 'messageAttachment',
operation: 'getAll',
authentication: 'microsoftOutlookOAuth2Api',
messageId: { __rl: true, mode: 'id', value: '={{ $json.messageId }}' }
},
credentials: GRAPH_CRED
}
});
const selectAtts = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Select PO Attachments',
parameters: {
mode: 'runOnceForAllItems',
jsCode: `const items = $input.all();
const DOC_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'];
let messageId = '';
try { messageId = $('Select PO Messages').item.json.messageId || ''; } catch (e) { messageId = ''; }
if (!messageId) { try { messageId = $('Loop Messages').item.json.messageId || ''; } catch (e) { messageId = ''; } }
const out = [];
for (const it of items) {
const a = it && it.json ? it.json : {};
if (!a.id) continue;
if (a.isInline === true) continue;
const ct = String(a.contentType || '').toLowerCase();
const name = String(a.name || '');
if (DOC_TYPES.indexOf(ct) < 0) continue;
out.push({ json: {
messageId: messageId,
attachmentId: a.id,
filename: name,
contentType: ct,
size: a.size || 0
} });
}
return out;`
}
}
});
const download = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
type: 'n8n-nodes-base.microsoftOutlook',
version: 2,
config: {
name: 'Download Attachment',
parameters: {
method: 'GET',
url: expr('"' + MSG + '" + $json.messageId + "/attachments/" + $json.attachmentId + "/$value"'),
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'file', outputPropertyName: 'data' } } }
resource: 'messageAttachment',
operation: 'download',
authentication: 'microsoftOutlookOAuth2Api',
messageId: { __rl: true, mode: 'id', value: '={{ $json.messageId }}' },
attachmentId: { __rl: true, mode: 'id', value: '={{ $json.attachmentId }}' },
binaryPropertyName: 'data'
},
credentials: GRAPH_CRED
}
@ -206,9 +239,14 @@ const capture = node({
jsCode: `const items = $input.all();
const out = [];
for (const item of items) {
let meta = {};
try { meta = $('Select PO Documents').item.json || {}; } catch (e) { meta = {}; }
if (!meta.messageId) { try { meta = $('Loop Documents').item.json || {}; } catch (e) { meta = {}; } }
// Site and message metadata live on the message node; attachment details on the
// attachment node. Reading site info from the attachment node leaves siteId empty
// and PocketBase rejects the row (site is a required relation).
let msgMeta = {};
try { msgMeta = $('Select PO Messages').item.json || {}; } catch (e) { msgMeta = {}; }
if (!msgMeta.messageId) { try { msgMeta = $('Loop Messages').item.json || {}; } catch (e) { msgMeta = {}; } }
let attMeta = {};
try { attMeta = $('Select PO Attachments').item.json || {}; } catch (e) { attMeta = {}; }
const bin = (item.binary && item.binary.data) || {};
const b64 = typeof bin.data === 'string' ? bin.data : '';
let fileHash = '';
@ -219,22 +257,22 @@ for (const item of items) {
const stamp = Date.now().toString(36).toUpperCase();
out.push({
json: {
messageId: meta.messageId || '',
attachmentId: meta.attachmentId || '',
filename: bin.fileName || meta.filename || 'purchase-order.pdf',
contentType: bin.mimeType || meta.contentType || 'application/pdf',
messageId: msgMeta.messageId || attMeta.messageId || '',
attachmentId: attMeta.attachmentId || '',
filename: bin.fileName || attMeta.filename || 'purchase-order.pdf',
contentType: bin.mimeType || attMeta.contentType || 'application/pdf',
fileHash: fileHash,
siteId: meta.siteId || '',
siteCode: meta.siteCode || '',
siteId: msgMeta.siteId || '',
siteCode: msgMeta.siteCode || '',
purchaseOrderNumber: fileHash ? ('PENDING-' + fileHash.slice(0, 12).toUpperCase()) : ('PENDING-' + stamp),
emailMeta: {
source: 'email',
message_id: meta.messageId || '',
subject: meta.subject || '',
from: meta.from || '',
received: meta.received || '',
site_code: meta.siteCode || '',
file_name: bin.fileName || meta.filename || ''
message_id: msgMeta.messageId || attMeta.messageId || '',
subject: msgMeta.subject || '',
from: msgMeta.from || '',
received: msgMeta.received || '',
site_code: msgMeta.siteCode || '',
file_name: bin.fileName || attMeta.filename || ''
}
},
binary: item.binary
@ -344,84 +382,64 @@ const filedOk = ifElse({
});
const archiveNew = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
type: 'n8n-nodes-base.microsoftOutlook',
version: 2,
config: {
name: 'Archive Message',
parameters: {
method: 'POST',
url: expr('"' + MSG + '" + $("Capture File").item.json.messageId + "/move"'),
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody: '={"destinationId":"archive"}',
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
resource: 'message',
operation: 'move',
authentication: 'microsoftOutlookOAuth2Api',
messageId: { __rl: true, mode: 'id', value: '={{ $("Capture File").item.json.messageId }}' },
folderId: { __rl: true, mode: 'id', value: 'archive' }
},
credentials: GRAPH_CRED
}
});
const archiveDup = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
type: 'n8n-nodes-base.microsoftOutlook',
version: 2,
config: {
name: 'Archive Message (already filed)',
parameters: {
method: 'POST',
url: expr('"' + MSG + '" + $("Capture File").item.json.messageId + "/move"'),
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody: '={"destinationId":"archive"}',
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
resource: 'message',
operation: 'move',
authentication: 'microsoftOutlookOAuth2Api',
messageId: { __rl: true, mode: 'id', value: '={{ $("Capture File").item.json.messageId }}' },
folderId: { __rl: true, mode: 'id', value: 'archive' }
},
credentials: GRAPH_CRED
}
});
const flagReview = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
type: 'n8n-nodes-base.microsoftOutlook',
version: 2,
config: {
name: 'Flag Needs Review',
parameters: {
method: 'PATCH',
url: expr('"' + MSG + '" + $("Capture File").item.json.messageId'),
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody: '={"categories":["PO Needs Review"]}',
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
resource: 'message',
operation: 'update',
authentication: 'microsoftOutlookOAuth2Api',
messageId: { __rl: true, mode: 'id', value: '={{ $("Capture File").item.json.messageId }}' },
updateFields: { categories: ['PO Needs Review'], isRead: true }
},
credentials: GRAPH_CRED
}
});
const flagNoSite = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.5,
type: 'n8n-nodes-base.microsoftOutlook',
version: 2,
config: {
name: 'Flag Needs Review (no site)',
parameters: {
method: 'PATCH',
url: expr('"' + MSG + '" + $json.messageId'),
sendHeaders: true,
headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] },
sendBody: true,
specifyBody: 'json',
jsonBody: '={"categories":["PO Needs Review"]}',
authentication: 'genericCredentialType',
genericAuthType: 'oAuth2Api',
options: { response: { response: { responseFormat: 'json', neverError: true } } }
resource: 'message',
operation: 'update',
authentication: 'microsoftOutlookOAuth2Api',
messageId: { __rl: true, mode: 'id', value: '={{ $json.messageId }}' },
updateFields: { categories: ['PO Needs Review'], isRead: true }
},
credentials: GRAPH_CRED
}
@ -440,24 +458,26 @@ return [{ json: { finished: true, processed: items.length } }];`
}
});
export default workflow('mega-po-email-intake', 'MEGA - PO Email Intake (Microsoft Graph)')
export default workflow('mega-po-email-intake-v2', 'MEGA - PO Email Intake (Microsoft Outlook)')
.add(schedule)
.to(readSites)
.to(collectSites)
.to(listInbox)
.to(selectDocs)
.to(selectMsgs)
.to(loop
.onDone(summary)
.onEachBatch(siteResolved
.onTrue(download
.to(capture)
.to(dupCheck)
.to(alreadyFiled
.onTrue(archiveDup.to(nextBatch(loop)))
.onFalse(createPo
.to(checkResult)
.to(filedOk
.onTrue(archiveNew.to(nextBatch(loop)))
.onFalse(flagReview.to(nextBatch(loop)))))))
.onTrue(listAtts
.to(selectAtts)
.to(download
.to(capture)
.to(dupCheck)
.to(alreadyFiled
.onTrue(archiveDup.to(nextBatch(loop)))
.onFalse(createPo
.to(checkResult)
.to(filedOk
.onTrue(archiveNew.to(nextBatch(loop)))
.onFalse(flagReview.to(nextBatch(loop))))))))
.onFalse(flagNoSite.to(nextBatch(loop))))
);

View file

@ -39,7 +39,7 @@ afterEach(() => vi.useRealTimers());
it("counts the same actionable items as the dashboards, grouping missing POs rather than deliveries", async () => {
const response = await GET(request("?site=site1"));
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ plant: 1, financial: 1, onSite: 0, cashFloats: 0 });
expect(await response.json()).toEqual({ plant: 1, plantNoRate: 1, financial: 1, onSite: 0, cashFloats: 0 });
expect(response.headers.get("cache-control")).toContain("no-store");
expect(getScope).toHaveBeenCalledWith({ site: "site1" });
expect(getFullList).toHaveBeenCalledWith("vehicles", expect.objectContaining({ filter: 'site="site1"' }));
@ -55,17 +55,17 @@ it("loads only workforce alerts for a workforce office account", async () => {
});
const response = await GET(request());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ plant: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(await response.json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats: 0 });
});
it("clears the attention counts after classification and matching PO upload", async () => {
getFullList.mockImplementation(async (name: string) => {
if (name === "vehicles") return [{ unit_type: "fuel" }, { unit_type: "plant" }];
if (name === "vehicles") return [{ unit_type: "fuel" }, { unit_type: "plant", cost_per_hour: 450 }];
if (name === "dn_batch_notes") return [waitingNote("one")];
if (name === "purchase_orders") return [{ site: site.id, purchase_order_number: "MEGCPO9373", processing_status: "processing" }];
return [];
});
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats: 0 });
});
it("counts the cash float cards under their limit for an admin only", async () => {
@ -92,7 +92,7 @@ it("preserves restricted all-sites access in every query", async () => {
it("returns all clear without reading records when no sites are accessible", async () => {
getScope.mockResolvedValue({ allowed: [], active: [], mode: "all", unrestricted: false });
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(getFullList).not.toHaveBeenCalled();
});
@ -123,13 +123,13 @@ it("flags old missed clock-outs while excluding today's shifts, completed shifts
if (name === "clock_missed_out_corrections") return [{ clock_in_event: "corrected" }];
return [];
});
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 1, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 1, cashFloats: 0 });
getFullList.mockImplementation(async (name: string) => {
if (name === "clock_events") return events;
if (name === "clock_missed_out_corrections") return [{ clock_in_event: "corrected" }, { clock_in_event: "missed" }];
return [];
});
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats: 0 });
});
it("surfaces correction read failures instead of wrongly flagging corrected shifts", async () => {
@ -145,6 +145,6 @@ it("surfaces correction read failures instead of wrongly flagging corrected shif
{ id: "missed", employee: "a", direction: "in", scanned_at: "2026-10-03T05:00:00Z", expand: { employee: { employment_status: "inactive" } } },
{ id: "active", employee: "b", direction: "in", scanned_at: "2026-10-03T05:00:00Z", expand: { employee: { employment_status: "active" } } },
] : []);
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 1, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 1, cashFloats: 0 });
expect(getFullList).toHaveBeenCalledWith("clock_events", expect.objectContaining({ expand: "employee" }));
});

View file

@ -14,6 +14,7 @@ import {
} from "./plant-detail-view";
import { plantFuelFillSeries } from "@/lib/metrics/fuel-fill-series";
import { plantUsage, type PlantPeriod } from "@/lib/metrics/plant-usage";
import { plantBilledDays } from "@/lib/metrics/plant-billing";
import type { FuelLogRecord } from "@/lib/pb-types";
import { MergeVehicleButton } from "@/components/merge-vehicle-button";
import { loadMergeCandidates } from "@/lib/merge-candidates-load";
@ -90,46 +91,7 @@ export default async function PlantDetailPage({
projectCode:logSiteById[point.site] ?? vehicleProjectCode });
}
// Billed days: the site is billed a flat 9 h per weekday, however many hours
// the machine ran. First find the days with recorded work (weekly sheets cover
// every day of their period, meter intervals count their closing day); they
// fix when the hire starts/ends and which rate applies.
const BILLED_HOURS_PER_DAY = 9;
const billedByDate = new Map<string, number>();
const periodById = new Map(periods.map(p => [p.id, p]));
for (const p of points) {
if (p.rejected || p.hours <= 0) continue;
const period = p.source === "weekly" ? periodById.get(p.id) : undefined;
if (period?.start_date && period.end_date) {
for (let t = Date.parse(period.start_date.slice(0, 10)); t <= Date.parse(period.end_date.slice(0, 10)); t += 86400000) {
const d = new Date(t).toISOString().slice(0, 10);
if (d <= today && !billedByDate.has(d)) billedByDate.set(d, p.costPerHour);
}
} else {
const d = p.date.slice(0, 10);
if (!billedByDate.has(d)) billedByDate.set(d, p.costPerHour);
}
}
// The machine is on hire for the whole month: from its first recorded day to
// the end of its last active month (never beyond today), every weekday is
// billed 9 h whether or not it worked. Rate: that day's recorded rate, else
// the last known one, else the current hire rate.
const billedDays: { date: string; hours: number; value: number }[] = [];
const workedDates = [...billedByDate.keys()].sort();
if (workedDates.length > 0) {
const lastWorked = workedDates[workedDates.length - 1];
const monthEnd = new Date(Date.UTC(Number(lastWorked.slice(0, 4)), Number(lastWorked.slice(5, 7)), 0)).toISOString().slice(0, 10);
const end = Date.parse(monthEnd < today ? monthEnd : today);
let rate = 0;
for (let t = Date.parse(workedDates[0]); t <= end; t += 86400000) {
const date = new Date(t).toISOString().slice(0, 10);
rate = billedByDate.get(date) ?? rate;
const dow = new Date(t).getUTCDay();
if (dow === 0 || dow === 6) continue;
const dayRate = rate > 0 ? rate : costPerHour;
billedDays.push({ date, hours: BILLED_HOURS_PER_DAY, value: Math.round(BILLED_HOURS_PER_DAY * dayRate * 100) / 100 });
}
}
const billedDays = plantBilledDays(points, periods, costPerHour, today);
// Latest hour-meter reading = last odo in the series (we sorted asc).
const latestHourMeter = points.length > 0 ? points[points.length - 1].odoReading : 0;

View file

@ -1,3 +1,4 @@
import { Fragment } from "react";
import Link from "next/link";
import {
Droplets,
@ -17,6 +18,9 @@ import { plantUsage, type PlantPeriod } from "@/lib/metrics/plant-usage";
import { UnclassifiedVehicles } from "./unclassified-vehicles";
import { MonthlyFuelCharts } from "./monthly-fuel-charts";
import { monthlyPlantFuel } from "@/lib/metrics/monthly-plant-fuel";
import { plantFuelFillSeries } from "@/lib/metrics/fuel-fill-series";
import { monthlyPlantBilling, plantBilledDays } from "@/lib/metrics/plant-billing";
import { PlantBillingCharts } from "./plant-billing-charts";
import type { FuelLogRecord, VehicleRecord } from "@/lib/pb-types";
export const dynamic = "force-dynamic";
@ -82,8 +86,20 @@ async function loadPlantSummary(scope: SiteScope) {
fuelSpendThisMonth += log.litres * resolveFuelPrice(log.date_logged, fuelPrices);
}
}
const billingMachines = [];
for (const v of equipmentWithHistory.values()) {
const usage = plantUsage(allowedFuelLogs.filter(l=>l.vehicle===v.id), periods.filter(p=>p.vehicle===v.id), v.cost_per_hour ?? 0, today);
// Same figures as the equipment detail tiles, limited to the selected sites.
const siteLogs = allFuelLogs.filter(l=>l.vehicle===v.id);
const sitePeriods = periods.filter(p=>p.vehicle===v.id && activeIds.has(p.site ?? ""));
const sitePoints = plantUsage(siteLogs, sitePeriods, v.cost_per_hour ?? 0, today).filter(p=>p.date.slice(0,10)<=today);
billingMachines.push({
fillHours: plantFuelFillSeries(siteLogs)
.filter(p=>p.fillHours>0 && (p.log.date_logged ?? "").slice(0,10)<=today)
.map(p=>({date:(p.log.date_logged ?? "").slice(0,10),hours:p.fillHours})),
points: sitePoints,
billedDays: plantBilledDays(sitePoints, sitePeriods, v.cost_per_hour ?? 0, today),
});
for (const point of usage) {
const date = point.date.slice(0,10);
if (activeIds.has(point.site) && date >= monthStartIso && date <= today) {
@ -108,6 +124,7 @@ async function loadPlantSummary(scope: SiteScope) {
combinedSpend: fuelSpendThisMonth + equipmentValueThisMonth,
unclassified,
monthlyFuel,
monthlyBilling: monthlyPlantBilling(billingMachines),
};
}
@ -144,16 +161,15 @@ export default async function PlantLanding({
<KpiTile label="Equipment" value={formatNumber(summary.equipmentCount)} sub="Currently assigned" icon={Wrench} />
</section>
{(summary.estimatedFuel || summary.missingRates > 0 || summary.readingsForReview > 0) && (
<div role="status" className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{summary.estimatedFuel && <p>Fuel spend is estimated at the default diesel price because no dated fuel prices are configured. <Link className="font-medium text-foreground underline" href="/dashboard/plant/fuel-prices">Add Fuel Prices</Link></p>}
{summary.missingRates > 0 && <p>{summary.missingRates} equipment items have no hire rate. Their meter hours are recorded, but their hire cost is excluded until a rate is set. <Link className="font-medium text-foreground underline" href="/dashboard/plant/equipment?rates=1">Add Rates</Link></p>}
{summary.readingsForReview > 0 && <p>{summary.readingsForReview} reading intervals need review and are excluded from hire cost. <Link className="font-medium text-foreground underline" href="/dashboard/plant/equipment?review=1">Review Readings</Link></p>}
</div>
)}
<MonthlyFuelCharts {...summary.monthlyFuel} />
<UnclassifiedVehicles vehicles={summary.unclassified} notices={[
summary.estimatedFuel && <Fragment key="fuel-prices">Fuel spend is estimated at the default diesel price because no dated fuel prices are configured. <Link className="font-medium text-foreground underline" href="/dashboard/plant/fuel-prices">Add Fuel Prices</Link></Fragment>,
summary.missingRates > 0 && <Fragment key="rates">{summary.missingRates} equipment items have no hire rate. Their meter hours are recorded, but their hire cost is excluded until a rate is set. <Link className="font-medium text-foreground underline" href="/dashboard/plant/equipment?rates=1">Add Rates</Link></Fragment>,
summary.readingsForReview > 0 && <Fragment key="readings">{summary.readingsForReview} reading intervals need review and are excluded from hire cost. <Link className="font-medium text-foreground underline" href="/dashboard/plant/equipment?review=1">Review Readings</Link></Fragment>,
]} />
<MonthlyFuelCharts {...summary.monthlyFuel} />
<PlantBillingCharts months={summary.monthlyBilling} />
<UnclassifiedVehicles vehicles={summary.unclassified} />
</div>
</>
);

View file

@ -0,0 +1,97 @@
"use client";
import { format, parseISO } from "date-fns";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency, formatNumber } from "@/lib/utils";
import type { MonthlyPlantBillingPoint } from "@/lib/metrics/plant-billing";
// Recorded figures in the equipment green, billed figures in the sky blue the
// equipment detail tiles use for billing.
const RECORDED = "#007160";
const BILLED = "#0284c7";
const CHARTS = [
{
title: "Actual Hours vs Bill Hours",
unit: "Hours",
recorded: { key: "actualHours", label: "Actual hours" },
billed: { key: "billHours", label: "Bill hours" },
format: (n: number) => `${formatNumber(n, { maximumFractionDigits: 1 })} h`,
axis: (n: number) => formatNumber(n, { notation: "compact", maximumFractionDigits: 1 }),
},
{
title: "Total Value vs Bill Value",
unit: "Rand",
recorded: { key: "totalValue", label: "Total value" },
billed: { key: "billValue", label: "Bill value" },
format: (n: number) => formatCurrency(n),
axis: (n: number) => `R ${formatNumber(n, { notation: "compact", maximumFractionDigits: 1 })}`,
},
] as const;
export function PlantBillingCharts({ months }: { months: MonthlyPlantBillingPoint[] }) {
const period = months.length > 0
? `${format(parseISO(`${months[0].month}-01`), "MMM yyyy")} to ${format(parseISO(`${months[months.length - 1].month}-01`), "MMM yyyy")}`
: "Recorded equipment history";
return (
<section aria-label="Equipment actual vs billed" className="grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2">
{CHARTS.map((chart) => {
const recordedTotal = months.reduce((sum, m) => sum + m[chart.recorded.key], 0);
const billedTotal = months.reduce((sum, m) => sum + m[chart.billed.key], 0);
return (
<Card key={chart.title} className="min-w-0 rounded-lg shadow-none">
<CardHeader className="pb-3">
<CardTitle>{chart.title}</CardTitle>
<p className="mt-2 text-xs text-muted-foreground">{period} · All equipment</p>
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm">
{[[chart.recorded, recordedTotal, RECORDED], [chart.billed, billedTotal, BILLED]].map(([series, total, color]) => (
<div key={(series as { key: string }).key}>
<dt className="flex items-center gap-2 text-xs text-muted-foreground">
<span aria-hidden className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color as string }} />
{(series as { label: string }).label}
</dt>
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{chart.format(total as number)}</dd>
</div>
))}
</dl>
</CardHeader>
<CardContent>
<div className="h-64 w-full min-w-0">
{months.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">No equipment history yet.</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={months} accessibilityLayer barGap={2} margin={{ top: 12, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" tickFormatter={(value: string) => format(parseISO(`${value}-01`), "MMM yy")}
stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} minTickGap={16} />
<YAxis tickFormatter={chart.axis} stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} width={56} />
<Tooltip cursor={{ fill: "var(--muted)", opacity: 0.6 }}
contentStyle={{ backgroundColor: "var(--popover)", color: "var(--popover-foreground)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 12 }}
itemStyle={{ color: "var(--popover-foreground)" }}
labelFormatter={(value) => format(parseISO(`${String(value)}-01`), "MMMM yyyy")}
formatter={(value: number, name: string) => [chart.format(value), name]} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey={chart.recorded.key} name={chart.recorded.label} fill={RECORDED} radius={[4, 4, 0, 0]} maxBarSize={32} isAnimationActive={false} />
<Bar dataKey={chart.billed.key} name={chart.billed.label} fill={BILLED} radius={[4, 4, 0, 0]} maxBarSize={32} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="sr-only"><table>
<caption>{chart.title} by month</caption>
<thead><tr><th scope="col">Month</th><th scope="col">{chart.recorded.label}</th><th scope="col">{chart.billed.label}</th></tr></thead>
<tbody>{months.map((m) => <tr key={m.month}><th scope="row">{m.month}</th><td>{chart.format(m[chart.recorded.key])}</td><td>{chart.format(m[chart.billed.key])}</td></tr>)}</tbody>
</table></div>
<p className="mt-3 text-xs text-muted-foreground">
Bill hours are 9 h every weekday while a machine is on hire. Current month is still in progress.
</p>
</CardContent>
</Card>
);
})}
</section>
);
}

View file

@ -23,7 +23,11 @@ const OPTIONS = [
{ value: "plant", title: "Equipment", unit: "L/hour", description: "Track operating hours and fuel consumption.", icon: Wrench },
] as const;
export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehicle[] }) {
export function UnclassifiedVehicles({ vehicles, notices = [] }: {
vehicles: UnclassifiedVehicle[];
/** Other plant issues (missing hire rates, readings to review) shown above the list. */
notices?: React.ReactNode[];
}) {
const router = useRouter();
const [assignedIds, setAssignedIds] = React.useState<string[]>([]);
const [selected, setSelected] = React.useState<UnclassifiedVehicle | null>(null);
@ -33,6 +37,8 @@ export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehic
const [error, setError] = React.useState<string | null>(null);
const [message, setMessage] = React.useState("");
const remaining = vehicles.filter((vehicle) => !assignedIds.includes(vehicle.id));
const issues = notices.filter(Boolean);
const needsAttention = remaining.length > 0 || issues.length > 0;
function chooseVehicle(vehicle: UnclassifiedVehicle) {
setSelected(vehicle);
@ -62,11 +68,11 @@ export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehic
return (
<section aria-label="Attention needed">
<Card className={cn(remaining.length > 0 && "border-red-500 dark:border-red-400")}>
<Card className={cn(needsAttention && "border-red-500 dark:border-red-400")}>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<AlertTriangle className={cn("h-4 w-4 shrink-0", remaining.length > 0 ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} aria-hidden />
<AlertTriangle className={cn("h-4 w-4 shrink-0", needsAttention ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} aria-hidden />
<CardTitle>Attention Needed</CardTitle>
</div>
<p className="mt-2 text-sm text-muted-foreground">
@ -74,10 +80,15 @@ export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehic
</p>
</div>
<Badge variant="outline" className="shrink-0">
{remaining.length === 0 ? "All clear" : `${remaining.length} unclassified`}
{!needsAttention ? "All clear" : remaining.length > 0 ? `${remaining.length} unclassified` : `${issues.length} to review`}
</Badge>
</CardHeader>
<CardContent>
{issues.length > 0 && (
<ul role="status" className="mb-4 space-y-2 rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{issues.map((notice, index) => <li key={index}>{notice}</li>)}
</ul>
)}
{remaining.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" aria-hidden />

View file

@ -239,14 +239,19 @@ export function Sidebar({ user }: { user: AppUser }) {
}
const groupActive = groupIsActive(entry, pathname);
const plantUntyped = attention?.plant ?? 0;
const plantNoRate = attention?.plantNoRate ?? 0;
const attentionCount = entry.href === "/dashboard/plant"
? attention?.plant ?? 0
? plantUntyped + plantNoRate
: entry.href === "/dashboard/financial" ? attention?.financial ?? 0
: entry.href === "/dashboard/workforce" ? attention?.onSite ?? 0 : 0;
// Management is always expanded, so a low cash float card is marked
// on its Cash Floats child only, not on the group as well.
const attentionDescription = entry.href === "/dashboard/plant"
? `${attentionCount} ${attentionCount === 1 ? "vehicle needs" : "vehicles need"} a type assigned`
? [
plantUntyped > 0 && `${plantUntyped} ${plantUntyped === 1 ? "vehicle needs" : "vehicles need"} a type assigned`,
plantNoRate > 0 && `${plantNoRate} equipment ${plantNoRate === 1 ? "item has" : "items have"} no hire rate`,
].filter(Boolean).join(" · ")
: entry.href === "/dashboard/financial"
? `${attentionCount} missing purchase ${attentionCount === 1 ? "order" : "orders"}`
: `${attentionCount} missed ${attentionCount === 1 ? "clock-out needs" : "clock-outs need"} correcting`;

View file

@ -1,7 +1,7 @@
import type PocketBase from "pocketbase";
import { missingPurchaseOrders } from "@/lib/dn-pipeline/missing-purchase-orders";
import { filterOpt, siteFilter, type SiteScope } from "@/lib/site-scope";
import { needsVehicleType } from "@/lib/vehicle-buckets";
import { isPlant, needsVehicleType } from "@/lib/vehicle-buckets";
import { unresolvedClockIns } from "@/lib/clock-events";
import { loadClockCorrectionIds } from "@/lib/clock-corrections";
import { isWorkerActive } from "@/lib/worker-status";
@ -10,7 +10,10 @@ import { DASHBOARD_MODULES, type DashboardModule } from "@/lib/auth/access";
import { lowCardCount } from "@/lib/cash-float-server";
export interface DashboardAttention {
/** Vehicles still waiting for a type (Vehicles or Equipment). */
plant: number;
/** Equipment with no hire rate: its hours are recorded but not costed. */
plantNoRate?: number;
financial: number;
onSite: number;
/** Cash float cards that are under their limit. Admin only. */
@ -26,11 +29,11 @@ export async function loadDashboardAttention(
): Promise<DashboardAttention> {
// The cards are not site-scoped, so this is counted on its own.
const cashFloats = options.management ? await lowCardCount(pb) : 0;
if (scope.active.length === 0) return { plant: 0, financial: 0, onSite: 0, cashFloats };
if (scope.active.length === 0) return { plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats };
const filter = siteFilter(scope);
const [vehicles, orders, notes, clockEvents, correctedIds] = await Promise.all([
modules.includes("plant") ? pb.collection("vehicles").getFullList({
fields: "id,unit_type", ...filterOpt(filter), $autoCancel: false,
fields: "id,unit_type,cost_per_hour", ...filterOpt(filter), $autoCancel: false,
}) : [],
modules.includes("financial") ? pb.collection("purchase_orders").getFullList({
fields: "id,site,purchase_order_number", ...filterOpt(filter), $autoCancel: false,
@ -50,6 +53,7 @@ export async function loadDashboardAttention(
]);
return {
plant: vehicles.filter(needsVehicleType).length,
plantNoRate: vehicles.filter((v) => isPlant(v) && !(Number(v.cost_per_hour) > 0)).length,
financial: missingPurchaseOrders(notes, orders).length,
onSite: unresolvedClockIns(clockEvents.filter((event) => isWorkerActive(event.expand?.employee)), new Date(), correctedIds).length,
cashFloats,

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { monthlyPlantBilling, plantBilledDays } from "./plant-billing";
import type { PlantUsage } from "./plant-usage";
const point = (date: string, hours: number, costPerHour = 100): PlantUsage => ({
id: date, date, site: "s", odoReading: 0, hours, value: hours * costPerHour, costPerHour, litres: 0, rejected: false, source: "meter",
});
describe("plantBilledDays", () => {
it("bills 9 h every weekday from the first worked day to month end, capped at today", () => {
// Wed 1 Oct 2025 worked; today Tue 7 Oct → Wed, Thu, Fri, Mon, Tue.
const days = plantBilledDays([point("2025-10-01", 3)], [], 100, "2025-10-07");
expect(days.map(d => d.date)).toEqual(["2025-10-01", "2025-10-02", "2025-10-03", "2025-10-06", "2025-10-07"]);
expect(days.every(d => d.hours === 9 && d.value === 900)).toBe(true);
});
it("ignores rejected and zero-hour readings", () => {
expect(plantBilledDays([{ ...point("2025-10-01", 3), rejected: true }, point("2025-10-02", 0)], [], 100, "2025-10-07")).toEqual([]);
});
});
describe("monthlyPlantBilling", () => {
it("sums machines per month", () => {
const rows = monthlyPlantBilling([
{ fillHours: [{ date: "2025-10-03", hours: 20 }], points: [{ date: "2025-10-03", value: 2000 }], billedDays: [{ date: "2025-10-03", hours: 9, value: 900 }] },
{ fillHours: [{ date: "2025-11-04", hours: 5 }], points: [], billedDays: [{ date: "2025-10-06", hours: 9, value: 450 }] },
]);
expect(rows).toEqual([
{ month: "2025-10", actualHours: 20, billHours: 18, totalValue: 2000, billValue: 1350 },
{ month: "2025-11", actualHours: 5, billHours: 0, totalValue: 0, billValue: 0 },
]);
});
});

View file

@ -0,0 +1,81 @@
import type { PlantPeriod, PlantUsage } from "./plant-usage";
/** The site is billed a flat 9 h per weekday, however many hours the machine ran. */
export const BILLED_HOURS_PER_DAY = 9;
export interface BilledDay { date: string; hours: number; value: number }
/** Billed weekdays for one machine. Days with recorded work (weekly sheets cover
* every day of their period, meter intervals count their closing day) fix when
* the hire starts and which rate applies. The machine is then on hire from its
* first recorded day to the end of its last active month (never beyond today):
* every weekday is billed 9 h whether or not it worked. Rate: that day's
* recorded rate, else the last known one, else the current hire rate. */
export function plantBilledDays(points: (Pick<PlantUsage, "id" | "date" | "hours" | "costPerHour"> & Partial<Pick<PlantUsage, "rejected" | "source">>)[], periods: PlantPeriod[], costPerHour: number, today: string): BilledDay[] {
const billedByDate = new Map<string, number>();
const periodById = new Map(periods.map(p => [p.id, p]));
for (const p of points) {
if (p.rejected || p.hours <= 0) continue;
const period = p.source === "weekly" ? periodById.get(p.id) : undefined;
if (period?.start_date && period.end_date) {
for (let t = Date.parse(period.start_date.slice(0, 10)); t <= Date.parse(period.end_date.slice(0, 10)); t += 86400000) {
const d = new Date(t).toISOString().slice(0, 10);
if (d <= today && !billedByDate.has(d)) billedByDate.set(d, p.costPerHour);
}
} else {
const d = p.date.slice(0, 10);
if (!billedByDate.has(d)) billedByDate.set(d, p.costPerHour);
}
}
const billedDays: BilledDay[] = [];
const workedDates = [...billedByDate.keys()].sort();
if (workedDates.length === 0) return billedDays;
const lastWorked = workedDates[workedDates.length - 1];
const monthEnd = new Date(Date.UTC(Number(lastWorked.slice(0, 4)), Number(lastWorked.slice(5, 7)), 0)).toISOString().slice(0, 10);
const end = Date.parse(monthEnd < today ? monthEnd : today);
let rate = 0;
for (let t = Date.parse(workedDates[0]); t <= end; t += 86400000) {
const date = new Date(t).toISOString().slice(0, 10);
rate = billedByDate.get(date) ?? rate;
const dow = new Date(t).getUTCDay();
if (dow === 0 || dow === 6) continue;
const dayRate = rate > 0 ? rate : costPerHour;
billedDays.push({ date, hours: BILLED_HOURS_PER_DAY, value: Math.round(BILLED_HOURS_PER_DAY * dayRate * 100) / 100 });
}
return billedDays;
}
export interface MonthlyPlantBillingPoint {
month: string;
actualHours: number;
billHours: number;
totalValue: number;
billValue: number;
}
/** Monthly totals across machines, matching the equipment detail tiles:
* actual hours = meter hours between refuels, total value = recorded hire value,
* bill hours/value = flat billed weekdays. */
export function monthlyPlantBilling(machines: {
fillHours: { date: string; hours: number }[];
points: { date: string; value: number }[];
billedDays: BilledDay[];
}[]): MonthlyPlantBillingPoint[] {
const byMonth = new Map<string, MonthlyPlantBillingPoint>();
const at = (date: string) => {
const month = date.slice(0, 7);
let row = byMonth.get(month);
if (!row) byMonth.set(month, row = { month, actualHours: 0, billHours: 0, totalValue: 0, billValue: 0 });
return row;
};
for (const m of machines) {
for (const f of m.fillHours) at(f.date).actualHours += f.hours;
for (const p of m.points) if (p.value) at(p.date).totalValue += p.value;
for (const b of m.billedDays) {
const row = at(b.date);
row.billHours += b.hours;
row.billValue += b.value;
}
}
return [...byMonth.values()].sort((a, b) => a.month.localeCompare(b.month));
}