Shared Expenses
index.jsx 212 lines
Files · 8
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212import "./style.css";
import React from "react";
import { Button } from "../../shared/ui.jsx";
import { createRoot } from "react-dom/client";
import { RecordApp, RecordActions } from "../../shared/records.jsx";
import { displayDate, money, totalsByCurrency } from "../../shared/format.js";
import { store } from "./store.js";
function groupLabel(id) {
const groups = [
...new Set(
store
.getSnapshot()
.rows.map((row) => row.group_id)
.filter((id) => id !== null),
),
].sort((a, b) => a - b);
return `Group ${groups.indexOf(id) + 1}`;
}
const config = {
slug: "expenses",
singular: "expense",
plural: "expenses",
navigationLabel: "Expense ledger",
subtitle: "Shared purchases and settlements.",
listLabel: "Ledger",
searchPlaceholder: "Search descriptions or groups…",
emptyTitle: "Start your expense ledger",
emptyDescription:
"Add an expense, or try a few sample purchases and a settlement.",
title: (row) => row.description,
search: (row) =>
`${row.description} ${row.details || ""} ${row.currency_code} ${row.group_id ?? ""}`,
sort: (a, b) => b.date.localeCompare(a.date) || b.id - a.id,
filters: (rows) => [
{ id: "all", label: "All entries", count: rows.length },
{
id: "purchases",
label: "Purchases",
count: rows.filter((row) => !row.payment).length,
},
{
id: "payments",
label: "Settlements",
count: rows.filter((row) => row.payment).length,
},
...[
...new Set(
rows.filter((row) => row.group_id !== null).map((row) => row.group_id),
),
]
.sort((a, b) => a - b)
.map((id) => ({
id: `group:${id}`,
label: groupLabel(id),
count: rows.filter((row) => row.group_id === id).length,
})),
],
matches: (row, filter) =>
filter === "purchases"
? row.payment === 0
: filter === "payments"
? row.payment === 1
: row.group_id === Number(filter.slice(6)),
stats: (rows) => [
{
label: "Purchases",
value: totalsByCurrency(rows, 0),
},
{
label: "Settlements",
value: totalsByCurrency(rows, 1),
},
{
label: "Groups",
value: new Set(
rows.filter((row) => row.group_id !== null).map((row) => row.group_id),
).size,
},
],
fields: [
{ key: "description", label: "Description", required: true, wide: true },
{
key: "cost",
label: "Amount",
required: true,
decimal: true,
placeholder: "42.50",
help: "Major currency units, with up to two decimal places.",
},
{
key: "currency_code",
label: "Currency",
required: true,
default: () => "USD",
placeholder: "USD",
help: "Three uppercase letters, such as USD or EUR.",
},
{
key: "date",
label: "Date and time",
required: true,
default: () => new Date().toISOString(),
help: "ISO 8601 occurrence time, including the timezone.",
},
{
key: "group_id",
label: "Source group ID",
advanced: true,
numeric: true,
help: "Leave blank for an ungrouped expense.",
},
{
key: "payment",
label: "Entry type",
options: [
{ value: 0, label: "Purchase" },
{ value: 1, label: "Settlement payment" },
],
default: () => 0,
},
{
key: "repeat_interval",
label: "Repeat interval",
options: ["never", "weekly", "fortnightly", "monthly", "yearly"],
default: () => "never",
help: "Records the interval; entries are added manually.",
},
{ key: "details", label: "Notes", multiline: true, wide: true },
{
key: "created_at",
label: "Created at",
advanced: true,
default: () => new Date().toISOString(),
},
{ key: "updated_at", label: "Updated at", advanced: true },
],
prepare: (data, previous) => {
if (previous && data.updated_at === previous.updated_at)
data.updated_at = new Date().toISOString();
return data;
},
renderCard: (row, actions) => (
<article className="ui-panel ui-record-card ra-ledger-row">
<div className="ra-expense-icon" aria-hidden="true">
{row.payment ? "↔" : "↗"}
</div>
<div className="ra-expense-body">
<h3 className="ra-card-title">{row.description}</h3>
<div className="ra-meta">
<span>{displayDate(row.date)}</span>
<span className="ra-dot">
{row.group_id !== null ? groupLabel(row.group_id) : "Ungrouped"}
</span>
{row.repeat_interval !== "never" && (
<span className="ui-badge">{row.repeat_interval}</span>
)}
</div>
{row.details && <p className="ra-expense-notes">{row.details}</p>}
</div>
<div className="ra-expense-amount">
<strong>{money(row.cost, row.currency_code)}</strong>
<small>{row.payment ? "Settlement payment" : "Purchase"}</small>
</div>
<RecordActions row={row} actions={actions} title={row.description} />
</article>
),
samples: [
{
cost: "68.40",
description: "Dinner at the neighborhood café",
date: "2026-10-14T19:30:00Z",
currency_code: "USD",
group_id: 24,
details: "Dinner for three after the film.",
},
{
cost: "42.50",
description: "Groceries for the apartment",
date: "2026-10-13T17:15:00Z",
currency_code: "USD",
group_id: 24,
details: "Breakfast supplies and a few pantry staples.",
},
{
cost: "35.00",
description: "October settlement",
date: "2026-10-12T10:00:00Z",
currency_code: "USD",
group_id: 24,
payment: 1,
details: "Recorded payment from Alex.",
},
{
cost: "24.00",
description: "Train tickets to the coast",
date: "2026-10-10T08:40:00Z",
currency_code: "EUR",
group_id: 31,
},
{
cost: "16.99",
description: "Shared music subscription",
date: "2026-10-08T09:00:00Z",
currency_code: "USD",
group_id: 24,
repeat_interval: "monthly",
},
],
};
createRoot(document.getElementById("root")).render(
<RecordApp store={store} config={config} />,
);