Fold purchase orders into the Running Costs Overview, with incl/excl totals
A purchase order buys parts and repairs against a plant item, so its money is running cost and belongs in the same per-machine, per-month series the Overview already reads. Without it September showed nothing at all. Merged at READ time, never copied into monthly_running_costs: that collection is deleted and rebuilt by every seed, so a purchase written there would vanish on the next one and have to be re-uploaded. purchase_lines is never deleted, so the money lives in one place and the Overview adds it up. - Month comes from the PO date (when it was ordered), not "Required By": MEGPPO50532 is dated 07/10 but required 29/08 and those must not be confused. - Only lines allocated to a machine are counted. An unallocated line is real money not yet on a plant item; it stays on /purchases, reported, rather than being hidden or added to the fleet total. - A PO with no readable date is excluded and named, never guessed into January. - Bottom total row shows incl ABOVE excl per month and for the YTD. Workbook figures carry no VAT column, so RUNNING_VAT_PCT (15, confirmed by every PO line) is the single constant to revisit if they are ever restated ex-VAT. September now reads R 5 842 incl / R 5 080 excl from the two September POs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f85b9378a2
commit
aac440cfef
5 changed files with 294 additions and 21 deletions
32
AGENTS.md
32
AGENTS.md
|
|
@ -485,6 +485,38 @@ Three traps this file has already hit, all in AGENTS.md terms of "do not regress
|
|||
Collections created by the collections API do **not** get `created`/`updated` for free —
|
||||
declare them explicitly or a `sort: "-created"` fails with an opaque server error.
|
||||
|
||||
## Running Costs Overview — purchases ARE running cost, merged at read time
|
||||
|
||||
The Overview adds two sources per machine per month, both **VAT-INCLUSIVE** (client
|
||||
confirmed 2026-10-09 that the workbook's figures are the amount paid):
|
||||
|
||||
1. the workbook's "Plant Monthly Running Cost" (Jan–Aug 2026), and
|
||||
2. uploaded **purchase orders** allocated to a machine, filed under the **PO date**
|
||||
(client ruling: the month it was *ordered*, not the "Required By" month —
|
||||
MEGPPO50532 is dated 07/10 but was "required" 29/08, and those must not be confused).
|
||||
|
||||
**They are merged at READ time, never copied into `monthly_running_costs`.** That
|
||||
collection is deleted and rebuilt by every seed, so a purchase written into it would
|
||||
vanish on the next one and have to be re-uploaded. `purchase_lines` is never deleted, so
|
||||
the money lives in one place and the Overview adds it up.
|
||||
|
||||
- Only purchase lines with an `asset` are counted. An unallocated line is real money not
|
||||
yet on a plant item — adding it to no machine hides it, adding it to the fleet total
|
||||
overstates one. It stays on /purchases, reported as unallocated.
|
||||
- A purchase with **no readable PO date** is excluded and named in a banner, never
|
||||
guessed into January.
|
||||
|
||||
### The incl / excl split
|
||||
|
||||
`lib/purchase-booking.ts` holds `RUNNING_VAT_PCT = 15`. Purchase lines state their own
|
||||
VAT% (all 14 read 15.00, and the arithmetic reconciles to exactly 15.000%), and the same
|
||||
rate is applied to the workbook side because that sheet records ONE amount with no VAT
|
||||
column at all. The bottom total row shows **incl above excl** for every month and for the
|
||||
YTD, so nobody has to back the VAT out by hand.
|
||||
|
||||
**If the workbook figures are ever restated ex-VAT, `RUNNING_VAT_PCT` is the one constant
|
||||
to revisit** — every excl figure on the Overview derives from it.
|
||||
|
||||
## Oil Stock — a cost code can name a STORE, not a machine
|
||||
|
||||
`/running/oils` tracks the two bulk oils in stock and prints their drum labels.
|
||||
|
|
|
|||
|
|
@ -1,41 +1,129 @@
|
|||
import { getAssets, getRunningCosts } from "@/lib/server-data";
|
||||
import { serverAuth } from "@/lib/pb";
|
||||
import Link from "next/link";
|
||||
import { ZAR } from "@/lib/utils";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { Wrench, TriangleAlert } from "lucide-react";
|
||||
import { RunningView } from "@/components/RunningView";
|
||||
import { buildRunningRows } from "@/lib/rows";
|
||||
import { purchaseRunningCosts, RUNNING_VAT_PCT } from "@/lib/purchase-booking";
|
||||
import type { Purchase, PurchaseLine } from "@/lib/data";
|
||||
|
||||
// Reads PocketBase on every request. Without this Next tries to prerender the
|
||||
// page at build time, which makes `docker build` depend on the database being
|
||||
// reachable and then serves a frozen build-time snapshot to users.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Running Costs Overview — repairs & parts per machine per month.
|
||||
*
|
||||
* Two sources are added together, both VAT-INCLUSIVE (client confirmed 2026-10-09):
|
||||
* 1. the workbook's "Plant Monthly Running Cost" (Jan–Aug), and
|
||||
* 2. uploaded PURCHASE ORDERS allocated to a machine, filed under the PO month.
|
||||
*
|
||||
* Purchases are merged here at read time rather than copied into
|
||||
* `monthly_running_costs`, which every seed deletes and rebuilds — a purchase written
|
||||
* there would vanish on the next seed and have to be re-uploaded.
|
||||
*/
|
||||
export default async function RunningPage() {
|
||||
const [assets, costs] = await Promise.all([getAssets(), getRunningCosts()]);
|
||||
const pb = await serverAuth();
|
||||
const [assets, costs, purchases, lines] = await Promise.all([
|
||||
getAssets(),
|
||||
getRunningCosts(),
|
||||
pb.collection("purchases").getFullList<Purchase>(),
|
||||
pb.collection("purchase_lines").getFullList<PurchaseLine>(),
|
||||
]);
|
||||
|
||||
const rows = buildRunningRows(assets, costs);
|
||||
const { byAsset, undated } = purchaseRunningCosts(purchases, lines);
|
||||
const rows = buildRunningRows(assets, costs, byAsset);
|
||||
|
||||
// Totals straight off the rows, so the summary can never disagree with the table
|
||||
// underneath it.
|
||||
const monthTotals = Array(12).fill(0);
|
||||
for (const c of costs) monthTotals[c.month - 1] += c.amount;
|
||||
const monthTotalsExcl = Array(12).fill(0);
|
||||
const monthPurchases = Array(12).fill(0);
|
||||
for (const r of rows) {
|
||||
r.months.forEach((v, i) => {
|
||||
monthTotals[i] += v;
|
||||
monthPurchases[i] += r.purchasesIncl[i];
|
||||
});
|
||||
r.monthsExcl.forEach((v, i) => (monthTotalsExcl[i] += v));
|
||||
}
|
||||
const grand = monthTotals.reduce((s, v) => s + v, 0);
|
||||
const grandExcl = monthTotalsExcl.reduce((s, v) => s + v, 0);
|
||||
const purchasedTotal = monthPurchases.reduce((s, v) => s + v, 0);
|
||||
const unallocated = lines.filter((l) => !l.asset).length;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-[1400px]">
|
||||
<div className="flex items-end justify-between mb-6">
|
||||
<div className="p-4 sm:p-8 max-w-[1400px]">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#EDAE49]/15 to-[#D1495B]/15 grid place-items-center text-[#EDAE49]">
|
||||
<div className="w-10 h-10 shrink-0 rounded-xl bg-gradient-to-br from-[#EDAE49]/15 to-[#D1495B]/15 grid place-items-center text-[#EDAE49]">
|
||||
<Wrench className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-[#003D5B] dark:text-white">Running Costs</h1>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">Repairs & parts per machine per month · 2026</p>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
Repairs & parts per machine per month · 2026
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-text-soft mt-1">
|
||||
Workbook costs plus purchase orders allocated to a machine, filed under the
|
||||
month the PO was raised. All figures incl VAT, with the excl figure derived
|
||||
at {RUNNING_VAT_PCT}%.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-orange-500/20">
|
||||
<div className="text-xs text-white/80">YTD running cost</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{ZAR(monthTotals.reduce((s, v) => s + v, 0))}</div>
|
||||
<div className="bg-gradient-to-br from-[#EDAE49] to-[#D1495B] rounded-2xl px-5 py-3 text-white shadow-lg shadow-orange-500/20 self-start sm:self-auto">
|
||||
<div className="text-xs text-white/80">YTD running cost · incl VAT</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{ZAR(grand)}</div>
|
||||
<div className="text-xs text-white/75 tabular-nums mt-0.5">
|
||||
excl VAT {ZAR(grandExcl)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RunningView rows={rows} palette={{ income: "#EDAE49", profit: "#EDAE49", loss: "#EDAE49" }} monthTotals={monthTotals} />
|
||||
{purchasedTotal > 0 && (
|
||||
<div className="mb-4 rounded-2xl border border-[#30638E]/25 bg-[#30638E]/[0.05] p-4 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-semibold text-[#003D5B] dark:text-white">
|
||||
{ZAR(purchasedTotal)} of this comes from uploaded purchase orders
|
||||
</span>{" "}
|
||||
allocated to a machine.
|
||||
{unallocated > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
{unallocated} purchase line{unallocated !== 1 && "s"} are still unallocated
|
||||
and are <span className="font-medium">not</span> in these totals — see{" "}
|
||||
{/* Inline in a sentence, but the hit area still meets the 44px minimum.
|
||||
`-my-4` cancels the vertical padding back out, so the sentence's line
|
||||
spacing is unchanged while the tap target reaches 48px. */}
|
||||
<Link href="/running/purchases" className="underline inline-block py-4 -my-4">
|
||||
Purchases
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{undated.length > 0 && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-2xl border border-[#EDAE49]/30 bg-[#EDAE49]/[0.06] p-4 text-xs text-gray-600 dark:text-gray-400">
|
||||
<TriangleAlert className="w-4 h-4 mt-0.5 shrink-0 text-[#EDAE49]" />
|
||||
<span>
|
||||
<span className="font-semibold text-[#003D5B] dark:text-white">
|
||||
{undated.length} purchase line{undated.length !== 1 && "s"} could not be filed
|
||||
</span>{" "}
|
||||
— no readable PO date ({undated.slice(0, 3).join(", ")}). They are excluded from
|
||||
the monthly totals rather than guessed into January.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RunningView
|
||||
rows={rows}
|
||||
palette={{ income: "#EDAE49", profit: "#EDAE49", loss: "#EDAE49" }}
|
||||
monthTotals={monthTotals}
|
||||
monthTotalsExcl={monthTotalsExcl}
|
||||
vatPct={RUNNING_VAT_PCT}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,10 +11,16 @@ export function RunningView({
|
|||
rows,
|
||||
palette,
|
||||
monthTotals,
|
||||
monthTotalsExcl,
|
||||
vatPct,
|
||||
}: {
|
||||
rows: RunningRow[];
|
||||
palette: { income: string; profit: string; loss: string };
|
||||
/** Per month, VAT-INCLUSIVE. */
|
||||
monthTotals: number[];
|
||||
/** The same months net of VAT, shown UNDER each incl figure. */
|
||||
monthTotalsExcl?: number[];
|
||||
vatPct?: number;
|
||||
}) {
|
||||
const [view, setView] = useState<"cards" | "grid">("cards");
|
||||
const [sort, setSort] = useState<"ytd" | "code">("ytd");
|
||||
|
|
@ -97,7 +103,7 @@ export function RunningView({
|
|||
{view === "cards" ? (
|
||||
<CardsView rows={sorted} expanded={expanded} setExpanded={setExpanded} palette={palette} />
|
||||
) : (
|
||||
<GridView rows={sorted} monthTotals={monthTotals} grandTotal={grandTotal} />
|
||||
<GridView rows={sorted} monthTotals={monthTotals} monthTotalsExcl={monthTotalsExcl} vatPct={vatPct} grandTotal={grandTotal} grandExcl={(monthTotalsExcl || []).reduce((s, v) => s + v, 0)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -182,11 +188,14 @@ function CardsView({
|
|||
}
|
||||
|
||||
function GridView({
|
||||
rows, monthTotals, grandTotal,
|
||||
rows, monthTotals, monthTotalsExcl, vatPct, grandTotal, grandExcl,
|
||||
}: {
|
||||
rows: RunningRow[];
|
||||
monthTotals: number[];
|
||||
monthTotalsExcl?: number[];
|
||||
vatPct?: number;
|
||||
grandTotal: number;
|
||||
grandExcl: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-surface rounded-2xl border border-gray-100 dark:border-border shadow-sm overflow-hidden">
|
||||
|
|
@ -226,15 +235,38 @@ function GridView({
|
|||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* Bottom row: the month's total on BOTH bases, incl ABOVE excl, so a
|
||||
reader never has to back the VAT out by hand. */}
|
||||
<tr className="bg-gradient-to-r from-[#EDAE49]/10 dark:from-[#EDAE49]/15 to-transparent border-t-2 border-[#EDAE49]/30">
|
||||
<td colSpan={2} className="py-3 px-6 font-semibold text-right text-[#003D5B] dark:text-white">Total</td>
|
||||
<td colSpan={2} className="py-3 px-6 font-semibold text-right align-bottom text-[#003D5B] dark:text-white">
|
||||
Total
|
||||
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
|
||||
incl / excl
|
||||
</span>
|
||||
</td>
|
||||
{monthTotals.map((v, i) => (
|
||||
<td key={i} className="py-3 px-2 text-right tabular-nums font-semibold text-[#003D5B] dark:text-white">
|
||||
{v > 0 ? ZAR(v) : "—"}
|
||||
<td key={i} className="py-3 px-2 text-right tabular-nums align-bottom">
|
||||
{v > 0 ? (
|
||||
<>
|
||||
<span className="block font-semibold text-[#003D5B] dark:text-white">{ZAR(v)}</span>
|
||||
{monthTotalsExcl && (
|
||||
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
|
||||
{ZAR(monthTotalsExcl[i] ?? 0)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[#003D5B] dark:text-white">—</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-3 px-6 text-right tabular-nums font-bold text-base text-[#EDAE49]">
|
||||
{ZAR(grandTotal)}
|
||||
<td className="py-3 px-6 text-right tabular-nums align-bottom">
|
||||
<span className="block font-bold text-base text-[#EDAE49]">{ZAR(grandTotal)}</span>
|
||||
{grandExcl > 0 && (
|
||||
<span className="block text-[12px] font-normal text-gray-400 dark:text-text-soft">
|
||||
{ZAR(grandExcl)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
|||
87
lib/purchase-booking.ts
Normal file
87
lib/purchase-booking.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import type { Purchase, PurchaseLine } from "./data";
|
||||
|
||||
/**
|
||||
* Purchases are running cost. A PO buys parts and repairs against a plant item, so
|
||||
* its money belongs in the same per-machine, per-month series as the workbook's
|
||||
* "Plant Monthly Running Cost" — and the Overview is where somebody reads the total
|
||||
* for a month.
|
||||
*
|
||||
* They are MERGED AT READ TIME rather than copied into `monthly_running_costs`, for
|
||||
* one decisive reason: that collection is deleted and rebuilt by every seed, so any
|
||||
* purchase written into it would vanish on the next one and have to be re-imported.
|
||||
* `purchase_lines` is never deleted, so the money lives in one place and the Overview
|
||||
* simply adds it up.
|
||||
*/
|
||||
|
||||
/**
|
||||
* VAT rate used to split a workbook running-cost figure, which records ONE amount
|
||||
* with no VAT column (client confirmed 2026-10-09 that it is the amount paid, i.e.
|
||||
* VAT-INCLUSIVE). Purchase lines state their own VAT% — all 14 read 15.00 — so the
|
||||
* same rate is applied to the workbook side to produce a comparable excl figure.
|
||||
*/
|
||||
export const RUNNING_VAT_PCT = 15;
|
||||
|
||||
/** Split a VAT-inclusive amount into its net value. */
|
||||
export function exclFromIncl(incl: number, vatPct: number = RUNNING_VAT_PCT): number {
|
||||
return incl / (1 + vatPct / 100);
|
||||
}
|
||||
|
||||
/** One machine's spend in one month, on both bases. */
|
||||
export type RunningSlice = { incl: number; excl: number };
|
||||
|
||||
/** The month a purchase order lands in: the PO date, i.e. the month it was ordered. */
|
||||
function poMonth(p: Purchase): number | null {
|
||||
const m = /^(\d{4})-(\d{2})/.exec(p.po_date || "");
|
||||
if (!m) return null;
|
||||
const month = Number(m[2]);
|
||||
return month >= 1 && month <= 12 ? month : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-machine, per-month spend from uploaded purchase orders.
|
||||
*
|
||||
* Only lines ALLOCATED to a machine are counted. An unallocated line is real money
|
||||
* that is not yet on a plant item — adding it to no machine would hide it, and adding
|
||||
* it to the fleet total would overstate a specific machine. It stays on /purchases,
|
||||
* reported as unallocated, until it is pointed somewhere.
|
||||
*
|
||||
* A purchase with no readable PO date is skipped and surfaced in `undated`, rather
|
||||
* than being quietly filed under January.
|
||||
*/
|
||||
export function purchaseRunningCosts(
|
||||
purchases: Purchase[],
|
||||
lines: PurchaseLine[],
|
||||
year = 2026,
|
||||
): {
|
||||
byAsset: Map<string, (RunningSlice | null)[]>;
|
||||
undated: string[];
|
||||
} {
|
||||
const poById = new Map(purchases.map((p) => [p.id, p]));
|
||||
const byAsset = new Map<string, (RunningSlice | null)[]>();
|
||||
const undated: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.asset) continue;
|
||||
const po = poById.get(line.purchase);
|
||||
if (!po) continue;
|
||||
if (po.year && po.year !== year) continue;
|
||||
|
||||
const month = poMonth(po);
|
||||
if (month === null) {
|
||||
const label = `${po.po_no} #${line.line_no}`;
|
||||
if (!undated.includes(label)) undated.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
const incl = line.total_incl ?? line.amount_excl ?? 0;
|
||||
const excl = line.amount_excl ?? exclFromIncl(incl);
|
||||
const months = byAsset.get(line.asset) || Array(12).fill(null);
|
||||
const at = months[month - 1];
|
||||
months[month - 1] = at
|
||||
? { incl: at.incl + incl, excl: at.excl + excl }
|
||||
: { incl, excl };
|
||||
byAsset.set(line.asset, months);
|
||||
}
|
||||
|
||||
return { byAsset, undated };
|
||||
}
|
||||
36
lib/rows.ts
36
lib/rows.ts
|
|
@ -2,6 +2,7 @@ import type { Asset, FinancingTerm, InsuranceRate, InvoiceLine, MonthlyIncome, M
|
|||
import { computeProfit, type ProfitOptions } from "./profit";
|
||||
import { currentRate, monthlyCost, validatePeriods, type RatePeriod } from "./insurance";
|
||||
import { financeCalc, toInput, type FinanceCalc } from "./financing";
|
||||
import { exclFromIncl, type RunningSlice } from "./purchase-booking";
|
||||
|
||||
export type IncomeRow = {
|
||||
id: string;
|
||||
|
|
@ -121,13 +122,23 @@ export type RunningRow = {
|
|||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
/** Per month, VAT-INCLUSIVE: the workbook figure plus any purchased parts. */
|
||||
months: number[];
|
||||
ytd: number;
|
||||
/** Per month, net of VAT at the confirmed rate. Shown under `months`, never instead of it. */
|
||||
monthsExcl: number[];
|
||||
ytdExcl: number;
|
||||
/** The purchased portion of each month, so the Overview can say what came from where. */
|
||||
purchasesIncl: number[];
|
||||
};
|
||||
|
||||
export function buildRunningRows(
|
||||
assets: Asset[],
|
||||
costs: MonthlyRunningCost[]
|
||||
costs: MonthlyRunningCost[],
|
||||
// Purchases merged in on top of the workbook, per machine and month, VAT-INCLUSIVE
|
||||
// (see lib/purchase-booking.ts). Optional so the shape stays usable where only the
|
||||
// workbook is loaded.
|
||||
purchases?: Map<string, (RunningSlice | null)[]>,
|
||||
): RunningRow[] {
|
||||
const byAsset = new Map<string, RunningRow>();
|
||||
for (const a of assets) {
|
||||
|
|
@ -136,6 +147,11 @@ export function buildRunningRows(
|
|||
id: a.id, code: a.code, name: a.name,
|
||||
months: Array(12).fill(0),
|
||||
ytd: 0,
|
||||
// Net of VAT, derived at the confirmed rate. Kept alongside so the Overview can
|
||||
// show incl above excl rather than making the reader back the VAT out.
|
||||
monthsExcl: Array(12).fill(0),
|
||||
ytdExcl: 0,
|
||||
purchasesIncl: Array(12).fill(0),
|
||||
});
|
||||
}
|
||||
for (const c of costs) {
|
||||
|
|
@ -143,6 +159,24 @@ export function buildRunningRows(
|
|||
if (!row) continue;
|
||||
row.months[c.month - 1] = c.amount;
|
||||
row.ytd += c.amount;
|
||||
row.monthsExcl[c.month - 1] = exclFromIncl(c.amount);
|
||||
row.ytdExcl += exclFromIncl(c.amount);
|
||||
}
|
||||
if (purchases) {
|
||||
for (const [assetId, months] of purchases) {
|
||||
const row = byAsset.get(assetId);
|
||||
if (!row) continue;
|
||||
months.forEach((slice, i) => {
|
||||
if (slice == null) return;
|
||||
// Use the excl the PO line actually carries rather than backing it out of the
|
||||
// incl — the document states both, so there is no reason to re-derive one.
|
||||
row.months[i] += slice.incl;
|
||||
row.ytd += slice.incl;
|
||||
row.purchasesIncl[i] += slice.incl;
|
||||
row.monthsExcl[i] += slice.excl;
|
||||
row.ytdExcl += slice.excl;
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(byAsset.values());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue