Track cash float spend since top-up
Replace the last-slip field with cumulative spending since the latest top-up, falling back to the opening when no top-up exists. Update the management table to display the new amount and cover the calculation with server and UI tests.
This commit is contained in:
parent
be890df613
commit
9ff13b3ce6
5 changed files with 34 additions and 12 deletions
|
|
@ -24,7 +24,7 @@ const card = (over: Record<string, unknown> = {}) => ({
|
|||
limit: 100000,
|
||||
balance: 234000,
|
||||
spentThisMonth: 60000,
|
||||
lastSlip: null,
|
||||
spentSinceTopup: 0,
|
||||
low: false,
|
||||
hasOpening: true,
|
||||
...over,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const card = (over: Partial<CashFloatCard> = {}): CashFloatCard => ({
|
|||
limit: 100000,
|
||||
balance: 234000,
|
||||
spentThisMonth: 60000,
|
||||
lastSlip: { date: "2026-10-20T08:00:00Z", supplier: "Build Supply" },
|
||||
spentSinceTopup: 25000,
|
||||
low: false,
|
||||
hasOpening: true,
|
||||
...over,
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ export function CashFloatsView({ cards, users }: { cards: CashFloatCard[]; users
|
|||
<div className="mgmt-table-scroll">
|
||||
<table className="mgmt-table mgmt-cash-float-table">
|
||||
<caption className="sr-only">Cash float cards, their holders and their balances</caption>
|
||||
<thead><tr><th>Card</th><th>Holder</th><th>Assigned</th><th>Balance</th><th>Spent this month</th><th>Last slip</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<thead><tr><th>Card</th><th>Holder</th><th>Assigned</th><th>Balance</th><th>Spent this month</th><th>Spent since top-up</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{!cards.length && <tr><td colSpan={8} className="text-left text-muted-foreground">No cards yet. Add one to get started.</td></tr>}
|
||||
{visible.map(card => (
|
||||
|
|
@ -131,7 +131,7 @@ export function CashFloatsView({ cards, users }: { cards: CashFloatCard[]; users
|
|||
<td>{card.assignedOn || "—"}</td>
|
||||
<td className={card.low ? "font-semibold text-destructive" : ""}>{formatCents(card.balance)}</td>
|
||||
<td>{formatCents(card.spentThisMonth)}</td>
|
||||
<td className="text-left">{card.lastSlip ? `${card.lastSlip.date.slice(0, 10)}${card.lastSlip.supplier ? ` · ${card.lastSlip.supplier}` : ""}` : "—"}</td>
|
||||
<td>{formatCents(card.spentSinceTopup)}</td>
|
||||
<td>
|
||||
{card.low
|
||||
? <span className="flex items-center justify-end gap-1 font-semibold text-destructive"><TriangleAlert className="h-3.5 w-3.5" />Under the limit</span>
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ describe("loadCashFloatCards", () => {
|
|||
expect(cards[1].hasOpening).toBe(true);
|
||||
});
|
||||
|
||||
it("counts what was spent this month and finds the last slip", async () => {
|
||||
it("counts what was spent this month and since the last top-up", async () => {
|
||||
const { pb } = fakePb({
|
||||
users: [holder()],
|
||||
cash_float_log: [
|
||||
|
|
@ -149,7 +149,22 @@ describe("loadCashFloatCards", () => {
|
|||
});
|
||||
const [card] = await loadCashFloatCards(pb, new Date("2026-10-24T00:00:00Z"));
|
||||
expect(card.spentThisMonth).toBe(60000);
|
||||
expect(card.lastSlip).toEqual({ date: "2026-10-20T08:00:00Z", supplier: "Build Supply" });
|
||||
// Never topped up: counts from the opening on the 1st.
|
||||
expect(card.spentSinceTopup).toBe(60000);
|
||||
});
|
||||
|
||||
it("counts only the slips after the latest top-up", async () => {
|
||||
const { pb } = fakePb({
|
||||
users: [holder()],
|
||||
cash_float_log: [
|
||||
log({ id: "l1", kind: "opening", amount: 1500, occurred_on: "2026-10-01T08:00:00Z" }),
|
||||
log({ id: "l2", amount: -600, occurred_on: "2026-10-05T08:00:00Z" }),
|
||||
log({ id: "l3", kind: "topup", amount: 1000, occurred_on: "2026-10-10T08:00:00Z" }),
|
||||
log({ id: "l4", amount: -250, occurred_on: "2026-10-20T08:00:00Z" }),
|
||||
],
|
||||
});
|
||||
const [card] = await loadCashFloatCards(pb, new Date("2026-10-24T00:00:00Z"));
|
||||
expect(card.spentSinceTopup).toBe(25000);
|
||||
});
|
||||
|
||||
it("leaves unticked users out", async () => {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ export interface CashFloatCard {
|
|||
balance: number;
|
||||
/** What has been spent off the card this month, in cents, positive. */
|
||||
spentThisMonth: number;
|
||||
lastSlip: { date: string; supplier: string } | null;
|
||||
/** What has been spent since the last top-up (or the opening, if never topped up), in cents, positive. */
|
||||
spentSinceTopup: number;
|
||||
low: boolean;
|
||||
/** A card with no opening row has not been set up yet. */
|
||||
hasOpening: boolean;
|
||||
|
|
@ -140,7 +141,7 @@ export async function cardHeldBy(pb: PocketBase, userId: string): Promise<{ card
|
|||
* Every card, lowest balance first.
|
||||
*
|
||||
* The balance of every card comes from the one log read; the per-card
|
||||
* figures (spent this month, the last slip) are picked out of the same rows.
|
||||
* figures (spent this month, spent since the last top-up) are picked out of the same rows.
|
||||
*/
|
||||
export async function loadCashFloatCards(pb: PocketBase, now: Date = new Date()): Promise<CashFloatCard[]> {
|
||||
const [holders, logRows] = await Promise.all([loadHolders(pb), loadLogRows(pb)]);
|
||||
|
|
@ -157,9 +158,15 @@ export async function loadCashFloatCards(pb: PocketBase, now: Date = new Date())
|
|||
const limit = lowLimit(holder);
|
||||
const slips = cardRows.filter((row) => row.kind === "slip");
|
||||
const thisMonth = slips.filter((row) => rowMonth(row) === month);
|
||||
const lastSlip = slips
|
||||
.slice()
|
||||
.sort((a, b) => (b.occurred_on ?? "").localeCompare(a.occurred_on ?? ""))[0];
|
||||
// The reference point is the latest top-up; a card never topped up counts from its opening.
|
||||
const latest = (kind: string) =>
|
||||
cardRows
|
||||
.filter((row) => row.kind === kind)
|
||||
.map((row) => row.occurred_on ?? "")
|
||||
.sort()
|
||||
.pop();
|
||||
const since = latest("topup") ?? latest("opening") ?? "";
|
||||
const sinceTopup = slips.filter((row) => (row.occurred_on ?? "") >= since);
|
||||
return {
|
||||
userId: holder.id,
|
||||
holder: holderName(holder),
|
||||
|
|
@ -169,7 +176,7 @@ export async function loadCashFloatCards(pb: PocketBase, now: Date = new Date())
|
|||
limit,
|
||||
balance,
|
||||
spentThisMonth: thisMonth.reduce((total, row) => total + Math.abs(row.amount), 0),
|
||||
lastSlip: lastSlip ? { date: lastSlip.occurred_on ?? "", supplier: lastSlip.supplier_name ?? "" } : null,
|
||||
spentSinceTopup: sinceTopup.reduce((total, row) => total + Math.abs(row.amount), 0),
|
||||
low: isLow(balance, limit),
|
||||
hasOpening: cardRows.some((row) => row.kind === "opening"),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue