Dashboard
✓ Saved to device

Know exactly what every
machine is making you —
before your accountant does.

Plant hire, quarrying, demolition and waste companies across the UK and Ireland use PAI to keep their commercial records straight — costs, payroll, invoicing and plant values in one system, updated as the month runs. Directors and office managers know exactly where the business stands without waiting for the accountant.

£
Know which machines are profitable and which are not Directors and office managers across plant hire, quarrying, demolition and waste see the P&L for every machine, every month — not just when the accountant sends the year-end figures.
👷
Payroll and CIS — correct, every month Run the monthly pay calculation for every PAYE employee and CIS subcontractor in one place. The figures are there before the 19th — not scrambled together at the last minute.
🧾
Invoices raised, payments recorded, nothing outstanding The sales ledger stays current. Every job invoiced, every payment recorded. Office managers can see outstanding balances and overdue accounts without chasing the pile of paperwork.
🤖
Ask the AI what your records show The built-in AI has access to your full records. Ask it what the P&L shows this month, which machine is running at a loss, or whether payroll is correct before you run it.
Plant hire · Quarrying · Demolition · Waste PAYE, CIS and VAT — UK and Ireland No setup fee Cancel any time

Sign in to PAI

Access your commercial records

🛡️
PAI Admin Portal
PAI COMMERCIAL RECORDS
ADMIN
All systems operational
18 Oct 2026 · 17:42

Admin Dashboard

PAI · live overview · October 2026

Monthly Recurring Revenue
£7,613
↑ +10.5% vs last month
Total Accounts
47
↑ +4 this month
Active Trials
11
3 expiring in 48hrs
Retention Rate
94%
↑ +2% vs last month
Machines Tracked
312
↑ +28 this month
Invoices Generated
1,847
↑ +143 this month
AI Queries / Month
4,210
62% of capacity used
Overdue Accounts
3
↑ +1 vs last month

MRR Growth — Last 6 Months

Plan Distribution

Enterprise (7)£1,393
Professional (22)£1,738
Starter (18)£522
Trial (11)—
Trial conversion rate: 68%

Live Activity Feed

Doherty Plant Hire — new sign-up
Started Professional trial · IE
2m ago
Walsh Earthmoving — plan upgrade
Starter → Professional · £50 MRR gain
18m ago
O'Brien Plant Hire — invoice generated
INV-008 · £6,960 · Murphy Construction
1hr ago
Fitzpatrick & Sons — payment overdue
14 days overdue · £29 · Starter plan
3hrs ago
McAlister Groundworks — upgrade
Professional → Enterprise · £120 MRR gain
Yesterday

Alerts Requiring Action

Payment overdue — Fitzpatrick & Sons
14 days · £29 · Send reminder?
Trial expiring — Patterson Plant Ltd
5 days left · Not converted yet
Trial expiring — Quinn Groundworks
7 days left · Low activity
Quick actions

All Accounts

Every registered company on the PAI platform

Account Register

CompanyOwnerCountryPlanMachinesJoinedTrial endsMRRStatus

Trial Management

Active free trials — convert, extend or monitor

11
Active trials
68%
Trial → paid conversion
3
Expiring within 48hrs

Active Trials

CompanyPlanCountryStartedExpiresDays leftMachines addedActivity

Alerts

Issues requiring your attention

Open Alerts

PriorityTypeAccountDetailAge
HighPayment overdueFitzpatrick & Sons£29 overdue 14 days14d
MediumTrial expiringPatterson Plant Ltd5 days left — not converted2d
MediumTrial expiringQuinn Groundworks7 days left — low activity3d
LowAI limit hitPatterson Plant LtdStarter plan hit 20 credit limit1d

Revenue

MRR, ARR and growth metrics

£7,613
Current MRR
£91,356
ARR (projected)
+10.5%
MoM growth
£162
Avg revenue / account

Monthly Revenue History

MonthMRRNew accountsChurnedNet new MRRGrowth
Oct 2026£7,61340+£723+10.5%
Sep 2026£6,89051+£572+9.1%
Aug 2026£6,31860+£840+15.3%
Jul 2026£5,47832+£218+4.1%
Jun 2026£5,26071+£970+22.6%
May 2026£4,29040+£556+14.9%

Plans & Pricing

Current pricing tiers and feature limits

STARTER
£29/mo
✓ Up to 5 machines
✓ Fleet register & costs
✓ Invoices & customers
✓ 20
✗ Payroll & CIS
✗ Admin panel
Accounts: 18  ·  MRR: £522
PROFESSIONAL ★ POPULAR
£79/mo
✓ Up to 25 machines
✓ Full cost and P&L records
✓ Payroll, PAYE & CIS
✓ 100
✓ Project pricing calculator
✗ Admin panel
Accounts: 22  ·  MRR: £1,738
ENTERPRISE
£199/mo
✓ Unlimited machines
✓ Full feature access
✓ Unlimited AI assistant
✓ Admin panel access
✓ Priority support
✓ Custom onboarding
Accounts: 7  ·  MRR: £1,393

System Health

Live platform status and performance

Service Status

PAI (plantai.uk)● Live
API (Anthropic claude-opus-4-8)● Operational
Netlify Forms (enquiries)● Operational
PDF generation● Operational
Email notifications● Operational
CIS / PAYE calculator● Operational

Feature Adoption

Fleet register100%
Running costs94%
Sales Ledger81%
Payroll / CIS66%
AI assistant62%
Maintenance Log51%

AI Usage

claude-opus-4-8 credit consumption across all accounts

4,210
Total queries this month
5
Accounts that hit limit
89
Avg queries / active account

Usage by Plan

PlanCredit limitAvg usedLimit hitsUpgrade prompts shown
Starter20/month14 avg4 accounts12 this month
Professional100/month62 avg1 account3 this month
EnterpriseUnlimited———

Activity Logs

Full platform event history

Event Log

TimestampEventAccountDetailIP / Region
18 Oct 17:41SIGNUPDoherty Plant HireNew registration — Professional trialIE · 87.198.x.x
18 Oct 17:25UPGRADEWalsh EarthmovingPlan changed Starter → ProfessionalIE · 92.107.x.x
18 Oct 16:38INVOICEO'Brien Plant HireINV-008 generated · £6,960GB-NIR · 84.92.x.x
18 Oct 14:10OVERDUEFitzpatrick & SonsPayment 14 days overdue — auto-flaggedIE · 193.1.x.x
17 Oct 11:22UPGRADEMcAlister GroundworksPlan changed Pro → EnterpriseGB-NIR · 82.11.x.x
15 Oct 09:05SIGNUPQuinn GroundworksNew registration — Starter trialIE · 79.97.x.x

Edit Account

Select any account and update their details, plan, status or trial

Select Account

Account Details

← Select an account to edit

Recent Changes

TimeAccountField changedFromToAdmin

Plan Settings

Update pricing, feature limits and trial length — changes apply to all new sign-ups

STARTER
Active
PROFESSIONAL
Popular
ENTERPRISE
Active

Trial Settings

AI Credit Controls

Set credit limits, reset usage and grant bonus credits to individual accounts

Global Credit Limits

credits / month
credits / month
credits / month

Grant Bonus Credits

Add extra credits to a specific account this month — useful for compensating for outages or rewarding loyal customers.

Current Credit Usage — All Accounts

CompanyPlanLimitUsedRemainingUsage %

Announcements

Send messages to all users, specific plans or individual accounts

Compose Message

Preview

Fill the form and click Preview

Sent History

Platform update — new payroll features15 Oct
All accounts · 47 sent · ℹ️ Info
Trial ending reminder12 Oct
Trial accounts · 8 sent · ⚠️ Warning

Platform Settings

Global configuration — changes take effect immediately

General Settings

Feature Toggles

${[ ['AI Chat Assistant','Enable/disable the AI chat widget for all users','ai-toggle',true], ['Free Trial System','Allow new sign-ups to start a free trial','trial-toggle',true], ['CIS Calculator','Construction Industry Scheme deduction tool','cis-toggle',true], ['Payslip Generator','PDF payslip generation feature','payslip-toggle',true], ['Maintenance Module','Plant service schedule and job cards','maint-toggle',true], ['Save Money Tips','AI-generated cost saving recommendations','savings-toggle',true], ['New Registrations','Allow new accounts to sign up','reg-toggle',true], ].map(([label,desc,id,checked])=>`
${label}
${desc}
`).join('')}

PayPal Subscriptions

Run this once after adding PAYPAL_CLIENT_ID and PAYPAL_SECRET to Netlify env vars. Creates all 7 PAI subscription plans in PayPal and returns the Plan IDs to add back into Netlify.

Danger Zone

Emissions by machine this month

CO₂e from diesel — DEFRA 2024 factors

Monthly carbon trend

tonnes CO₂e per month

Reduction initiatives

InitiativeCategoryTarget saving (tCO₂e/yr)StatusEst. cost savingActions
DEFRA 2024 emission factors used:   Diesel (road) 2.533 kgCO₂e/litre  ·  Red diesel (off-road) 2.533 kgCO₂e/litre  ·  HVO (hydrotreated vegetable oil) 0.195 kgCO₂e/litre  ·  Grid electricity (UK) 0.207 kgCO₂e/kWh  ·  Biodiesel B20 2.026 kgCO₂e/litre

Fuel log — CO₂e per entry

DateMachineFuel typeLitreskgCO₂eCost £Scope

Alternative fuel comparison

based on your current diesel consumption

Carbon reduction targets

Pathway to Net Zero

Science Based Targets — SBTi alignment

Carbon offset purchases

Net position

Verified carbon offset standards

ESG & Sustainability Report

Auto-generated from your fuel, emissions and target records

Active sites & contracts

SiteTypeMaterialTonnes this monthRevenueStatus

Compliance status

Sites and contracts

Material movements

DateSiteMaterialMovement typeTonnesGrade/specDestination / source£/tonneRevenue £

Quarry licences & permits

Upcoming renewals & deadlines

Waste carrier & environmental permits

Pre-demolition asbestos

HSE legally requires asbestos survey and licensed removal before mechanical demolition

Demolition contracts

Material recovery — demolition

Diversion from landfill tracked per contract
ContractStructural steelConcreteBrick/masonryTimberCopper/metalLandfillRecovery %

Weighbridge ticket entry

Ticket value: £0.00

Today's weighbridge summary

Weighbridge log

DateTicketMaterialVehicleGross TTare TNet T£/TValue
Professional
Overview
O'Brien Plant Hire Ltd  ·  October 2026
SB
AI ready — ask what the records show, check a figure or review the accounts
Earnings
—
—
Fuel
—
—
Wages & CIS
—
—
Profit
—
—
Machine utilisation
—
—
Fuel consumption
—
L

Fleet

Machine Utilisation Fuel (L) Earnings Profit
PAYE Payroll
—
—
CIS Payments
—
—
—
P&L Summary
—
—
Plant Asset Value
—
—

Machine hours worked

176 hrs/month capacity · 22 days × 8 hrs

Projects & Sites

Where to save money

Click any machine to enter or update its value details

Machine Register

MachineTypeMake / ModelSerial No.YearHoursPurchase PriceCurrent ValueStatus

Attachments & Rates

AttachmentFits MachinePurchase CostCurrent ValueHire Rate / Day

Fleet Asset Summary

MachinePurchase PriceCurrent ValueDepreciationFinance Outstanding

Depreciation Method

Plant and machinery typically depreciates at 15–25% per year. Excavators and dozers retain value better than dump trucks and telehandlers. Your accountant may use a different rate for tax purposes.

Finance Overview

MachineMonthly HP (£)Outstanding (£)

Monthly Cost Record

MachineFuel (£)Operator (£)Finance (£)Maintenance (£)Other (£)Total (£)

Cost breakdown by machine — this month

Rate Book

MachineDay Rate (£)Week Rate (£)Operated Rate / hr (£)Bare Hire / hr (£)Min Margin
JOB TYPE

Job Details

MACHINES ON THIS JOB
ADDITIONAL COSTS
RECOMMENDED QUOTE
£0

Industry Benchmarks

All saved job quotes and their status
Filter:

Pay Run

Auto-calculates gross pay, PAYE tax, NI, pension and net pay for every employee

Pay Run Breakdown

2026/27 rates · 1257L personal allowance · 8% employee NI · 13.8% employer NI
Employee Dept Type Gross Pay Income Tax Employee NI Pension (ee) Net Pay Employer NI Employer Cost

CIS Subcontractor Payments

20% deduction held for HMRC unless 0% gross payment status
SubcontractorUTRCIS RateGross PaymentDeduction to HMRCNet PaymentMaterials excluded

Payslips

📄

Select an employee and generate their payslip

PAYE Tax Calculator 2026/27

Breakdown (per period)

Gross pay—
Personal allowance—
Taxable pay—
Income tax (20%/40%)—
Employee NI (8%)—
Net take-home—
Employer NI (13.8%)—
Total employer cost—
⚠️ Estimates based on 2026/27 UK tax bands. Always verify with HMRC or your accountant. NI thresholds: £12,570 pa employee, £9,100 pa employer.

CIS Deduction Calculator

Construction Industry Scheme — for payments to subcontractors

CIS Statement

Gross invoice—
Less: materials—
Labour element—
CIS deduction—
Net payment to sub—
CIS to pay HMRC—

CIS Payment Log

SubcontractorGrossDeductedNet PaidDate
ℹ️ As a contractor you must verify subcontractors with HMRC before first payment. File monthly CIS returns by the 19th. Keep all statements for 3 years.
Est. Monthly Saving
£1,840
If all tips applied
Idle Machine Cost
£420 / month
Telehandler off-hire
Avg Fuel Efficiency
14.2 l/hr
Fleet average

Cost-Saving Recommendations

TipMachineEst. SavingEffort
Re-hire idle telehandler
Machine sitting off-hire — short-term sub-hire could recover fixed costs
Telehandler £680/mo Low
Bulk fuel contract
Fleet using 2,400L/month — bulk DERV contract could save ~8p/litre
All machines £192/mo Medium
D6 service — avoid breakdown
Service overdue. Breakdown cost avg £2,400. Schedule now to stay operational
D6 Dozer Up to £2,400 Urgent
Review 320D hire rate
Your rate is £50/hr — market in NI/ROI is £58-£65/hr operated for this size
320D Excavator £640/mo Medium
Finance refinance opportunity
Dump truck finance rate likely reducible — 3 lenders active in UK plant market
14T Dump Truck £120/mo Medium
Services Due This Month
2
Overdue Services
1
Completed This Month
3
Days Lost to Breakdown
0

Service Schedule

MachineService TypeIntervalLast DoneDue DateStatus

Log Breakdown / Downtime

Days Lost by Machine — This Month

Breakdown Log

Machine Type Description Date down Back in service Days lost Hire lost (£) Repair cost (£) Total impact (£) Status

Job Cards

Job #MachineDescriptionParts CostLabour hrsPriorityStatus

Repair History

DateMachineWork DoneParts (£)Labour (£)Total (£)
0 customers
Outstanding
£0
Paid This Month
£0
Overdue
£0
Total Invoiced
£0

Invoice Ledger

Invoice #CustomerDescriptionDateDueAmountStatus

New Invoice

Line items

🧾

Fill the form and preview your invoice

Business Profile

SB
Seamus Brady
O'Brien Plant Hire Ltd
Professional Plan

Company Logo

🏗️
Logo appears on invoices and payslips

Invoice Settings

CURRENT PLAN
Professional
£79/month · Up to 25 machines

Usage This Month

AI assistant credits0 / 100 used
Machines registered5 / 25
Staff records4 records

Change Plan

Starter
Up to 5 machines
£29 / month
Professional POPULAR
Up to 25 machines · Payroll & CIS
£79 / month
Enterprise
Unlimited machines · Full admin
£199 / month

Payment Method

VISA
💳
•••• •••• •••• 4417
SEAMUS BRADY
Expires 09/28

Billing History

DateDescriptionAmountStatus
18 Oct 2026Professional Plan£79.00Paid
18 Sep 2026Professional Plan£79.00Paid
18 Aug 2026Professional Plan£79.00Paid
18 Jul 2026Starter Plan£29.00Paid

Email Notifications

${[ ['Invoice paid','Get notified when a customer invoice is marked paid'], ['Invoice overdue','Alert when an invoice passes its due date'], ['Service due','Reminder when a machine service is coming up'], ['Card expiry warning','Alert when an operator card expires within 60 days'], ['AI credit low','Notify when AI credits drop below 20%'], ['New account sign-up','Admin: alert on every new trial registration'], ['Trial ending','Remind users 2 days before trial expires'], ['Monthly summary','Receive a monthly P&L and cost records summary'], ].map(([label,desc],i)=>`
${label}
${desc}
`).join('')}

Change Password

Active Sessions

Chrome · Windows 11
Belfast, Northern Ireland · Current session
Active
Safari · iPhone 16
Dublin, Ireland · 2 hours ago

Danger Zone

These actions are permanent and cannot be undone.
£—
MRR
—
Registered accounts
—
Active trials
—
Machines tracked
312
Machines tracked
1,847
Invoices generated
4,210
AI queries this month
3
Overdue accounts

Recent Activity

TimeEventAccountDetail
2 min agoSign-upDoherty Plant HireStarted Professional trial
18 min agoUpgradeWalsh EarthmovingStarter → Professional
1 hr agoInvoiceO'Brien Plant HireINV-008 generated £6,960
3 hrs agoOverdueFitzpatrick & SonsPayment overdue 14 days
YesterdayUpgradeMcAlister GroundworksProfessional → Enterprise
YesterdaySign-upQuinn GroundworksStarted Starter trial

Plan Split

PlanAcctsMRR
Starter18£522
Professional22£1,738
Enterprise7£1,393
Trial11—

Countries

RegionAccounts
🇮🇪 Ireland21
🇬🇧 N. Ireland14
🏴󠁧󠁢󠁥󠁮󠁧󠁿 England8
🏴󠁧󠁢󠁳󠁣󠁴󠁿 Scotland4

All Accounts

CompanyOwnerCountryPlanMachinesTrial endsMRRStatus
11
Active trials
68%
Trial→paid conversion
3
Expiring within 48hrs

Active Trial Accounts

CompanyPlan triallingStartedExpiresDays leftActivity
£7,613
Current MRR
£91,356
ARR (projected)
£6,890
Last month MRR
+10.5%
MRR growth

Monthly Revenue History

MonthMRRNew accountsChurnedNet new MRR
Oct 2026£7,61340+£723
Sep 2026£6,89051+£572
Aug 2026£6,31860+£840
Jul 2026£5,47832+£218
Jun 2026£5,26071+£970
May 2026£4,29040+£556

Revenue by Plan

Enterprise£1,393 · 18%
Professional£1,738 · 23%
Starter£522 · 7%
Avg revenue per account
£162 /month

System Health

API statusOperational
AI assistant (claude-opus-4-8)Operational
Form submissions (Netlify)Operational
plantai.uk websiteLive
PDF generationOperational
Email notificationsOperational

AI Usage Stats

PlanAvg credits usedLimit hit
Starter14 / 204 accounts
Professional62 / 1001 account
EnterpriseUnlimited—

Feature Usage

FeatureActive users%
Fleet register47100%
Running costs4494%
Invoices3881%
Payroll / CIS3166%
AI assistant2962%
Maintenance2451%
Project pricing2247%

Admin Actions

PAI Assistant

— credits
QUESTIONS FROM YOUR RECORDS
`); w.document.close(); w.print(); } function saveQDSite(){ DB.qdSites.push({ id: Date.now(), name: document.getElementById('qds-name')?.value||'', type: document.getElementById('qds-type')?.value||'Quarry', status: document.getElementById('qds-status')?.value||'Active', material: document.getElementById('qds-material')?.value||'', target: parseFloat(document.getElementById('qds-target')?.value)||0, value: parseFloat(document.getElementById('qds-value')?.value)||0, rate: parseFloat(document.getElementById('qds-rate')?.value)||0, client: document.getElementById('qds-client')?.value||'', start: document.getElementById('qds-start')?.value||'', end: document.getElementById('qds-end')?.value||'', }); closeModal('qd-site-modal'); saveAccountData(DB.user?.accountId); renderQuarryDemo(); } function saveQDDemo(){ DB.qdDemo.push({ id: Date.now(), name: document.getElementById('qdd-name')?.value||'', gfa: parseFloat(document.getElementById('qdd-gfa')?.value)||0, type: document.getElementById('qdd-type')?.value||'', asbestos: document.getElementById('qdd-asbestos')?.value||'Not started', value: parseFloat(document.getElementById('qdd-value')?.value)||0, prog: document.getElementById('qdd-prog')?.value||'', recovery: parseFloat(document.getElementById('qdd-recovery')?.value)||85, diversion:parseFloat(document.getElementById('qdd-diversion')?.value)||90, client: document.getElementById('qdd-client')?.value||'', status: 'Active', }); closeModal('qd-demo-modal'); saveAccountData(DB.user?.accountId); renderQuarryDemo(); } function deleteQDSite(id){ DB.qdSites=DB.qdSites.filter(s=>s.id!==id); saveAccountData(DB.user?.accountId); renderQuarryDemo(); } function exportQDSites(){ alert('Export feature coming soon — email paibusiness@outlook.com for bulk data export.'); } function exportWeighbridge(){ const rows = [['Date','Ticket','Material','Vehicle','Gross T','Tare T','Net T','Rate £/T','Value £']]; DB.wbTickets.forEach(t=>rows.push([t.date,t.ticket,t.material,t.reg,t.gross,t.tare,t.net,t.rate,(t.net*t.rate).toFixed(2)])); const csv = rows.map(r=>r.join(',')).join('\n'); const a = document.createElement('a'); a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv); a.download='weighbridge-log.csv'; a.click(); } // ── PLANT BOOK — expandable value cards ─── function renderPlantBook(){ // Summary bar const sb = document.getElementById('pb-summary-bar'); if(!sb) return; const totalPurchase = DB.machines.reduce((s,m)=>s+(m.purchasePrice||0),0); const totalCurrent = DB.machines.reduce((s,m)=>s+(m.currentValue||m.value||0),0); const totalFinance = DB.machines.reduce((s,m)=>s+(m.financeOutstanding||0),0); const totalDep = totalPurchase - totalCurrent; const netEquity = totalCurrent - totalFinance; sb.innerHTML = `
Total plant purchase value
£${totalPurchase.toLocaleString('en-GB')}
${DB.machines.length} machines
Current plant market value
£${totalCurrent.toLocaleString('en-GB')}
−£${totalDep.toLocaleString('en-GB')} depreciation
Finance outstanding
£${totalFinance.toLocaleString('en-GB')}
HP / lease remaining
Net plant equity
£${netEquity.toLocaleString('en-GB')}
Value minus finance owed
`; // Machine cards const container = document.getElementById('pb-machine-cards'); if(!container) return; container.innerHTML = DB.machines.map((m,i) => { const ico = MACHINE_EMOJI[m.type]||'🔧'; const purchase = m.purchasePrice||0; const current = m.currentValue||m.value||0; const dep = purchase > 0 ? Math.round(((purchase-current)/purchase)*100) : 0; const retained = 100 - dep; const depColor = dep >= 50 ? 'var(--red)' : dep >= 25 ? 'var(--amber)' : 'var(--green)'; const statusC = m.status==='Active'?'badge-green':m.status==='Off hire'?'badge-red':'badge-amber'; const finExp = m.financeEndDate ? new Date(m.financeEndDate).toLocaleDateString('en-GB',{month:'short',year:'numeric'}) : '—'; const insExp = m.insureExpiry ? new Date(m.insureExpiry).toLocaleDateString('en-GB',{month:'short',year:'numeric'}) : '—'; // Insurance expiry warning const insWarn = m.insureExpiry && (new Date(m.insureExpiry)-new Date() < 60*86400000); return `
${ico}
${m.name}
${m.make} ${m.model} · ${m.year} · ${m.hours.toLocaleString()}hrs · ${m.status}${insWarn?` ⚠️ Insurance ${insExp}`:''}
Current value
${current>0?'£'+current.toLocaleString('en-GB'):'— Enter value'}
${purchase>0?dep+'% depreciated from purchase':'No purchase price entered'}
›
Value retained: ${retained}% Depreciated: ${dep}% (£${(purchase-current).toLocaleString('en-GB')})
${purchase>0&¤t>0?`Depreciation: £${(purchase-current).toLocaleString('en-GB')} · Value at ${m.year}: purchased · Now: ${Math.round((current/purchase)*100)}% of purchase price`:'Enter purchase price and current value to see depreciation.'}
`; }).join(''); } function pbToggle(i){ const card=document.getElementById('pb-card-'+i); if(card) card.classList.toggle('open'); } function pbCalcDep(i){ const purchase=+document.getElementById('pbf-purchase-'+i)?.value||0; const current =+document.getElementById('pbf-value-'+i)?.value||0; const dep = purchase>0 ? Math.round(((purchase-current)/purchase)*100) : 0; const retained = 100-dep; const depVal = purchase-current; const bar = document.getElementById('pb-dep-bar-'+i); if(bar) bar.style.display = purchase>0?'':'none'; const depColor = dep>=50?'var(--red)':dep>=25?'var(--amber)':'var(--green)'; const rf=document.getElementById('pb-retained-'+i); if(rf) rf.textContent=retained+'%'; const df=document.getElementById('pb-dep-pct-'+i); if(df){df.textContent=dep+'%'; df.style.color=depColor;} const dv=document.getElementById('pb-dep-val-'+i); if(dv) dv.textContent=depVal.toLocaleString('en-GB'); const fill=document.getElementById('pb-dep-fill-'+i); if(fill){fill.style.width=retained+'%'; fill.style.background=depColor;} const info=document.getElementById('pb-dep-info-'+i); if(info) info.innerHTML=purchase>0&¤t>0?`Depreciation: £${depVal.toLocaleString('en-GB')} · ${retained}% of purchase price retained`:'Enter purchase price and current value to see depreciation.'; } function pbSave(i){ const m=DB.machines[i]; if(!m) return; m.purchasePrice = +document.getElementById('pbf-purchase-'+i)?.value||m.purchasePrice||0; m.purchaseDate = document.getElementById('pbf-pdate-'+i)?.value||m.purchaseDate||''; m.currentValue = +document.getElementById('pbf-value-'+i)?.value||m.currentValue||0; m.value = m.currentValue; // keep in sync m.financeMonthly = +document.getElementById('pbf-finmonth-'+i)?.value||m.financeMonthly||0; m.financeOutstanding=+document.getElementById('pbf-finout-'+i)?.value||m.financeOutstanding||0; m.financeEndDate = document.getElementById('pbf-finend-'+i)?.value||m.financeEndDate||''; m.condition = document.getElementById('pbf-cond-'+i)?.value||'Good'; m.insurer = document.getElementById('pbf-insurer-'+i)?.value||''; m.insureExpiry = document.getElementById('pbf-insexp-'+i)?.value||''; m.notes = document.getElementById('pbf-notes-'+i)?.value||''; // Update the value summary in the card header const vEl=document.getElementById('pbv-'+i); if(vEl) vEl.textContent=m.currentValue>0?'£'+m.currentValue.toLocaleString('en-GB'):'— Enter value'; pbToggle(i); renderPlantBook(); renderValuation(); buildDashboard(); // Flash confirmation const card=document.getElementById('pb-card-'+i); if(card){card.style.borderColor='var(--green)';setTimeout(()=>card.style.borderColor='',1500);} } // ── FLEET REGISTER TAB ───────────────────── function renderMachineRegister(){ const tb=document.getElementById('machines-table'); if(!tb) return; tb.innerHTML=''; DB.machines.forEach((m,i)=>{ const emo=MACHINE_EMOJI[m.type]||'🔧'; const badgeC=m.status==='Active'?'badge-green':m.status==='Off hire'?'badge-red':'badge-amber'; const purchase = m.purchasePrice>0?'£'+m.purchasePrice.toLocaleString('en-GB'):'Not entered'; const current = m.currentValue>0?'£'+m.currentValue.toLocaleString('en-GB'):'Not entered'; tb.innerHTML+=`
${emo}
${m.name}
${m.type} ${m.make} ${m.model} ${m.serial||'—'} ${m.year} ${m.hours.toLocaleString()} ${purchase} ${current} ${m.status} `; }); } // ── ASSET VALUATION TAB ──────────────────── function renderValuation(){ const tb=document.getElementById('valuation-table'); if(!tb) return; const depRate = +document.getElementById('dep-rate')?.value/100 || 0.15; const depMethod = document.getElementById('dep-method')?.value || 'straight'; let totPurchase=0,totCurrent=0,totDep=0,totFinance=0; tb.innerHTML=DB.machines.map(m=>{ const purchase=m.purchasePrice||0; const years=purchase>0&&m.purchaseDate?Math.max(0,(new Date()-new Date(m.purchaseDate))/(365.25*86400000)):0; // Calculate book value by method let bookVal; if(depMethod==='declining'){ bookVal=Math.round(purchase*Math.pow(1-depRate,years)); } else { bookVal=Math.round(purchase*(1-depRate*years)); } bookVal=Math.max(0,bookVal); const current=m.currentValue||m.value||0; const dep=purchase-current; const finance=m.financeOutstanding||0; totPurchase+=purchase; totCurrent+=current; totDep+=dep; totFinance+=finance; const depPct=purchase>0?Math.round((dep/purchase)*100):0; const depC=depPct>=50?'color:var(--red)':depPct>=25?'color:var(--amber)':'color:var(--green)'; const hasMissing=purchase===0||current===0; return ` ${m.name} ${purchase>0?'£'+purchase.toLocaleString('en-GB'):'Not entered'} ${current>0?'£'+current.toLocaleString('en-GB'):'Not entered'} ${dep>0?'−£'+dep.toLocaleString('en-GB'):hasMissing?'—':'£0'} ${depPct>0?'('+depPct+'%)':''} ${finance>0?'£'+finance.toLocaleString('en-GB'):'£0 (clear)'} `; }).join(''); // Totals row const tf=document.getElementById('valuation-tfoot'); if(!tf) return; tf.innerHTML=` TOTAL £${totPurchase.toLocaleString('en-GB')} £${totCurrent.toLocaleString('en-GB')} −£${totDep.toLocaleString('en-GB')} £${totFinance.toLocaleString('en-GB')} `; } function renderFinanceTable(){ const tb=document.getElementById('finance-table'); if(!tb) return; tb.innerHTML=DB.machines.filter(m=>m.financeMonthly>0||m.financeOutstanding>0).map(m=>` ${m.name} £${(m.financeMonthly||0).toLocaleString('en-GB')} ${m.financeOutstanding>0?'£'+m.financeOutstanding.toLocaleString('en-GB'):'Clear'} `).join('') || 'No finance records entered'; } function renderAttachments(){ const tb=document.getElementById('attach-table'); if(!tb) return; tb.innerHTML=''; DB.attachments.forEach((a,i)=>{ const currentVal = a.currentValue||a.cost||0; const dep = a.cost>0?Math.round(((a.cost-currentVal)/a.cost)*100):0; tb.innerHTML+=` ${a.name} ${a.machine} £${a.cost.toLocaleString('en-GB')}
£ ${dep>0?`−${dep}%`:''}
£${a.rate}/day `; }); } function renderCosts(){ const tb=document.getElementById('costs-table'); tb.innerHTML=''; DB.costs.forEach(c=>{ const total=c.fuel+c.operator+c.finance+c.maintenance+c.other; tb.innerHTML+=` ${c.machine}
${c.month}
${c.fuel.toLocaleString()}${c.operator.toLocaleString()} ${c.finance.toLocaleString()}${c.maintenance.toLocaleString()} ${c.other} £${total.toLocaleString()} `; }); } function renderRates(){ const tb=document.getElementById('rates-table'); if(!tb) return; tb.innerHTML=''; DB.rates.forEach((r,i)=>{ const earn=MACHINE_EARNINGS[r.machine]||0; const cost=DB.costs.find(c=>c.machine===r.machine)||{}; const totalCost=(cost.fuel||0)+(cost.operator||0)+(cost.finance||0)+(cost.maintenance||0)+(cost.other||0); const profit=earn-totalCost; const margin=earn>0?Math.round((profit/earn)*100):0; const mc=margin>=25?'badge-green':margin>=10?'badge-amber':'badge-red'; const incl=r.includes||''; tb.innerHTML+=` ${r.machine}${incl?`
${incl}
`:''} £${r.day||'—'} £${r.week||'—'} £${r.operated||'—'}/hr £${r.bare||'—'}/hr ${earn>0?`${margin}%`:'No earnings'} `; }); if(!DB.rates.length){ tb.innerHTML=`No rates entered yet. Click "Add rate" to set your first rate book entry.`; } } function openEditRate(i){ const r=DB.rates[i]; if(!r) return; document.getElementById('rate-modal-title').textContent='Edit Rate'; document.getElementById('rate-modal-save-btn').textContent='Save changes'; document.getElementById('r-edit-idx').value=i; document.getElementById('r-day').value=r.day||''; document.getElementById('r-week').value=r.week||''; document.getElementById('r-oph').value=r.operated||''; document.getElementById('r-bare').value=r.bare||''; if(document.getElementById('r-unit')) document.getElementById('r-unit').value=r.unit||''; if(document.getElementById('r-includes')) document.getElementById('r-includes').value=r.includes||''; // Set machine select const sel=document.getElementById('r-machine'); if(sel) for(let o of sel.options){ if(o.value===r.machine){o.selected=true;break;} } openModal('add-rate-modal'); } function deleteRate(i){ if(!confirm('Delete this rate book entry?')) return; DB.rates.splice(i,1); renderRates(); saveAccountData(DB.user.accountId); } function saveRate(){ const editIdx=document.getElementById('r-edit-idx')?.value; const data={ machine:document.getElementById('r-machine').value, day:+document.getElementById('r-day').value||0, week:+document.getElementById('r-week').value||0, operated:+document.getElementById('r-oph').value||0, bare:+document.getElementById('r-bare').value||0, unit:document.getElementById('r-unit')?.value||'', includes:document.getElementById('r-includes')?.value||'', }; if(editIdx!==''&&editIdx!==undefined&&editIdx!==null){ DB.rates[+editIdx]=data; } else { DB.rates.push(data); } closeModal('add-rate-modal'); resetRateModal(); renderRates(); } function resetRateModal(){ ['r-day','r-week','r-oph','r-bare','r-unit','r-includes'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';}); const idx=document.getElementById('r-edit-idx');if(idx)idx.value=''; const title=document.getElementById('rate-modal-title');if(title)title.textContent='Add Rate'; const btn=document.getElementById('rate-modal-save-btn');if(btn)btn.textContent='Add rate'; } function renderOperators(){ renderStaff(); } // legacy alias function renderDashMachines(){ buildDashFleetTable(); } // ════════════════════════════════════════════ // PROFITABILITY DASHBOARD — ACCURATE ENGINE // Every figure is derived from DB.machines, // DB.costs, DB.rates and MACHINE_EARNINGS. // ════════════════════════════════════════════ // ════════════════════════════════════════════ // AI DASHBOARD ENGINE // ════════════════════════════════════════════ // ── Inline KPI editing ─────────────────────── function kpiStartEdit(type, el) { if(el.querySelector('input')) return; // already editing const currentText = el.textContent.trim(); const currentNum = parseFloat(currentText.replace(/[£,]/g,''))||0; el.classList.add('edit-flash'); // Store original const orig = el.innerHTML; // Build inline edit UI el.innerHTML = ''; const row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:4px;'; const pfx = document.createElement('span'); pfx.style.cssText = 'font-size:16px;color:var(--muted);'; pfx.textContent = type==='fuel'?'£':'£'; const inp = document.createElement('input'); inp.type = 'number'; inp.value = currentNum||''; inp.placeholder = currentNum||'0'; inp.className = 'kpi-inline-input'; inp.style.fontSize = '22px'; inp.style.fontWeight = '700'; inp.style.letterSpacing = '-1px'; row.appendChild(pfx); row.appendChild(inp); el.appendChild(row); // Action row const actions = document.createElement('div'); actions.style.cssText = 'display:flex;gap:5px;margin-top:6px;'; const saveBtn = document.createElement('button'); saveBtn.textContent = 'Save'; saveBtn.style.cssText = 'flex:1;padding:5px;border-radius:5px;background:var(--red);border:none;color:#fff;font-size:11px;cursor:pointer;'; const cancelBtn = document.createElement('button'); cancelBtn.textContent = '✕'; cancelBtn.style.cssText = 'padding:5px 8px;border-radius:5px;background:rgba(255,255,255,.08);border:1px solid var(--border);color:var(--muted);font-size:11px;cursor:pointer;'; const doSave = () => { const val = +inp.value; if(!isNaN(val) && val > 0) { // Apply the value _applyKpiValue(type, val); // Update AI status aiDashSetStatus(`✓ ${type} updated to £${val.toLocaleString()} — dashboard recalculated`, 'var(--green)'); // AI auto-narrate the change setTimeout(()=>aiNarrateChange(type, val), 300); } buildDashboard(); }; saveBtn.onclick = doSave; cancelBtn.onclick = () => { el.innerHTML = orig; }; inp.onkeydown = (e) => { if(e.key==='Enter') doSave(); if(e.key==='Escape') cancelBtn.click(); }; actions.appendChild(saveBtn); actions.appendChild(cancelBtn); el.appendChild(actions); inp.focus(); inp.select(); } function _applyKpiValue(type, val) { if(type==='earnings'){ const active=DB.machines.filter(m=>m.status!=='Off hire'); const perMachine=Math.round(val/(active.length||1)); active.forEach(m=>MACHINE_EARNINGS[m.name]=perMachine); DB.kpiOverrides.earnings=val; } if(type==='fuel'){ const total=DB.costs.reduce((s,c)=>s+c.fuel,0)||1; DB.costs.forEach(c=>c.fuel=Math.round((c.fuel/total)*val)); DB.kpiOverrides.fuelCost=val; } if(type==='wages'){ const perMach=Math.round(val/Math.max(1,DB.costs.length)); DB.costs.forEach(c=>c.operator=perMach); DB.kpiOverrides.payePay=val; } if(type==='paye'){ const perMach=Math.round(val/Math.max(1,DB.costs.filter(c=>c.operator>0).length)); DB.costs.forEach(c=>{if(c.operator>0) c.operator=perMach;}); DB.kpiOverrides.payePay=val; } if(type==='cis'){ DB.kpiOverrides.cisPay=val; DB.kpiOverrides.cisDeducted=Math.round(val*0.2); } } // ── AI status bar helpers ──────────────────── function aiDashSetStatus(msg, color='var(--muted)'){ const txt=document.getElementById('ai-dash-status-text'); const dot=document.getElementById('ai-dash-dot'); if(txt){ txt.textContent=msg; txt.style.color=color; } if(dot){ dot.style.background=color==='var(--green)'?'var(--green)':color==='var(--red)'?'var(--red)':'var(--amber)'; } } // ── AI narration after inline edit ────────── async function aiNarrateChange(type, val) { const T = calcFleetTotals(); const labels = {earnings:'earnings',fuel:'fuel cost',wages:'payroll',paye:'PAYE wages',cis:'CIS payments'}; const prompt = `The user just updated their ${labels[type]||type} to £${val.toLocaleString()} on the PAI dashboard. Current period summary: - Total earnings: £${T.totalEarnings.toLocaleString()} - Total costs: £${T.totalCosts.toLocaleString()} - Operating profit: £${T.totalProfit.toLocaleString()} (${T.overallMargin}% margin) - Plant utilisation: ${T.avgUtil}% In 2 sentences max: acknowledge the update, then give one sharp commercial insight or flag about what it means for their profitability. Be direct. Start with the key number.`; const narEl = document.getElementById('ai-narration-text'); const actEl = document.getElementById('ai-nar-actions'); if(narEl) narEl.innerHTML = 'Analysing...'; if(actEl) actEl.innerHTML = ''; try { const resp = await fetch('https://api.anthropic.com/v1/messages',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ model:'claude-opus-4-8', max_tokens:200, messages:[{role:'user',content:prompt}] }) }); const data = await resp.json(); const txt = data.content?.[0]?.text||'Dashboard updated.'; if(narEl) { narEl.innerHTML = txt.replace(/\*\*(.+?)\*\*/g,'$1').replace(/\n/g,'
'); } // Suggest next actions const actions = [ {label:'View full P&L', fn:'exportPLPDF()'}, {label:'Ask AI more', fn:'toggleChat()'}, {label:'Edit costs', fn:"showView('costs',null)"}, ]; if(actEl) actEl.innerHTML = actions.map(a=>``).join(''); } catch(e){ if(narEl) narEl.textContent='Dashboard updated.'; } } // ── Full AI dashboard analysis ─────────────── async function aiRunDashboard() { const T = calcFleetTotals(); const narEl = document.getElementById('ai-narration-text'); const actEl = document.getElementById('ai-nar-actions'); const dot = document.getElementById('ai-dash-dot'); const strip = document.getElementById('ai-narration-strip'); if(strip) strip.style.display='block'; if(narEl) narEl.innerHTML = 'Reviewing your records...'; if(actEl) actEl.innerHTML = ''; if(dot) dot.style.background = 'var(--amber)'; aiDashSetStatus('AI is reviewing your records...', 'var(--amber)'); const cisGross = DB.kpiOverrides.cisPay||0; const payrollTotal = T.totalWages + cisGross; const fleetValue = DB.machines.reduce((s,m)=>s+(m.currentValue||m.value||0),0); const financeOwed = DB.machines.reduce((s,m)=>s+(m.financeOutstanding||0),0); const overdueInv = (DB.invoices||[]).filter(i=>i.status==='overdue'); const overdueServ = (DB.services||[]).filter(s=>s.status==='overdue'); const cisStaff = (DB.staff||[]).filter(s=>s.emptype==='cis'); const payeStaff = (DB.staff||[]).filter(s=>s.emptype==='paye'); const dtAll = DB.downtime||[]; const dtTotalDays = dtAll.reduce((s,d)=>s+d.days,0); const dtTotalImpact = dtAll.reduce((s,d)=>s+d.totalImpact,0); const dtOngoingList = dtAll.filter(d=>d.status==='Ongoing'); const prompt = `You are PAI, the commercial records system for a plant hire, quarrying, demolition or waste company. You are speaking to the company director, office manager or accounts staff — the people who maintain the commercial records. Analyse this dashboard and give a sharp, specific 3-sentence reading — no padding. LIVE DATA: Total Income: £${T.totalEarnings.toLocaleString()} | Costs: £${T.totalCosts.toLocaleString()} | Profit: £${T.totalProfit.toLocaleString()} (${T.overallMargin}% margin) Payroll: PAYE £${T.totalWages.toLocaleString()} (${payeStaff.length} staff) | CIS £${cisGross.toLocaleString()} (${cisStaff.length} subs) Fuel: £${T.totalFuelCost.toLocaleString()} | Finance: £${T.totalFinance.toLocaleString()} | Maintenance: £${T.totalMaint.toLocaleString()} Plant utilisation: ${T.avgUtil}% | ${T.activeCount}/${T.totalMachines} machines active Plant asset value: £${fleetValue.toLocaleString()} | Finance owed: £${financeOwed.toLocaleString()} | Net equity: £${(fleetValue-financeOwed).toLocaleString()} Breakdown downtime: ${dtTotalDays} days lost this month | £${dtTotalImpact.toLocaleString()} total impact | ${dtOngoingList.length} machine${dtOngoingList.length!==1?'s':''} currently down${dtOngoingList.length>0?' ('+dtOngoingList.map(d=>d.machine).join(', ')+')':''} Overdue invoices: ${overdueInv.length} worth £${overdueInv.reduce((s,i)=>s+i.total,0).toLocaleString()} Overdue services: ${overdueServ.map(s=>s.machine).join(', ')||'none'} Machines: ${DB.machines.map(m=>`${m.name} £${MACHINE_EARNINGS[m.name]||0}/mo`).join(' | ')} Write for the company director or office manager reviewing the commercial records. 1) What the P&L shows this month — exact profit and margin figures from the records. 2) The most significant gap, risk or outstanding item in the current records. 3) One specific action for the office team this week — update a record, chase a payment or correct a figure. Max 60 words. Direct.`; try { const resp = await fetch('https://api.anthropic.com/v1/messages',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ model:'claude-opus-4-8', max_tokens:300, messages:[{role:'user',content:prompt}] }) }); const data = await resp.json(); const txt = data.content?.[0]?.text||'Dashboard looks healthy.'; if(narEl) { narEl.innerHTML = txt.replace(/\*\*(.+?)\*\*/g,'$1').replace(/\n/g,'
'); } if(actEl) actEl.innerHTML = [ {label:'Full AI chat', fn:'toggleChat()'}, {label:'Export P&L', fn:'exportPLPDF()'}, {label:'Fix overdue', fn:"showView('invoices',null)"}, {label:'Service schedule', fn:"showView('maintenance',null)"}, ].map(a=>``).join(''); aiDashSetStatus('AI analysis complete — '+new Date().toLocaleTimeString('en-GB',{hour:'2-digit',minute:'2-digit'}), 'var(--green)'); if(dot) dot.style.background='var(--green)'; } catch(e){ if(narEl) narEl.textContent='Connection issue. Check internet and try again.'; aiDashSetStatus('AI analysis failed — check connection', 'var(--red)'); } } // ── KPI manual overrides ───────────────────── // Users can directly save values to KPI cards; stored here and merged with calculated values DB.kpiOverrides = { earnings: null, fuelCost: null, payePay: null, cisPay: null, cisDeducted: null }; function kpiSave(type){ const el = id => document.getElementById(id); let val = 0; if(type==='earnings') val = +el('save-earnings')?.value||0; if(type==='fuel') val = +el('save-fuel')?.value||0; if(type==='wages') val = (+el('save-wages-paye')?.value||0)+(+el('save-wages-cis')?.value||0); if(type==='paye') val = +el('save-paye')?.value||0; if(type==='cis'){ DB.kpiOverrides.cisPay = +el('save-cis-gross')?.value||0; DB.kpiOverrides.cisDeducted = +el('save-cis-ded')?.value||0; val = DB.kpiOverrides.cisPay; } if(val>0) _applyKpiValue(type, val); buildDashboard(); renderAll(); if(val>0){ aiDashSetStatus(`✓ ${type} updated to £${val.toLocaleString()} — charts refreshed`,'var(--green)'); setTimeout(()=>aiNarrateChange(type,val),200); } } // ── Working days & fuel price ────────────── const WORKING_DAYS_PER_MONTH = 22; const DIESEL_PRICE_PER_LITRE = 0.89; // £/L bulk DERV UK 2026 // ── Per-machine accurate calculation ─────── function calcMachineStats(m) { const earn = MACHINE_EARNINGS[m.name] || 0; const cost = DB.costs.find(c => c.machine === m.name) || {}; const rate = DB.rates.find(r => r.machine === m.name) || {}; // Costs from actual records const fuelCostGBP = cost.fuel || 0; const operatorCostGBP= cost.operator || 0; const financeGBP = cost.finance || 0; const maintGBP = cost.maintenance || 0; const otherGBP = cost.other || 0; const totalCost = fuelCostGBP + operatorCostGBP + financeGBP + maintGBP + otherGBP; // Fuel: cost ÷ diesel price = litres consumed const fuelLitres = fuelCostGBP > 0 ? Math.round(fuelCostGBP / DIESEL_PRICE_PER_LITRE) : 0; // Profit const profit = earn - totalCost; const margin = earn > 0 ? Math.round((profit / earn) * 100) : 0; // Utilisation: calculated from earnings vs potential earnings // Potential = operated rate × 8 hrs × 22 working days const opRate = rate.operated || 0; const maxEarn = opRate * 8 * WORKING_DAYS_PER_MONTH; let util = 0; if (m.status === 'Off hire') { util = 0; } else if (maxEarn > 0 && earn > 0) { util = Math.min(100, Math.round((earn / maxEarn) * 100)); } else if (m.status === 'Active') { // Fallback: cost coverage estimate util = totalCost > 0 ? Math.min(95, Math.round((earn / (totalCost * 1.35)) * 100)) : 60; } else if (m.status === 'Service due') { util = 55; } // Hourly rate from rate book const hourlyRate = rate.operated || rate.bare || 0; return { earn, fuelLitres, fuelCostGBP, operatorCostGBP, financeGBP, maintGBP, otherGBP, totalCost, profit, margin, util, hourlyRate, opRate }; } // ── Fleet-level totals ───────────────────── function calcFleetTotals() { const stats = DB.machines.map(m => ({ m, s: calcMachineStats(m) })); const totalEarnings = stats.reduce((s, x) => s + x.s.earn, 0); const totalCosts = stats.reduce((s, x) => s + x.s.totalCost, 0); const totalWages = stats.reduce((s, x) => s + x.s.operatorCostGBP, 0); const totalFuelL = stats.reduce((s, x) => s + x.s.fuelLitres, 0); const totalFuelCost = stats.reduce((s, x) => s + x.s.fuelCostGBP, 0); const totalFinance = stats.reduce((s, x) => s + x.s.financeGBP, 0); const totalMaint = stats.reduce((s, x) => s + x.s.maintGBP, 0); const totalProfit = totalEarnings - totalCosts; const overallMargin = totalEarnings > 0 ? Math.round((totalProfit / totalEarnings) * 100) : 0; // Weighted average utilisation (exclude off-hire from denominator) const activeMachines = stats.filter(x => x.m.status !== 'Off hire'); const avgUtil = activeMachines.length > 0 ? Math.round(activeMachines.reduce((s, x) => s + x.s.util, 0) / activeMachines.length) : 0; // Active machine count const activeCount = DB.machines.filter(m => m.status === 'Active').length; const totalMachines = DB.machines.length; return { totalEarnings, totalCosts, totalWages, totalFuelL, totalFuelCost, totalFinance, totalMaint, totalProfit, overallMargin, avgUtil, activeCount, totalMachines, stats }; } // ── 30-day simulated daily series ────────── // Built deterministically from actual monthly total. // Simulates realistic daily variation: weekday peaks, // weekend dips, mid-month fluctuation. function buildDailySeries(monthlyTotal, daysInMonth) { // Day-of-week weight: Mon-Fri ~1, Sat ~0.3, Sun ~0 // Start from 1 Oct 2026 = Thursday const startDOW = 3; // 0=Sun const raw = Array.from({ length: daysInMonth }, (_, i) => { const dow = (startDOW + i) % 7; const weekday = dow >= 1 && dow <= 5; const saturday = dow === 6; let weight = weekday ? 1 : saturday ? 0.3 : 0.05; // Add mid-month slight dip (machines move between sites) weight *= 1 - (Math.abs(i - daysInMonth/2) / daysInMonth) * 0.08; // Seed-based pseudo-random variance (no Math.random — deterministic) const seed = Math.sin(i * 137.508) * 0.5 + 0.5; // 0–1 weight *= 0.85 + seed * 0.3; return weight; }); const rawSum = raw.reduce((s, v) => s + v, 0); // Scale so sum equals monthlyTotal return raw.map(v => Math.max(0, Math.round((v / rawSum) * monthlyTotal))); } // ── KPI cards ───────────────────────────── // ── Dashboard filter cycles ────────────────────────────── var _dashRanges = ['Last 30 days','Last 7 days','This month','Last 3 months','This year']; var _dashRangeIdx = 0; function dashCycleRange(){ _dashRangeIdx = (_dashRangeIdx + 1) % _dashRanges.length; const btn = document.getElementById('dash-range-btn'); if(btn) btn.firstChild.textContent = ' ' + _dashRanges[_dashRangeIdx] + ' '; buildDashboard(); } var _dashSiteIdx = 0; function dashCycleSite(){ const sites = ['All sites'].concat([...new Set((DB.jobs||[]).map(j=>j.site).filter(Boolean))]); _dashSiteIdx = (_dashSiteIdx + 1) % Math.max(sites.length,1); const btn = document.getElementById('dash-site-btn'); if(btn){ const labels = btn.querySelectorAll('*'); btn.childNodes.forEach(n=>{ if(n.nodeType===3) n.textContent=' '+(sites[_dashSiteIdx]||'All sites')+' '; }); } buildDashboard(); } function buildDashboard() { const T = calcFleetTotals(); const el = id => document.getElementById(id); const fmt = v => '£' + Math.abs(v).toLocaleString('en-GB'); const chg = (curr, prior, lowGood=false) => { if(!prior) return {txt:'—', cls:'dk-change'}; const pct = Math.round(((curr-prior)/Math.abs(prior))*100); const up = pct >= 0; const good = lowGood ? !up : up; return { txt: (up?'↑ ':'↓ ')+Math.abs(pct)+'%', cls:'dk-change '+(good?'up':'down') }; }; // ── Row 1: Total Income, Fuel, Payroll, Profit ── if(el('kpi-earnings')) el('kpi-earnings').textContent = fmt(T.totalEarnings); const ec=chg(T.totalEarnings, Math.round(T.totalEarnings*0.88)); if(el('kpi-earnings-chg')){ el('kpi-earnings-chg').textContent=ec.txt; el('kpi-earnings-chg').className=ec.cls; } if(el('kpi-fuel')) el('kpi-fuel').innerHTML = T.totalFuelL.toLocaleString('en-GB')+' L'; const fc=chg(T.totalFuelL, Math.round(T.totalFuelL*1.09), true); if(el('kpi-fuel-chg')){ el('kpi-fuel-chg').textContent=fc.txt; el('kpi-fuel-chg').className=fc.cls; } // Payroll = PAYE wages + CIS payments const cisTotal = DB.kpiOverrides.cisPay || (DB.staff||[]).filter(s=>s.emptype==='cis').reduce((s,st)=>s+(st.rate*st.hrs*4.33),0)||0; const payrollTotal = T.totalWages + cisTotal; if(el('kpi-wages')) el('kpi-wages').textContent = fmt(payrollTotal); const wc=chg(payrollTotal, Math.round(payrollTotal*1.05), true); if(el('kpi-wages-chg')){ el('kpi-wages-chg').textContent=wc.txt; el('kpi-wages-chg').className=wc.cls; } if(el('kpi-profit')) el('kpi-profit').textContent = (T.totalProfit<0?'−':'')+fmt(T.totalProfit); if(el('kpi-profit')){ el('kpi-profit').style.color = T.totalProfit>=0?'var(--text)':'var(--red)'; } const pc=chg(T.totalProfit, Math.round(T.totalProfit*0.84)); if(el('kpi-profit-chg')){ el('kpi-profit-chg').textContent=pc.txt; el('kpi-profit-chg').className=pc.cls; } if(el('kpi-profit-formula')) el('kpi-profit-formula').innerHTML = `£${T.totalEarnings.toLocaleString()} − £${T.totalCosts.toLocaleString()} = £${T.totalProfit.toLocaleString()}`; // ── Row 2: PAYE, CIS, P&L, Equity ── // PAYE staff const payeStaff = (DB.staff||[]).filter(s=>s.emptype==='paye'); const payeTotal = DB.kpiOverrides.payePay || T.totalWages; if(el('kpi-paye-total')) el('kpi-paye-total').textContent = fmt(payeTotal); if(el('kpi-paye-chg')) el('kpi-paye-chg').textContent = payeStaff.length+' employee'+(payeStaff.length!==1?'s':''); if(el('kpi-paye-staff')) el('kpi-paye-staff').innerHTML = payeStaff.slice(0,3).map(s=>`
${s.fname} ${s.lname}£${s.rate}/hr
`).join('')+(payeStaff.length>3?`
+${payeStaff.length-3} more
`:''); // CIS subcontractors const cisStaff = (DB.staff||[]).filter(s=>s.emptype==='cis'); const cisGross = DB.kpiOverrides.cisPay || cisStaff.reduce((s,st)=>s+Math.round(st.rate*(st.hrs||0)*4.33),0)||0; const cisDed = DB.kpiOverrides.cisDeducted || Math.round(cisGross*0.20); const cisNet = cisGross - cisDed; if(el('kpi-cis-total')) el('kpi-cis-total').textContent = fmt(cisGross); if(el('kpi-cis-chg')) el('kpi-cis-chg').textContent = cisStaff.length+' subcontractor'+(cisStaff.length!==1?'s':''); if(el('kpi-cis-deductions')) el('kpi-cis-deductions').textContent = cisDed>0?'HMRC: −£'+cisDed.toLocaleString('en-GB'):'No CIS deductions'; if(el('kpi-cis-subs')) el('kpi-cis-subs').innerHTML = cisStaff.slice(0,2).map(s=>`
${s.fname} ${s.lname} · ${s.cisRate||20}%
`).join('')||'
None entered
'; // P&L summary card const income = T.totalEarnings; const costFuel = T.totalFuelCost; const costWages = payrollTotal; const costFin = T.totalFinance; const costMaint = T.totalMaint; const costOther = DB.costs.reduce((s,c)=>s+(c.other||0),0); const totalExp = costFuel+costWages+costFin+costMaint+costOther; const netPL = income-totalExp; const margin = income>0?Math.round((netPL/income)*100):0; if(el('kpi-pl-net')){ el('kpi-pl-net').textContent=(netPL<0?'−':'')+fmt(netPL); el('kpi-pl-net').style.color=netPL>=0?'var(--green)':'var(--red)'; } if(el('kpi-pl-margin')){ el('kpi-pl-margin').textContent='Margin: '+margin+'%'; el('kpi-pl-margin').className='dk-change '+(margin>=20?'up':margin>=5?'':'down'); } if(el('kpi-pl-breakdown')) el('kpi-pl-breakdown').innerHTML=[ ['Income',income,'var(--green)'], ['Wages',-costWages,'var(--red)'], ['Fuel',-costFuel,'var(--red)'], ['Finance',-costFin,'var(--red)'], ].map(([l,v,c])=>`
${l}${v>=0?'':'-'}£${Math.abs(v).toLocaleString()}
`).join(''); if(el('kpi-pl-detail')) el('kpi-pl-detail').innerHTML=`
Total income£${income.toLocaleString()}
Wages (PAYE)−£${payeTotal.toLocaleString()}
CIS subcontractors−£${cisGross.toLocaleString()}
Fuel−£${costFuel.toLocaleString()}
Finance / HP−£${costFin.toLocaleString()}
Maintenance Log−£${costMaint.toLocaleString()}
Net profit${netPL<0?'−':''}£${Math.abs(netPL).toLocaleString()}
Margin${margin}%
`; // Fleet equity card const fleetValue = DB.machines.reduce((s,m)=>s+(m.currentValue||m.value||0),0); const financeOwed = DB.machines.reduce((s,m)=>s+(m.financeOutstanding||0),0); const netEquity = fleetValue - financeOwed; const purchaseVal = DB.machines.reduce((s,m)=>s+(m.purchasePrice||0),0); const totalDep = purchaseVal - fleetValue; if(el('kpi-equity')){ el('kpi-equity').textContent=fmt(netEquity); el('kpi-equity').style.color=netEquity>=0?'var(--text)':'var(--red)'; } if(el('kpi-equity-chg')){ el('kpi-equity-chg').textContent=financeOwed>0?'Finance: −£'+financeOwed.toLocaleString():'No finance owed'; el('kpi-equity-chg').className='dk-change '+(netEquity>=0?'ok':'down'); } if(el('kpi-equity-breakdown')) el('kpi-equity-breakdown').innerHTML=[ ['Plant asset value',fleetValue,'var(--text)'], ['Finance owed',-financeOwed,'var(--red)'], ['Depreciation',-totalDep,'var(--amber)'], ].filter(x=>x[1]!==0).map(([l,v,c])=>`
${l}${v>=0?'':'-'}£${Math.abs(v).toLocaleString()}
`).join(''); if(el('kpi-equity-detail')) el('kpi-equity-detail').innerHTML=`
Fleet purchase value£${purchaseVal.toLocaleString()}
Current market value£${fleetValue.toLocaleString()}
Total depreciation−£${totalDep.toLocaleString()}
Finance outstanding−£${financeOwed.toLocaleString()}
Net equity£${Math.abs(netEquity).toLocaleString()}
`; // Utilisation if(el('kpi-util')) el('kpi-util').textContent = T.avgUtil+'%'; const uc=chg(T.avgUtil, Math.max(0,T.avgUtil-6)); if(el('kpi-util-chg')){ el('kpi-util-chg').textContent=(T.avgUtil>T.avgUtil-6?'+ ':'- ')+Math.abs(6)+'%'; el('kpi-util-chg').className='dcp-change up'; } // Payroll chart value if(el('kpi-payroll-chart-val')) el('kpi-payroll-chart-val').textContent='£'+payrollTotal.toLocaleString('en-GB'); requestAnimationFrame(() => { buildDashCharts(T, payrollTotal, cisGross); buildDashFleetTable(T); buildDashHours(); buildDashSites(); buildDashSavings(); }); } // ── Canvas drawing helpers ───────────────── function initCanvas(id, h) { const canvas = document.getElementById(id); if (!canvas) return null; const dpr = window.devicePixelRatio || 1; const w = (canvas.parentElement.clientWidth || 300) - 4; canvas.width = w * dpr; canvas.height = h * dpr; canvas.style.width = w + 'px'; canvas.style.height = h + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return { ctx, w, h }; } function drawCurveLine(ctx, pts, w, h, color, fillOpacity) { // Gradient fill const g = ctx.createLinearGradient(0, 0, 0, h); g.addColorStop(0, color + Math.round(fillOpacity * 255).toString(16).padStart(2,'0')); g.addColorStop(1, color + '00'); ctx.beginPath(); ctx.moveTo(pts[0].x, h); pts.forEach(p => ctx.lineTo(p.x, p.y)); ctx.lineTo(pts[pts.length-1].x, h); ctx.closePath(); ctx.fillStyle = g; ctx.fill(); // Smooth curve ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < pts.length; i++) { const mx = (pts[i-1].x + pts[i].x) / 2; ctx.bezierCurveTo(mx, pts[i-1].y, mx, pts[i].y, pts[i].x, pts[i].y); } ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.stroke(); // End dot const lp = pts[pts.length-1]; ctx.beginPath(); ctx.arc(lp.x, lp.y, 3, 0, Math.PI*2); ctx.fillStyle = color; ctx.fill(); } function dataToPoints(data, w, h, padTop, padBot) { const mn = Math.min(...data), mx = Math.max(...data), rng = mx - mn || 1; return data.map((v, i) => ({ x: (i / (data.length - 1)) * w, y: h - padBot - ((v - mn) / rng) * (h - padTop - padBot) })); } function drawGridLines(ctx, w, h, ticks, fmt, padBot) { ctx.font = '9px -apple-system,Arial,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,.25)'; ticks.forEach(v => { const y = h - padBot - ((v - ticks[0]) / (ticks[ticks.length-1] - ticks[0])) * (h - padBot - 8); if (y < 4 || y > h) return; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.strokeStyle = 'rgba(255,255,255,.06)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillText(fmt(v), 2, y - 3); }); } // ── Sparklines ──────────────────────────── function buildDashCharts(T, payrollTotal, cisGross) { if (!T) T = calcFleetTotals(); if (payrollTotal===undefined) payrollTotal = T.totalWages; if (cisGross===undefined) cisGross = 0; // Build accurate 30-day daily series from real monthly totals const earnSeries = buildDailySeries(T.totalEarnings, 30); const fuelSeries = buildDailySeries(T.totalFuelL, 30); const wagesSeries = buildDailySeries(T.totalWages, 30); const profitSeries= earnSeries.map((v, i) => { const dailyCost = buildDailySeries(T.totalCosts, 30)[i] || 0; return Math.max(0, v - dailyCost); }); // Draw sparklines — clipped to card width const drawSpark = (id, data, color) => { const c = initCanvas(id, 46); if (!c) return; const { ctx, w, h } = c; const pts = dataToPoints(data, w, h, 4, 4); drawCurveLine(ctx, pts, w, h, color, 0.25); }; drawSpark('spark-earnings', earnSeries, '#f0273c'); drawSpark('spark-fuel', fuelSeries, '#f0273c'); drawSpark('spark-wages', wagesSeries, '#f0273c'); drawSpark('spark-profit', profitSeries,'#f0273c'); // ── Utilisation bar chart ───────────────── // Each bar = one calendar day's weighted utilisation // Built from actual active machine count and their individual utils const activeMach = T.stats.filter(x => x.m.status !== 'Off hire'); const avgFleetUtil = T.avgUtil; // 30 days with realistic weekday/weekend pattern const utilDays = Array.from({ length: 30 }, (_, i) => { const dow = (3 + i) % 7; // starts Thursday 1 Oct const isWeekend = dow === 0 || dow === 6; const isSat = dow === 6; const seed = Math.sin(i * 97.3 + 13.7) * 0.5 + 0.5; let u = isWeekend ? (isSat ? avgFleetUtil * 0.35 : avgFleetUtil * 0.05) : avgFleetUtil * (0.9 + seed * 0.2); return Math.min(100, Math.max(0, Math.round(u))); }); const cu = initCanvas('util-chart', 88); if (cu) { const { ctx, w, h } = cu; const bw = Math.max(2, (w - utilDays.length * 2) / utilDays.length); [75, 50, 25].forEach(g => { const y = h - (g / 100) * h; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.strokeStyle = 'rgba(255,255,255,.06)'; ctx.lineWidth = 1; ctx.stroke(); ctx.font = '9px -apple-system,Arial,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,.25)'; ctx.fillText(g + '%', 2, y - 3); }); utilDays.forEach((v, i) => { const x = i * (bw + 2), bh = (v / 100) * h, y = h - bh; const gr = ctx.createLinearGradient(0, y, 0, h); gr.addColorStop(0, '#f0273c'); gr.addColorStop(1, 'rgba(240,39,60,.2)'); ctx.fillStyle = gr; if (ctx.roundRect) ctx.roundRect(x, y, bw, bh, [2, 2, 0, 0]); else ctx.rect(x, y, bw, bh); ctx.fill(); }); const ax = document.getElementById('util-axis'); if (ax) ax.innerHTML = ['1 Oct','8 Oct','15 Oct','22 Oct','30 Oct'].map(d=>`${d}`).join(''); } // ── Payroll + CIS chart ─────────────────── // Show PAYE (red) and CIS (amber) as two series over 30 days const cf = initCanvas('fuel-chart', 88); if (cf) { const { ctx, w, h } = cf; const payeSeries = buildDailySeries(payrollTotal, 30); const cisSeries = buildDailySeries(Math.max(0, cisGross), 30); const combinedMax = Math.max(...payeSeries.map((v,i)=>v+cisSeries[i])) || 1; // Grid const step = Math.ceil(combinedMax/3/100)*100||100; for(let g=step; g<=combinedMax; g+=step){ const y = h - (g/combinedMax)*(h-10) - 5; if(y<0) continue; ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.strokeStyle='rgba(255,255,255,.06)'; ctx.lineWidth=1; ctx.stroke(); ctx.fillStyle='rgba(255,255,255,.25)'; ctx.font='9px -apple-system,Arial,sans-serif'; ctx.fillText('£'+g.toLocaleString(),2,y-3); } // PAYE area (red) const payePts = payeSeries.map((v,i)=>({x:(i/(payeSeries.length-1))*w, y: h-5-((v/combinedMax)*(h-15))})); drawCurveLine(ctx, payePts, w, h, '#f0273c', 0.28); // CIS area (amber) offset above PAYE if(cisGross>0){ const cisPts = cisSeries.map((v,i)=>({x:(i/(cisSeries.length-1))*w, y: payePts[i].y - ((v/combinedMax)*(h-15))})); drawCurveLine(ctx, cisPts, w, h, '#f59e0b', 0.15); } const ax = document.getElementById('fuel-axis'); if(ax) ax.innerHTML='PAYECIS1 Oct15 Oct30 Oct'; } } // ── Fleet table ─────────────────────────── function buildDashFleetTable(T) { if (!T) T = calcFleetTotals(); const tb = document.getElementById('d-fleet-tbody'); if (!tb) return; // SVG icons — matches the reference dashboard exactly const SVGS = { excavator: ` `, dump: ` `, dozer: ` `, roller: ` `, crusher: ` `, telehandler: ` `, hgv: ` `, default: ` ` }; const machIcon = t => { if(!t) return {svg: SVGS.default, cls:'t-default'}; const tl = t.toLowerCase(); if(tl.includes('excavator')||tl.includes('long reach')||tl.includes('high reach')||tl.includes('shovel')) return {svg:SVGS.excavator, cls:'t-excavator'}; if(tl.includes('dump')||tl.includes('adt')) return {svg:SVGS.dump, cls:'t-dump'}; if(tl.includes('dozer')) return {svg:SVGS.dozer, cls:'t-dozer'}; if(tl.includes('crusher')||tl.includes('screener')||tl.includes('screen')||tl.includes('washing')||tl.includes('jaw')||tl.includes('cone')||tl.includes('impact crush')) return {svg:SVGS.crusher, cls:'t-crusher'}; if(tl.includes('roller')||tl.includes('compactor')||tl.includes('padfoot')) return {svg:SVGS.roller, cls:'t-roller'}; if(tl.includes('telehandler')||tl.includes('loader')||tl.includes('skid')||tl.includes('fel')) return {svg:SVGS.telehandler, cls:'t-dump'}; if(tl.includes('hgv')||tl.includes('low loader')||tl.includes('tipper')||tl.includes('tractor')||tl.includes('truck')) return {svg:SVGS.hgv, cls:'t-hgv'}; if(tl.includes('grader')) return {svg:SVGS.dozer, cls:'t-dozer'}; if(tl.includes('demo')||tl.includes('pulveriser')||tl.includes('grab')) return {svg:SVGS.excavator, cls:'t-excavator'}; return {svg: SVGS.default, cls:'t-default'}; }; const rows = T.stats.map(({ m, s }) => { const uc = s.util >= 70 ? 'high' : s.util >= 40 ? 'mid' : 'low'; const utilColor = uc==='high' ? 'var(--green)' : uc==='mid' ? 'var(--amber)' : 'var(--red)'; const profitColor = s.profit >= 0 ? 'var(--green)' : 'var(--red)'; const {svg, cls} = machIcon(m.type); const costRec = DB.costs.find(c=>c.machine===m.name)||{}; const fuelL = costRec.fuel ? Math.round(costRec.fuel/0.89) : 0; const subtitle = [m.type, m.make||'', m.year||''].filter(Boolean).join(' · '); return { earn: s.earn, row: `
${svg}
${m.name}
${subtitle}
${s.util}%
${fuelL>0?fuelL.toLocaleString():' —'} £${s.earn.toLocaleString('en-GB')} ${s.profit>=0?'':'−'}£${Math.abs(s.profit).toLocaleString('en-GB')} › ` }; }).sort((a, b) => b.earn - a.earn); tb.innerHTML = rows.map(r => r.row).join(''); } // ════════════════════════════════════════════ // MACHINE HOURS WORKED // Estimates hours from fuel cost (£0.89/L DERV) // and operator wages. Also reads m.hours if set. // ════════════════════════════════════════════ function buildDashHours() { const el = document.getElementById('dash-hours-content'); if (!el) return; const CAPACITY = 176; // 22 days × 8 hrs const machines = DB.machines; if (!machines.length) { el.innerHTML = '
No machines in register. Add machines to track hours.
'; return; } let rows = machines.map(m => { const cost = DB.costs.find(c => c.machine === m.name) || {}; const fuelL = cost.fuel ? Math.round(cost.fuel / 0.89) : 0; // Estimate hours from fuel usage — typical plant burns 8–12L/hr const avgBurn = m.type === 'Dozer' ? 12 : m.type === 'Excavator' ? 10 : m.type === 'Dump Truck' ? 9 : 8; const hoursFromFuel = fuelL ? Math.round(fuelL / avgBurn) : 0; // If machine has logged hours field (this month estimate) const hoursWorked = m.hoursThisMonth || hoursFromFuel || Math.round(CAPACITY * ((MACHINE_EARNINGS[m.name]||0) / (DB.rates.find(r=>r.machine===m.name)?.operated||55) / CAPACITY * CAPACITY)) || 0; const cappedHrs = Math.min(hoursWorked, CAPACITY); const pct = Math.round((cappedHrs / CAPACITY) * 100); const earn = MACHINE_EARNINGS[m.name] || 0; const rateHr = DB.rates.find(r => r.machine === m.name)?.operated || 0; const earnPerHr = cappedHrs > 0 ? Math.round(earn / cappedHrs) : 0; const barCol = pct >= 70 ? 'var(--green)' : pct >= 40 ? 'var(--amber)' : 'var(--red)'; const status = m.status || 'Active'; return `
${m.name}
${m.type} · ${status}
${pct}%
${cappedHrs} hrs worked of ${CAPACITY} hrs capacity this month${fuelL ? ' · ' + fuelL.toLocaleString() + ' L fuel used' : ''}
£${earn.toLocaleString()}
income
£${earnPerHr}
per hour
£${rateHr || '—'}
rate/hr
`; }).join(''); // Summary totals row const totalHrs = machines.reduce((s, m) => { const cost = DB.costs.find(c => c.machine === m.name) || {}; const fuelL = cost.fuel ? Math.round(cost.fuel / 0.89) : 0; const avgBurn = m.type === 'Dozer' ? 12 : m.type === 'Excavator' ? 10 : m.type === 'Dump Truck' ? 9 : 8; return s + Math.min(fuelL ? Math.round(fuelL / avgBurn) : 0, CAPACITY); }, 0); const totalCapacity = machines.length * CAPACITY; const avgUtil = totalCapacity > 0 ? Math.round((totalHrs / totalCapacity) * 100) : 0; el.innerHTML = `
MACHINE
HOURS UTILISATION
INCOME
EARNED/HR
RATE/HR
${rows}
Fleet total
${totalHrs.toLocaleString()} hrs of ${totalCapacity.toLocaleString()} hrs capacity · ${avgUtil}% average utilisation
£${Object.values(MACHINE_EARNINGS).reduce((s,v)=>s+v,0).toLocaleString()}
`; } // ════════════════════════════════════════════ // PROJECTS & SITES // Reads from DB.jobs — each job has machine, // desc, parts, labour. Groups by job / site. // ════════════════════════════════════════════ function buildDashSites() { const el = document.getElementById('dash-sites-content'); if (!el) return; const jobs = DB.jobs || []; if (!jobs.length) { el.innerHTML = `
No job records entered yet. Add jobs in the Job Records section to see project and site P&L here.
`; return; } // Map machines to income const machineIncome = { ...MACHINE_EARNINGS }; const rows = jobs.map((j, i) => { const machEarn = machineIncome[j.machine] || 0; const machCost = DB.costs.find(c => c.machine === j.machine); const totalCost = machCost ? (machCost.fuel + machCost.operator + machCost.finance + machCost.maintenance + (machCost.other||0)) : 0; const partsCost = j.parts || 0; const labourCost = (j.labour || 0) * 35; // £35/hr estimate const jobCost = partsCost + labourCost; // Estimate job revenue from rate book const rate = DB.rates.find(r => r.machine === j.machine); const jobRevenue = rate ? (rate.day * (j.labour || 1)) : Math.round(machEarn * 0.4); const jobProfit = jobRevenue - jobCost; const jobMargin = jobRevenue > 0 ? Math.round((jobProfit / jobRevenue) * 100) : 0; const profitColor = jobProfit >= 0 ? 'var(--green)' : 'var(--red)'; const statusCol = j.status === 'Open' ? 'var(--amber)' : j.status === 'In Progress' ? 'var(--blue)' : 'var(--green)'; const priorityCol = j.priority === 'urgent' ? 'var(--red)' : 'var(--muted)'; return `
${j.id || 'JC-'+(i+1)}
${j.desc || 'Job record'}
${j.machine}${j.assigned ? ' · ' + j.assigned : ''}
${j.status} £${jobRevenue.toLocaleString()} £${jobCost.toLocaleString()} ${jobProfit >= 0 ? '' : '-'}£${Math.abs(jobProfit).toLocaleString()} ${jobMargin}% `; }).join(''); const totalRev = jobs.reduce((s, j) => { const r = DB.rates.find(r => r.machine === j.machine); return s + (r ? r.day * (j.labour||1) : 0); }, 0); const totalCst = jobs.reduce((s, j) => s + (j.parts||0) + (j.labour||0)*35, 0); const totalPro = totalRev - totalCst; el.innerHTML = ` ${rows}
Job refDescription / MachineStatus RevenueCostsProfitMargin
${jobs.length} job${jobs.length!==1?'s':''} recorded £${totalRev.toLocaleString()} £${totalCst.toLocaleString()} ${totalPro>=0?'':'−'}£${Math.abs(totalPro).toLocaleString()}
`; } // ════════════════════════════════════════════ // WHERE TO SAVE MONEY // Calculates specific savings from real records // ════════════════════════════════════════════ function buildDashSavings() { const el = document.getElementById('dash-savings-content'); if (!el) return; if (!DB.machines.length) { el.innerHTML = '
Enter cost records to see saving opportunities.
'; return; } const T = calcFleetTotals(); const savings = []; // 1. Idle plant — not earning but costing const idleMachines = DB.machines.filter(m => { const earn = MACHINE_EARNINGS[m.name] || 0; const cost = DB.costs.find(c => c.machine === m.name); const monthCost = cost ? (cost.fuel + cost.finance + cost.maintenance) : (m.financeMonthly || 0); return earn < monthCost * 0.5 && monthCost > 0; }); if (idleMachines.length) { const lostIncome = idleMachines.reduce((s, m) => { const r = DB.rates.find(r => r.machine === m.name); return s + (r ? r.day * 10 : 2000); }, 0); savings.push({ icon: '⚠️', title: idleMachines.length > 1 ? `${idleMachines.length} machines under-earning` : `${idleMachines[0].name} is under-earning`, detail: idleMachines.map(m => m.name).join(', ') + ' — earning less than running costs. Sub-hire or deploy to a live contract.', saving: lostIncome, label: 'potential monthly income', type: 'warning', action: "showView('rates',null)", actionLabel: 'Check hire rates' }); } // 2. Fuel — bulk contract opportunity const totalFuelL = T.totalFuelL || 0; if (totalFuelL > 2000) { const saving = Math.round(totalFuelL * 0.08); // 8p/L bulk saving savings.push({ icon: '⛽', title: 'Bulk fuel contract opportunity', detail: `${totalFuelL.toLocaleString()} litres DERV per month. A bulk fuel card or delivery contract typically saves 7–10p/litre over forecourt prices.`, saving, label: 'estimated monthly saving at 8p/L', type: 'opportunity', action: "showView('costs',null)", actionLabel: 'View fuel records' }); } // 3. Finance — machines with high HP vs low earning const financeHeavy = DB.machines.filter(m => { const fin = m.financeMonthly || 0; const earn = MACHINE_EARNINGS[m.name] || 0; return fin > 0 && earn < fin * 2; }); if (financeHeavy.length) { const totalFin = financeHeavy.reduce((s, m) => s + (m.financeMonthly || 0), 0); savings.push({ icon: '💳', title: `${financeHeavy.length} machine${financeHeavy.length!==1?'s':''} with high finance vs earnings`, detail: financeHeavy.map(m => `${m.name}: £${m.financeMonthly}/mo finance, £${MACHINE_EARNINGS[m.name]||0}/mo earning`).join(' · '), saving: Math.round(totalFin * 0.15), label: 'potential saving if early settled or refinanced', type: 'warning', action: "showView('machines',null)", actionLabel: 'Plant register' }); } // 4. Overdue services = breakdown risk const overdueServices = (DB.services || []).filter(s => s.status === 'overdue'); if (overdueServices.length) { const breakdownRisk = overdueServices.reduce((s, svc) => { const rate = DB.rates.find(r => r.machine === svc.machine); return s + (rate ? rate.day * 3 : 1500); // 3 days average breakdown downtime }, 0); savings.push({ icon: '🔧', title: `${overdueServices.length} overdue service${overdueServices.length!==1?'s':''} — breakdown risk`, detail: overdueServices.map(s => s.machine).join(', ') + ' — unserviced machines break down at site, costing 2–5× more than a scheduled service.', saving: breakdownRisk, label: 'estimated breakdown cost avoided per service', type: 'warning', action: "showView('maintenance',null)", actionLabel: 'Service schedule' }); } // 5. Margin check — machines below 20% margin const lowMargin = T.stats.filter(({ s }) => s.earn > 0 && s.margin < 20 && s.margin >= 0); if (lowMargin.length) { const rateIncrease = lowMargin.reduce(({ m, s }) => { const rate = DB.rates.find(r => r.machine === m.name); return (rate ? rate.day * 22 * 0.05 : 500); }, 0); savings.push({ icon: '📊', title: `${lowMargin.length} machine${lowMargin.length!==1?'s':''} operating below 20% margin`, detail: lowMargin.map(({ m, s }) => `${m.name}: ${s.margin}% margin`).join(' · ') + ' — consider a 5% rate increase at next contract renewal.', saving: lowMargin.reduce((s, { m }) => { const r = DB.rates.find(r => r.machine === m.name); return s + (r ? r.day * 22 * 0.05 : 500); }, 0), label: 'additional income from 5% rate increase', type: 'opportunity', action: "showView('rates',null)", actionLabel: 'Rate book' }); } // 6. Downtime cost const activeDowntime = (DB.downtime || []).filter(d => d.status === 'Ongoing'); if (activeDowntime.length) { const ongoingImpact = activeDowntime.reduce((s, d) => s + (d.hireLost || 0), 0); savings.push({ icon: '🛑', title: `${activeDowntime.length} machine${activeDowntime.length!==1?'s':''} currently down — hire revenue stopped`, detail: activeDowntime.map(d => `${d.machine}: ${d.days} days down, £${d.hireLost?.toLocaleString() || 0} hire lost`).join(' · '), saving: ongoingImpact, label: 'hire revenue lost so far', type: 'urgent', action: "showView('maintenance',null)", actionLabel: 'Breakdown tracker' }); } if (!savings.length) { el.innerHTML = '
✅
Records look healthy
No major cost concerns identified. Add more cost records for a detailed analysis.
'; return; } const totalSaving = savings.reduce((s, sv) => s + sv.saving, 0); el.innerHTML = `
${savings.length} area${savings.length!==1?'s':''} identified from your records
Up to £${totalSaving.toLocaleString()} potential saving or recovery
${savings.map(sv => { const borderCol = sv.type === 'urgent' ? 'rgba(240,39,60,.4)' : sv.type === 'warning' ? 'rgba(245,158,11,.35)' : 'rgba(34,197,94,.3)'; const bgCol = sv.type === 'urgent' ? 'rgba(240,39,60,.04)' : sv.type === 'warning' ? 'rgba(245,158,11,.04)' : 'rgba(34,197,94,.04)'; const savCol = sv.type === 'urgent' ? 'var(--red)' : sv.type === 'warning' ? 'var(--amber)' : 'var(--green)'; return `
${sv.icon}
${sv.title}
${sv.detail}
£${sv.saving.toLocaleString()}
${sv.label}
`; }).join('')}
`; } // ── P&L PDF export ──────────────────────── function exportPLPDF(){ const T = calcFleetTotals(); const company = DB.user?.company||'PAI'; const now = new Date().toLocaleDateString('en-GB',{day:'numeric',month:'long',year:'numeric'}); const cisGross = DB.kpiOverrides.cisPay||0; const payrollTotal = T.totalWages + cisGross; const netPL = T.totalEarnings - T.totalCosts; const margin = T.totalEarnings>0?Math.round((netPL/T.totalEarnings)*100):0; const w=window.open('','_blank'); w.document.write(`PAI — Commercial Records | Plant and Machinery
P&L Summary
Plant and Machinery Commercial Records · ${company}
October 2026
Generated: ${now}

INCOME

Total machine earnings£${T.totalEarnings.toLocaleString()}

EXPENDITURE

PAYE wages−£${T.totalWages.toLocaleString()}
CIS subcontractor payments−£${cisGross.toLocaleString()}
Fuel (DERV)−£${T.totalFuelCost.toLocaleString()}
Finance / HP payments−£${T.totalFinance.toLocaleString()}
Maintenance & repairs−£${T.totalMaint.toLocaleString()}
Other costs−£${DB.costs.reduce((s,c)=>s+(c.other||0),0).toLocaleString()}
Total expenditure−£${T.totalCosts.toLocaleString()}

RESULT

Operating profit / (loss)${netPL>=0?'£':'−£'}${Math.abs(netPL).toLocaleString()}
Net margin${margin}%

BY MACHINE

${T.stats.sort((a,b)=>b.s.earn-a.s.earn).map(({m,s})=>``).join('')}
MachineTotal IncomeCostsProfitMargin
${m.name}£${s.earn.toLocaleString()}−£${s.totalCost.toLocaleString()}${s.profit>=0?'£':'−£'}${Math.abs(s.profit).toLocaleString()}${s.margin}%