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,
|
limit: 100000,
|
||||||
balance: 234000,
|
balance: 234000,
|
||||||
spentThisMonth: 60000,
|
spentThisMonth: 60000,
|
||||||
lastSlip: null,
|
spentSinceTopup: 0,
|
||||||
low: false,
|
low: false,
|
||||||
hasOpening: true,
|
hasOpening: true,
|
||||||
...over,
|
...over,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const card = (over: Partial<CashFloatCard> = {}): CashFloatCard => ({
|
||||||
limit: 100000,
|
limit: 100000,
|
||||||
balance: 234000,
|
balance: 234000,
|
||||||
spentThisMonth: 60000,
|
spentThisMonth: 60000,
|
||||||
lastSlip: { date: "2026-10-20T08:00:00Z", supplier: "Build Supply" },
|
spentSinceTopup: 25000,
|
||||||
low: false,
|
low: false,
|
||||||
hasOpening: true,
|
hasOpening: true,
|
||||||
...over,
|
...over,
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ export function CashFloatsView({ cards, users }: { cards: CashFloatCard[]; users
|
||||||
<div className="mgmt-table-scroll">
|
<div className="mgmt-table-scroll">
|
||||||
<table className="mgmt-table mgmt-cash-float-table">
|
<table className="mgmt-table mgmt-cash-float-table">
|
||||||
<caption className="sr-only">Cash float cards, their holders and their balances</caption>
|
<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>
|
<tbody>
|
||||||
{!cards.length && <tr><td colSpan={8} className="text-left text-muted-foreground">No cards yet. Add one to get started.</td></tr>}
|
{!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 => (
|
{visible.map(card => (
|
||||||
|
|
@ -131,7 +131,7 @@ export function CashFloatsView({ cards, users }: { cards: CashFloatCard[]; users
|
||||||
<td>{card.assignedOn || "—"}</td>
|
<td>{card.assignedOn || "—"}</td>
|
||||||
<td className={card.low ? "font-semibold text-destructive" : ""}>{formatCents(card.balance)}</td>
|
<td className={card.low ? "font-semibold text-destructive" : ""}>{formatCents(card.balance)}</td>
|
||||||
<td>{formatCents(card.spentThisMonth)}</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>
|
<td>
|
||||||
{card.low
|
{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>
|
? <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);
|
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({
|
const { pb } = fakePb({
|
||||||
users: [holder()],
|
users: [holder()],
|
||||||
cash_float_log: [
|
cash_float_log: [
|
||||||
|
|
@ -149,7 +149,22 @@ describe("loadCashFloatCards", () => {
|
||||||
});
|
});
|
||||||
const [card] = await loadCashFloatCards(pb, new Date("2026-10-24T00:00:00Z"));
|
const [card] = await loadCashFloatCards(pb, new Date("2026-10-24T00:00:00Z"));
|
||||||
expect(card.spentThisMonth).toBe(60000);
|
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 () => {
|
it("leaves unticked users out", async () => {
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ export interface CashFloatCard {
|
||||||
balance: number;
|
balance: number;
|
||||||
/** What has been spent off the card this month, in cents, positive. */
|
/** What has been spent off the card this month, in cents, positive. */
|
||||||
spentThisMonth: number;
|
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;
|
low: boolean;
|
||||||
/** A card with no opening row has not been set up yet. */
|
/** A card with no opening row has not been set up yet. */
|
||||||
hasOpening: boolean;
|
hasOpening: boolean;
|
||||||
|
|
@ -140,7 +141,7 @@ export async function cardHeldBy(pb: PocketBase, userId: string): Promise<{ card
|
||||||
* Every card, lowest balance first.
|
* Every card, lowest balance first.
|
||||||
*
|
*
|
||||||
* The balance of every card comes from the one log read; the per-card
|
* 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[]> {
|
export async function loadCashFloatCards(pb: PocketBase, now: Date = new Date()): Promise<CashFloatCard[]> {
|
||||||
const [holders, logRows] = await Promise.all([loadHolders(pb), loadLogRows(pb)]);
|
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 limit = lowLimit(holder);
|
||||||
const slips = cardRows.filter((row) => row.kind === "slip");
|
const slips = cardRows.filter((row) => row.kind === "slip");
|
||||||
const thisMonth = slips.filter((row) => rowMonth(row) === month);
|
const thisMonth = slips.filter((row) => rowMonth(row) === month);
|
||||||
const lastSlip = slips
|
// The reference point is the latest top-up; a card never topped up counts from its opening.
|
||||||
.slice()
|
const latest = (kind: string) =>
|
||||||
.sort((a, b) => (b.occurred_on ?? "").localeCompare(a.occurred_on ?? ""))[0];
|
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 {
|
return {
|
||||||
userId: holder.id,
|
userId: holder.id,
|
||||||
holder: holderName(holder),
|
holder: holderName(holder),
|
||||||
|
|
@ -169,7 +176,7 @@ export async function loadCashFloatCards(pb: PocketBase, now: Date = new Date())
|
||||||
limit,
|
limit,
|
||||||
balance,
|
balance,
|
||||||
spentThisMonth: thisMonth.reduce((total, row) => total + Math.abs(row.amount), 0),
|
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),
|
low: isLow(balance, limit),
|
||||||
hasOpening: cardRows.some((row) => row.kind === "opening"),
|
hasOpening: cardRows.some((row) => row.kind === "opening"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue