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} />,
);