`assets` already carried latitude/longitude, but `sites` had nowhere to put a pin — so there was no way to locate a site. Both fields added, matching the plant collection's shape so the same map component can plot either. Coordinates are entered as two plain number fields with an "open in maps" link to verify the point, rather than a drag-to-pin map: the in-app Leaflet map is still being worked on, and a coordinate is usually copied off some other map anyway. Clearing removes the pin rather than leaving half a coordinate, which would plot in the Gulf of Guinea and read as "located". Bounds are checked server-side, not just in the form: a point outside South Africa is refused with the numbers echoed back, because a swapped pair or a dropped digit is far likelier than a machine abroad. 0,0 is rejected by the same rule. The sites list marks each row as pinned or "no pin", with a banner counting the gaps, so an unlocated site is visible without opening it. While checking this, found that all 44 assets carry the SAME coordinate (-26.1016, 28.3487) — the Plant Map currently stacks every marker on one pin while reporting "44 located on the map". Left alone, but worth knowing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
255 lines
7.3 KiB
TypeScript
255 lines
7.3 KiB
TypeScript
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;
|
|
// Plant Map. Null until the on-site scan sheet supplies them. 0/0 counts as
|
|
// "not located" — see `hasCoords` in components/PlantMap.
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
} & 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_site: string;
|
|
booked_month: number;
|
|
booked_year: number;
|
|
} & RecordModel;
|
|
|
|
export async function getAssets(): Promise<Asset[]> {
|
|
const pb = await serverAuth();
|
|
return pb.collection("assets").getFullList<Asset>({ sort: "category,name" });
|
|
}
|
|
|
|
export async function getAssetsByCategory(category: string): Promise<Asset[]> {
|
|
const all = await getAssets();
|
|
return all.filter((a) => a.category === category);
|
|
}
|
|
|
|
export async function getMonthlyIncome(year = 2026): Promise<MonthlyIncome[]> {
|
|
const pb = await serverAuth();
|
|
return pb.collection("monthly_income").getFullList<MonthlyIncome>({
|
|
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<MonthlyIncome[]> {
|
|
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<MonthlyRunningCost[]> {
|
|
const pb = await serverAuth();
|
|
return pb.collection("monthly_running_costs").getFullList<MonthlyRunningCost>({
|
|
filter: `year = ${year}`,
|
|
sort: "month",
|
|
});
|
|
}
|
|
|
|
export async function getOverheads(): Promise<Overhead[]> {
|
|
const pb = await serverAuth();
|
|
return pb.collection("overheads").getFullList<Overhead>({ sort: "section,description" });
|
|
}
|
|
|
|
export async function getOverheadsBySection(section: string): Promise<Overhead[]> {
|
|
const all = await getOverheads();
|
|
return all.filter((o) => o.section === section);
|
|
}
|
|
|
|
export async function getInvoices(): Promise<Invoice[]> {
|
|
const pb = await serverAuth();
|
|
return pb.collection("invoices").getFullList<Invoice>({ sort: "-invoice_date,-invoice_no" });
|
|
}
|
|
|
|
export async function getInvoiceLines(invoiceId?: string): Promise<InvoiceLine[]> {
|
|
const pb = await serverAuth();
|
|
return pb.collection("invoice_lines").getFullList<InvoiceLine>({
|
|
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<string, string[]> {
|
|
const byId = new Map(invoices.map((i) => [i.id, i]));
|
|
const groups = new Map<string, BookedLine[]>();
|
|
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<string, string[]>();
|
|
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<string>();
|
|
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 type Site = {
|
|
id: string;
|
|
name: string;
|
|
code: string;
|
|
notes: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
is_active: boolean;
|
|
} & RecordModel;
|
|
|
|
export async function getSites(): Promise<Site[]> {
|
|
const pb = await serverAuth();
|
|
return pb.collection("sites").getFullList<Site>({ sort: "name" });
|
|
}
|
|
|
|
export const MONTH_NAMES = [
|
|
"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",
|
|
];
|