import { serverAuth } from "./pb"; import type { RecordModel } from "pocketbase"; export type Asset = { id: string; code: string; name: string; category: string; type: string; gl_account: string; supplier: string; monthly_excl: number; monthly_incl: number; purchase_value: number; is_provisional: boolean; is_active: boolean; } & RecordModel; export type CostLine = { id: string; asset: string; year: number; month: number; kind: "instalment" | "insurance" | "tracking" | "operator" | "running"; amount: number; } & RecordModel; export type MonthlyIncome = { id: string; asset: string; year: number; month: number; income: number; fixed_expenses: number; running_cost: number; } & RecordModel; export type MonthlyRunningCost = { id: string; asset: string; year: number; month: number; amount: number; } & RecordModel; export type Overhead = { id: string; section: string; description: string; gl_account: string; supplier: string; qty: number; unit_price: number; amount_excl: number; vat: number; amount_incl: number; } & RecordModel; // Invoice header. `booked_month`/`booked_year` record the period the invoice was // posted into monthly_income; `is_booked` flips once every line has a target asset. export type Invoice = { id: string; invoice_no: string; invoice_date: string; order_no: string; debtor: string; debtor_no: string; period_from: string; period_to: string; description: string; site: string; section: string; category: string; currency: string; vat_reg_no: string; source_file: string; subtotal: number; escalation: number; vat: number; vat_pct: number; total_excl: number; total_incl: number; source_pdf: string; booked_month: number; booked_year: number; is_booked: boolean; } & RecordModel; // Invoice line. `machine_code` is what the invoice prints; `asset_code` is the // register match; `booked_asset` is the machine the income is actually booked to. // These can differ — an invoice line may legitimately be charged to another machine. export type InvoiceLine = { id: string; invoice: string; line_no: number; machine_code: string; asset_code: string; description: string; qty: number; unit: string; rate: number; rate_type: string; amount_excl: number; vat: number; total_incl: number; booked_asset: string; booked_month: number; booked_year: number; } & RecordModel; export async function getAssets(): Promise { const pb = await serverAuth(); return pb.collection("assets").getFullList({ sort: "category,name" }); } export async function getAssetsByCategory(category: string): Promise { const all = await getAssets(); return all.filter((a) => a.category === category); } export async function getMonthlyIncome(year = 2026): Promise { const pb = await serverAuth(); return pb.collection("monthly_income").getFullList({ filter: `year = ${year}`, sort: "month", }); } // Part 1.2: YTD aggregations operate on actuals only by default. // `is_forecast` is a boolean field on monthly_income; rows where it's true are excluded. export async function getActualMonthlyIncome(year = 2026): Promise { const all = await getMonthlyIncome(year); return all.filter((r) => !(r as any).is_forecast); } // Returns the last month that has any actual income — used to label forecast ranges. export function lastActualMonth(rows: MonthlyIncome[]): number { let m = 0; for (const r of rows) if (r.income > 0 && r.month > m) m = r.month; return m; } export async function getRunningCosts(year = 2026): Promise { const pb = await serverAuth(); return pb.collection("monthly_running_costs").getFullList({ filter: `year = ${year}`, sort: "month", }); } export async function getOverheads(): Promise { const pb = await serverAuth(); return pb.collection("overheads").getFullList({ sort: "section,description" }); } export async function getOverheadsBySection(section: string): Promise { const all = await getOverheads(); return all.filter((o) => o.section === section); } export async function getInvoices(): Promise { const pb = await serverAuth(); return pb.collection("invoices").getFullList({ sort: "-invoice_date,-invoice_no" }); } export async function getInvoiceLines(invoiceId?: string): Promise { const pb = await serverAuth(); return pb.collection("invoice_lines").getFullList({ filter: invoiceId ? `invoice = "${invoiceId}"` : "", sort: "line_no", }); } // An invoice line joined to the asset it is booked against. Kept as a plain derived // type so the page never has to deal with PocketBase expand payloads. export type BookedLine = InvoiceLine & { bookedCode: string; bookedName: string; isBooked: boolean; }; export function joinBookedLines(lines: InvoiceLine[], assets: Asset[]): BookedLine[] { const byId = new Map(assets.map((a) => [a.id, a])); return lines.map((l) => { const a = l.booked_asset ? byId.get(l.booked_asset) : undefined; return { ...l, bookedCode: a?.code || "", bookedName: a?.name || "", isBooked: Boolean(a), }; }); } // One machine can legitimately be hired to several sites in the same month — that is // multi-site use, not a duplicate. These are flagged so the reader can see the pattern, // NOT as a data error, so nothing here is styled as a problem. // // Returns a map of line id → the other (invoice, site) pairs this same machine code was // billed on over an overlapping period. export function findDuplicateMachineCodes( invoices: Invoice[], lines: BookedLine[], ): Map { const byId = new Map(invoices.map((i) => [i.id, i])); const groups = new Map(); for (const l of lines) { if (!l.machine_code) continue; if (!groups.has(l.machine_code)) groups.set(l.machine_code, []); groups.get(l.machine_code)!.push(l); } const out = new Map(); for (const [, group] of groups) { if (group.length < 2) continue; for (const line of group) { const inv = byId.get(line.invoice); if (!inv) continue; const others = new Set(); for (const other of group) { if (other.id === line.id) continue; const oInv = byId.get(other.invoice); if (!oInv || oInv.id === inv.id) continue; const overlaps = inv.period_from <= oInv.period_to && oInv.period_from <= inv.period_to; if (overlaps) others.add(oInv.invoice_no); } if (others.size) out.set(line.id, [...others]); } } return out; } export const MONTH_NAMES = [ "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec", ];