Compare commits
2 commits
e7c8c90e32
...
de85bf897e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de85bf897e | ||
|
|
2c2d493e12 |
11 changed files with 515 additions and 250 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" }));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
97
src/app/dashboard/plant/plant-billing-charts.tsx
Normal file
97
src/app/dashboard/plant/plant-billing-charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
33
src/lib/metrics/plant-billing.test.ts
Normal file
33
src/lib/metrics/plant-billing.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
81
src/lib/metrics/plant-billing.ts
Normal file
81
src/lib/metrics/plant-billing.ts
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue