Compare commits
1
Commits
main
..
e77d2d6b6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e77d2d6b6f |
No files matched your search
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM oven/bun:1-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS deps
|
FROM oven/bun:1-alpine AS deps
|
||||||
|
|
||||||
ENV TERM=xterm-256color
|
ENV TERM=xterm-256color
|
||||||
ENV COLORTERM=truecolor
|
ENV COLORTERM=truecolor
|
||||||
@@ -19,7 +19,7 @@ RUN bun --bun svelte-kit sync
|
|||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
|
|
||||||
FROM oven/bun:1-alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373 AS runtime
|
FROM oven/bun:1-alpine AS runtime
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
"@biomejs/biome": "^2.5.15",
|
"@biomejs/biome": "^2.5.15",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
|
||||||
"@fontsource-variable/source-sans-3": "^5.3.1",
|
|
||||||
"@internationalized/date": "^3.12.0",
|
"@internationalized/date": "^3.12.0",
|
||||||
"@lucide/svelte": "^1.23.0",
|
"@lucide/svelte": "^1.23.0",
|
||||||
"@sveltejs/adapter-static": "^4.0.0",
|
"@sveltejs/adapter-static": "^4.0.0",
|
||||||
@@ -86,10 +84,6 @@
|
|||||||
|
|
||||||
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="],
|
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="],
|
||||||
|
|
||||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
|
|
||||||
|
|
||||||
"@fontsource-variable/source-sans-3": ["@fontsource-variable/source-sans-3@5.3.1", "", {}, "sha512-u0p5Z/PM2e1uxYOMiodLM6NVzxHwULcB6Oi7QSr2WU/sdoLBFP/BeOZ3YYHeJ6rHsjPZA1nZweZVaV7sf2MAyQ=="],
|
|
||||||
|
|
||||||
"@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="],
|
"@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ services:
|
|||||||
command: [run, serve.ts]
|
command: [run, serve.ts]
|
||||||
|
|
||||||
frontend-dev:
|
frontend-dev:
|
||||||
image: oven/bun:alpine@sha256:629e17411f1f129dbec3af78d5af9c9f2a937435c80349437206c6b0b7422373
|
image: oven/bun:alpine
|
||||||
profiles: [frontend-dev]
|
profiles: [frontend-dev]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|||||||
@@ -21,8 +21,6 @@
|
|||||||
"@biomejs/biome": "^2.5.15",
|
"@biomejs/biome": "^2.5.15",
|
||||||
"@fontsource-variable/geist": "^5.3.0",
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
|
||||||
"@fontsource-variable/source-sans-3": "^5.3.1",
|
|
||||||
"@internationalized/date": "^3.12.0",
|
"@internationalized/date": "^3.12.0",
|
||||||
"@lucide/svelte": "^1.23.0",
|
"@lucide/svelte": "^1.23.0",
|
||||||
"@sveltejs/adapter-static": "^4.0.0",
|
"@sveltejs/adapter-static": "^4.0.0",
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import { addMonths, daysBetween, today } from "#lib/dates.js";
|
|
||||||
import { formatMoney, toNumber } from "#lib/format.js";
|
|
||||||
|
|
||||||
export interface Movement {
|
|
||||||
amount: number;
|
|
||||||
counter: { id: string; name: string; type: string };
|
|
||||||
date: string;
|
|
||||||
description: string;
|
|
||||||
group: string;
|
|
||||||
into: boolean;
|
|
||||||
opening: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Month {
|
|
||||||
month: string;
|
|
||||||
received: number;
|
|
||||||
sent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Stance = "owed" | "owe" | "settled";
|
|
||||||
|
|
||||||
const OPENING = new Set(["opening balance", "liability credit"]);
|
|
||||||
const ASSET = "Asset account";
|
|
||||||
const DUST = 0.005;
|
|
||||||
|
|
||||||
export const isDebt = (item: Item<Account>) =>
|
|
||||||
item.attributes.liability_type === "debt";
|
|
||||||
|
|
||||||
export function owed(account: Account): number {
|
|
||||||
const debt = toNumber(account.debt_amount);
|
|
||||||
return account.liability_direction === "debit" ? -debt : debt;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stance(account: Account): Stance {
|
|
||||||
const position = owed(account);
|
|
||||||
if (position > DUST) {
|
|
||||||
return "owed";
|
|
||||||
}
|
|
||||||
return position < -DUST ? "owe" : "settled";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const overpaid = (account: Account) =>
|
|
||||||
toNumber(account.debt_amount) < -DUST;
|
|
||||||
|
|
||||||
export function movements(
|
|
||||||
account: Item<Account>,
|
|
||||||
groups: Item<TransactionGroup>[]
|
|
||||||
): Movement[] {
|
|
||||||
const { id } = account;
|
|
||||||
const code = account.attributes.currency_code;
|
|
||||||
const list: Movement[] = [];
|
|
||||||
for (const group of groups) {
|
|
||||||
for (const split of group.attributes.transactions) {
|
|
||||||
const into = split.destination_id === id;
|
|
||||||
if (!(into || split.source_id === id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const foreign =
|
|
||||||
split.currency_code !== code && split.foreign_currency_code === code;
|
|
||||||
const side = into ? "source" : "destination";
|
|
||||||
list.push({
|
|
||||||
amount: Math.abs(
|
|
||||||
toNumber(foreign ? split.foreign_amount : split.amount)
|
|
||||||
),
|
|
||||||
counter: {
|
|
||||||
id: split[`${side}_id`] ?? "",
|
|
||||||
name: split[`${side}_name`] ?? "",
|
|
||||||
type: split[`${side}_type`] ?? "",
|
|
||||||
},
|
|
||||||
date: split.date.slice(0, 10),
|
|
||||||
description: split.description,
|
|
||||||
group: group.id,
|
|
||||||
into,
|
|
||||||
opening: OPENING.has(split.type),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return list.sort((a, b) => b.date.localeCompare(a.date));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function averageDays(list: Movement[], credit: boolean): number | null {
|
|
||||||
const open: { amount: number; date: string }[] = [];
|
|
||||||
let weighted = 0;
|
|
||||||
let total = 0;
|
|
||||||
for (const m of list.toReversed()) {
|
|
||||||
if (m.into === credit) {
|
|
||||||
open.push({ amount: m.amount, date: m.date });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let left = m.amount;
|
|
||||||
while (left > DUST && open.length > 0) {
|
|
||||||
const [head] = open;
|
|
||||||
const used = Math.min(left, head.amount);
|
|
||||||
weighted += used * daysBetween(head.date, m.date);
|
|
||||||
total += used;
|
|
||||||
left -= used;
|
|
||||||
head.amount -= used;
|
|
||||||
if (head.amount < DUST) {
|
|
||||||
open.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total > 0 ? Math.round(weighted / total) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function months(list: Movement[], count = 12): Month[] {
|
|
||||||
const first = addMonths(`${today().slice(0, 7)}-01`, 1 - count);
|
|
||||||
const buckets = Array.from({ length: count }, (_, i) => ({
|
|
||||||
month: addMonths(first, i).slice(0, 7),
|
|
||||||
received: 0,
|
|
||||||
sent: 0,
|
|
||||||
}));
|
|
||||||
const index = new Map(buckets.map((b, i) => [b.month, i]));
|
|
||||||
for (const m of list) {
|
|
||||||
const bucket = buckets[index.get(m.date.slice(0, 7)) ?? -1];
|
|
||||||
if (!bucket || m.opening) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (m.into) {
|
|
||||||
bucket.sent += m.amount;
|
|
||||||
} else {
|
|
||||||
bucket.received += m.amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buckets;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createUrl = (type: string, query: Record<string, string | undefined>) =>
|
|
||||||
`/transactions/create/${type}?${new URLSearchParams(
|
|
||||||
Object.entries(query).filter((entry): entry is [string, string] =>
|
|
||||||
Boolean(entry[1])
|
|
||||||
)
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
function counterAsset(
|
|
||||||
list: Movement[],
|
|
||||||
assets: Item<Account>[],
|
|
||||||
code: string | undefined
|
|
||||||
): string | undefined {
|
|
||||||
const usable = assets
|
|
||||||
.filter((a) => a.attributes.active && a.attributes.currency_code === code)
|
|
||||||
.map((a) => a.id);
|
|
||||||
return (
|
|
||||||
list.find((m) => usable.includes(m.counter.id))?.counter.id ?? usable[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastSaid = (list: Movement[], into: boolean) =>
|
|
||||||
list.find((m) => m.into === into && m.counter.type === ASSET)?.description;
|
|
||||||
|
|
||||||
const plain = (account: Account, value: number) =>
|
|
||||||
Math.abs(value).toFixed(account.currency_decimal_places ?? 2);
|
|
||||||
|
|
||||||
const money = (account: Account, value: number) =>
|
|
||||||
formatMoney({
|
|
||||||
amount: Math.abs(value),
|
|
||||||
code: account.currency_code,
|
|
||||||
decimals: account.currency_decimal_places,
|
|
||||||
symbol: account.currency_symbol,
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface Action {
|
|
||||||
hint: string;
|
|
||||||
href: string;
|
|
||||||
label: string;
|
|
||||||
line?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Actions {
|
|
||||||
more: Action;
|
|
||||||
offset: Action | null;
|
|
||||||
repay: Action | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Move {
|
|
||||||
amount?: string;
|
|
||||||
asset?: string;
|
|
||||||
fallback: string;
|
|
||||||
label: string;
|
|
||||||
toThem: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveAction(item: Item<Account>, list: Movement[], move: Move) {
|
|
||||||
const a = item.attributes;
|
|
||||||
const said = overpaid(a) ? undefined : lastSaid(list, move.toThem);
|
|
||||||
return {
|
|
||||||
hint: `${move.label}: ${a.name}`,
|
|
||||||
href: createUrl(move.toThem ? "withdrawal" : "deposit", {
|
|
||||||
amount: move.amount,
|
|
||||||
description: said ?? move.fallback,
|
|
||||||
destination: move.toThem ? item.id : move.asset,
|
|
||||||
source: move.toThem ? move.asset : item.id,
|
|
||||||
}),
|
|
||||||
label: move.label,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function repayAction(item: Item<Account>, list: Movement[], asset?: string) {
|
|
||||||
const a = item.attributes;
|
|
||||||
const side = stance(a);
|
|
||||||
if (side === "settled") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const toThem = side === "owe";
|
|
||||||
return moveAction(item, list, {
|
|
||||||
amount: plain(a, owed(a)),
|
|
||||||
asset,
|
|
||||||
fallback: toThem ? "Paid back" : "Repayment",
|
|
||||||
label: toThem ? "Pay back" : "Record repayment",
|
|
||||||
toThem,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function moreAction(item: Item<Account>, list: Movement[], asset?: string) {
|
|
||||||
const a = item.attributes;
|
|
||||||
const side = stance(a);
|
|
||||||
const toThem =
|
|
||||||
side === "settled" ? a.liability_direction !== "debit" : side === "owed";
|
|
||||||
return moveAction(item, list, {
|
|
||||||
asset,
|
|
||||||
fallback: toThem ? "Lent" : "Borrowed",
|
|
||||||
label: toThem ? "Lend more" : "Borrow more",
|
|
||||||
toThem,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function offsetAction(item: Item<Account>, debts: Item<Account>[]) {
|
|
||||||
const a = item.attributes;
|
|
||||||
const position = owed(a);
|
|
||||||
const [other] = debts
|
|
||||||
.filter(
|
|
||||||
(d) =>
|
|
||||||
d.attributes.currency_code === a.currency_code &&
|
|
||||||
owed(d.attributes) * position < 0
|
|
||||||
)
|
|
||||||
.sort(
|
|
||||||
(x, y) => Math.abs(owed(y.attributes)) - Math.abs(owed(x.attributes))
|
|
||||||
);
|
|
||||||
if (!other) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const amount = Math.min(Math.abs(position), Math.abs(owed(other.attributes)));
|
|
||||||
const [from, to] = position > 0 ? [item, other] : [other, item];
|
|
||||||
const label = "Offset against another debt";
|
|
||||||
return {
|
|
||||||
hint: `${label}: ${a.name}`,
|
|
||||||
href: createUrl("transfer", {
|
|
||||||
amount: plain(a, amount),
|
|
||||||
description: `Offset ${from.attributes.name} against ${to.attributes.name}`,
|
|
||||||
destination: to.id,
|
|
||||||
source: from.id,
|
|
||||||
}),
|
|
||||||
label,
|
|
||||||
line: `Moves ${money(a, amount)} from ${from.attributes.name} to ${to.attributes.name} so both debts shrink.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function actionsFor(
|
|
||||||
item: Item<Account>,
|
|
||||||
list: Movement[],
|
|
||||||
assets: Item<Account>[],
|
|
||||||
debts: Item<Account>[]
|
|
||||||
): Actions {
|
|
||||||
const asset = counterAsset(list, assets, item.attributes.currency_code);
|
|
||||||
return {
|
|
||||||
more: moreAction(item, list, asset),
|
|
||||||
offset: offsetAction(item, debts),
|
|
||||||
repay: repayAction(item, list, asset),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
|
||||||
import { lists } from "#lib/api/lists.js";
|
|
||||||
import type { TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import { today } from "#lib/dates.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
|
||||||
import type { Flow } from "#lib/reports/flows.js";
|
|
||||||
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
|
||||||
import { pages } from "#lib/reports/queries.js";
|
|
||||||
import { impliedRates } from "#lib/reports/rates.js";
|
|
||||||
import { typicalMonths } from "#lib/reports/typical.js";
|
|
||||||
import { session } from "#lib/session.svelte.js";
|
|
||||||
import { board, type Kind, monthDay } from "./merchants.js";
|
|
||||||
|
|
||||||
export function useMerchants(kind: () => Kind) {
|
|
||||||
const now = today();
|
|
||||||
const current = ledgerWindow(now);
|
|
||||||
const anchor = $derived(period.end >= current.start ? now : period.end);
|
|
||||||
const window = $derived(ledgerWindow(anchor));
|
|
||||||
const inside = $derived(
|
|
||||||
period.start >= window.start && period.end <= window.end
|
|
||||||
);
|
|
||||||
|
|
||||||
const accounts = createQuery(() => lists.accounts("all"));
|
|
||||||
const ledger = createQuery(() => ({
|
|
||||||
...ledgerQuery(anchor),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
}));
|
|
||||||
const extra = createQuery(() => ({
|
|
||||||
enabled: !inside,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
queryFn: () =>
|
|
||||||
pages<TransactionGroup>("/v1/transactions", {
|
|
||||||
end: period.end,
|
|
||||||
start: period.start,
|
|
||||||
type: "default",
|
|
||||||
}),
|
|
||||||
queryKey: ["merchants-period", period.start, period.end],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const own = $derived(
|
|
||||||
ledger.data && accounts.data ? ownFlows(ledger.data, accounts.data) : []
|
|
||||||
);
|
|
||||||
const ownPeriod = $derived.by(() => {
|
|
||||||
if (inside) {
|
|
||||||
return own;
|
|
||||||
}
|
|
||||||
return extra.data && accounts.data
|
|
||||||
? ownFlows(extra.data, accounts.data)
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
const rates = $derived.by(() => {
|
|
||||||
if (!display.active) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
for (const code of new Set([...own, ...ownPeriod].map((f) => f.currency))) {
|
|
||||||
const rate = display.rate(code);
|
|
||||||
if (rate === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
map.set(code, rate);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
const merge = $derived(rates ? display.code : null);
|
|
||||||
const inTarget = (list: Flow[]) =>
|
|
||||||
rates
|
|
||||||
? list.map((flow) => ({
|
|
||||||
...flow,
|
|
||||||
amount: flow.amount * (rates.get(flow.currency) ?? 0),
|
|
||||||
}))
|
|
||||||
: list;
|
|
||||||
const flows = $derived(inTarget(own));
|
|
||||||
const periodFlows = $derived(inside ? flows : inTarget(ownPeriod));
|
|
||||||
const implied = $derived(impliedRates(ledger.data ?? []));
|
|
||||||
const day = $derived(monthDay(period.range, now));
|
|
||||||
const result = $derived.by(() => {
|
|
||||||
if (!(accounts.data && ledger.data && (inside || extra.data))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const month = period.end.slice(0, 7);
|
|
||||||
const since = flows.reduce(
|
|
||||||
(first, flow) => (flow.date < first ? flow.date : first),
|
|
||||||
now
|
|
||||||
);
|
|
||||||
const base = merge ?? session.primary?.code ?? "";
|
|
||||||
return board(accounts.data, flows, periodFlows, {
|
|
||||||
day,
|
|
||||||
kind: kind(),
|
|
||||||
merge,
|
|
||||||
months: window.months.filter((m) => m <= month),
|
|
||||||
past: typicalMonths(window.months, period.end, since),
|
|
||||||
range: period.range,
|
|
||||||
rate: (code) => implied(code, base) ?? 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
get board() {
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
get day() {
|
|
||||||
return day;
|
|
||||||
},
|
|
||||||
get error() {
|
|
||||||
return accounts.error ?? ledger.error ?? extra.error ?? null;
|
|
||||||
},
|
|
||||||
get loading() {
|
|
||||||
return result === null;
|
|
||||||
},
|
|
||||||
get stale() {
|
|
||||||
return ledger.isFetching || extra.isPlaceholderData;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
import type { Account, Item } from "#lib/api/types.js";
|
|
||||||
import type { Range } from "#lib/dates.js";
|
|
||||||
import type { Flow } from "#lib/reports/flows.js";
|
|
||||||
import { typical, typicalToDay, versus } from "#lib/reports/typical.js";
|
|
||||||
import type { Targets } from "#lib/transactions/mass.js";
|
|
||||||
|
|
||||||
export type Kind = "expense" | "revenue";
|
|
||||||
export type SortKey = "month" | "year" | "visits" | "last" | "name";
|
|
||||||
|
|
||||||
export const SORT_KEYS: SortKey[] = ["month", "year", "visits", "last", "name"];
|
|
||||||
export const SPARK_MONTHS = 12;
|
|
||||||
const TOP = 5;
|
|
||||||
|
|
||||||
export interface Line {
|
|
||||||
amount: number;
|
|
||||||
approx: boolean;
|
|
||||||
base: number;
|
|
||||||
code: string;
|
|
||||||
months: number[];
|
|
||||||
ratio: number | null;
|
|
||||||
seen: number;
|
|
||||||
typical: number;
|
|
||||||
visits: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Merchant {
|
|
||||||
category: string | null;
|
|
||||||
fresh: boolean;
|
|
||||||
id: string;
|
|
||||||
last: string;
|
|
||||||
lines: Line[];
|
|
||||||
name: string;
|
|
||||||
others: number;
|
|
||||||
quiet: boolean;
|
|
||||||
share: number;
|
|
||||||
targets: Targets;
|
|
||||||
value: number;
|
|
||||||
visits: number;
|
|
||||||
year: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Total {
|
|
||||||
amount: number;
|
|
||||||
approx: boolean;
|
|
||||||
base: number;
|
|
||||||
code: string;
|
|
||||||
ratio: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Section {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
merchants: Merchant[];
|
|
||||||
share: number;
|
|
||||||
sums: Total[];
|
|
||||||
visits: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Board {
|
|
||||||
fresh: number;
|
|
||||||
merchants: Merchant[];
|
|
||||||
past: number;
|
|
||||||
top: number;
|
|
||||||
totals: Total[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Context {
|
|
||||||
day: number | null;
|
|
||||||
kind: Kind;
|
|
||||||
merge: string | null;
|
|
||||||
months: string[];
|
|
||||||
past: string[];
|
|
||||||
range: Range;
|
|
||||||
rate: (code: string) => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
|
|
||||||
const foreign = (flows: Flow[], code: string) =>
|
|
||||||
flows.some((flow) => flow.currency !== code);
|
|
||||||
|
|
||||||
function monthly(flows: Flow[]): Map<string, number> {
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
for (const flow of flows) {
|
|
||||||
const month = flow.date.slice(0, 7);
|
|
||||||
map.set(month, (map.get(month) ?? 0) + flow.amount);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lineOf(code: string, year: Flow[], now: Flow[], ctx: Context): Line {
|
|
||||||
const byMonth = monthly(year);
|
|
||||||
const amount = sum(now.map((flow) => flow.amount));
|
|
||||||
const base = ctx.day === null ? 0 : typicalToDay(year, ctx.past, ctx.day);
|
|
||||||
return {
|
|
||||||
amount,
|
|
||||||
approx: foreign(year, code) || foreign(now, code),
|
|
||||||
base,
|
|
||||||
code,
|
|
||||||
months: ctx.months.slice(-SPARK_MONTHS).map((m) => byMonth.get(m) ?? 0),
|
|
||||||
ratio: ctx.day === null ? null : versus(amount, base),
|
|
||||||
seen: ctx.past.filter((m) => byMonth.has(m)).length,
|
|
||||||
typical: typical(byMonth, ctx.past),
|
|
||||||
visits: now.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function dominant(flows: Flow[]): [string | null, number] {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const flow of flows) {
|
|
||||||
const name = flow.category?.name ?? "";
|
|
||||||
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
const [top] = [...counts].sort((a, b) => b[1] - a[1]);
|
|
||||||
return [top?.[0] || null, Math.max(0, counts.size - 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function targetsOf(flows: Flow[]): Targets {
|
|
||||||
const targets: Targets = new Map();
|
|
||||||
for (const flow of flows) {
|
|
||||||
const set = targets.get(flow.group) ?? new Set<string>();
|
|
||||||
set.add(flow.journal);
|
|
||||||
targets.set(flow.group, set);
|
|
||||||
}
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bucket<T>(items: T[], key: (item: T) => string): Map<string, T[]> {
|
|
||||||
const map = new Map<string, T[]>();
|
|
||||||
for (const item of items) {
|
|
||||||
const list = map.get(key(item));
|
|
||||||
if (list) {
|
|
||||||
list.push(item);
|
|
||||||
} else {
|
|
||||||
map.set(key(item), [item]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeOf = (ctx: Context) => (flow: Flow) => ctx.merge ?? flow.currency;
|
|
||||||
|
|
||||||
const latest = (flows: Flow[]) =>
|
|
||||||
flows.reduce((last, flow) => (flow.date > last ? flow.date : last), "");
|
|
||||||
|
|
||||||
function merchantOf(
|
|
||||||
id: string,
|
|
||||||
name: string,
|
|
||||||
seen: string,
|
|
||||||
year: Flow[],
|
|
||||||
now: Flow[],
|
|
||||||
ctx: Context
|
|
||||||
): Omit<Merchant, "share"> {
|
|
||||||
const yearBy = bucket(year, codeOf(ctx));
|
|
||||||
const nowBy = bucket(now, codeOf(ctx));
|
|
||||||
const codes = new Set([...yearBy.keys(), ...nowBy.keys()]);
|
|
||||||
const quiet = now.length === 0;
|
|
||||||
const lines = [...codes]
|
|
||||||
.map((code) =>
|
|
||||||
lineOf(code, yearBy.get(code) ?? [], nowBy.get(code) ?? [], ctx)
|
|
||||||
)
|
|
||||||
.filter((line) => (quiet ? sum(line.months) > 0 : line.amount > 0))
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
b.amount * ctx.rate(b.code) - a.amount * ctx.rate(a.code) ||
|
|
||||||
sum(b.months) * ctx.rate(b.code) - sum(a.months) * ctx.rate(a.code)
|
|
||||||
);
|
|
||||||
const [category, others] = dominant(quiet ? year : now);
|
|
||||||
const before = year.filter((flow) => flow.date < ctx.range.start);
|
|
||||||
return {
|
|
||||||
category,
|
|
||||||
fresh: !quiet && before.length === 0,
|
|
||||||
id,
|
|
||||||
last: latest(now) || seen || latest(year),
|
|
||||||
lines,
|
|
||||||
name,
|
|
||||||
others,
|
|
||||||
quiet,
|
|
||||||
targets: targetsOf(now),
|
|
||||||
value: sum(lines.map((line) => line.amount * ctx.rate(line.code))),
|
|
||||||
visits: now.length,
|
|
||||||
year: sum(lines.map((line) => sum(line.months) * ctx.rate(line.code))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function board(
|
|
||||||
accounts: Item<Account>[],
|
|
||||||
flows: Flow[],
|
|
||||||
periodFlows: Flow[],
|
|
||||||
ctx: Context
|
|
||||||
): Board {
|
|
||||||
const dir = ctx.kind === "expense" ? -1 : 1;
|
|
||||||
const inRange = (flow: Flow) =>
|
|
||||||
flow.date >= ctx.range.start && flow.date <= ctx.range.end;
|
|
||||||
const mine = flows.filter((flow) => flow.dir === dir);
|
|
||||||
const now = periodFlows.filter((flow) => flow.dir === dir && inRange(flow));
|
|
||||||
const yearBy = bucket(mine, (flow) => flow.other.id);
|
|
||||||
const nowBy = bucket(now, (flow) => flow.other.id);
|
|
||||||
const names = new Map(
|
|
||||||
[...mine, ...now].map((flow) => [flow.other.id, flow.other.name])
|
|
||||||
);
|
|
||||||
const seen = new Map<string, string>();
|
|
||||||
for (const { id, attributes: a } of accounts) {
|
|
||||||
if (a.type === ctx.kind && (a.active !== false || names.has(id))) {
|
|
||||||
names.set(id, a.name);
|
|
||||||
seen.set(id, a.last_activity?.slice(0, 10) ?? "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const drafts = [...names].map(([id, name]) =>
|
|
||||||
merchantOf(
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
seen.get(id) ?? "",
|
|
||||||
yearBy.get(id) ?? [],
|
|
||||||
nowBy.get(id) ?? [],
|
|
||||||
ctx
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const total = sum(drafts.map((m) => m.value)) || 1;
|
|
||||||
const merchants = drafts
|
|
||||||
.map((m) => ({ ...m, share: m.value / total }))
|
|
||||||
.sort(sortBy("month"));
|
|
||||||
return {
|
|
||||||
fresh: merchants.filter((m) => m.fresh).length,
|
|
||||||
merchants,
|
|
||||||
past: ctx.past.length,
|
|
||||||
top: sum(merchants.slice(0, TOP).map((m) => m.share)),
|
|
||||||
totals: totalsOf(mine, now, ctx),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function totalsOf(year: Flow[], now: Flow[], ctx: Context): Total[] {
|
|
||||||
const nowBy = bucket(now, codeOf(ctx));
|
|
||||||
const yearBy = bucket(year, codeOf(ctx));
|
|
||||||
return [...nowBy]
|
|
||||||
.map(([code, list]) => {
|
|
||||||
const amount = sum(list.map((flow) => flow.amount));
|
|
||||||
const base =
|
|
||||||
ctx.day === null
|
|
||||||
? 0
|
|
||||||
: typicalToDay(yearBy.get(code) ?? [], ctx.past, ctx.day);
|
|
||||||
return {
|
|
||||||
amount,
|
|
||||||
approx: foreign(list, code),
|
|
||||||
base,
|
|
||||||
code,
|
|
||||||
ratio: ctx.day === null ? null : versus(amount, base),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.amount * ctx.rate(b.code) - a.amount * ctx.rate(a.code));
|
|
||||||
}
|
|
||||||
|
|
||||||
const ORDER: Record<SortKey, (a: Merchant, b: Merchant) => number> = {
|
|
||||||
last: (a, b) => b.last.localeCompare(a.last),
|
|
||||||
month: (a, b) => b.value - a.value,
|
|
||||||
name: (a, b) => a.name.localeCompare(b.name),
|
|
||||||
visits: (a, b) => b.visits - a.visits,
|
|
||||||
year: (a, b) => b.year - a.year,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sortBy =
|
|
||||||
(key: SortKey) =>
|
|
||||||
(a: Merchant, b: Merchant): number =>
|
|
||||||
ORDER[key](a, b) || ORDER.last(a, b) || ORDER.name(a, b);
|
|
||||||
|
|
||||||
export function sumLines(merchants: Merchant[]): Total[] {
|
|
||||||
const map = new Map<string, Total>();
|
|
||||||
for (const line of merchants.flatMap((m) => m.lines)) {
|
|
||||||
const total = map.get(line.code) ?? {
|
|
||||||
amount: 0,
|
|
||||||
approx: false,
|
|
||||||
base: 0,
|
|
||||||
code: line.code,
|
|
||||||
ratio: null,
|
|
||||||
};
|
|
||||||
total.amount += line.amount;
|
|
||||||
total.approx ||= line.approx;
|
|
||||||
map.set(line.code, total);
|
|
||||||
}
|
|
||||||
return [...map.values()].filter((total) => total.amount > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function byCategory(merchants: Merchant[], none: string): Section[] {
|
|
||||||
return [...bucket(merchants, (m) => m.category ?? "")]
|
|
||||||
.map(([key, list]) => ({
|
|
||||||
key: key || "none",
|
|
||||||
label: key || none,
|
|
||||||
merchants: list,
|
|
||||||
share: sum(list.map((m) => m.share)),
|
|
||||||
sums: sumLines(list),
|
|
||||||
visits: sum(list.map((m) => m.visits)),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.share - a.share || a.label.localeCompare(b.label));
|
|
||||||
}
|
|
||||||
|
|
||||||
const WHOLE_MONTH = 31;
|
|
||||||
|
|
||||||
export function monthDay(range: Range, now: string): number | null {
|
|
||||||
const month = range.start.slice(0, 7);
|
|
||||||
const last = new Date(
|
|
||||||
Number(month.slice(0, 4)),
|
|
||||||
Number(month.slice(5, 7)),
|
|
||||||
0
|
|
||||||
).getDate();
|
|
||||||
const whole =
|
|
||||||
range.start === `${month}-01` && range.end === `${month}-${last}`;
|
|
||||||
if (!whole || month > now.slice(0, 7)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return month === now.slice(0, 7) ? Number(now.slice(8, 10)) : WHOLE_MONTH;
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import {
|
|
||||||
addDays,
|
|
||||||
addMonths,
|
|
||||||
contains,
|
|
||||||
eachDay,
|
|
||||||
type Range,
|
|
||||||
} from "#lib/dates.js";
|
|
||||||
import {
|
|
||||||
compactMoney,
|
|
||||||
formatMoney,
|
|
||||||
type Money,
|
|
||||||
toNumber,
|
|
||||||
} from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
|
|
||||||
export type Flow =
|
|
||||||
| "income"
|
|
||||||
| "movedIn"
|
|
||||||
| "spent"
|
|
||||||
| "movedOut"
|
|
||||||
| "opening"
|
|
||||||
| "adjusted";
|
|
||||||
export type Side = "asset" | "credit" | "debit";
|
|
||||||
export type Tone = "income" | "expense" | "transfer" | "neutral";
|
|
||||||
|
|
||||||
export interface Move {
|
|
||||||
amount: number;
|
|
||||||
date: string;
|
|
||||||
flow: Flow;
|
|
||||||
reconciled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Anchor {
|
|
||||||
balance: number;
|
|
||||||
date: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Term {
|
|
||||||
flow: Flow;
|
|
||||||
label: string;
|
|
||||||
tone: Tone;
|
|
||||||
up: boolean;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Part = Omit<Term, "up">;
|
|
||||||
|
|
||||||
export type Sums = Record<Flow, number>;
|
|
||||||
|
|
||||||
export const MONTHS = 13;
|
|
||||||
export const FOUR: Flow[] = ["income", "movedIn", "spent", "movedOut"];
|
|
||||||
const FLOWS: Flow[] = [...FOUR, "opening", "adjusted"];
|
|
||||||
const REAL = new Set(["withdrawal", "deposit"]);
|
|
||||||
const OPENING = new Set(["opening balance", "liability credit"]);
|
|
||||||
const CENT = 0.005;
|
|
||||||
const PRECISION = 1e6;
|
|
||||||
|
|
||||||
const clean = (value: number) => Math.round(value * PRECISION) / PRECISION;
|
|
||||||
|
|
||||||
const WORDS: Record<Side, { into: string; out: string }> = {
|
|
||||||
asset: { into: "Income", out: "Spent" },
|
|
||||||
credit: { into: "Lent", out: "Got back" },
|
|
||||||
debit: { into: "Paid back", out: "Borrowed" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sideOf = (account: Account, liability: boolean): Side => {
|
|
||||||
if (!liability) {
|
|
||||||
return "asset";
|
|
||||||
}
|
|
||||||
return account.liability_direction === "debit" ? "debit" : "credit";
|
|
||||||
};
|
|
||||||
|
|
||||||
export const signOf = (side: Side) => (side === "debit" ? -1 : 1);
|
|
||||||
|
|
||||||
function flowOf(type: string, into: boolean): Flow {
|
|
||||||
if (type === "transfer") {
|
|
||||||
return into ? "movedIn" : "movedOut";
|
|
||||||
}
|
|
||||||
if (REAL.has(type)) {
|
|
||||||
return into ? "income" : "spent";
|
|
||||||
}
|
|
||||||
return OPENING.has(type) ? "opening" : "adjusted";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moves(
|
|
||||||
id: string,
|
|
||||||
code: string | null | undefined,
|
|
||||||
groups: Item<TransactionGroup>[]
|
|
||||||
): Move[] {
|
|
||||||
const list: Move[] = [];
|
|
||||||
for (const group of groups) {
|
|
||||||
for (const split of group.attributes.transactions) {
|
|
||||||
const into = split.destination_id === id;
|
|
||||||
if (!(into || split.source_id === id)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const foreign =
|
|
||||||
split.currency_code !== code && split.foreign_currency_code === code;
|
|
||||||
const amount = Math.abs(
|
|
||||||
toNumber(foreign ? split.foreign_amount : split.amount)
|
|
||||||
);
|
|
||||||
list.push({
|
|
||||||
amount: into ? amount : -amount,
|
|
||||||
date: split.date.slice(0, 10),
|
|
||||||
flow: flowOf(split.type, into),
|
|
||||||
reconciled: Boolean(split.reconciled),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return list.sort((a, b) => a.date.localeCompare(b.date));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sums(list: Move[], range: Range) {
|
|
||||||
const total = Object.fromEntries(FLOWS.map((f) => [f, 0])) as Sums;
|
|
||||||
let rows = 0;
|
|
||||||
for (const m of list) {
|
|
||||||
if (contains(range, m.date)) {
|
|
||||||
total[m.flow] += m.amount;
|
|
||||||
rows += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const flow of FLOWS) {
|
|
||||||
total[flow] = clean(total[flow]);
|
|
||||||
}
|
|
||||||
return { rows, total };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function balanceAt(list: Move[], anchor: Anchor, day: string): number {
|
|
||||||
let value = anchor.balance;
|
|
||||||
for (const m of list) {
|
|
||||||
if (m.date > anchor.date && m.date <= day) {
|
|
||||||
value += m.amount;
|
|
||||||
} else if (m.date <= anchor.date && m.date > day) {
|
|
||||||
value -= m.amount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return clean(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function daily(list: Move[], anchor: Anchor, range: Range) {
|
|
||||||
const byDay = new Map<string, number>();
|
|
||||||
for (const m of list) {
|
|
||||||
byDay.set(m.date, (byDay.get(m.date) ?? 0) + m.amount);
|
|
||||||
}
|
|
||||||
let value = balanceAt(list, anchor, addDays(range.start, -1));
|
|
||||||
return eachDay(range).map((day) => {
|
|
||||||
value = clean(value + (byDay.get(day) ?? 0));
|
|
||||||
return { x: day, y: value };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function monthsOf(range: Range, list: Move[]) {
|
|
||||||
return Array.from({ length: MONTHS }, (_, i) => {
|
|
||||||
const start = addMonths(range.start, i);
|
|
||||||
return {
|
|
||||||
month: start,
|
|
||||||
total: sums(list, { end: addDays(addMonths(start, 1), -1), start }).total,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function chartRange(period: Range, now: string): Range {
|
|
||||||
const current = `${now.slice(0, 7)}-01`;
|
|
||||||
const asked = `${period.end.slice(0, 7)}-01`;
|
|
||||||
const recent = asked <= current && asked >= addMonths(current, 1 - MONTHS);
|
|
||||||
const last = recent ? current : asked;
|
|
||||||
return {
|
|
||||||
end: addDays(addMonths(last, 1), -1),
|
|
||||||
start: addMonths(last, 1 - MONTHS),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function terms(side: Side, total: Sums): Term[] {
|
|
||||||
const s = signOf(side);
|
|
||||||
const words = WORDS[side];
|
|
||||||
const asset = side === "asset";
|
|
||||||
const into: Part = {
|
|
||||||
flow: "income",
|
|
||||||
label: words.into,
|
|
||||||
tone: asset ? "income" : "expense",
|
|
||||||
value: s * total.income,
|
|
||||||
};
|
|
||||||
const movedIn: Part = {
|
|
||||||
flow: "movedIn",
|
|
||||||
label: "Moved in",
|
|
||||||
tone: "transfer",
|
|
||||||
value: s * total.movedIn,
|
|
||||||
};
|
|
||||||
const out: Part = {
|
|
||||||
flow: "spent",
|
|
||||||
label: words.out,
|
|
||||||
tone: asset ? "expense" : "income",
|
|
||||||
value: s * total.spent,
|
|
||||||
};
|
|
||||||
const movedOut: Part = {
|
|
||||||
flow: "movedOut",
|
|
||||||
label: "Moved out",
|
|
||||||
tone: "transfer",
|
|
||||||
value: s * total.movedOut,
|
|
||||||
};
|
|
||||||
const four =
|
|
||||||
s > 0 ? [into, movedIn, out, movedOut] : [out, movedOut, into, movedIn];
|
|
||||||
const extra: Part[] = [
|
|
||||||
{
|
|
||||||
flow: "opening",
|
|
||||||
label: "Opening balance",
|
|
||||||
tone: "neutral",
|
|
||||||
value: s * total.opening,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
flow: "adjusted",
|
|
||||||
label: "Reconciled",
|
|
||||||
tone: "neutral",
|
|
||||||
value: s * total.adjusted,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return [
|
|
||||||
...four.map((t, i) => ({ ...t, up: i < 2 })),
|
|
||||||
...extra
|
|
||||||
.filter((t) => Math.abs(t.value) > CENT)
|
|
||||||
.map((t) => ({ ...t, up: t.value > 0 })),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reconciledThrough(list: Move[]): string | null {
|
|
||||||
return list.findLast((m) => m.reconciled)?.date ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function viewOf(cur: Omit<Money, "amount">) {
|
|
||||||
const rate = cur.code === display.code ? null : display.rate(cur.code);
|
|
||||||
const shown = rate === null ? cur : display.target;
|
|
||||||
const mark = rate === null ? "" : "≈";
|
|
||||||
return {
|
|
||||||
axis: (value: number) => compactMoney(value, shown.symbol ?? ""),
|
|
||||||
format: (value: number, signed = false) =>
|
|
||||||
`${mark}${formatMoney({ ...shown, amount: value }, signed)}`,
|
|
||||||
rate: rate ?? 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
const KEY = "ffui.budget-map";
|
|
||||||
|
|
||||||
export interface Learned {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function read(): Record<string, Learned> {
|
|
||||||
try {
|
|
||||||
const value = JSON.parse(localStorage.getItem(KEY) ?? "{}");
|
|
||||||
return value && typeof value === "object" ? value : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const learnedBudget = (destinationId: string): Learned | null =>
|
|
||||||
read()[destinationId] ?? null;
|
|
||||||
|
|
||||||
export function learnBudget(destinationId: string, budget: Learned | null) {
|
|
||||||
const map = read();
|
|
||||||
if (budget) {
|
|
||||||
map[destinationId] = { id: budget.id, name: budget.name };
|
|
||||||
} else {
|
|
||||||
delete map[destinationId];
|
|
||||||
}
|
|
||||||
localStorage.setItem(KEY, JSON.stringify(map));
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import { all, queryClient } from "#lib/api/lists.js";
|
|
||||||
import type { Item, Page, Split, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import {
|
|
||||||
add,
|
|
||||||
curOfSplit,
|
|
||||||
list,
|
|
||||||
type Sum,
|
|
||||||
type Sums,
|
|
||||||
} from "#lib/components/categories/sums.js";
|
|
||||||
import type { Range } from "#lib/dates.js";
|
|
||||||
import { toNumber } from "#lib/format.js";
|
|
||||||
import {
|
|
||||||
type Patch,
|
|
||||||
type Targets,
|
|
||||||
updateGroups,
|
|
||||||
} from "#lib/transactions/mass.js";
|
|
||||||
import { journalId } from "#lib/transactions/split.js";
|
|
||||||
|
|
||||||
const IN_FLIGHT = 6;
|
|
||||||
const SEARCH_LIMIT = 500;
|
|
||||||
const SAMPLES = 3;
|
|
||||||
|
|
||||||
export type Scope = "period" | "all";
|
|
||||||
|
|
||||||
export interface Option {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Entry {
|
|
||||||
group: Item<TransactionGroup>;
|
|
||||||
key: string;
|
|
||||||
split: Split;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Place {
|
|
||||||
accountId: string;
|
|
||||||
entries: Entry[];
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const withoutBudget = (range: Range | null) =>
|
|
||||||
all<TransactionGroup>("/v1/budgets/transactions-without-budget", {
|
|
||||||
type: "withdrawal",
|
|
||||||
...range,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function searchFrom(
|
|
||||||
query: string,
|
|
||||||
page: number
|
|
||||||
): Promise<Item<TransactionGroup>[]> {
|
|
||||||
const result = await api<Page<TransactionGroup>>("/v1/search/transactions", {
|
|
||||||
query: { limit: SEARCH_LIMIT, page, query },
|
|
||||||
});
|
|
||||||
return result.data.length < SEARCH_LIMIT
|
|
||||||
? result.data
|
|
||||||
: [...result.data, ...(await searchFrom(query, page + 1))];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withoutCategory(range: Range | null) {
|
|
||||||
const dates = range
|
|
||||||
? ` date_after:${range.start} date_before:${range.end}`
|
|
||||||
: "";
|
|
||||||
const groups = await searchFrom(`has_no_category:true${dates}`, 1);
|
|
||||||
return [...new Map(groups.map((group) => [group.id, group])).values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
const byDate = (a: Entry, b: Entry) => b.split.date.localeCompare(a.split.date);
|
|
||||||
|
|
||||||
export function placesOf(
|
|
||||||
groups: Item<TransactionGroup>[],
|
|
||||||
keep: (split: Split) => boolean
|
|
||||||
): Place[] {
|
|
||||||
const places = new Map<string, Place>();
|
|
||||||
for (const group of groups) {
|
|
||||||
for (const split of group.attributes.transactions.filter(keep)) {
|
|
||||||
const deposit = split.type === "deposit";
|
|
||||||
const accountId =
|
|
||||||
(deposit ? split.source_id : split.destination_id) ?? "";
|
|
||||||
const key = `${split.type}:${accountId}`;
|
|
||||||
const place = places.get(key) ?? {
|
|
||||||
accountId,
|
|
||||||
entries: [],
|
|
||||||
key,
|
|
||||||
name: (deposit ? split.source_name : split.destination_name) ?? "",
|
|
||||||
type: split.type,
|
|
||||||
};
|
|
||||||
place.entries.push({ group, key: journalId(split), split });
|
|
||||||
places.set(key, place);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...places.values()]
|
|
||||||
.map((place) => ({ ...place, entries: place.entries.sort(byDate) }))
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
b.entries.length - a.entries.length || a.name.localeCompare(b.name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sumsOf(entries: Entry[]): Sum[] {
|
|
||||||
const sums: Sums = {};
|
|
||||||
for (const { split } of entries) {
|
|
||||||
const sign = split.type === "withdrawal" ? -1 : 1;
|
|
||||||
add(sums, curOfSplit(split), sign * toNumber(split.amount));
|
|
||||||
}
|
|
||||||
return list(sums);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const samplesOf = (entries: Entry[]) =>
|
|
||||||
[...new Set(entries.map((entry) => entry.split.description))].slice(
|
|
||||||
0,
|
|
||||||
SAMPLES
|
|
||||||
);
|
|
||||||
|
|
||||||
export function targetsOf(entries: Entry[]): Targets {
|
|
||||||
const targets: Targets = new Map();
|
|
||||||
for (const { group, key } of entries) {
|
|
||||||
const set = targets.get(group.id) ?? new Set<string>();
|
|
||||||
set.add(key);
|
|
||||||
targets.set(group.id, set);
|
|
||||||
}
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
let running = 0;
|
|
||||||
const waiting: (() => void)[] = [];
|
|
||||||
|
|
||||||
async function slot<T>(task: () => Promise<T>): Promise<T> {
|
|
||||||
if (running < IN_FLIGHT) {
|
|
||||||
running += 1;
|
|
||||||
} else {
|
|
||||||
await new Promise<void>((resolve) => waiting.push(resolve));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await task();
|
|
||||||
} finally {
|
|
||||||
const next = waiting.shift();
|
|
||||||
if (next) {
|
|
||||||
next();
|
|
||||||
} else {
|
|
||||||
running -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyPatch(
|
|
||||||
targets: Targets,
|
|
||||||
patch: Patch,
|
|
||||||
progress: (done: number) => void
|
|
||||||
): Promise<string[]> {
|
|
||||||
let done = 0;
|
|
||||||
const errors: string[] = [];
|
|
||||||
await Promise.all(
|
|
||||||
[...targets].map((target) =>
|
|
||||||
slot(async () => {
|
|
||||||
const outcome = await updateGroups(
|
|
||||||
new Map([target]),
|
|
||||||
() => patch,
|
|
||||||
() => undefined
|
|
||||||
);
|
|
||||||
errors.push(...outcome.errors);
|
|
||||||
done += 1;
|
|
||||||
progress(done);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const refreshExcept = (key: unknown[]) => {
|
|
||||||
const own = JSON.stringify(key);
|
|
||||||
return queryClient.invalidateQueries({
|
|
||||||
predicate: (query) => JSON.stringify(query.queryKey) !== own,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export function storedSet(key: string) {
|
|
||||||
return {
|
|
||||||
read(): Set<string> {
|
|
||||||
try {
|
|
||||||
const value = JSON.parse(localStorage.getItem(key) ?? "[]");
|
|
||||||
return new Set(Array.isArray(value) ? value : []);
|
|
||||||
} catch {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
write(set: Set<string>) {
|
|
||||||
localStorage.setItem(key, JSON.stringify([...set]));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatMoney, type Money, toNumber } from "#lib/format.js";
|
import { formatMoney, type Money, toNumber } from "#lib/format.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
|
|
||||||
export type Tone = "income" | "expense" | "transfer" | "neutral" | "auto";
|
export type Tone = "income" | "expense" | "transfer" | "neutral" | "auto";
|
||||||
|
|
||||||
@@ -11,31 +10,10 @@
|
|||||||
decimals,
|
decimals,
|
||||||
tone = "auto",
|
tone = "auto",
|
||||||
signed = false,
|
signed = false,
|
||||||
approx = false,
|
|
||||||
exact = false,
|
|
||||||
class: className = "",
|
class: className = "",
|
||||||
}: Money & {
|
}: Money & { tone?: Tone; signed?: boolean; class?: string } = $props();
|
||||||
tone?: Tone;
|
|
||||||
signed?: boolean;
|
|
||||||
approx?: boolean;
|
|
||||||
exact?: boolean;
|
|
||||||
class?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const value = $derived(toNumber(amount));
|
const value = $derived(toNumber(amount));
|
||||||
const converted = $derived(
|
|
||||||
exact || !code || code === display.code
|
|
||||||
? null
|
|
||||||
: display.convert(value, code)
|
|
||||||
);
|
|
||||||
const original = $derived(
|
|
||||||
formatMoney({ amount, code, decimals, symbol }, signed)
|
|
||||||
);
|
|
||||||
const shown = $derived(
|
|
||||||
converted === null
|
|
||||||
? original
|
|
||||||
: formatMoney({ ...display.target, amount: converted }, signed)
|
|
||||||
);
|
|
||||||
const resolved = $derived.by(() => {
|
const resolved = $derived.by(() => {
|
||||||
if (tone !== "auto") {
|
if (tone !== "auto") {
|
||||||
return tone;
|
return tone;
|
||||||
@@ -55,8 +33,6 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span class="num {color} {className}"
|
||||||
class="num font-medium {color} {className}"
|
>{formatMoney({ amount, code, decimals, symbol }, signed)}</span
|
||||||
title={converted === null ? undefined : original}
|
|
||||||
>{approx || converted !== null ? "≈" : ""}{shown}</span
|
|
||||||
>
|
>
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Money } from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import Amount, { type Tone } from "./Amount.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
parts,
|
|
||||||
tone = "auto",
|
|
||||||
signed = false,
|
|
||||||
inline = false,
|
|
||||||
class: className = "",
|
|
||||||
}: {
|
|
||||||
parts: Money[];
|
|
||||||
tone?: Tone;
|
|
||||||
signed?: boolean;
|
|
||||||
inline?: boolean;
|
|
||||||
class?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const total = $derived(display.total(parts));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if total}
|
|
||||||
<Amount
|
|
||||||
amount={total.amount}
|
|
||||||
approx={total.converted}
|
|
||||||
class={className}
|
|
||||||
code={total.code}
|
|
||||||
decimals={total.decimals}
|
|
||||||
{signed}
|
|
||||||
symbol={total.symbol}
|
|
||||||
{tone}
|
|
||||||
/>
|
|
||||||
{:else if inline}
|
|
||||||
{#each parts as part, index (part.code ?? index)}
|
|
||||||
{#if index > 0}
|
|
||||||
<span class="mx-1 text-cladd-fg-softer">+</span>
|
|
||||||
{/if}
|
|
||||||
<Amount {...part} class={className} {signed} {tone} />
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each parts as part, index (part.code ?? index)}
|
|
||||||
<div><Amount {...part} class={className} {signed} {tone} /></div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
tagsOf,
|
tagsOf,
|
||||||
} from "#lib/components/editor/quick.js";
|
} from "#lib/components/editor/quick.js";
|
||||||
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
import { evalAmount } from "#lib/components/inputs/parse.js";
|
|
||||||
import { LIST } from "#lib/components/inputs/styles.js";
|
import { LIST } from "#lib/components/inputs/styles.js";
|
||||||
import { today } from "#lib/dates.js";
|
import { today } from "#lib/dates.js";
|
||||||
import { formatDate, formatMoney } from "#lib/format.js";
|
import { formatDate, formatMoney } from "#lib/format.js";
|
||||||
@@ -36,7 +35,7 @@
|
|||||||
|
|
||||||
const SUGGESTIONS = 6;
|
const SUGGESTIONS = 6;
|
||||||
const HINT =
|
const HINT =
|
||||||
"12.50 lunch @payee ^account !category $budget %subscription #tag ~yesterday · + in front for a deposit · >account for a transfer · 12.50 USD =520 UAH for another currency";
|
"12.50 lunch @payee ^account !category $budget #tag ~yesterday · + in front for a deposit · >account for a transfer";
|
||||||
|
|
||||||
const catalog = new Catalog();
|
const catalog = new Catalog();
|
||||||
let input = $state<HTMLInputElement | null>(null);
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
@@ -62,7 +61,6 @@
|
|||||||
"!": () => catalog.categoryOptions(query),
|
"!": () => catalog.categoryOptions(query),
|
||||||
"@": () => catalog.accountOptions(KINDS[quick.type][other], query),
|
"@": () => catalog.accountOptions(KINDS[quick.type][other], query),
|
||||||
"#": () => catalog.tagOptions(query, tagsOf(quick)),
|
"#": () => catalog.tagOptions(query, tagsOf(quick)),
|
||||||
"%": () => catalog.billOptions(query),
|
|
||||||
"^": () => catalog.accountOptions(KINDS[quick.type][own], query),
|
"^": () => catalog.accountOptions(KINDS[quick.type][own], query),
|
||||||
">": () => catalog.accountOptions(KINDS.transfer.destination, query),
|
">": () => catalog.accountOptions(KINDS.transfer.destination, query),
|
||||||
"~": () => [],
|
"~": () => [],
|
||||||
@@ -109,18 +107,12 @@
|
|||||||
|
|
||||||
function money(form: Form) {
|
function money(form: Form) {
|
||||||
const [split] = form.splits;
|
const [split] = form.splits;
|
||||||
const main = formatMoney({
|
return formatMoney({
|
||||||
...split.currency,
|
amount: quick.amount ?? 0,
|
||||||
amount: evalAmount(split.amount) ?? 0,
|
code: split.currency?.code,
|
||||||
|
decimals: split.currency?.decimals,
|
||||||
|
symbol: split.currency?.symbol,
|
||||||
});
|
});
|
||||||
if (!split.foreignCurrency) {
|
|
||||||
return main;
|
|
||||||
}
|
|
||||||
const foreign = formatMoney({
|
|
||||||
...split.foreignCurrency,
|
|
||||||
amount: evalAmount(split.foreignAmount) ?? 0,
|
|
||||||
});
|
|
||||||
return `${main} (${foreign})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCaret() {
|
function syncCaret() {
|
||||||
@@ -260,9 +252,6 @@
|
|||||||
text: `category ${split.category}${fresh}${last(fromPast.category)}`,
|
text: `category ${split.category}${fresh}${last(fromPast.category)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (split.bill.id) {
|
|
||||||
parts.push({ text: `subscription ${split.bill.name}` });
|
|
||||||
}
|
|
||||||
if (split.budget.id) {
|
if (split.budget.id) {
|
||||||
parts.push({
|
parts.push({
|
||||||
text: `budget ${split.budget.name}${last(fromPast.budget)}`,
|
text: `budget ${split.budget.name}${last(fromPast.budget)}`,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
import { DropdownMenu } from "bits-ui";
|
import { DropdownMenu } from "bits-ui";
|
||||||
import { viewOf } from "#lib/accounts/statement.js";
|
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { all, invalidate } from "#lib/api/lists.js";
|
import { all, invalidate } from "#lib/api/lists.js";
|
||||||
import type { Account, One, Page, TransactionGroup } from "#lib/api/types.js";
|
import type { Account, One, Page, TransactionGroup } from "#lib/api/types.js";
|
||||||
@@ -20,22 +19,19 @@
|
|||||||
setPrimary,
|
setPrimary,
|
||||||
span,
|
span,
|
||||||
} from "#lib/components/dashboard/data.js";
|
} from "#lib/components/dashboard/data.js";
|
||||||
import Merchant from "#lib/components/dimension/Merchant.svelte";
|
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import Pager from "#lib/components/transactions/Pager.svelte";
|
import Pager from "#lib/components/transactions/Pager.svelte";
|
||||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||||
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
||||||
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
||||||
import { contains, daysBetween, today } from "#lib/dates.js";
|
import { contains, daysBetween, today } from "#lib/dates.js";
|
||||||
import { formatRange } from "#lib/format.js";
|
import { formatMoney, formatRange } from "#lib/format.js";
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import Breakdown from "./Breakdown.svelte";
|
import Breakdown from "./Breakdown.svelte";
|
||||||
import DebtPanel from "./DebtPanel.svelte";
|
|
||||||
import {
|
import {
|
||||||
canReconcile,
|
canReconcile,
|
||||||
createUrl,
|
createUrl,
|
||||||
@@ -51,7 +47,6 @@
|
|||||||
typeOf,
|
typeOf,
|
||||||
} from "./links.js";
|
} from "./links.js";
|
||||||
import PeriodList from "./PeriodList.svelte";
|
import PeriodList from "./PeriodList.svelte";
|
||||||
import Statement from "./Statement.svelte";
|
|
||||||
|
|
||||||
const SPECIAL = new Set(["initial-balance", "reconciliation"]);
|
const SPECIAL = new Set(["initial-balance", "reconciliation"]);
|
||||||
const BUTTON =
|
const BUTTON =
|
||||||
@@ -74,14 +69,9 @@
|
|||||||
const a = $derived(account.data?.data.attributes);
|
const a = $derived(account.data?.data.attributes);
|
||||||
const type = $derived(a ? typeOf(a) : "asset");
|
const type = $derived(a ? typeOf(a) : "asset");
|
||||||
const item = $derived(account.data?.data);
|
const item = $derived(account.data?.data);
|
||||||
const debt = $derived(a?.liability_type === "debt");
|
|
||||||
const statement = $derived(
|
|
||||||
!everything && (type === "asset" || type === "liabilities")
|
|
||||||
);
|
|
||||||
const merchant = $derived(type === "expense" || type === "revenue");
|
|
||||||
|
|
||||||
const first = createQuery(() => ({
|
const first = createQuery(() => ({
|
||||||
enabled: everything && !merchant,
|
enabled: everything,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const head = await api<Page<TransactionGroup>>(
|
const head = await api<Page<TransactionGroup>>(
|
||||||
`/v1/accounts/${id}/transactions`,
|
`/v1/accounts/${id}/transactions`,
|
||||||
@@ -120,7 +110,7 @@
|
|||||||
return days <= 3000 ? "1M" : "1Y";
|
return days <= 3000 ? "1M" : "1Y";
|
||||||
});
|
});
|
||||||
const chart = createQuery(() => ({
|
const chart = createQuery(() => ({
|
||||||
enabled: !(statement || merchant) && (!everything || first.isSuccess),
|
enabled: !everything || first.isSuccess,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api<ChartSet[]>("/v1/chart/account/overview", {
|
api<ChartSet[]>("/v1/chart/account/overview", {
|
||||||
@@ -133,34 +123,25 @@
|
|||||||
}),
|
}),
|
||||||
queryKey: ["account-chart", id, range.start, range.end, step],
|
queryKey: ["account-chart", id, range.start, range.end, step],
|
||||||
}));
|
}));
|
||||||
const converted = $derived(!display.active && hasPrimary(chart.data ?? []));
|
const converted = $derived(hasPrimary(chart.data ?? []));
|
||||||
const cur = $derived(
|
|
||||||
currency(a?.currency_code ?? session.primary?.code ?? "")
|
|
||||||
);
|
|
||||||
const view = $derived(display.active ? viewOf(cur) : null);
|
|
||||||
const series = $derived(
|
const series = $derived(
|
||||||
(chart.data ?? []).map((set) => {
|
(chart.data ?? []).map((set) => ({
|
||||||
const own = viewOf(currency(set.currency_code));
|
|
||||||
return {
|
|
||||||
area: true,
|
area: true,
|
||||||
format: view
|
format: money(converted ? setPrimary(set) : currency(set.currency_code)),
|
||||||
? own.format
|
|
||||||
: money(converted ? setPrimary(set) : currency(set.currency_code)),
|
|
||||||
key: set.label,
|
key: set.label,
|
||||||
label: set.label,
|
label: set.label,
|
||||||
points: seriesPoints(set, converted).map((p) => ({
|
points: seriesPoints(set, converted),
|
||||||
...p,
|
}))
|
||||||
y: p.y * own.rate,
|
);
|
||||||
})),
|
const cur = $derived(
|
||||||
};
|
currency(a?.currency_code ?? session.primary?.code ?? "")
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pageNo = $derived(
|
const pageNo = $derived(
|
||||||
Number(page.url.searchParams.get("page") ?? "1") || 1
|
Number(page.url.searchParams.get("page") ?? "1") || 1
|
||||||
);
|
);
|
||||||
const dated = createQuery(() => ({
|
const dated = createQuery(() => ({
|
||||||
enabled: !(everything || statement || merchant),
|
enabled: !everything,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
all<TransactionGroup>(
|
all<TransactionGroup>(
|
||||||
@@ -174,7 +155,7 @@
|
|||||||
queryKey: ["account-tx-all", id, period.start, period.end],
|
queryKey: ["account-tx-all", id, period.start, period.end],
|
||||||
}));
|
}));
|
||||||
const paged = createQuery(() => ({
|
const paged = createQuery(() => ({
|
||||||
enabled: everything && !merchant,
|
enabled: everything,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
|
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
|
||||||
@@ -218,7 +199,7 @@
|
|||||||
return TYPE_TITLE[type].replace(" accounts", " account");
|
return TYPE_TITLE[type].replace(" accounts", " account");
|
||||||
});
|
});
|
||||||
const balance = $derived(
|
const balance = $derived(
|
||||||
a ? display.text([{ amount: a.current_balance, ...cur }]) : ""
|
a ? formatMoney({ amount: a.current_balance, ...cur }) : ""
|
||||||
);
|
);
|
||||||
|
|
||||||
function pageHref(n: number) {
|
function pageHref(n: number) {
|
||||||
@@ -319,44 +300,26 @@
|
|||||||
<p class="pb-3 text-expense">{failure}</p>
|
<p class="pb-3 text-expense">{failure}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if statement}
|
<div class="flex flex-col gap-3">
|
||||||
{#if item}
|
|
||||||
<Statement {item} />
|
|
||||||
{/if}
|
|
||||||
{:else if merchant}
|
|
||||||
<Merchant {everything} {id} />
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div
|
|
||||||
class="grid gap-3 {debt
|
|
||||||
? "xl:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
{#if debt && item}
|
|
||||||
<DebtPanel {item} />
|
|
||||||
{/if}
|
|
||||||
<Box
|
<Box
|
||||||
title={a
|
title={a
|
||||||
? `Chart for ${a.name} (${balance}) between ${formatRange(range.start, range.end)}`
|
? `Chart for ${a.name} (${balance}) between ${formatRange(range.start, range.end)}`
|
||||||
: "Chart"}
|
: "Chart"}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="transition-opacity {chart.isPlaceholderData
|
class="transition-opacity {chart.isPlaceholderData ? "opacity-60" : ""}"
|
||||||
? "opacity-60"
|
|
||||||
: ""}"
|
|
||||||
>
|
>
|
||||||
{#if chart.isPending || (everything && first.isPending)}
|
{#if chart.isPending || (everything && first.isPending)}
|
||||||
<div class="h-[300px]"></div>
|
<div class="h-[300px]"></div>
|
||||||
{:else if chart.isError}
|
{:else if chart.isError}
|
||||||
<p class="text-expense">{chart.error.message}</p>
|
<p class="text-expense">{chart.error.message}</p>
|
||||||
{:else if series.length === 0}
|
{:else if series.length === 0}
|
||||||
<p class="py-10 text-center text-cladd-fg-soft">
|
<p class="py-10 text-center text-cladd-fg-soft">No balance to draw.</p>
|
||||||
No balance to draw.
|
|
||||||
</p>
|
|
||||||
{:else}
|
{:else}
|
||||||
<LineChart
|
<LineChart
|
||||||
axisFormat={view?.axis ??
|
axisFormat={axis(
|
||||||
axis(converted ? setPrimary(chart.data?.[0] as ChartSet) : cur)}
|
converted ? setPrimary(chart.data?.[0] as ChartSet) : cur
|
||||||
|
)}
|
||||||
height={300}
|
height={300}
|
||||||
label="Balance of {a?.name ?? "the account"}"
|
label="Balance of {a?.name ?? "the account"}"
|
||||||
{marker}
|
{marker}
|
||||||
@@ -366,7 +329,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !everything}
|
{#if !everything}
|
||||||
<div class="grid gap-3 md:grid-cols-3">
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
@@ -446,5 +408,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
@@ -13,11 +13,9 @@
|
|||||||
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
||||||
import {
|
import {
|
||||||
balanceRange,
|
balanceRange,
|
||||||
byCurrency,
|
|
||||||
type ChartSet,
|
type ChartSet,
|
||||||
seriesPoints,
|
seriesPoints,
|
||||||
} from "#lib/components/dashboard/data.js";
|
} from "#lib/components/dashboard/data.js";
|
||||||
import Money from "#lib/components/Money.svelte";
|
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -28,9 +26,8 @@
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "#lib/components/ui/context-menu/index.js";
|
} from "#lib/components/ui/context-menu/index.js";
|
||||||
import { daysBetween } from "#lib/dates.js";
|
import { daysBetween } from "#lib/dates.js";
|
||||||
import { formatDate, toNumber } from "#lib/format.js";
|
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -126,33 +123,15 @@
|
|||||||
let armed = $state(false);
|
let armed = $state(false);
|
||||||
let failure = $state("");
|
let failure = $state("");
|
||||||
|
|
||||||
const ROLE_RANK: Record<string, number> = {
|
|
||||||
cashWalletAsset: 0,
|
|
||||||
ccAsset: 1,
|
|
||||||
defaultAsset: 2,
|
|
||||||
savingAsset: 4,
|
|
||||||
sharedAsset: 3,
|
|
||||||
};
|
|
||||||
const perCurrency = $derived(type === "asset" && active);
|
|
||||||
const showInactive = $derived(
|
|
||||||
perCurrency && page.url.searchParams.has("inactive")
|
|
||||||
);
|
|
||||||
|
|
||||||
const accounts = createQuery(() => ({
|
const accounts = createQuery(() => ({
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
all<Account>("/v1/accounts", {
|
all<Account>("/v1/accounts", {
|
||||||
...balanceRange(period.start, period.end),
|
...balanceRange(period.start, period.end),
|
||||||
...(showInactive ? {} : { active: String(active) }),
|
active: String(active),
|
||||||
type,
|
type,
|
||||||
}),
|
}),
|
||||||
queryKey: [
|
queryKey: ["accounts-index", type, active, period.start, period.end],
|
||||||
"accounts-index",
|
|
||||||
type,
|
|
||||||
showInactive ? "all" : active,
|
|
||||||
period.start,
|
|
||||||
period.end,
|
|
||||||
],
|
|
||||||
}));
|
}));
|
||||||
const others = createQuery(() => ({
|
const others = createQuery(() => ({
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -203,8 +182,6 @@
|
|||||||
|
|
||||||
function sortValue(item: Item<Account>, key: Key): string | number {
|
function sortValue(item: Item<Account>, key: Key): string | number {
|
||||||
const a = item.attributes;
|
const a = item.attributes;
|
||||||
const shown = (value: string | null | undefined) =>
|
|
||||||
display.convert(toNumber(value), a.currency_code) ?? toNumber(value);
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "name":
|
case "name":
|
||||||
return a.name.toLowerCase();
|
return a.name.toLowerCase();
|
||||||
@@ -219,15 +196,15 @@
|
|||||||
case "number":
|
case "number":
|
||||||
return formatIban(a);
|
return formatIban(a);
|
||||||
case "balance":
|
case "balance":
|
||||||
return shown(a.current_balance);
|
return toNumber(a.current_balance);
|
||||||
case "due":
|
case "due":
|
||||||
return shown(a.debt_amount);
|
return toNumber(a.debt_amount);
|
||||||
case "active":
|
case "active":
|
||||||
return a.active ? 1 : 0;
|
return a.active ? 1 : 0;
|
||||||
case "activity":
|
case "activity":
|
||||||
return a.last_activity ?? "";
|
return a.last_activity ?? "";
|
||||||
default:
|
default:
|
||||||
return shown(a.balance_difference);
|
return toNumber(a.balance_difference);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +228,8 @@
|
|||||||
const sorted = $derived.by(() => {
|
const sorted = $derived.by(() => {
|
||||||
const list = [...matching];
|
const list = [...matching];
|
||||||
if (!sortKey) {
|
if (!sortKey) {
|
||||||
const rank = (item: Item<Account>) =>
|
|
||||||
perCurrency ? (ROLE_RANK[item.attributes.account_role ?? ""] ?? 5) : 0;
|
|
||||||
return list.sort(
|
return list.sort(
|
||||||
(x, y) =>
|
(x, y) => (x.attributes.order ?? 0) - (y.attributes.order ?? 0)
|
||||||
rank(x) - rank(y) ||
|
|
||||||
(x.attributes.order ?? 0) - (y.attributes.order ?? 0)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const dir = descending ? -1 : 1;
|
const dir = descending ? -1 : 1;
|
||||||
@@ -269,48 +242,10 @@
|
|||||||
return (a > b ? 1 : -1) * dir;
|
return (a > b ? 1 : -1) * dir;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
interface Total {
|
|
||||||
balance: number;
|
|
||||||
code: string;
|
|
||||||
decimals: number;
|
|
||||||
difference: number;
|
|
||||||
due: number;
|
|
||||||
symbol: string;
|
|
||||||
}
|
|
||||||
const totals = $derived.by(() => {
|
|
||||||
const map = new Map<string, Total>();
|
|
||||||
for (const { attributes: a } of matching) {
|
|
||||||
const code = a.currency_code ?? "";
|
|
||||||
const t = map.get(code) ?? {
|
|
||||||
balance: 0,
|
|
||||||
code,
|
|
||||||
decimals: a.currency_decimal_places ?? 2,
|
|
||||||
difference: 0,
|
|
||||||
due: 0,
|
|
||||||
symbol: a.currency_symbol ?? code,
|
|
||||||
};
|
|
||||||
t.balance += toNumber(a.current_balance);
|
|
||||||
t.difference += toNumber(a.balance_difference);
|
|
||||||
t.due += toNumber(a.debt_amount);
|
|
||||||
map.set(code, t);
|
|
||||||
}
|
|
||||||
return [...map.values()];
|
|
||||||
});
|
|
||||||
const parts = (list: Total[], key: "balance" | "difference" | "due") =>
|
|
||||||
list.map((t) => ({ ...t, amount: t[key] }));
|
|
||||||
const merged = $derived(display.total(parts(totals, "balance")) !== null);
|
|
||||||
const sections = $derived(perCurrency && !merged);
|
|
||||||
|
|
||||||
const grouped = $derived(
|
const grouped = $derived(
|
||||||
sections || sorted.some((item) => item.attributes.object_group_title)
|
sorted.some((item) => item.attributes.object_group_title)
|
||||||
);
|
);
|
||||||
const groups = $derived.by(() => {
|
const groups = $derived.by(() => {
|
||||||
if (sections) {
|
|
||||||
return byCurrency(
|
|
||||||
sorted,
|
|
||||||
(item) => item.attributes.currency_code ?? ""
|
|
||||||
).map(([code, items]) => ({ items, title: code }));
|
|
||||||
}
|
|
||||||
if (!grouped) {
|
if (!grouped) {
|
||||||
return [{ items: sorted, title: "" }];
|
return [{ items: sorted, title: "" }];
|
||||||
}
|
}
|
||||||
@@ -334,6 +269,34 @@
|
|||||||
const rows = $derived(groups.flatMap((g) => g.items));
|
const rows = $derived(groups.flatMap((g) => g.items));
|
||||||
const index = $derived(new Map(rows.map((item, i) => [item.id, i])));
|
const index = $derived(new Map(rows.map((item, i) => [item.id, i])));
|
||||||
|
|
||||||
|
interface Total {
|
||||||
|
balance: number;
|
||||||
|
code: string;
|
||||||
|
decimals: number;
|
||||||
|
difference: number;
|
||||||
|
due: number;
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
const totals = $derived.by(() => {
|
||||||
|
const map = new Map<string, Total>();
|
||||||
|
for (const { attributes: a } of rows) {
|
||||||
|
const code = a.currency_code ?? "";
|
||||||
|
const t = map.get(code) ?? {
|
||||||
|
balance: 0,
|
||||||
|
code,
|
||||||
|
decimals: a.currency_decimal_places ?? 2,
|
||||||
|
difference: 0,
|
||||||
|
due: 0,
|
||||||
|
symbol: a.currency_symbol ?? code,
|
||||||
|
};
|
||||||
|
t.balance += toNumber(a.current_balance);
|
||||||
|
t.difference += toNumber(a.balance_difference);
|
||||||
|
t.due += toNumber(a.debt_amount);
|
||||||
|
map.set(code, t);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
});
|
||||||
|
|
||||||
const title = $derived(
|
const title = $derived(
|
||||||
active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)`
|
active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)`
|
||||||
);
|
);
|
||||||
@@ -364,18 +327,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(perCurrency
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
group: "Accounts",
|
|
||||||
keys: "i",
|
|
||||||
label: showInactive
|
|
||||||
? "Hide inactive accounts"
|
|
||||||
: "Show inactive accounts",
|
|
||||||
run: () => goto(inactiveHref(), { replace: true }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
],
|
],
|
||||||
id: "accounts",
|
id: "accounts",
|
||||||
items: () => rows,
|
items: () => rows,
|
||||||
@@ -384,18 +335,6 @@
|
|||||||
});
|
});
|
||||||
nav.use();
|
nav.use();
|
||||||
|
|
||||||
function inactiveHref() {
|
|
||||||
const url = new URL(page.url.href);
|
|
||||||
if (showInactive) {
|
|
||||||
url.searchParams.delete("inactive");
|
|
||||||
} else {
|
|
||||||
url.searchParams.set("inactive", "1");
|
|
||||||
}
|
|
||||||
url.searchParams.set("start", period.start);
|
|
||||||
url.searchParams.set("end", period.end);
|
|
||||||
return url.pathname + url.search;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onfilterkey(event: KeyboardEvent) {
|
function onfilterkey(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape" && filter) {
|
if (event.key === "Escape" && filter) {
|
||||||
filter = "";
|
filter = "";
|
||||||
@@ -478,47 +417,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet sums(
|
|
||||||
list: Total[],
|
|
||||||
label: string,
|
|
||||||
head = false
|
|
||||||
)}
|
|
||||||
{#each columns as col, c (col.key)}
|
|
||||||
<td
|
|
||||||
class="px-3 {col.num ? "text-right" : ""} {col.wide
|
|
||||||
? "hidden md:table-cell"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
{#if c === 0}
|
|
||||||
<span
|
|
||||||
class="font-semibold text-[12px] {head
|
|
||||||
? "text-cladd-fg"
|
|
||||||
: "text-cladd-fg-soft"}"
|
|
||||||
>{label}</span
|
|
||||||
>
|
|
||||||
{#if head}
|
|
||||||
<Money
|
|
||||||
class="ml-2 text-[12px] md:hidden"
|
|
||||||
inline
|
|
||||||
parts={parts(list, "difference")}
|
|
||||||
signed
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{:else if col.key === "balance"}
|
|
||||||
<span class="num font-semibold"
|
|
||||||
>{display.text(parts(list, "balance"))}</span
|
|
||||||
>
|
|
||||||
{:else if col.key === "due"}
|
|
||||||
<span class="num font-semibold"
|
|
||||||
>{display.text(parts(list, "due"))}</span
|
|
||||||
>
|
|
||||||
{:else if col.key === "difference"}
|
|
||||||
<Money class="font-semibold" parts={parts(list, "difference")} />
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: title }]}
|
crumbs={[{ label: title }]}
|
||||||
subtitle={active ? undefined : "Archive"}
|
subtitle={active ? undefined : "Archive"}
|
||||||
@@ -604,15 +502,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each groups as group (group.title)}
|
{#each groups as group (group.title)}
|
||||||
{#if sections}
|
{#if grouped}
|
||||||
<tr class="h-8 border-cladd-outline border-t-2">
|
|
||||||
{@render sums(
|
|
||||||
totals.filter((t) => t.code === group.title),
|
|
||||||
group.title,
|
|
||||||
true
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
{:else if grouped}
|
|
||||||
<tr class="h-8 border-cladd-outline border-t">
|
<tr class="h-8 border-cladd-outline border-t">
|
||||||
<th
|
<th
|
||||||
class="px-3 text-left font-semibold text-[12px] text-cladd-fg"
|
class="px-3 text-left font-semibold text-[12px] text-cladd-fg"
|
||||||
@@ -627,9 +517,7 @@
|
|||||||
{@const a = item.attributes}
|
{@const a = item.attributes}
|
||||||
{@const i = index.get(item.id) ?? -1}
|
{@const i = index.get(item.id) ?? -1}
|
||||||
<tr
|
<tr
|
||||||
class="h-7 border-cladd-outline/60 border-t {rowClass(
|
class="h-7 border-cladd-outline/60 border-t {rowClass(i)}"
|
||||||
i
|
|
||||||
)} {a.active ? "" : "opacity-60"}"
|
|
||||||
data-id={item.id}
|
data-id={item.id}
|
||||||
id={nav.rowId(i)}
|
id={nav.rowId(i)}
|
||||||
onclick={(event) => nav.click(i, event)}
|
onclick={(event) => nav.click(i, event)}
|
||||||
@@ -715,19 +603,40 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
{#if totals.length > 0 && !sections}
|
{#if totals.length > 0}
|
||||||
<tfoot class="border-cladd-outline border-t-2">
|
<tfoot class="border-cladd-outline border-t-2">
|
||||||
{#if merged}
|
|
||||||
<tr class="h-7">
|
|
||||||
{@render sums(totals, "Total")}
|
|
||||||
</tr>
|
|
||||||
{:else}
|
|
||||||
{#each totals as t (t.code)}
|
{#each totals as t (t.code)}
|
||||||
<tr class="h-7">
|
<tr class="h-7">
|
||||||
{@render sums([t], `Total in ${t.code}`)}
|
{#each columns as col, c (col.key)}
|
||||||
|
<td
|
||||||
|
class="px-3 {col.num ? "text-right" : ""} {col.wide
|
||||||
|
? "hidden md:table-cell"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
{#if c === 0}
|
||||||
|
<span
|
||||||
|
class="font-semibold text-[12px] text-cladd-fg-soft"
|
||||||
|
>Total in {t.code}</span
|
||||||
|
>
|
||||||
|
{:else if col.key === "balance"}
|
||||||
|
<span class="num font-semibold"
|
||||||
|
>{formatMoney({ ...t, amount: t.balance })}</span
|
||||||
|
>
|
||||||
|
{:else if col.key === "due"}
|
||||||
|
<span class="num font-semibold"
|
||||||
|
>{formatMoney({ ...t, amount: t.due })}</span
|
||||||
|
>
|
||||||
|
{:else if col.key === "difference"}
|
||||||
|
<Amount
|
||||||
|
{...t}
|
||||||
|
amount={t.difference}
|
||||||
|
class="font-semibold"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
|
||||||
</tfoot>
|
</tfoot>
|
||||||
{/if}
|
{/if}
|
||||||
</table>
|
</table>
|
||||||
@@ -781,17 +690,7 @@
|
|||||||
{#if failure}
|
{#if failure}
|
||||||
<span class="text-expense">{failure}</span>
|
<span class="text-expense">{failure}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if perCurrency && (showInactive || otherCount > 0)}
|
{#if active && otherCount > 0}
|
||||||
<a
|
|
||||||
class="ml-auto text-cladd-fg-soft hover:underline"
|
|
||||||
data-sveltekit-replacestate
|
|
||||||
href={inactiveHref()}
|
|
||||||
>{showInactive
|
|
||||||
? "Hide inactive accounts"
|
|
||||||
: `Show ${otherCount} inactive ${otherCount === 1 ? "account" : "accounts"}`}
|
|
||||||
<span class="kbd">i</span></a
|
|
||||||
>
|
|
||||||
{:else if active && otherCount > 0}
|
|
||||||
<a
|
<a
|
||||||
class="ml-auto text-cladd-fg-soft hover:underline"
|
class="ml-auto text-cladd-fg-soft hover:underline"
|
||||||
href="/inactive-accounts/{type}"
|
href="/inactive-accounts/{type}"
|
||||||
|
|||||||
@@ -1,260 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
|
||||||
import { owed } from "#lib/accounts/ledger.js";
|
|
||||||
import { moves, sums } from "#lib/accounts/statement.js";
|
|
||||||
import { lists } from "#lib/api/lists.js";
|
|
||||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import { addDays } from "#lib/dates.js";
|
|
||||||
import { formatMoney, type Money, toNumber } from "#lib/format.js";
|
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { pages, reportQueries } from "#lib/reports/queries.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { showUrl, typeOf } from "./links.js";
|
|
||||||
|
|
||||||
interface Line {
|
|
||||||
closing: number;
|
|
||||||
cur: Omit<Money, "amount">;
|
|
||||||
id: string;
|
|
||||||
income: number;
|
|
||||||
liability: boolean;
|
|
||||||
moved: number;
|
|
||||||
name: string;
|
|
||||||
opening: number;
|
|
||||||
other: number;
|
|
||||||
rows: number;
|
|
||||||
spent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CENT = 0.005;
|
|
||||||
const NUM = "num whitespace-nowrap text-right";
|
|
||||||
|
|
||||||
let {
|
|
||||||
accounts = [],
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
}: { accounts?: string[]; start: string; end: string } = $props();
|
|
||||||
|
|
||||||
const assets = createQuery(() => lists.accounts("asset"));
|
|
||||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
|
||||||
const balances = createQuery(() => ({
|
|
||||||
...reportQueries.balances(start, end),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
}));
|
|
||||||
const ledger = createQuery(() => ({
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
queryFn: () =>
|
|
||||||
pages<TransactionGroup>("/v1/transactions", {
|
|
||||||
end: end > start ? end : addDays(start, 1),
|
|
||||||
start,
|
|
||||||
type: "all",
|
|
||||||
}),
|
|
||||||
queryKey: ["accounts-audit", start, end],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const range = $derived({ end, start });
|
|
||||||
const ready = $derived(
|
|
||||||
Boolean(assets.data && liabilities.data && balances.data && ledger.data)
|
|
||||||
);
|
|
||||||
const loading = $derived(
|
|
||||||
balances.isPlaceholderData || ledger.isPlaceholderData
|
|
||||||
);
|
|
||||||
|
|
||||||
function flip(a: Account): number {
|
|
||||||
const debt = owed(a);
|
|
||||||
const balance = toNumber(a.current_balance);
|
|
||||||
return Math.abs(debt) > CENT && Math.sign(balance) === -Math.sign(debt)
|
|
||||||
? -1
|
|
||||||
: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lineOf(item: Item<Account>, liability: boolean): Line {
|
|
||||||
const a = item.attributes;
|
|
||||||
const sign = liability ? flip(a) : 1;
|
|
||||||
const { rows, total } = sums(
|
|
||||||
moves(item.id, a.currency_code, ledger.data ?? []),
|
|
||||||
range
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
closing: sign * (balances.data?.after.get(item.id) ?? 0),
|
|
||||||
cur: {
|
|
||||||
code: a.currency_code,
|
|
||||||
decimals: a.currency_decimal_places,
|
|
||||||
symbol: a.currency_symbol,
|
|
||||||
},
|
|
||||||
id: item.id,
|
|
||||||
income: total.income,
|
|
||||||
liability,
|
|
||||||
moved: total.movedIn + total.movedOut,
|
|
||||||
name: a.name,
|
|
||||||
opening: sign * (balances.data?.before.get(item.id) ?? 0),
|
|
||||||
other: total.opening + total.adjusted,
|
|
||||||
rows,
|
|
||||||
spent: total.spent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = $derived.by(() => {
|
|
||||||
if (!ready) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const chosen = new Set(accounts);
|
|
||||||
return [...(assets.data ?? []), ...(liabilities.data ?? [])]
|
|
||||||
.filter((item) =>
|
|
||||||
chosen.size > 0 ? chosen.has(item.id) : item.attributes.active
|
|
||||||
)
|
|
||||||
.map((item) => lineOf(item, typeOf(item.attributes) === "liabilities"));
|
|
||||||
});
|
|
||||||
const byActivity = (x: Line, y: Line) =>
|
|
||||||
y.rows - x.rows || x.name.localeCompare(y.name);
|
|
||||||
const busy = $derived(lines.filter((l) => l.rows > 0));
|
|
||||||
const sections = $derived(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
key: "asset",
|
|
||||||
lines: busy.filter((l) => !l.liability).sort(byActivity),
|
|
||||||
title: "Asset accounts",
|
|
||||||
words: ["Income", "Spent"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "liability",
|
|
||||||
lines: busy.filter((l) => l.liability).sort(byActivity),
|
|
||||||
title: "Liabilities",
|
|
||||||
words: ["In", "Out"],
|
|
||||||
},
|
|
||||||
].filter((s) => s.lines.length > 0)
|
|
||||||
);
|
|
||||||
const ordered = $derived(sections.flatMap((s) => s.lines));
|
|
||||||
const idle = $derived(
|
|
||||||
lines
|
|
||||||
.filter((l) => l.rows === 0)
|
|
||||||
.sort((x, y) => x.name.localeCompare(y.name))
|
|
||||||
);
|
|
||||||
const adjusted = $derived(lines.some((l) => Math.abs(l.other) > CENT));
|
|
||||||
const href = (id: string) => `${showUrl(id)}/${start}/${end}`;
|
|
||||||
|
|
||||||
const nav = new ListNav<Line>({
|
|
||||||
id: "all-accounts",
|
|
||||||
items: () => ordered,
|
|
||||||
key: (line) => line.id,
|
|
||||||
open: (line) => goto(href(line.id)),
|
|
||||||
});
|
|
||||||
nav.use();
|
|
||||||
|
|
||||||
const money = (line: Line, amount: number, signed = false) =>
|
|
||||||
display.text([{ ...line.cur, amount }], signed);
|
|
||||||
const original = (line: Line, amount: number, signed = false) =>
|
|
||||||
display.total([{ ...line.cur, amount }])?.converted
|
|
||||||
? formatMoney({ ...line.cur, amount }, signed)
|
|
||||||
: undefined;
|
|
||||||
function tone(value: number, positive: string, negative = positive) {
|
|
||||||
if (Math.abs(value) < CENT) {
|
|
||||||
return "text-cladd-fg-softer";
|
|
||||||
}
|
|
||||||
return value > 0 ? positive : negative;
|
|
||||||
}
|
|
||||||
|
|
||||||
const real = (line: Line, value: number) =>
|
|
||||||
line.liability
|
|
||||||
? tone(value, "text-cladd-fg")
|
|
||||||
: tone(value, "text-income", "text-expense");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet cell(
|
|
||||||
line: Line,
|
|
||||||
amount: number,
|
|
||||||
extra: string,
|
|
||||||
signed = true
|
|
||||||
)}
|
|
||||||
<td class="{NUM} {extra}" title={original(line, amount, signed)}>
|
|
||||||
{money(line, amount, signed)}
|
|
||||||
</td>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Box body="p-0 overflow-x-auto" title="Accounts">
|
|
||||||
<table
|
|
||||||
class="w-full min-w-[38rem] border-collapse md:min-w-[46rem] text-[13px] transition-opacity {loading
|
|
||||||
? "opacity-60"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
{#each sections as section (section.key)}
|
|
||||||
<thead>
|
|
||||||
<tr
|
|
||||||
class="border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft [&>th]:h-8 [&>th]:whitespace-nowrap [&>th]:px-2 [&>th]:font-medium {section.key ===
|
|
||||||
"liability"
|
|
||||||
? "border-t"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
<th class="text-cladd-fg">{section.title}</th>
|
|
||||||
<th class="w-14 max-md:hidden">Currency</th>
|
|
||||||
<th class="w-32 text-right">Opening</th>
|
|
||||||
<th class="w-28 text-right">{section.words[0]}</th>
|
|
||||||
<th class="w-28 text-right">{section.words[1]}</th>
|
|
||||||
<th class="w-28 text-right">Moved</th>
|
|
||||||
{#if adjusted}
|
|
||||||
<th class="w-28 text-right">Adjusted</th>
|
|
||||||
{/if}
|
|
||||||
<th class="w-32 text-right">Closing</th>
|
|
||||||
<th class="w-14 text-right max-md:hidden">Rows</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each section.lines as line (line.id)}
|
|
||||||
{@const index = ordered.indexOf(line)}
|
|
||||||
<tr
|
|
||||||
class="h-7 border-cladd-outline/60 border-b [&>td]:px-2 {nav.cursor ===
|
|
||||||
index
|
|
||||||
? "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]"
|
|
||||||
: "hover:bg-row-hover"}"
|
|
||||||
id={nav.rowId(index)}
|
|
||||||
onclick={(event) => nav.click(index, event)}
|
|
||||||
>
|
|
||||||
<td class="max-w-0 truncate max-md:min-w-32">
|
|
||||||
<a class="hover:underline" href={href(line.id)} title={line.name}
|
|
||||||
>{line.name}</a
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="text-cladd-fg-soft max-md:hidden">{line.cur.code}</td>
|
|
||||||
{@render cell(line, line.opening, "", false)}
|
|
||||||
{@render cell(line, line.income, real(line, line.income))}
|
|
||||||
{@render cell(line, line.spent, real(line, line.spent))}
|
|
||||||
{@render cell(line, line.moved, tone(line.moved, "text-transfer"))}
|
|
||||||
{#if adjusted}
|
|
||||||
{@render cell(
|
|
||||||
line,
|
|
||||||
line.other,
|
|
||||||
tone(line.other, "text-cladd-fg")
|
|
||||||
)}
|
|
||||||
{/if}
|
|
||||||
{@render cell(line, line.closing, "font-semibold", false)}
|
|
||||||
<td class="{NUM} text-cladd-fg-soft max-md:hidden">{line.rows}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
{/each}
|
|
||||||
{#if idle.length > 0}
|
|
||||||
<tbody>
|
|
||||||
<tr class="text-[12px] text-cladd-fg-softer [&>td]:px-2 [&>td]:py-1.5">
|
|
||||||
<td colspan={adjusted ? 9 : 8}>
|
|
||||||
No activity:
|
|
||||||
{#each idle as line (line.id)}
|
|
||||||
<a
|
|
||||||
class="hover:text-cladd-fg hover:underline [&:not(:last-child)]:after:content-[',_']"
|
|
||||||
href={href(line.id)}
|
|
||||||
>{line.name}</a
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
{/if}
|
|
||||||
</table>
|
|
||||||
{#if !ready}
|
|
||||||
<p class="px-3 py-6 text-center text-cladd-fg-soft">Loading accounts...</p>
|
|
||||||
{:else if lines.length === 0}
|
|
||||||
<p class="px-3 py-6 text-center text-cladd-fg-soft">
|
|
||||||
No accounts selected.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
currency,
|
currency,
|
||||||
money,
|
money,
|
||||||
} from "#lib/components/dashboard/data.js";
|
} from "#lib/components/dashboard/data.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import type { Group } from "#lib/transactions/split.js";
|
import type { Group } from "#lib/transactions/split.js";
|
||||||
|
|
||||||
const TOP = 6;
|
const TOP = 6;
|
||||||
@@ -42,29 +41,13 @@
|
|||||||
.filter((s) => s.type === kind)
|
.filter((s) => s.type === kind)
|
||||||
);
|
);
|
||||||
|
|
||||||
const merged = $derived(
|
|
||||||
display.active &&
|
|
||||||
splits.every((s) => display.rate(s.currency_code) !== null)
|
|
||||||
);
|
|
||||||
const buckets = $derived<[string, Split[]][]>(
|
|
||||||
merged
|
|
||||||
? [[display.code, splits]]
|
|
||||||
: byCurrency(splits, (s) => s.currency_code ?? "")
|
|
||||||
);
|
|
||||||
const approx = $derived(
|
|
||||||
merged && splits.some((s) => s.currency_code !== display.code)
|
|
||||||
);
|
|
||||||
|
|
||||||
const sections = $derived(
|
const sections = $derived(
|
||||||
buckets.map(([code, list]) => {
|
byCurrency(splits, (s) => s.currency_code ?? "").map(([code, list]) => {
|
||||||
const sums = new Map<string, Bar>();
|
const sums = new Map<string, Bar>();
|
||||||
for (const split of list) {
|
for (const split of list) {
|
||||||
const [label, href] = pick(split);
|
const [label, href] = pick(split);
|
||||||
const bar = sums.get(label) ?? { href, key: label, label, value: 0 };
|
const bar = sums.get(label) ?? { href, key: label, label, value: 0 };
|
||||||
const value = Number(split.amount);
|
bar.value += Number(split.amount);
|
||||||
bar.value += merged
|
|
||||||
? (display.convert(value, split.currency_code) ?? 0)
|
|
||||||
: value;
|
|
||||||
sums.set(label, bar);
|
sums.set(label, bar);
|
||||||
}
|
}
|
||||||
const sorted = [...sums.values()].sort((a, b) => b.value - a.value);
|
const sorted = [...sums.values()].sort((a, b) => b.value - a.value);
|
||||||
@@ -80,12 +63,7 @@
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: sorted;
|
: sorted;
|
||||||
const format = money(currency(code));
|
return { bars, code, format: money(currency(code)) };
|
||||||
return {
|
|
||||||
bars,
|
|
||||||
code,
|
|
||||||
format: approx ? (value: number) => `≈${format(value)}` : format,
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
type Action,
|
|
||||||
type Actions,
|
|
||||||
averageDays,
|
|
||||||
type Movement,
|
|
||||||
months,
|
|
||||||
overpaid,
|
|
||||||
owed,
|
|
||||||
stance,
|
|
||||||
} from "#lib/accounts/ledger.js";
|
|
||||||
import type { Account, Item } from "#lib/api/types.js";
|
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
|
||||||
import { daysBetween, today } from "#lib/dates.js";
|
|
||||||
import { formatDate, formatMoney } from "#lib/format.js";
|
|
||||||
import { showUrl as transactionUrl } from "#lib/transactions/split.js";
|
|
||||||
import { showUrl } from "./links.js";
|
|
||||||
import MonthBars from "./MonthBars.svelte";
|
|
||||||
|
|
||||||
const BUTTON =
|
|
||||||
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-fg";
|
|
||||||
const PRIMARY = `${BUTTON} cladd-color-green text-cladd-primary`;
|
|
||||||
|
|
||||||
let {
|
|
||||||
item,
|
|
||||||
list,
|
|
||||||
actions,
|
|
||||||
keys,
|
|
||||||
rows = 5,
|
|
||||||
named = true,
|
|
||||||
current = false,
|
|
||||||
id,
|
|
||||||
onfocusin,
|
|
||||||
}: {
|
|
||||||
item: Item<Account>;
|
|
||||||
list: Movement[] | undefined;
|
|
||||||
actions: Actions | null;
|
|
||||||
keys: Record<keyof Actions, string>;
|
|
||||||
rows?: number;
|
|
||||||
named?: boolean;
|
|
||||||
current?: boolean;
|
|
||||||
id?: string;
|
|
||||||
onfocusin?: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const a = $derived(item.attributes);
|
|
||||||
const credit = $derived(a.liability_direction !== "debit");
|
|
||||||
const side = $derived(stance(a));
|
|
||||||
const amount = $derived(Math.abs(owed(a)));
|
|
||||||
const cur = $derived({
|
|
||||||
code: a.currency_code,
|
|
||||||
decimals: a.currency_decimal_places,
|
|
||||||
symbol: a.currency_symbol,
|
|
||||||
});
|
|
||||||
const format = (value: number) => formatMoney({ ...cur, amount: value });
|
|
||||||
const bars = $derived(list ? months(list) : []);
|
|
||||||
const sent = $derived(bars.reduce((s, m) => s + m.sent, 0));
|
|
||||||
const received = $derived(bars.reduce((s, m) => s + m.received, 0));
|
|
||||||
const average = $derived(list ? averageDays(list, credit) : null);
|
|
||||||
const last = $derived(list?.[0]);
|
|
||||||
const shown = $derived(list?.slice(0, rows) ?? []);
|
|
||||||
|
|
||||||
function ago(date: string) {
|
|
||||||
const days = daysBetween(date, today());
|
|
||||||
if (days === 0) {
|
|
||||||
return "today";
|
|
||||||
}
|
|
||||||
return days === 1 ? "yesterday" : `${days} days ago`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet button(
|
|
||||||
action: Action,
|
|
||||||
key: string,
|
|
||||||
primary = false
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
aria-label={action.hint}
|
|
||||||
class={primary ? PRIMARY : BUTTON}
|
|
||||||
href={action.href}
|
|
||||||
>{action.label} <span class="kbd">{key}</span></a
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<article
|
|
||||||
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {current
|
|
||||||
? "outline-2 outline-(--ff-brand)"
|
|
||||||
: ""}"
|
|
||||||
{id}
|
|
||||||
{onfocusin}
|
|
||||||
>
|
|
||||||
<div class="flex flex-1 flex-col gap-3 p-3">
|
|
||||||
<div>
|
|
||||||
{#if named}
|
|
||||||
<h3 class="truncate font-semibold text-[15px] leading-snug">
|
|
||||||
<a class="hover:underline" href={showUrl(item.id)} title={a.name}
|
|
||||||
>{a.name}</a
|
|
||||||
>
|
|
||||||
</h3>
|
|
||||||
{/if}
|
|
||||||
{#if side === "settled"}
|
|
||||||
<p class="font-semibold text-[28px] text-cladd-fg-soft leading-tight">
|
|
||||||
Settled
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p class="flex flex-wrap items-baseline gap-x-2">
|
|
||||||
<span class="text-[15px] text-cladd-fg-soft"
|
|
||||||
>{side === "owed" ? "Owes you" : "You owe"}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="num font-semibold text-[28px] leading-tight {side === "owed"
|
|
||||||
? "text-income"
|
|
||||||
: "text-expense"}"
|
|
||||||
>{format(amount)}</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#if overpaid(a)}
|
|
||||||
<p class="text-[13px] text-cladd-fg-soft">
|
|
||||||
{credit ? "Paid you back" : "You paid back"}
|
|
||||||
<span class="num">{format(amount)}</span>
|
|
||||||
more than borrowed
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if last}
|
|
||||||
<dl
|
|
||||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-[13px]"
|
|
||||||
>
|
|
||||||
<dt class="text-cladd-fg-soft">Last movement</dt>
|
|
||||||
<dd class="text-right">{formatDate(last.date)}, {ago(last.date)}</dd>
|
|
||||||
{#if average === null}
|
|
||||||
<dt class="col-span-2 text-cladd-fg-soft">No repayments yet</dt>
|
|
||||||
{:else}
|
|
||||||
<dt class="text-cladd-fg-soft">
|
|
||||||
{credit ? "Usually pays back in" : "You usually pay back in"}
|
|
||||||
</dt>
|
|
||||||
<dd class="text-right">
|
|
||||||
{average}
|
|
||||||
{average === 1 ? "day" : "days"}
|
|
||||||
</dd>
|
|
||||||
{/if}
|
|
||||||
{#if received > 0}
|
|
||||||
<dt class="text-cladd-fg-soft">Received in 12 months</dt>
|
|
||||||
<dd class="num text-right text-income">{format(received)}</dd>
|
|
||||||
{/if}
|
|
||||||
{#if sent > 0}
|
|
||||||
<dt class="text-cladd-fg-soft">Sent in 12 months</dt>
|
|
||||||
<dd class="num text-right text-expense">{format(sent)}</dd>
|
|
||||||
{/if}
|
|
||||||
</dl>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if received + sent > 0}
|
|
||||||
<MonthBars {format} months={bars} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if rows > 0}
|
|
||||||
<table class="w-full table-fixed border-collapse text-[13px]">
|
|
||||||
<tbody>
|
|
||||||
{#each shown as m, i (`${m.group}-${i}`)}
|
|
||||||
<tr class="h-7 border-cladd-outline/60 border-t">
|
|
||||||
<td class="w-16 whitespace-nowrap text-cladd-fg-soft">
|
|
||||||
{formatDate(m.date, "short")}
|
|
||||||
</td>
|
|
||||||
<td class="truncate">
|
|
||||||
<a
|
|
||||||
class="hover:underline"
|
|
||||||
href={transactionUrl(m.group)}
|
|
||||||
title={m.description}
|
|
||||||
>{m.opening ? "Opening balance" : m.description}</a
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="w-28 whitespace-nowrap text-right">
|
|
||||||
<Amount
|
|
||||||
{...cur}
|
|
||||||
amount={m.into ? -m.amount : m.amount}
|
|
||||||
exact
|
|
||||||
signed
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{:else}
|
|
||||||
<tr class="h-7 border-cladd-outline/60 border-t">
|
|
||||||
<td class="text-cladd-fg-soft" colspan="3">
|
|
||||||
{list ? "No movements yet" : ""}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if actions}
|
|
||||||
<footer
|
|
||||||
class="flex flex-col gap-1.5 border-cladd-outline border-t px-3 py-2"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
|
||||||
{#if actions.repay}
|
|
||||||
{@render button(actions.repay, keys.repay, true)}
|
|
||||||
{/if}
|
|
||||||
{@render button(actions.more, keys.more)}
|
|
||||||
{#if actions.offset}
|
|
||||||
{@render button(actions.offset, keys.offset)}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if actions.offset?.line}
|
|
||||||
<p class="text-[12px] text-cladd-fg-soft">{actions.offset.line}</p>
|
|
||||||
{/if}
|
|
||||||
</footer>
|
|
||||||
{/if}
|
|
||||||
</article>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
|
||||||
import {
|
|
||||||
type Action,
|
|
||||||
actionsFor,
|
|
||||||
isDebt,
|
|
||||||
movements,
|
|
||||||
} from "#lib/accounts/ledger.js";
|
|
||||||
import { all, lists } from "#lib/api/lists.js";
|
|
||||||
import type { Account, Item, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import type { Binding } from "#lib/keys.svelte.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import DebtCard from "./DebtCard.svelte";
|
|
||||||
|
|
||||||
const KEYS = { more: "+", offset: "=", repay: "S" };
|
|
||||||
|
|
||||||
let { item }: { item: Item<Account> } = $props();
|
|
||||||
|
|
||||||
const history = createQuery(() => ({
|
|
||||||
queryFn: () =>
|
|
||||||
all<TransactionGroup>(`/v1/accounts/${item.id}/transactions`),
|
|
||||||
queryKey: ["debt-history", item.id],
|
|
||||||
}));
|
|
||||||
const assets = createQuery(() => lists.accounts("asset"));
|
|
||||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
|
||||||
|
|
||||||
const list = $derived(
|
|
||||||
history.data ? movements(item, history.data) : undefined
|
|
||||||
);
|
|
||||||
const plan = $derived(
|
|
||||||
list && assets.data && liabilities.data
|
|
||||||
? actionsFor(
|
|
||||||
item,
|
|
||||||
list,
|
|
||||||
assets.data,
|
|
||||||
liabilities.data.filter((d) => isDebt(d) && d.attributes.active)
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const bind = (action: Action | null | undefined, keys: string): Binding[] =>
|
|
||||||
action
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
group: "Debt",
|
|
||||||
keys,
|
|
||||||
label: action.hint,
|
|
||||||
run: () => goto(action.href),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
useKeys(() => [
|
|
||||||
...bind(plan?.repay, KEYS.repay),
|
|
||||||
...bind(plan?.more, KEYS.more),
|
|
||||||
...bind(plan?.offset, KEYS.offset),
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<DebtCard actions={plan} {item} keys={KEYS} {list} named={false} rows={0} />
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Plus from "@lucide/svelte/icons/plus";
|
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
|
||||||
import {
|
|
||||||
type Action,
|
|
||||||
actionsFor,
|
|
||||||
isDebt,
|
|
||||||
movements,
|
|
||||||
owed,
|
|
||||||
type Stance,
|
|
||||||
stance,
|
|
||||||
} from "#lib/accounts/ledger.js";
|
|
||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import { all, lists } from "#lib/api/lists.js";
|
|
||||||
import type {
|
|
||||||
Account,
|
|
||||||
Item,
|
|
||||||
Page,
|
|
||||||
TransactionGroup,
|
|
||||||
} from "#lib/api/types.js";
|
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import Money from "#lib/components/Money.svelte";
|
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
|
||||||
import { today } from "#lib/dates.js";
|
|
||||||
import { formatDate, toNumber } from "#lib/format.js";
|
|
||||||
import type { Binding } from "#lib/keys.svelte.js";
|
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import DebtCard from "./DebtCard.svelte";
|
|
||||||
import {
|
|
||||||
editUrl,
|
|
||||||
LIABILITY_LABEL,
|
|
||||||
PERIOD_LABEL,
|
|
||||||
showUrl,
|
|
||||||
TYPE_ONE,
|
|
||||||
} from "./links.js";
|
|
||||||
|
|
||||||
const KEYS = { more: "+", offset: "o", repay: "s" };
|
|
||||||
const ALIASES = { more: "", offset: "=", repay: "S" };
|
|
||||||
const ORDER: Stance[] = ["owed", "owe", "settled"];
|
|
||||||
const CREATE = "/accounts/create/liabilities";
|
|
||||||
|
|
||||||
const liabilities = createQuery(() => ({
|
|
||||||
queryFn: () =>
|
|
||||||
all<Account>("/v1/accounts", {
|
|
||||||
active: "true",
|
|
||||||
date: today(),
|
|
||||||
type: "liabilities",
|
|
||||||
}),
|
|
||||||
queryKey: ["ledger-accounts"],
|
|
||||||
}));
|
|
||||||
const inactive = createQuery(() => ({
|
|
||||||
queryFn: () =>
|
|
||||||
api<Page<Account>>("/v1/accounts", {
|
|
||||||
query: { active: "false", limit: 1, type: "liabilities" },
|
|
||||||
}),
|
|
||||||
queryKey: ["accounts-count", "liabilities", false],
|
|
||||||
}));
|
|
||||||
const assets = createQuery(() => lists.accounts("asset"));
|
|
||||||
|
|
||||||
const debts = $derived((liabilities.data ?? []).filter(isDebt));
|
|
||||||
const others = $derived(
|
|
||||||
(liabilities.data ?? []).filter((item) => !isDebt(item))
|
|
||||||
);
|
|
||||||
const history = createQuery(() => ({
|
|
||||||
enabled: debts.length > 0,
|
|
||||||
queryFn: async () =>
|
|
||||||
new Map(
|
|
||||||
await Promise.all(
|
|
||||||
debts.map(
|
|
||||||
async (item) =>
|
|
||||||
[
|
|
||||||
item.id,
|
|
||||||
movements(
|
|
||||||
item,
|
|
||||||
await all<TransactionGroup>(
|
|
||||||
`/v1/accounts/${item.id}/transactions`
|
|
||||||
)
|
|
||||||
),
|
|
||||||
] as const
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
queryKey: ["ledger-history", debts.map((d) => d.id).join(",")],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const rank = (item: Item<Account>) => ORDER.indexOf(stance(item.attributes));
|
|
||||||
const lastDate = (item: Item<Account>) =>
|
|
||||||
history.data?.get(item.id)?.[0]?.date ?? "";
|
|
||||||
const cards = $derived(
|
|
||||||
debts.toSorted(
|
|
||||||
(x, y) => rank(x) - rank(y) || lastDate(y).localeCompare(lastDate(x))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const items = $derived([...cards, ...others]);
|
|
||||||
const index = $derived(new Map(items.map((item, i) => [item.id, i])));
|
|
||||||
|
|
||||||
const plans = $derived(
|
|
||||||
new Map(
|
|
||||||
cards.map((item) => [
|
|
||||||
item.id,
|
|
||||||
history.data && assets.data
|
|
||||||
? actionsFor(
|
|
||||||
item,
|
|
||||||
history.data.get(item.id) ?? [],
|
|
||||||
assets.data,
|
|
||||||
debts
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Sum {
|
|
||||||
code: string;
|
|
||||||
decimals: number;
|
|
||||||
owe: number;
|
|
||||||
owed: number;
|
|
||||||
symbol: string;
|
|
||||||
}
|
|
||||||
const sums = $derived.by(() => {
|
|
||||||
const map = new Map<string, Sum>();
|
|
||||||
for (const { attributes: a } of debts) {
|
|
||||||
const code = a.currency_code ?? "";
|
|
||||||
const sum = map.get(code) ?? {
|
|
||||||
code,
|
|
||||||
decimals: a.currency_decimal_places ?? 2,
|
|
||||||
owe: 0,
|
|
||||||
owed: 0,
|
|
||||||
symbol: a.currency_symbol ?? code,
|
|
||||||
};
|
|
||||||
const position = owed(a);
|
|
||||||
if (position > 0) {
|
|
||||||
sum.owed += position;
|
|
||||||
} else {
|
|
||||||
sum.owe -= position;
|
|
||||||
}
|
|
||||||
map.set(code, sum);
|
|
||||||
}
|
|
||||||
return [...map.values()];
|
|
||||||
});
|
|
||||||
const owedToYou = $derived(sums.filter((s) => s.owed > 0));
|
|
||||||
const youOwe = $derived(sums.filter((s) => s.owe > 0));
|
|
||||||
|
|
||||||
function current(): Item<Account> | undefined {
|
|
||||||
return nav.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bind(kind: keyof typeof KEYS, action?: Action | null): Binding[] {
|
|
||||||
if (!action) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const run = () => goto(action.href);
|
|
||||||
const main = { group: "Debts", keys: KEYS[kind], label: action.hint, run };
|
|
||||||
const alias = ALIASES[kind];
|
|
||||||
return alias ? [main, { ...main, hidden: true, keys: alias }] : [main];
|
|
||||||
}
|
|
||||||
|
|
||||||
const nav = new ListNav<Item<Account>>({
|
|
||||||
edit: (item) => goto(editUrl(item.id)),
|
|
||||||
extra: (): Binding[] => {
|
|
||||||
const plan = plans.get(current()?.id ?? "");
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
group: "List",
|
|
||||||
keys: "Enter",
|
|
||||||
label: "Open account",
|
|
||||||
run: () => {
|
|
||||||
const item = current();
|
|
||||||
if (item) {
|
|
||||||
goto(showUrl(item.id));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...bind("repay", plan?.repay),
|
|
||||||
...bind("more", plan?.more),
|
|
||||||
...bind("offset", plan?.offset),
|
|
||||||
{
|
|
||||||
group: "Accounts",
|
|
||||||
keys: "c",
|
|
||||||
label: `Create a new ${TYPE_ONE.liabilities}`,
|
|
||||||
run: () => goto(CREATE),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
id: "ledger",
|
|
||||||
items: () => items,
|
|
||||||
key: (item) => item.id,
|
|
||||||
});
|
|
||||||
nav.use();
|
|
||||||
|
|
||||||
function rowClass(i: number) {
|
|
||||||
if (nav.cursor === i) {
|
|
||||||
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
|
|
||||||
}
|
|
||||||
return "hover:bg-row-hover";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet totals(
|
|
||||||
label: string,
|
|
||||||
list: Sum[],
|
|
||||||
key: "owe" | "owed"
|
|
||||||
)}
|
|
||||||
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
|
||||||
<span class="text-[13px] text-cladd-fg-soft">{label}</span>
|
|
||||||
<span class="text-[24px] leading-tight">
|
|
||||||
{#if list.length > 0}
|
|
||||||
<Money
|
|
||||||
class="font-semibold"
|
|
||||||
inline
|
|
||||||
parts={list.map((sum) => ({ ...sum, amount: sum[key] }))}
|
|
||||||
tone={key === "owed" ? "income" : "expense"}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<span class="num font-semibold text-cladd-fg-soft">0</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<PageHeader crumbs={[{ label: "Liabilities" }]} title="Accounts">
|
|
||||||
{#snippet actions()}
|
|
||||||
<a
|
|
||||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-green inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-primary"
|
|
||||||
href={CREATE}
|
|
||||||
title="Create a new liability c"
|
|
||||||
>
|
|
||||||
<Plus class="size-3.5" />
|
|
||||||
Create a new liability
|
|
||||||
</a>
|
|
||||||
{/snippet}
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
{#if liabilities.isPending}
|
|
||||||
<div class="h-40"></div>
|
|
||||||
{:else if liabilities.isError}
|
|
||||||
<p class="text-expense">{liabilities.error.message}</p>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
{#if debts.length > 0}
|
|
||||||
<section
|
|
||||||
class="cladd-surface cladd-outline grid gap-3 rounded-cladd-lg p-3 md:grid-cols-2"
|
|
||||||
>
|
|
||||||
{@render totals("Owed to you", owedToYou, "owed")}
|
|
||||||
{@render totals("You owe", youOwe, "owe")}
|
|
||||||
</section>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{#each cards as item (item.id)}
|
|
||||||
{@const i = index.get(item.id) ?? -1}
|
|
||||||
<DebtCard
|
|
||||||
actions={plans.get(item.id) ?? null}
|
|
||||||
current={nav.cursor === i}
|
|
||||||
id={nav.rowId(i)}
|
|
||||||
{item}
|
|
||||||
keys={KEYS}
|
|
||||||
list={history.data?.get(item.id)}
|
|
||||||
onfocusin={() => (nav.cursor = i)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="cladd-surface cladd-outline flex flex-col items-center gap-3 rounded-cladd-lg px-3 py-10 text-center"
|
|
||||||
>
|
|
||||||
<p class="font-semibold text-[15px]">No debts yet</p>
|
|
||||||
<a class="text-brand underline dark:text-(--ff-series-1)" href={CREATE}
|
|
||||||
>Create a new liability</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if others.length > 0}
|
|
||||||
<Box body="p-0" title="Loans and other liabilities">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full border-collapse text-[13px]">
|
|
||||||
<thead class="border-cladd-outline border-b text-left">
|
|
||||||
<tr class="h-8 text-[12px] text-cladd-fg-soft">
|
|
||||||
<th class="px-3 font-semibold" scope="col">Name</th>
|
|
||||||
<th class="px-3 font-semibold max-md:hidden" scope="col">
|
|
||||||
Type of liability
|
|
||||||
</th>
|
|
||||||
<th class="px-3 font-semibold max-md:hidden" scope="col">
|
|
||||||
Interest
|
|
||||||
</th>
|
|
||||||
<th class="px-3 font-semibold max-md:hidden" scope="col">
|
|
||||||
Last activity
|
|
||||||
</th>
|
|
||||||
<th class="px-3 text-right font-semibold" scope="col">
|
|
||||||
Amount due
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each others as item (item.id)}
|
|
||||||
{@const a = item.attributes}
|
|
||||||
{@const i = index.get(item.id) ?? -1}
|
|
||||||
<tr
|
|
||||||
class="h-7 border-cladd-outline/60 border-t {rowClass(i)}"
|
|
||||||
id={nav.rowId(i)}
|
|
||||||
onclick={(event) => nav.click(i, event)}
|
|
||||||
>
|
|
||||||
<td class="px-3">
|
|
||||||
<a
|
|
||||||
class="text-brand hover:underline dark:text-(--ff-series-1)"
|
|
||||||
href={showUrl(item.id)}
|
|
||||||
>{a.name}</a
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 max-md:hidden">
|
|
||||||
{LIABILITY_LABEL[a.liability_type ?? ""] ?? ""}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-3 max-md:hidden">
|
|
||||||
<span class="num">{toNumber(a.interest)}%</span>
|
|
||||||
<span class="text-cladd-fg-soft"
|
|
||||||
>{PERIOD_LABEL[a.interest_period ?? ""] ??
|
|
||||||
a.interest_period}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="whitespace-nowrap px-3 text-cladd-fg-soft max-md:hidden"
|
|
||||||
>
|
|
||||||
{a.last_activity
|
|
||||||
? formatDate(a.last_activity.slice(0, 10))
|
|
||||||
: "never"}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-3 text-right">
|
|
||||||
<Amount
|
|
||||||
amount={a.debt_amount}
|
|
||||||
code={a.currency_code}
|
|
||||||
decimals={a.currency_decimal_places}
|
|
||||||
symbol={a.currency_symbol}
|
|
||||||
tone="expense"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if (inactive.data?.meta.pagination?.total ?? 0) > 0}
|
|
||||||
{@const count = inactive.data?.meta.pagination?.total ?? 0}
|
|
||||||
<a
|
|
||||||
class="self-start text-[13px] text-cladd-fg-soft hover:underline"
|
|
||||||
href="/inactive-accounts/liabilities"
|
|
||||||
>{count}
|
|
||||||
inactive {count === 1 ? "liability" : "liabilities"}</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
export type Column =
|
|
||||||
| "name"
|
|
||||||
| "month"
|
|
||||||
| "share"
|
|
||||||
| "visits"
|
|
||||||
| "avg"
|
|
||||||
| "spark"
|
|
||||||
| "typical"
|
|
||||||
| "change"
|
|
||||||
| "last"
|
|
||||||
| "category";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import type { Kind, Line, Merchant } from "#lib/accounts/merchants.js";
|
|
||||||
import { showUrl } from "#lib/components/accounts/links.js";
|
|
||||||
import { currency } from "#lib/components/dashboard/data.js";
|
|
||||||
import { today } from "#lib/dates.js";
|
|
||||||
import { formatDate, formatMoney, percent } from "#lib/format.js";
|
|
||||||
import MonthSpark from "./MonthSpark.svelte";
|
|
||||||
|
|
||||||
const LOUD = 0.15;
|
|
||||||
const SMALL = 0.005;
|
|
||||||
|
|
||||||
let {
|
|
||||||
col,
|
|
||||||
merchant: m,
|
|
||||||
kind,
|
|
||||||
compare,
|
|
||||||
max,
|
|
||||||
past,
|
|
||||||
note = "",
|
|
||||||
}: {
|
|
||||||
col: Column;
|
|
||||||
merchant: Merchant;
|
|
||||||
kind: Kind;
|
|
||||||
compare: boolean;
|
|
||||||
max: number;
|
|
||||||
past: number;
|
|
||||||
note?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const money = (line: Line, amount: number, decimals?: number) => {
|
|
||||||
const cur = currency(line.code);
|
|
||||||
const text = formatMoney({
|
|
||||||
...cur,
|
|
||||||
amount,
|
|
||||||
decimals: decimals ?? cur.decimals,
|
|
||||||
});
|
|
||||||
return line.approx ? `≈${text}` : text;
|
|
||||||
};
|
|
||||||
|
|
||||||
function change(line: Line): [string, string] {
|
|
||||||
if (!compare || m.quiet) {
|
|
||||||
return ["", ""];
|
|
||||||
}
|
|
||||||
if (line.seen === 0) {
|
|
||||||
return ["new", "text-warn"];
|
|
||||||
}
|
|
||||||
if (line.ratio === null) {
|
|
||||||
return [`${line.seen} of ${past} months`, "text-cladd-fg-soft"];
|
|
||||||
}
|
|
||||||
const pct = Math.round(line.ratio * 100);
|
|
||||||
let sign = "";
|
|
||||||
if (pct !== 0) {
|
|
||||||
sign = pct > 0 ? "+" : "−";
|
|
||||||
}
|
|
||||||
const bad = kind === "expense" ? line.ratio > LOUD : line.ratio < -LOUD;
|
|
||||||
return [
|
|
||||||
`${sign}${percent(Math.abs(line.ratio))}`,
|
|
||||||
bad ? "text-expense" : "text-cladd-fg-soft",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastText = $derived(
|
|
||||||
m.last.slice(0, 4) === today().slice(0, 4)
|
|
||||||
? formatDate(m.last, "short")
|
|
||||||
: formatDate(m.last)
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if col === "name"}
|
|
||||||
<a
|
|
||||||
class="block max-w-[9rem] truncate sm:max-w-[18rem] text-brand dark:text-(--ff-series-1) hover:underline"
|
|
||||||
href={showUrl(m.id)}
|
|
||||||
title={m.name}
|
|
||||||
>{m.name}</a
|
|
||||||
>
|
|
||||||
{:else if col === "month"}
|
|
||||||
{#each m.lines as line (line.code)}
|
|
||||||
<div class="num">{line.amount ? money(line, line.amount) : ""}</div>
|
|
||||||
{/each}
|
|
||||||
{:else if col === "share"}
|
|
||||||
{#if m.share > 0}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="h-2 w-16 shrink-0">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-[2px] bg-brand/70"
|
|
||||||
style:width="{Math.max(2, (m.share / (max || 1)) * 100)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="num w-8 text-right text-cladd-fg-soft"
|
|
||||||
>{m.share < SMALL ? "<1%" : percent(m.share)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if col === "visits"}
|
|
||||||
<span class="num">{m.visits || ""}</span>
|
|
||||||
{:else if col === "avg"}
|
|
||||||
{#each m.lines as line (line.code)}
|
|
||||||
<div class="num text-cladd-fg-soft">
|
|
||||||
{line.visits ? money(line, line.amount / line.visits, 0) : ""}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else if col === "spark"}
|
|
||||||
{#each m.lines as line (line.code)}
|
|
||||||
<div class="flex h-[1lh] items-center justify-end">
|
|
||||||
<MonthSpark typical={line.typical} values={line.months} />
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else if col === "typical"}
|
|
||||||
{#each m.lines as line (line.code)}
|
|
||||||
{@const value = compare && !m.quiet ? line.base : line.typical}
|
|
||||||
<div class="num text-cladd-fg-soft">
|
|
||||||
{value ? money(line, value, 0) : ""}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{:else if col === "change"}
|
|
||||||
{#each m.lines as line (line.code)}
|
|
||||||
{@const [text, tone] = change(line)}
|
|
||||||
<div class="num {tone}">{text}</div>
|
|
||||||
{/each}
|
|
||||||
{:else if col === "last"}
|
|
||||||
<span class="num text-cladd-fg-soft">{lastText}</span>
|
|
||||||
{:else}
|
|
||||||
<span class="flex max-w-[16rem] items-baseline gap-1.5">
|
|
||||||
<span class="truncate {m.category ? "" : "text-cladd-fg-softer"}"
|
|
||||||
>{m.category ?? "none"}</span
|
|
||||||
>
|
|
||||||
{#if m.others}
|
|
||||||
<span class="shrink-0 text-cladd-fg-softer">+{m.others}</span>
|
|
||||||
{/if}
|
|
||||||
{#if note}
|
|
||||||
<span class="shrink-0 text-cladd-fg-soft">{note}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
@@ -1,707 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
|
||||||
import Plus from "@lucide/svelte/icons/plus";
|
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
|
||||||
import { tick } from "svelte";
|
|
||||||
import {
|
|
||||||
byCategory,
|
|
||||||
type Kind,
|
|
||||||
type Merchant,
|
|
||||||
SORT_KEYS,
|
|
||||||
type SortKey,
|
|
||||||
sortBy,
|
|
||||||
type Total,
|
|
||||||
} from "#lib/accounts/merchants.js";
|
|
||||||
import { useMerchants } from "#lib/accounts/merchants.svelte.js";
|
|
||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import { invalidate, lists } from "#lib/api/lists.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import { currency } from "#lib/components/dashboard/data.js";
|
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
|
||||||
import SuggestInput from "#lib/components/transactions/SuggestInput.svelte";
|
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuShortcut,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "#lib/components/ui/context-menu/index.js";
|
|
||||||
import { today } from "#lib/dates.js";
|
|
||||||
import { formatDate, formatMoney, percent } from "#lib/format.js";
|
|
||||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
|
||||||
import { versusText } from "#lib/reports/typical.js";
|
|
||||||
import {
|
|
||||||
type Outcome,
|
|
||||||
patchFor,
|
|
||||||
type Targets,
|
|
||||||
updateGroups,
|
|
||||||
} from "#lib/transactions/mass.js";
|
|
||||||
import type { Suggestion } from "#lib/transactions/query.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import { editUrl, openOld, showUrl, TYPE_ONE, TYPE_TITLE } from "./links.js";
|
|
||||||
import MerchantCell, { type Column } from "./MerchantCell.svelte";
|
|
||||||
|
|
||||||
const NONE = "\u0000";
|
|
||||||
const SAME = 0.03;
|
|
||||||
const WIDE: Column[] = [
|
|
||||||
"share",
|
|
||||||
"visits",
|
|
||||||
"avg",
|
|
||||||
"spark",
|
|
||||||
"typical",
|
|
||||||
"last",
|
|
||||||
"category",
|
|
||||||
];
|
|
||||||
|
|
||||||
let { kind }: { kind: Kind } = $props();
|
|
||||||
|
|
||||||
const data = useMerchants(() => kind);
|
|
||||||
const noun = $derived(kind === "expense" ? "Merchants" : "Payers");
|
|
||||||
const createHref = $derived(`/accounts/create/${kind}`);
|
|
||||||
|
|
||||||
const params = $derived(page.url.searchParams);
|
|
||||||
const sort = $derived<SortKey>(
|
|
||||||
SORT_KEYS.find((key) => key === params.get("sort")) ?? "month"
|
|
||||||
);
|
|
||||||
const grouped = $derived(params.get("group") === "category");
|
|
||||||
const quietParam = $derived(params.has("quiet"));
|
|
||||||
|
|
||||||
let filter = $state(page.url.searchParams.get("filter") ?? "");
|
|
||||||
let filterInput = $state<HTMLInputElement>();
|
|
||||||
let menu = $state<Merchant | null>(null);
|
|
||||||
let armed = $state(false);
|
|
||||||
let failure = $state("");
|
|
||||||
|
|
||||||
let picking = $state<Merchant[] | null>(null);
|
|
||||||
let text = $state("");
|
|
||||||
let input = $state<HTMLInputElement>();
|
|
||||||
let progress = $state("");
|
|
||||||
let notes = $state<Record<string, string>>({});
|
|
||||||
|
|
||||||
const day = $derived(data.day);
|
|
||||||
const compare = $derived(day !== null);
|
|
||||||
const current = $derived(day !== null && day < 31);
|
|
||||||
const asOf = $derived(formatDate(today(), "short"));
|
|
||||||
const needle = $derived(filter.trim().toLowerCase());
|
|
||||||
const all = $derived(data.board?.merchants ?? []);
|
|
||||||
const matching = $derived(
|
|
||||||
needle
|
|
||||||
? all.filter((m) =>
|
|
||||||
`${m.name} ${m.category ?? ""}`.toLowerCase().includes(needle)
|
|
||||||
)
|
|
||||||
: all
|
|
||||||
);
|
|
||||||
const order = $derived(sortBy(sort));
|
|
||||||
const active = $derived(matching.filter((m) => !m.quiet).sort(order));
|
|
||||||
const quiet = $derived(matching.filter((m) => m.quiet).sort(order));
|
|
||||||
const quietOpen = $derived(quietParam || needle !== "");
|
|
||||||
const sections = $derived(grouped ? byCategory(active, "No category") : null);
|
|
||||||
const rows = $derived([
|
|
||||||
...(sections ? sections.flatMap((s) => s.merchants) : active),
|
|
||||||
...(quietOpen ? quiet : []),
|
|
||||||
]);
|
|
||||||
const index = $derived(new Map(rows.map((m, i) => [m.id, i])));
|
|
||||||
const max = $derived(Math.max(0, ...all.map((m) => m.share)));
|
|
||||||
const activeCount = $derived(all.filter((m) => !m.quiet).length);
|
|
||||||
const categorised = $derived(all.some((m) => m.category));
|
|
||||||
const columns = $derived<{ key: Column; label: string; sort?: SortKey }[]>([
|
|
||||||
{
|
|
||||||
key: "name",
|
|
||||||
label: kind === "expense" ? "Merchant" : "Payer",
|
|
||||||
sort: "name",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "month",
|
|
||||||
label: current ? "This month" : "In period",
|
|
||||||
sort: "month",
|
|
||||||
},
|
|
||||||
{ key: "share", label: "Share" },
|
|
||||||
{
|
|
||||||
key: "visits",
|
|
||||||
label: kind === "expense" ? "Visits" : "Payments",
|
|
||||||
sort: "visits",
|
|
||||||
},
|
|
||||||
{ key: "avg", label: "Average" },
|
|
||||||
{ key: "spark", label: "12 months", sort: "year" },
|
|
||||||
{ key: "typical", label: current ? `Typical by ${asOf}` : "Typical month" },
|
|
||||||
{ key: "change", label: "vs typical" },
|
|
||||||
{ key: "last", label: "Last", sort: "last" },
|
|
||||||
...(categorised ? [{ key: "category" as const, label: "Category" }] : []),
|
|
||||||
]);
|
|
||||||
const NUMERIC = new Set<Column>([
|
|
||||||
"month",
|
|
||||||
"visits",
|
|
||||||
"avg",
|
|
||||||
"spark",
|
|
||||||
"typical",
|
|
||||||
"change",
|
|
||||||
"last",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const categories = createQuery(() => ({
|
|
||||||
...lists.categories(),
|
|
||||||
enabled: picking !== null,
|
|
||||||
}));
|
|
||||||
const suggestions = $derived.by<Suggestion[]>(() => {
|
|
||||||
const names = (categories.data ?? []).map((c) => c.attributes.name);
|
|
||||||
const query = text.trim();
|
|
||||||
const found: Suggestion[] = fuzzyFilter(names, query, (n) => n, 8).map(
|
|
||||||
(name) => ({ id: name, insert: name, label: name })
|
|
||||||
);
|
|
||||||
if (query && !names.some((n) => n.toLowerCase() === query.toLowerCase())) {
|
|
||||||
found.push({
|
|
||||||
hint: "new",
|
|
||||||
id: `new:${query}`,
|
|
||||||
insert: query,
|
|
||||||
label: query,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!query) {
|
|
||||||
found.unshift({ id: NONE, insert: NONE, label: "(no category)" });
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
});
|
|
||||||
|
|
||||||
const journals = (targets: Targets) =>
|
|
||||||
[...targets.values()].reduce((n, set) => n + (set?.size ?? 0), 0);
|
|
||||||
|
|
||||||
function merge(list: Merchant[]): Targets {
|
|
||||||
const targets: Targets = new Map();
|
|
||||||
for (const m of list) {
|
|
||||||
for (const [group, set] of m.targets) {
|
|
||||||
targets.set(
|
|
||||||
group,
|
|
||||||
new Set([...(targets.get(group) ?? []), ...(set ?? [])])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickCount = $derived(picking ? journals(merge(picking)) : 0);
|
|
||||||
|
|
||||||
async function pick() {
|
|
||||||
const list = chosen.filter((m) => m.targets.size > 0);
|
|
||||||
if (list.length === 0) {
|
|
||||||
notes = Object.fromEntries(
|
|
||||||
chosen.map((m) => [m.id, `nothing in ${period.label}`])
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
picking = list;
|
|
||||||
text = "";
|
|
||||||
progress = "";
|
|
||||||
await tick();
|
|
||||||
input?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
if (!progress) {
|
|
||||||
picking = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apply(raw: string) {
|
|
||||||
const list = picking;
|
|
||||||
if (!list || progress) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const value = raw === NONE ? null : raw.trim() || null;
|
|
||||||
const say = (o: Outcome) => {
|
|
||||||
progress = `Setting “${value ?? "no category"}”: ${o.done} of ${o.total}`;
|
|
||||||
};
|
|
||||||
const outcome = await updateGroups(
|
|
||||||
merge(list),
|
|
||||||
patchFor({ id: null, mode: "category", value }),
|
|
||||||
say
|
|
||||||
);
|
|
||||||
await invalidate();
|
|
||||||
const failed = outcome.errors.join("; ");
|
|
||||||
const next = { ...notes };
|
|
||||||
for (const m of list) {
|
|
||||||
next[m.id] = failed
|
|
||||||
? `failed: ${failed}`
|
|
||||||
: `${value ? "set" : "cleared"} on ${journals(m.targets)}`;
|
|
||||||
}
|
|
||||||
notes = next;
|
|
||||||
progress = "";
|
|
||||||
picking = null;
|
|
||||||
nav.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
let pending = "";
|
|
||||||
|
|
||||||
function setParam(name: string, value: string | null) {
|
|
||||||
const url = new URL(pending || page.url.href);
|
|
||||||
if (value === null) {
|
|
||||||
url.searchParams.delete(name);
|
|
||||||
} else {
|
|
||||||
url.searchParams.set(name, value);
|
|
||||||
}
|
|
||||||
url.searchParams.set("start", period.start);
|
|
||||||
url.searchParams.set("end", period.end);
|
|
||||||
pending = url.href;
|
|
||||||
goto(url, { replace: true, reset: false }).finally(() => {
|
|
||||||
if (pending === url.href) {
|
|
||||||
pending = "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const setSort = (key: SortKey) =>
|
|
||||||
setParam("sort", key === "month" ? null : key);
|
|
||||||
const toggleGroup = () => setParam("group", grouped ? null : "category");
|
|
||||||
const toggleQuiet = () => setParam("quiet", quietParam ? null : "1");
|
|
||||||
|
|
||||||
const nav = new ListNav<Merchant>({
|
|
||||||
edit: (m) => goto(editUrl(m.id)),
|
|
||||||
extra: () => [
|
|
||||||
{
|
|
||||||
group: noun,
|
|
||||||
keys: "/",
|
|
||||||
label: `Filter ${noun.toLowerCase()}`,
|
|
||||||
run: () => {
|
|
||||||
filterInput?.focus();
|
|
||||||
filterInput?.select();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: noun,
|
|
||||||
keys: "c",
|
|
||||||
label: `Create a new ${TYPE_ONE[kind]}`,
|
|
||||||
run: () => goto(createHref),
|
|
||||||
},
|
|
||||||
...SORT_KEYS.map((key, i) => ({
|
|
||||||
group: noun,
|
|
||||||
keys: String(i + 1),
|
|
||||||
label: `Sort by ${columns.find((c) => c.sort === key)?.label.toLowerCase()}`,
|
|
||||||
run: () => setSort(key),
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
group: noun,
|
|
||||||
keys: ";",
|
|
||||||
label: grouped ? "Ungroup" : "Group by category",
|
|
||||||
run: toggleGroup,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: noun,
|
|
||||||
keys: "i",
|
|
||||||
label: quietParam
|
|
||||||
? `Hide quiet ${noun.toLowerCase()}`
|
|
||||||
: `Show quiet ${noun.toLowerCase()}`,
|
|
||||||
run: toggleQuiet,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: noun,
|
|
||||||
keys: "s c",
|
|
||||||
label: "Set category on their transactions",
|
|
||||||
run: pick,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
id: "merchants",
|
|
||||||
items: () => rows,
|
|
||||||
key: (m) => m.id,
|
|
||||||
open: (m) => goto(showUrl(m.id)),
|
|
||||||
selectable: true,
|
|
||||||
});
|
|
||||||
nav.use();
|
|
||||||
|
|
||||||
const chosen = $derived.by(() => {
|
|
||||||
if (nav.selected.size > 0) {
|
|
||||||
return all.filter((m) => nav.selected.has(m.id));
|
|
||||||
}
|
|
||||||
return nav.current ? [nav.current] : [];
|
|
||||||
});
|
|
||||||
const anchorId = $derived(
|
|
||||||
picking
|
|
||||||
? (rows.find((m) => picking?.some((p) => p.id === m.id)) ?? nav.current)
|
|
||||||
?.id
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
function onfilter() {
|
|
||||||
nav.cursor = -1;
|
|
||||||
setParam("filter", filter || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onfilterkey(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape" && filter) {
|
|
||||||
filter = "";
|
|
||||||
onfilter();
|
|
||||||
} else if (event.key === "Enter" && rows[0]) {
|
|
||||||
event.preventDefault();
|
|
||||||
filterInput?.blur();
|
|
||||||
nav.move(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function oncontextmenu(event: MouseEvent) {
|
|
||||||
const tr = (event.target as Element).closest("tr[data-id]");
|
|
||||||
armed = false;
|
|
||||||
menu = rows.find((m) => m.id === tr?.getAttribute("data-id")) ?? null;
|
|
||||||
if (menu) {
|
|
||||||
nav.cursor = index.get(menu.id) ?? -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(m: Merchant) {
|
|
||||||
failure = "";
|
|
||||||
try {
|
|
||||||
await api(`/v1/accounts/${m.id}`, { method: "DELETE" });
|
|
||||||
await invalidate();
|
|
||||||
} catch (e) {
|
|
||||||
failure = e instanceof Error ? e.message : String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowClass(m: Merchant, i: number) {
|
|
||||||
if (nav.cursor === i) {
|
|
||||||
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
|
|
||||||
}
|
|
||||||
return nav.isSelected(m) ? "bg-row-selected" : "hover:bg-row-hover";
|
|
||||||
}
|
|
||||||
|
|
||||||
function cellClass(key: Column) {
|
|
||||||
const align = NUMERIC.has(key) ? "text-right" : "";
|
|
||||||
const wide = WIDE.includes(key) ? "hidden md:table-cell" : "";
|
|
||||||
return `${align} ${wide}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function money(t: Total) {
|
|
||||||
const shown = formatMoney({ ...currency(t.code), amount: t.amount });
|
|
||||||
return t.approx ? `≈${shown}` : shown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tone(ratio: number | null) {
|
|
||||||
if (ratio === null || Math.abs(ratio) < SAME) {
|
|
||||||
return "text-cladd-fg-soft";
|
|
||||||
}
|
|
||||||
const bad = kind === "expense" ? ratio > 0 : ratio < 0;
|
|
||||||
return bad ? "text-expense" : "text-cladd-fg-soft";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet sums(
|
|
||||||
list: Total[]
|
|
||||||
)}
|
|
||||||
{#each list as t (t.code)}
|
|
||||||
<div class="num">{money(t)}</div>
|
|
||||||
{/each}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet row(
|
|
||||||
m: Merchant
|
|
||||||
)}
|
|
||||||
{@const i = index.get(m.id) ?? -1}
|
|
||||||
<tr
|
|
||||||
class="border-cladd-outline/60 border-t align-top {rowClass(m, i)}"
|
|
||||||
data-id={m.id}
|
|
||||||
id={nav.rowId(i)}
|
|
||||||
onclick={(event) => nav.click(i, event)}
|
|
||||||
>
|
|
||||||
{#each columns as col (col.key)}
|
|
||||||
<td class="h-7 whitespace-nowrap px-2 py-1 sm:px-3 {cellClass(col.key)}">
|
|
||||||
<MerchantCell
|
|
||||||
col={col.key}
|
|
||||||
{compare}
|
|
||||||
{kind}
|
|
||||||
{max}
|
|
||||||
merchant={m}
|
|
||||||
note={notes[m.id]}
|
|
||||||
past={data.board?.past ?? 0}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{#if anchorId === m.id && picking}
|
|
||||||
<tr class="bg-row-selected/50">
|
|
||||||
<td class="px-3 py-1.5" colspan={columns.length}>
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-[12px]">
|
|
||||||
{#if progress}
|
|
||||||
<span aria-live="polite">{progress}</span>
|
|
||||||
{:else}
|
|
||||||
<label class="text-cladd-fg-soft" for="merchant-category"
|
|
||||||
>Category for {pickCount}
|
|
||||||
{pickCount === 1 ? "transaction" : "transactions"}
|
|
||||||
at
|
|
||||||
{picking.length === 1
|
|
||||||
? picking[0].name
|
|
||||||
: `${picking.length} ${noun.toLowerCase()}`}:</label
|
|
||||||
>
|
|
||||||
<SuggestInput
|
|
||||||
class="w-72"
|
|
||||||
id="merchant-category"
|
|
||||||
onaccept={(item) => {
|
|
||||||
apply(item.insert);
|
|
||||||
return true;
|
|
||||||
}}
|
|
||||||
onblur={cancel}
|
|
||||||
oncancel={cancel}
|
|
||||||
onsubmit={() => apply(text)}
|
|
||||||
placeholder="Type to filter"
|
|
||||||
{suggestions}
|
|
||||||
bind:input
|
|
||||||
bind:value={text}
|
|
||||||
/>
|
|
||||||
<span class="text-cladd-fg-softer"
|
|
||||||
><span class="kbd">↵</span>
|
|
||||||
apply <span class="kbd">Esc</span>
|
|
||||||
cancel</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<PageHeader crumbs={[{ label: TYPE_TITLE[kind] }]} title="Accounts">
|
|
||||||
{#snippet actions()}
|
|
||||||
<a
|
|
||||||
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-green inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-primary"
|
|
||||||
href={createHref}
|
|
||||||
title="Create a new {TYPE_ONE[kind]} c"
|
|
||||||
>
|
|
||||||
<Plus class="size-3.5" />
|
|
||||||
Create a new {TYPE_ONE[kind]}
|
|
||||||
</a>
|
|
||||||
{/snippet}
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<Box body="p-0" title="{noun} in {period.label}">
|
|
||||||
{#snippet tools()}
|
|
||||||
<button
|
|
||||||
aria-pressed={grouped}
|
|
||||||
class="hidden h-7 sm:inline-flex items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] {grouped
|
|
||||||
? "bg-row-selected text-cladd-fg"
|
|
||||||
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
|
||||||
onclick={toggleGroup}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
By category <span class="kbd">;</span>
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
aria-label="Filter {noun.toLowerCase()}"
|
|
||||||
class="cladd-cut h-7 w-36 rounded-cladd-sm px-2 text-[12px] outline-none placeholder:text-cladd-fg-softer sm:w-56"
|
|
||||||
oninput={onfilter}
|
|
||||||
onkeydown={onfilterkey}
|
|
||||||
placeholder="Filter /"
|
|
||||||
type="search"
|
|
||||||
bind:this={filterInput}
|
|
||||||
bind:value={filter}
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
{#if data.error}
|
|
||||||
<p class="p-3 text-expense">{data.error.message}</p>
|
|
||||||
{:else if data.loading || !data.board}
|
|
||||||
<div class="h-40"></div>
|
|
||||||
{:else}
|
|
||||||
{@const b = data.board}
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-0.5 border-cladd-outline border-b px-3 py-2 text-[13px]"
|
|
||||||
>
|
|
||||||
<p class="flex flex-wrap items-baseline gap-x-4 gap-y-0.5">
|
|
||||||
<span class="text-cladd-fg-soft"
|
|
||||||
>{kind === "expense" ? "Spent" : "Earned"}</span
|
|
||||||
>
|
|
||||||
{#each b.totals as t (t.code)}
|
|
||||||
<span class="whitespace-nowrap">
|
|
||||||
<span class="num font-semibold text-[15px]">{money(t)}</span>
|
|
||||||
{#if t.ratio !== null}
|
|
||||||
<span class={tone(t.ratio)}
|
|
||||||
>{versusText(t.ratio)}{current ? ` by ${asOf}` : ""}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-cladd-fg-soft">nothing yet</span>
|
|
||||||
{/each}
|
|
||||||
</p>
|
|
||||||
<p class="text-[12px] text-cladd-fg-soft">
|
|
||||||
{activeCount}
|
|
||||||
of {all.length}
|
|
||||||
{noun.toLowerCase()}
|
|
||||||
{#if activeCount > 5}
|
|
||||||
· top 5 take {percent(b.top)}
|
|
||||||
{/if}
|
|
||||||
{#if b.fresh > 0}
|
|
||||||
· {b.fresh} new
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger
|
|
||||||
class="block {picking ? "overflow-visible" : "overflow-x-auto"}"
|
|
||||||
{oncontextmenu}
|
|
||||||
>
|
|
||||||
<table
|
|
||||||
class="w-full border-collapse text-[13px] transition-opacity {data.stale
|
|
||||||
? "opacity-60"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
<thead class="border-cladd-outline border-b text-left">
|
|
||||||
<tr class="h-8">
|
|
||||||
{#each columns as col (col.key)}
|
|
||||||
<th
|
|
||||||
aria-sort={col.sort && sort === col.sort
|
|
||||||
? "descending"
|
|
||||||
: undefined}
|
|
||||||
class="whitespace-nowrap px-2 font-semibold text-[12px] text-cladd-fg-soft sm:px-3 {cellClass(
|
|
||||||
col.key
|
|
||||||
)}"
|
|
||||||
scope="col"
|
|
||||||
>
|
|
||||||
{#if col.sort}
|
|
||||||
{@const n = SORT_KEYS.indexOf(col.sort) + 1}
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-1 hover:text-cladd-fg {sort ===
|
|
||||||
col.sort
|
|
||||||
? "text-cladd-fg"
|
|
||||||
: ""}"
|
|
||||||
onclick={() => col.sort && setSort(col.sort)}
|
|
||||||
title="Sort {n}"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
{#if sort === col.sort}
|
|
||||||
<ArrowDown class="size-3" />
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="font-normal text-[11px] text-cladd-fg-softer"
|
|
||||||
>{n}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
{col.label}
|
|
||||||
{/if}
|
|
||||||
</th>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#if sections}
|
|
||||||
{#each sections as s (s.key)}
|
|
||||||
<tr class="border-cladd-outline border-t-2 align-top">
|
|
||||||
<th
|
|
||||||
class="px-3 py-1 text-left font-semibold"
|
|
||||||
scope="rowgroup"
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
<span class="font-normal text-cladd-fg-soft"
|
|
||||||
>{s.merchants.length}</span
|
|
||||||
>
|
|
||||||
</th>
|
|
||||||
<td class="px-3 py-1 text-right font-semibold">
|
|
||||||
{@render sums(s.sums)}
|
|
||||||
</td>
|
|
||||||
<td class="hidden px-3 py-1 md:table-cell">
|
|
||||||
<span class="num text-cladd-fg-soft"
|
|
||||||
>{percent(s.share)}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="hidden px-3 py-1 text-right md:table-cell">
|
|
||||||
<span class="num">{s.visits}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-1" colspan={columns.length - 4}></td>
|
|
||||||
</tr>
|
|
||||||
{#each s.merchants as m (m.id)}
|
|
||||||
{@render row(m)}
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each active as m (m.id)}
|
|
||||||
{@render row(m)}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{#if quiet.length > 0}
|
|
||||||
<tr class="border-cladd-outline border-t-2">
|
|
||||||
<td class="px-3 py-1" colspan={columns.length}>
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-1.5 text-[12px] text-cladd-fg-soft hover:text-cladd-fg"
|
|
||||||
onclick={toggleQuiet}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{quietOpen ? "Quiet" : `${quiet.length} quiet`}
|
|
||||||
{current ? "this month" : `in ${period.label}`}
|
|
||||||
{#if !needle}
|
|
||||||
<span class="kbd">i</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{#if quietOpen}
|
|
||||||
{#each quiet as m (m.id)}
|
|
||||||
{@render row(m)}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if rows.length === 0 && needle}
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
class="px-3 py-6 text-center text-cladd-fg-soft"
|
|
||||||
colspan={columns.length}
|
|
||||||
>
|
|
||||||
Nothing matches “{filter}”. Press Escape to clear.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent class="w-56">
|
|
||||||
{#if menu}
|
|
||||||
{@const target = menu}
|
|
||||||
<ContextMenuItem onSelect={() => goto(showUrl(target.id))}>
|
|
||||||
Show <ContextMenuShortcut>↵</ContextMenuShortcut>
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={() => goto(editUrl(target.id))}>
|
|
||||||
Edit <ContextMenuShortcut>e</ContextMenuShortcut>
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onSelect={pick}>
|
|
||||||
Set category <ContextMenuShortcut>s c</ContextMenuShortcut>
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem
|
|
||||||
onSelect={(event) => {
|
|
||||||
if (armed) {
|
|
||||||
remove(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
armed = true;
|
|
||||||
}}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
{armed ? `Press again to delete ${target.name}` : "Delete"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem onSelect={() => openOld(showUrl(target.id))}>
|
|
||||||
Open in old UI
|
|
||||||
</ContextMenuItem>
|
|
||||||
{:else}
|
|
||||||
<ContextMenuItem disabled>Right-click a row</ContextMenuItem>
|
|
||||||
{/if}
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
{/if}
|
|
||||||
{#snippet footer()}
|
|
||||||
<a
|
|
||||||
class="text-brand dark:text-(--ff-series-1) hover:underline"
|
|
||||||
href={createHref}
|
|
||||||
>Create a new {TYPE_ONE[kind]}</a
|
|
||||||
>
|
|
||||||
{#if failure}
|
|
||||||
<span class="text-expense">{failure}</span>
|
|
||||||
{/if}
|
|
||||||
<a
|
|
||||||
class="ml-auto text-cladd-fg-soft hover:underline"
|
|
||||||
href="/inactive-accounts/{kind}"
|
|
||||||
>Inactive {TYPE_TITLE[kind].toLowerCase()}</a
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
</Box>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Kind } from "#lib/accounts/merchants.js";
|
|
||||||
import { useMerchants } from "#lib/accounts/merchants.svelte.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import { indexUrl } from "./links.js";
|
|
||||||
import MerchantCell, { type Column } from "./MerchantCell.svelte";
|
|
||||||
|
|
||||||
const COLUMNS: Column[] = ["name", "month", "share", "change"];
|
|
||||||
|
|
||||||
let { kind, limit = 8 }: { kind: Kind; limit?: number } = $props();
|
|
||||||
|
|
||||||
const data = useMerchants(() => kind);
|
|
||||||
const all = $derived(data.board?.merchants ?? []);
|
|
||||||
const top = $derived(all.filter((m) => !m.quiet).slice(0, limit));
|
|
||||||
const href = $derived(indexUrl(kind));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
body="p-0"
|
|
||||||
{href}
|
|
||||||
title={kind === "expense" ? "Where it went" : "Where it came from"}
|
|
||||||
>
|
|
||||||
{#if data.error}
|
|
||||||
<p class="p-3 text-expense">{data.error.message}</p>
|
|
||||||
{:else if data.board && top.length > 0}
|
|
||||||
<table class="w-full border-collapse text-[13px]">
|
|
||||||
<thead class="sr-only">
|
|
||||||
<tr>
|
|
||||||
<th scope="col">{kind === "expense" ? "Merchant" : "Payer"}</th>
|
|
||||||
<th scope="col">This period</th>
|
|
||||||
<th scope="col">Share</th>
|
|
||||||
<th scope="col">vs typical</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each top as m (m.id)}
|
|
||||||
<tr
|
|
||||||
class="border-cladd-outline/60 border-t align-top first:border-t-0"
|
|
||||||
>
|
|
||||||
{#each COLUMNS as col (col)}
|
|
||||||
<td
|
|
||||||
class="h-7 whitespace-nowrap px-2 py-1 sm:px-3 {col === "name"
|
|
||||||
? ""
|
|
||||||
: "text-right"} {col === "share"
|
|
||||||
? "hidden sm:table-cell"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
<MerchantCell
|
|
||||||
{col}
|
|
||||||
compare={data.day !== null}
|
|
||||||
{kind}
|
|
||||||
max={top[0]?.share ?? 0}
|
|
||||||
merchant={m}
|
|
||||||
past={data.board.past}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{:else if data.board}
|
|
||||||
<p class="p-3 text-cladd-fg-soft">Nothing in this period.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="h-40"></div>
|
|
||||||
{/if}
|
|
||||||
{#snippet footer()}
|
|
||||||
<a class="text-brand dark:text-(--ff-series-1) hover:underline" {href}
|
|
||||||
>See all {all.length}</a
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
</Box>
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Month } from "#lib/accounts/ledger.js";
|
|
||||||
import { currentLocale } from "#lib/format.js";
|
|
||||||
|
|
||||||
let {
|
|
||||||
months,
|
|
||||||
format,
|
|
||||||
}: {
|
|
||||||
months: Month[];
|
|
||||||
format: (value: number) => string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const max = $derived(
|
|
||||||
Math.max(1, ...months.flatMap((m) => [m.sent, m.received]))
|
|
||||||
);
|
|
||||||
const letter = (month: string) =>
|
|
||||||
new Date(`${month}-15T12:00:00`).toLocaleDateString(currentLocale(), {
|
|
||||||
month: "narrow",
|
|
||||||
});
|
|
||||||
const name = (month: string) =>
|
|
||||||
new Date(`${month}-15T12:00:00`).toLocaleDateString(currentLocale(), {
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
const height = (value: number) =>
|
|
||||||
value > 0 ? `max(2px, ${(value / max) * 100}%)` : "0";
|
|
||||||
const title = (m: Month) =>
|
|
||||||
`${name(m.month)}: received ${format(m.received)}, sent ${format(m.sent)}`;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
aria-label="Received and sent per month"
|
|
||||||
class="grid grid-cols-12 gap-x-1"
|
|
||||||
role="img"
|
|
||||||
>
|
|
||||||
{#each months as m (m.month)}
|
|
||||||
<div class="flex h-8 items-end" title={title(m)}>
|
|
||||||
<div
|
|
||||||
class="w-full rounded-t-[2px] bg-income"
|
|
||||||
style:height={height(m.received)}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<div class="col-span-12 h-px bg-cladd-outline"></div>
|
|
||||||
{#each months as m (m.month)}
|
|
||||||
<div class="flex h-8 items-start" title={title(m)}>
|
|
||||||
<div
|
|
||||||
class="w-full rounded-b-[2px] bg-expense"
|
|
||||||
style:height={height(m.sent)}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{#each months as m (m.month)}
|
|
||||||
<span class="pt-0.5 text-center text-[10px] text-cladd-fg-softer"
|
|
||||||
>{letter(m.month)}</span
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
let {
|
|
||||||
values,
|
|
||||||
typical = 0,
|
|
||||||
width = 72,
|
|
||||||
height = 14,
|
|
||||||
}: {
|
|
||||||
values: number[];
|
|
||||||
typical?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const GAP = 1;
|
|
||||||
|
|
||||||
const max = $derived(Math.max(typical, ...values) || 1);
|
|
||||||
const step = $derived(width / Math.max(1, values.length));
|
|
||||||
const y = (value: number) => height - (value / max) * height;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svg aria-hidden="true" class="block shrink-0" {height} {width}>
|
|
||||||
{#each values as value, i (i)}
|
|
||||||
{#if value > 0}
|
|
||||||
<rect
|
|
||||||
class={i === values.length - 1
|
|
||||||
? "fill-brand"
|
|
||||||
: "fill-cladd-fg-softer opacity-60"}
|
|
||||||
height={Math.max(1, height - y(value))}
|
|
||||||
width={Math.max(1, step - GAP)}
|
|
||||||
x={i * step}
|
|
||||||
y={Math.min(y(value), height - 1)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{#if typical > 0}
|
|
||||||
<line
|
|
||||||
class="stroke-cladd-fg"
|
|
||||||
stroke-dasharray="2 2"
|
|
||||||
stroke-width="1"
|
|
||||||
x1="0"
|
|
||||||
x2={width}
|
|
||||||
y1={y(typical) + 0.5}
|
|
||||||
y2={y(typical) + 0.5}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</svg>
|
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
type ViewRange,
|
type ViewRange,
|
||||||
} from "#lib/dates.js";
|
} from "#lib/dates.js";
|
||||||
import { toNumber } from "#lib/format.js";
|
import { toNumber } from "#lib/format.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period, periodLabel } from "#lib/period.svelte.js";
|
import { period, periodLabel } from "#lib/period.svelte.js";
|
||||||
|
|
||||||
const COUNT = 12;
|
const COUNT = 12;
|
||||||
@@ -51,7 +50,6 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
interface Flow {
|
interface Flow {
|
||||||
approx?: boolean;
|
|
||||||
code: string;
|
code: string;
|
||||||
earned: number;
|
earned: number;
|
||||||
spent: number;
|
spent: number;
|
||||||
@@ -78,34 +76,12 @@
|
|||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
const merged = $derived.by(() => {
|
|
||||||
if (!display.active) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const map = new Map<string, Flow[]>();
|
|
||||||
for (const [key, list] of byStart) {
|
|
||||||
const flow: Flow = { code: display.code, earned: 0, spent: 0 };
|
|
||||||
for (const f of list) {
|
|
||||||
const rate = display.rate(f.code);
|
|
||||||
if (rate === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
flow.earned += f.earned * rate;
|
|
||||||
flow.spent += f.spent * rate;
|
|
||||||
flow.approx ||= f.code !== display.code;
|
|
||||||
}
|
|
||||||
map.set(key, [flow]);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
const shown = $derived(merged ?? byStart);
|
|
||||||
const main = $derived(merged ? display.code : code);
|
|
||||||
const scale = $derived(
|
const scale = $derived(
|
||||||
Math.max(
|
Math.max(
|
||||||
1,
|
1,
|
||||||
...[...shown.values()].flatMap((list) =>
|
...[...byStart.values()].flatMap((list) =>
|
||||||
list
|
list
|
||||||
.filter((f) => f.code === main)
|
.filter((f) => f.code === code)
|
||||||
.map((f) => Math.max(f.earned, f.spent))
|
.map((f) => Math.max(f.earned, f.spent))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -119,7 +95,7 @@
|
|||||||
class="-mr-px grid grid-cols-1 overflow-hidden @xs:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-6"
|
class="-mr-px grid grid-cols-1 overflow-hidden @xs:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-6"
|
||||||
>
|
>
|
||||||
{#each blocks as block (block.start)}
|
{#each blocks as block (block.start)}
|
||||||
{@const list = (shown.get(block.start) ?? []).filter(
|
{@const list = (byStart.get(block.start) ?? []).filter(
|
||||||
(f) => f.earned || f.spent
|
(f) => f.earned || f.spent
|
||||||
)}
|
)}
|
||||||
<li class="border-cladd-outline/60 border-r border-b">
|
<li class="border-cladd-outline/60 border-r border-b">
|
||||||
@@ -141,20 +117,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each list as flow (flow.code)}
|
{#each list as flow (flow.code)}
|
||||||
{@const fmt = money(currency(flow.code))}
|
{@const fmt = money(currency(flow.code))}
|
||||||
{@const mark = flow.approx ? "≈" : ""}
|
|
||||||
<span
|
<span
|
||||||
class="num flex flex-wrap justify-between gap-x-2 text-[12px]"
|
class="num flex flex-wrap justify-between gap-x-2 text-[12px]"
|
||||||
>
|
>
|
||||||
{#if flow.earned}
|
{#if flow.earned}
|
||||||
<span class="text-income">{mark}+{fmt(flow.earned)}</span>
|
<span class="text-income">+{fmt(flow.earned)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if flow.spent}
|
{#if flow.spent}
|
||||||
<span class="ml-auto text-expense"
|
<span class="ml-auto text-expense">−{fmt(flow.spent)}</span>
|
||||||
>{mark}−{fmt(flow.spent)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if flow.code === main}
|
{#if flow.code === code}
|
||||||
<span aria-hidden="true" class="flex h-1 gap-0.5">
|
<span aria-hidden="true" class="flex h-1 gap-0.5">
|
||||||
<span class="flex flex-1 justify-end">
|
<span class="flex flex-1 justify-end">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let status = $state("");
|
let status = $state("");
|
||||||
let failure = $state("");
|
let failure = $state("");
|
||||||
let pressed = $state(false);
|
|
||||||
|
|
||||||
const account = createQuery(() => ({
|
const account = createQuery(() => ({
|
||||||
queryFn: () => api<One<Account>>(`/v1/accounts/${id}`),
|
queryFn: () => api<One<Account>>(`/v1/accounts/${id}`),
|
||||||
@@ -180,17 +179,6 @@
|
|||||||
const canCorrect = $derived(Boolean(reconciliationId));
|
const canCorrect = $derived(Boolean(reconciliationId));
|
||||||
const correcting = $derived(correct && canCorrect && difference !== 0);
|
const correcting = $derived(correct && canCorrect && difference !== 0);
|
||||||
const ready = $derived(before.isSuccess && after.isSuccess && list.isSuccess);
|
const ready = $derived(before.isSuccess && after.isSuccess && list.isSuccess);
|
||||||
const hint = $derived.by(() => {
|
|
||||||
if (!pressed) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (!ready) {
|
|
||||||
return "Balances are still loading";
|
|
||||||
}
|
|
||||||
return selected.length === 0 && !correcting
|
|
||||||
? "Tick the transactions to clear first"
|
|
||||||
: "";
|
|
||||||
});
|
|
||||||
|
|
||||||
function setDates(nextStart: string, nextEnd: string) {
|
function setDates(nextStart: string, nextEnd: string) {
|
||||||
if (!(nextStart && nextEnd) || nextStart > nextEnd) {
|
if (!(nextStart && nextEnd) || nextStart > nextEnd) {
|
||||||
@@ -253,8 +241,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function store() {
|
async function store() {
|
||||||
pressed = true;
|
if (busy || !ready || (selected.length === 0 && !correcting)) {
|
||||||
if (busy || hint) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -351,7 +338,7 @@
|
|||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 text-right">
|
<td class="whitespace-nowrap px-3 text-right">
|
||||||
<Amount amount={row.amount} exact {...cur} />
|
<Amount amount={row.amount} {...cur} />
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
|
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
|
||||||
{row.split.source_name}
|
{row.split.source_name}
|
||||||
@@ -538,7 +525,10 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Button disabled={busy} onclick={store} type="button"
|
<Button
|
||||||
|
disabled={busy || !ready || (selected.length === 0 && !correcting)}
|
||||||
|
onclick={store}
|
||||||
|
type="button"
|
||||||
>Store reconciliation</Button
|
>Store reconciliation</Button
|
||||||
>
|
>
|
||||||
<span class="flex gap-1">
|
<span class="flex gap-1">
|
||||||
@@ -546,9 +536,6 @@
|
|||||||
<span class="kbd">{key}</span>
|
<span class="kbd">{key}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{#if hint}
|
|
||||||
<span class="text-[12px] text-expense" role="alert">{hint}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{#if status}
|
{#if status}
|
||||||
<p class="text-[12px] text-cladd-fg-soft" role="status">{status}</p>
|
<p class="text-[12px] text-cladd-fg-soft" role="status">{status}</p>
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
|
||||||
import { owed } from "#lib/accounts/ledger.js";
|
|
||||||
import {
|
|
||||||
balanceAt,
|
|
||||||
chartRange,
|
|
||||||
daily,
|
|
||||||
monthsOf,
|
|
||||||
moves,
|
|
||||||
reconciledThrough,
|
|
||||||
sideOf,
|
|
||||||
signOf,
|
|
||||||
sums,
|
|
||||||
terms,
|
|
||||||
viewOf,
|
|
||||||
} from "#lib/accounts/statement.js";
|
|
||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import { all } from "#lib/api/lists.js";
|
|
||||||
import type { Account, Item, One, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
|
||||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
|
||||||
import { addDays, addMonths, contains, today } from "#lib/dates.js";
|
|
||||||
import { currentLocale, formatDate, toNumber } from "#lib/format.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
|
||||||
import { sectionOf } from "#lib/transactions/ledger.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import DebtPanel from "./DebtPanel.svelte";
|
|
||||||
import { canReconcile, reconcileUrl, showUrl, typeOf } from "./links.js";
|
|
||||||
import StatementHeader from "./StatementHeader.svelte";
|
|
||||||
import StatementMonths from "./StatementMonths.svelte";
|
|
||||||
|
|
||||||
const MOVED_KEY = "ffui.account.moved";
|
|
||||||
const DAYS_KEY = "ffui.account.days";
|
|
||||||
const TOGGLE =
|
|
||||||
"inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg";
|
|
||||||
const LINK = "text-brand dark:text-(--ff-series-1) hover:underline";
|
|
||||||
|
|
||||||
let { item }: { item: Item<Account> } = $props();
|
|
||||||
|
|
||||||
let moved = $state(localStorage.getItem(MOVED_KEY) !== "0");
|
|
||||||
let days = $state(localStorage.getItem(DAYS_KEY) === "1");
|
|
||||||
|
|
||||||
const now = today();
|
|
||||||
const id = $derived(item.id);
|
|
||||||
const a = $derived(item.attributes);
|
|
||||||
const liability = $derived(typeOf(a) === "liabilities");
|
|
||||||
const side = $derived(sideOf(a, liability));
|
|
||||||
const sign = $derived(signOf(side));
|
|
||||||
const cur = $derived({
|
|
||||||
code: a.currency_code,
|
|
||||||
decimals: a.currency_decimal_places,
|
|
||||||
symbol: a.currency_symbol,
|
|
||||||
});
|
|
||||||
const view = $derived(viewOf(cur));
|
|
||||||
const chart = $derived(chartRange(period.range, now));
|
|
||||||
const span = $derived({
|
|
||||||
end: [chart.end, period.end, now].sort().at(-1) ?? now,
|
|
||||||
start: [chart.start, period.start].sort()[0],
|
|
||||||
});
|
|
||||||
|
|
||||||
const current = createQuery(() => ({
|
|
||||||
enabled: !liability,
|
|
||||||
queryFn: () =>
|
|
||||||
api<One<Account>>(`/v1/accounts/${id}`, { query: { date: now } }),
|
|
||||||
queryKey: ["account", id, now],
|
|
||||||
}));
|
|
||||||
const history = createQuery(() =>
|
|
||||||
liability
|
|
||||||
? {
|
|
||||||
queryFn: () =>
|
|
||||||
all<TransactionGroup>(`/v1/accounts/${id}/transactions`),
|
|
||||||
queryKey: ["debt-history", id],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
queryFn: () =>
|
|
||||||
all<TransactionGroup>(`/v1/accounts/${id}/transactions`, span),
|
|
||||||
queryKey: ["account-history", id, span.start, span.end],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const anchor = $derived.by(() => {
|
|
||||||
if (liability) {
|
|
||||||
return { balance: owed(a), date: now };
|
|
||||||
}
|
|
||||||
const value = current.data?.data.attributes.current_balance;
|
|
||||||
return value === undefined ? null : { balance: toNumber(value), date: now };
|
|
||||||
});
|
|
||||||
const ready = $derived(
|
|
||||||
Boolean(anchor && history.data) && !history.isPlaceholderData
|
|
||||||
);
|
|
||||||
const list = $derived(
|
|
||||||
history.data ? moves(id, a.currency_code, history.data) : []
|
|
||||||
);
|
|
||||||
const at = (day: string) =>
|
|
||||||
anchor ? sign * balanceAt(list, anchor, day) : 0;
|
|
||||||
const opening = $derived(at(addDays(period.start, -1)));
|
|
||||||
const closing = $derived(at(period.end));
|
|
||||||
const parts = $derived(terms(side, sums(list, period.range).total));
|
|
||||||
const months = $derived(monthsOf(chart, list));
|
|
||||||
const line = $derived(
|
|
||||||
anchor
|
|
||||||
? daily(list, anchor, chart).map((p) => ({
|
|
||||||
x: p.x,
|
|
||||||
y: sign * p.y * view.rate,
|
|
||||||
}))
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
const groups = $derived(
|
|
||||||
(history.data ?? []).filter((g) =>
|
|
||||||
g.attributes.transactions.some((s) => contains(period.range, s.date))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const through = $derived(reconciledThrough(list));
|
|
||||||
const monthEnd = (month: string) => addDays(addMonths(month, 1), -1);
|
|
||||||
const selected = $derived(
|
|
||||||
period.start.endsWith("-01") && period.end === monthEnd(period.start)
|
|
||||||
? period.start
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
const thisMonth = `${now.slice(0, 7)}-01`;
|
|
||||||
const lineTitle = $derived(side === "asset" ? "Balance" : "Debt balance");
|
|
||||||
const series = $derived([
|
|
||||||
{
|
|
||||||
area: true,
|
|
||||||
format: (value: number) => view.format(value),
|
|
||||||
key: "balance",
|
|
||||||
label: lineTitle,
|
|
||||||
points: line,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const monthYear = (value: string) =>
|
|
||||||
new Date(`${value.slice(0, 10)}T12:00:00`).toLocaleDateString(
|
|
||||||
currentLocale(),
|
|
||||||
{ month: "short", year: "2-digit" }
|
|
||||||
);
|
|
||||||
|
|
||||||
function pick(month: string) {
|
|
||||||
goto(`${showUrl(id)}/${month}/${monthEnd(month)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMoved() {
|
|
||||||
moved = !moved;
|
|
||||||
localStorage.setItem(MOVED_KEY, moved ? "1" : "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDays() {
|
|
||||||
days = !days;
|
|
||||||
localStorage.setItem(DAYS_KEY, days ? "1" : "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
useKeys(() => [
|
|
||||||
...(item.attributes.liability_type === "debt"
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
group: "Account",
|
|
||||||
keys: "t",
|
|
||||||
label: moved ? "Hide moved money" : "Show moved money",
|
|
||||||
run: toggleMoved,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
group: "Account",
|
|
||||||
keys: ";",
|
|
||||||
label: days ? "Hide day subtotals" : "Show day subtotals",
|
|
||||||
run: toggleDays,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<StatementHeader
|
|
||||||
{closing}
|
|
||||||
{cur}
|
|
||||||
empty={!(anchor && history.data)}
|
|
||||||
end={period.end}
|
|
||||||
loading={!ready}
|
|
||||||
{opening}
|
|
||||||
{side}
|
|
||||||
start={period.start}
|
|
||||||
terms={parts}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="grid gap-3 {a.liability_type === "debt"
|
|
||||||
? "xl:grid-cols-[minmax(0,26rem)_minmax(0,1fr)]"
|
|
||||||
: "xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"}"
|
|
||||||
>
|
|
||||||
{#if a.liability_type === "debt"}
|
|
||||||
<DebtPanel {item} />
|
|
||||||
{:else}
|
|
||||||
<Box title="In and out by month">
|
|
||||||
{#snippet tools()}
|
|
||||||
<button class={TOGGLE} onclick={toggleMoved} type="button">
|
|
||||||
{moved ? "Hide moved" : "Show moved"}
|
|
||||||
<span class="kbd">t</span>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
<div class="transition-opacity {ready ? "" : "opacity-60"}">
|
|
||||||
<StatementMonths
|
|
||||||
{cur}
|
|
||||||
{months}
|
|
||||||
{moved}
|
|
||||||
onselect={pick}
|
|
||||||
partial={thisMonth}
|
|
||||||
{selected}
|
|
||||||
{side}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
{/if}
|
|
||||||
<Box title={lineTitle}>
|
|
||||||
<div class="transition-opacity {ready ? "" : "opacity-60"}">
|
|
||||||
{#if line.length > 0}
|
|
||||||
<LineChart
|
|
||||||
axisFormat={view.axis}
|
|
||||||
height={236}
|
|
||||||
label="{lineTitle} of {a.name} by day"
|
|
||||||
marker={contains(chart, now) ? now : undefined}
|
|
||||||
{series}
|
|
||||||
toggle={false}
|
|
||||||
xFormat={monthYear}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="h-[264px]"></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if a.notes}
|
|
||||||
<Box title="Notes">
|
|
||||||
<p class="whitespace-pre-wrap text-cladd-fg">{a.notes}</p>
|
|
||||||
</Box>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Box body="p-0" title="Statement for {period.label}">
|
|
||||||
{#snippet tools()}
|
|
||||||
<span class="num text-[12px] text-cladd-fg-soft max-sm:hidden"
|
|
||||||
>{groups.length}
|
|
||||||
{groups.length === 1 ? "transaction" : "transactions"}</span
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
aria-pressed={days}
|
|
||||||
class="{TOGGLE} {days ? "bg-cladd-surface-next text-cladd-fg" : ""}"
|
|
||||||
onclick={toggleDays}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Day subtotals
|
|
||||||
<span class="kbd">;</span>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
<TransactionTable
|
|
||||||
context={{ accountId: id }}
|
|
||||||
empty="No transactions in this period."
|
|
||||||
{groups}
|
|
||||||
id="account-tx"
|
|
||||||
loading={history.isFetching}
|
|
||||||
section={days ? (group) => sectionOf("day", group) : undefined}
|
|
||||||
/>
|
|
||||||
{#snippet footer()}
|
|
||||||
{#if canReconcile(item)}
|
|
||||||
<span class="text-cladd-fg-soft">
|
|
||||||
{through
|
|
||||||
? `Reconciled through ${formatDate(through)}`
|
|
||||||
: `Not reconciled since ${formatDate(span.start, "month")}`}
|
|
||||||
</span>
|
|
||||||
<a class={LINK} href={reconcileUrl(id)}
|
|
||||||
>Reconcile {period.label} <span class="kbd">R</span></a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<a class="{LINK} ml-auto" href="{showUrl(id)}/all"
|
|
||||||
>Show all transactions without grouping them by date</a
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Side, Term } from "#lib/accounts/statement.js";
|
|
||||||
import { addDays } from "#lib/dates.js";
|
|
||||||
import { formatDate, formatMoney, type Money } from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
|
|
||||||
let {
|
|
||||||
terms,
|
|
||||||
opening,
|
|
||||||
closing,
|
|
||||||
side,
|
|
||||||
cur,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
loading = false,
|
|
||||||
empty = false,
|
|
||||||
}: {
|
|
||||||
terms: Term[];
|
|
||||||
opening: number;
|
|
||||||
closing: number;
|
|
||||||
side: Side;
|
|
||||||
cur: Omit<Money, "amount">;
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
loading?: boolean;
|
|
||||||
empty?: boolean;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const TONE = {
|
|
||||||
expense: "text-expense",
|
|
||||||
income: "text-income",
|
|
||||||
neutral: "text-cladd-fg",
|
|
||||||
transfer: "text-transfer",
|
|
||||||
};
|
|
||||||
|
|
||||||
function word(value: number, asset: string) {
|
|
||||||
if (side === "asset") {
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
return (side === "credit") === value >= 0 ? "Owed to you" : "You owe";
|
|
||||||
}
|
|
||||||
|
|
||||||
const shown = (value: number) => (side === "asset" ? value : Math.abs(value));
|
|
||||||
const change = $derived(closing - opening);
|
|
||||||
const changeTone = $derived.by(() => {
|
|
||||||
if (side !== "asset" || Math.abs(change) < 0.005) {
|
|
||||||
return "text-cladd-fg-soft";
|
|
||||||
}
|
|
||||||
return change > 0 ? "text-income" : "text-expense";
|
|
||||||
});
|
|
||||||
const items = $derived([
|
|
||||||
{
|
|
||||||
key: "open",
|
|
||||||
label: `${word(opening, "Opening")}, ${formatDate(addDays(start, -1), "short")}`,
|
|
||||||
op: "",
|
|
||||||
tone: "text-cladd-fg",
|
|
||||||
value: shown(opening),
|
|
||||||
},
|
|
||||||
...terms.map((t) => ({
|
|
||||||
key: t.flow,
|
|
||||||
label: t.label,
|
|
||||||
op: t.up ? "+" : "−",
|
|
||||||
tone: Math.abs(t.value) < 0.005 ? "text-cladd-fg-soft" : TONE[t.tone],
|
|
||||||
value: Math.abs(t.value),
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
key: "close",
|
|
||||||
label: `${word(closing, "Closing")}, ${formatDate(end, "short")}`,
|
|
||||||
op: "=",
|
|
||||||
tone: "text-cladd-fg",
|
|
||||||
value: shown(closing),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const money = (amount: number, signed = false) =>
|
|
||||||
empty ? " " : display.text([{ ...cur, amount }], signed);
|
|
||||||
const original = (amount: number, signed = false) =>
|
|
||||||
display.total([{ ...cur, amount }])?.converted
|
|
||||||
? formatMoney({ ...cur, amount }, signed)
|
|
||||||
: undefined;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section
|
|
||||||
aria-busy={loading}
|
|
||||||
aria-label="Balance change"
|
|
||||||
class="cladd-surface cladd-outline rounded-cladd-lg px-3 py-3 transition-opacity lg:px-4 {loading
|
|
||||||
? "opacity-60"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-[1.25rem_minmax(0,1fr)_auto] items-baseline gap-x-2 gap-y-1 lg:flex lg:flex-wrap lg:items-end lg:gap-x-4 lg:gap-y-3"
|
|
||||||
>
|
|
||||||
{#each items as item (item.key)}
|
|
||||||
<div
|
|
||||||
class="col-span-3 grid grid-cols-subgrid items-baseline lg:grid-cols-[auto_auto] lg:gap-x-2 {item.key ===
|
|
||||||
"close"
|
|
||||||
? "border-cladd-outline max-lg:mt-1 max-lg:border-t max-lg:pt-1"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class="text-center text-[18px] text-cladd-fg-softer leading-tight lg:col-start-1 lg:row-start-2 lg:text-[22px] {item.op
|
|
||||||
? ""
|
|
||||||
: "lg:hidden"}"
|
|
||||||
>{item.op}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="truncate text-[13px] text-cladd-fg-soft lg:col-start-2 lg:row-start-1 lg:text-[12px]"
|
|
||||||
>{item.label}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="num text-right font-semibold text-[18px] leading-tight tracking-[-0.02em] lg:col-start-2 lg:row-start-2 lg:text-left lg:text-[22px] {item.tone}"
|
|
||||||
title={original(item.value)}
|
|
||||||
>{money(item.value)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<div
|
|
||||||
class="col-span-3 grid grid-cols-subgrid items-baseline lg:ml-auto lg:flex lg:flex-col lg:items-end"
|
|
||||||
>
|
|
||||||
<span class="col-start-2 text-[13px] text-cladd-fg-soft lg:text-[12px]"
|
|
||||||
>Change</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="num text-right font-semibold text-[15px] {changeTone}"
|
|
||||||
title={original(change, true)}
|
|
||||||
>{money(change, true)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
type Side,
|
|
||||||
type Sums,
|
|
||||||
terms,
|
|
||||||
viewOf,
|
|
||||||
} from "#lib/accounts/statement.js";
|
|
||||||
import Columns from "#lib/components/charts/Columns.svelte";
|
|
||||||
import {
|
|
||||||
type LegendItem,
|
|
||||||
type StackRow,
|
|
||||||
tones,
|
|
||||||
wash,
|
|
||||||
} from "#lib/components/charts/chart.js";
|
|
||||||
import Legend from "#lib/components/charts/Legend.svelte";
|
|
||||||
import { currentLocale, type Money } from "#lib/format.js";
|
|
||||||
|
|
||||||
const NARROW = 560;
|
|
||||||
const COLOR = {
|
|
||||||
expense: tones.expense,
|
|
||||||
income: tones.income,
|
|
||||||
neutral: "var(--cladd-fg-softer)",
|
|
||||||
transfer: wash(tones.transfer, 55),
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
|
||||||
months,
|
|
||||||
side,
|
|
||||||
cur,
|
|
||||||
moved,
|
|
||||||
selected,
|
|
||||||
partial,
|
|
||||||
onselect,
|
|
||||||
}: {
|
|
||||||
months: { month: string; total: Sums }[];
|
|
||||||
side: Side;
|
|
||||||
cur: Omit<Money, "amount">;
|
|
||||||
moved: boolean;
|
|
||||||
selected: string | null;
|
|
||||||
partial: string | null;
|
|
||||||
onselect: (month: string) => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let hover = $state<string | null>(null);
|
|
||||||
let width = $state(0);
|
|
||||||
|
|
||||||
const name = (month: string, style: "narrow" | "short" | "long") =>
|
|
||||||
new Date(`${month.slice(0, 7)}-15T12:00:00`).toLocaleDateString(
|
|
||||||
currentLocale(),
|
|
||||||
style === "long" ? { month: "long", year: "numeric" } : { month: style }
|
|
||||||
);
|
|
||||||
const view = $derived(viewOf(cur));
|
|
||||||
const format = (value: number) => view.format(value, true);
|
|
||||||
|
|
||||||
const parts = $derived(
|
|
||||||
months.map((m) => ({
|
|
||||||
month: m.month,
|
|
||||||
terms: terms(side, m.total).slice(0, 4),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
const keys = $derived(
|
|
||||||
(parts[0]?.terms ?? [])
|
|
||||||
.filter((t) => moved || t.tone !== "transfer")
|
|
||||||
.map((t) => ({ color: COLOR[t.tone], key: t.flow, label: t.label }))
|
|
||||||
);
|
|
||||||
const rows = $derived<StackRow[]>(
|
|
||||||
parts.map((p) => ({
|
|
||||||
key: p.month,
|
|
||||||
label: name(p.month, width < NARROW ? "narrow" : "short"),
|
|
||||||
values: Object.fromEntries(
|
|
||||||
p.terms.map((t) => [t.flow, t.value * view.rate])
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
const read = $derived(hover ?? selected ?? rows.at(-1)?.key ?? null);
|
|
||||||
const row = $derived(rows.find((r) => r.key === read));
|
|
||||||
const legend = $derived<LegendItem[]>(
|
|
||||||
keys.map((k) => ({
|
|
||||||
...k,
|
|
||||||
shape: "rect",
|
|
||||||
value: format(row?.values[k.key] ?? 0),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-2" bind:clientWidth={width}>
|
|
||||||
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
|
|
||||||
<span class="w-[8.5rem] shrink-0 text-[12px] text-cladd-fg"
|
|
||||||
>{read ? name(read, "long") : ""}</span
|
|
||||||
>
|
|
||||||
<Legend class="min-w-0 flex-1" items={legend} />
|
|
||||||
</div>
|
|
||||||
<Columns
|
|
||||||
axisFormat={view.axis}
|
|
||||||
{format}
|
|
||||||
height={208}
|
|
||||||
{keys}
|
|
||||||
label="Money in and out by month"
|
|
||||||
legend={false}
|
|
||||||
onselect={(r) => onselect(r.key)}
|
|
||||||
{partial}
|
|
||||||
{rows}
|
|
||||||
{selected}
|
|
||||||
bind:active={hover}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@@ -31,8 +31,8 @@ export const LIABILITY_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DIRECTION_LABEL: Record<string, string> = {
|
export const DIRECTION_LABEL: Record<string, string> = {
|
||||||
credit: "Owed to you",
|
credit: "Owed to me",
|
||||||
debit: "You owe",
|
debit: "I owe",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PERIOD_LABEL: Record<string, string> = {
|
export const PERIOD_LABEL: Record<string, string> = {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { invalidate } from "#lib/api/lists.js";
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
import type { Range } from "#lib/dates.js";
|
import type { Range } from "#lib/dates.js";
|
||||||
import { formatMoney } from "#lib/format.js";
|
import { formatMoney } from "#lib/format.js";
|
||||||
import { type Line, lineMoney, parseAmount, saveLimit } from "./data.js";
|
import { type Line, parseAmount, saveLimit } from "./data.js";
|
||||||
|
|
||||||
export type Move = 0 | 1 | -1;
|
export type Move = 0 | 1 | -1;
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
budgetId,
|
budgetId,
|
||||||
range,
|
range,
|
||||||
editing,
|
editing,
|
||||||
suggested = null,
|
|
||||||
trap = true,
|
trap = true,
|
||||||
onedit,
|
onedit,
|
||||||
ondone,
|
ondone,
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
budgetId: string;
|
budgetId: string;
|
||||||
range: Range;
|
range: Range;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
suggested?: number | null;
|
|
||||||
trap?: boolean;
|
trap?: boolean;
|
||||||
onedit: () => void;
|
onedit: () => void;
|
||||||
ondone: (move: Move) => void;
|
ondone: (move: Move) => void;
|
||||||
@@ -35,7 +33,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
value = String(line.amount || suggested || "");
|
value = line.amount ? String(line.amount) : "";
|
||||||
problem = "";
|
problem = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -125,20 +123,6 @@
|
|||||||
{@attach focus}
|
{@attach focus}
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
{:else if suggested && line.amount === null}
|
|
||||||
<button
|
|
||||||
class="num border-brand border-b border-dashed font-semibold leading-tight text-brand"
|
|
||||||
onclick={onedit}
|
|
||||||
title="Suggested from the last 3 months (e to change)"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span class="font-normal text-[11px]">suggested</span>
|
|
||||||
{formatMoney({
|
|
||||||
amount: suggested,
|
|
||||||
decimals: line.cur.decimals,
|
|
||||||
symbol: line.cur.symbol,
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
class="num border-cladd-fg-softest border-b border-dashed leading-tight hover:border-brand hover:text-brand {line.amount
|
class="num border-cladd-fg-softest border-b border-dashed leading-tight hover:border-brand hover:text-brand {line.amount
|
||||||
@@ -149,7 +133,11 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{line.amount
|
{line.amount
|
||||||
? lineMoney(line, line.amount)
|
? formatMoney({
|
||||||
|
amount: line.amount,
|
||||||
|
decimals: line.cur.decimals,
|
||||||
|
symbol: line.cur.symbol,
|
||||||
|
})
|
||||||
: `${line.cur.symbol} set amount`}
|
: `${line.cur.symbol} set amount`}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { isDynamic, type Range, shiftPeriod } from "#lib/dates.js";
|
import { isDynamic, type Range, shiftPeriod } from "#lib/dates.js";
|
||||||
import { period, periodLabel } from "#lib/period.svelte.js";
|
import { period, periodLabel } from "#lib/period.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { keptParams, periodPath } from "./period-path.svelte.js";
|
import { periodPath } from "./period-path.svelte.js";
|
||||||
|
|
||||||
const COUNT = 12;
|
const COUNT = 12;
|
||||||
|
|
||||||
@@ -24,9 +24,7 @@
|
|||||||
|
|
||||||
const before = $derived(walk(-1));
|
const before = $derived(walk(-1));
|
||||||
const after = $derived(walk(1));
|
const after = $derived(walk(1));
|
||||||
const kept = $derived(keptParams());
|
const href = (range: Range) => periodPath(base, range.start, range.end);
|
||||||
const href = (range: Range) =>
|
|
||||||
`${periodPath(base, range.start, range.end)}${kept}`;
|
|
||||||
|
|
||||||
function pick(event: Event) {
|
function pick(event: Event) {
|
||||||
const select = event.currentTarget as HTMLSelectElement;
|
const select = event.currentTarget as HTMLSelectElement;
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import { amountIn, type Sum } from "#lib/components/categories/sums.js";
|
||||||
|
import { tones } from "#lib/components/charts/chart.js";
|
||||||
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
|
import { formatMoney, formatRange, percent } from "#lib/format.js";
|
||||||
|
import type { Pool } from "./data.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
pool,
|
||||||
|
total,
|
||||||
|
noBudget,
|
||||||
|
noBudgetCount,
|
||||||
|
noBudgetHref,
|
||||||
|
}: {
|
||||||
|
pool: Pool;
|
||||||
|
total: Sum[];
|
||||||
|
noBudget: Sum[];
|
||||||
|
noBudgetCount: number;
|
||||||
|
noBudgetHref: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let explain = $state(false);
|
||||||
|
|
||||||
|
const money = (amount: number) =>
|
||||||
|
formatMoney({
|
||||||
|
amount,
|
||||||
|
decimals: pool.cur.decimals,
|
||||||
|
symbol: pool.cur.symbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
const spent = $derived(-pool.spent);
|
||||||
|
const left = $derived(pool.budgeted - spent);
|
||||||
|
const allSpent = $derived(-amountIn(total, pool.cur.code));
|
||||||
|
const outside = $derived(-amountIn(noBudget, pool.cur.code));
|
||||||
|
const available = $derived(pool.available ?? 0);
|
||||||
|
const planned = $derived(pool.available !== null || pool.budgeted > 0);
|
||||||
|
|
||||||
|
const budgetedSegments = $derived(
|
||||||
|
pool.budgeted > available && available > 0
|
||||||
|
? [
|
||||||
|
{ color: tones.transfer, value: available },
|
||||||
|
{ color: tones.expense, value: pool.budgeted - available },
|
||||||
|
]
|
||||||
|
: [{ color: tones.transfer, value: pool.budgeted }]
|
||||||
|
);
|
||||||
|
const spentSegments = $derived(
|
||||||
|
spent > pool.budgeted && pool.budgeted > 0
|
||||||
|
? [
|
||||||
|
{ color: tones.warn, value: pool.budgeted },
|
||||||
|
{ color: tones.expense, value: spent - pool.budgeted },
|
||||||
|
]
|
||||||
|
: [{ color: "var(--ff-brand)", value: spent }]
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
body="flex flex-col gap-3 p-3"
|
||||||
|
title="Total available budget in {pool.cur.code}"
|
||||||
|
>
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if pool.availableRange}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft"
|
||||||
|
>{formatRange(pool.availableRange.start, pool.availableRange.end)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#if !planned}
|
||||||
|
<p class="text-[13px] text-cladd-fg-soft">
|
||||||
|
Nothing budgeted in {pool.cur.code} for this period. Budgets spent
|
||||||
|
<span class="num text-expense">{money(spent)}</span>
|
||||||
|
in {pool.cur.code}.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[auto_auto_minmax(0,1fr)] items-center gap-x-4 gap-y-2 text-[13px]"
|
||||||
|
>
|
||||||
|
<span class="text-cladd-fg-soft">Available</span>
|
||||||
|
<button
|
||||||
|
class="num justify-self-end border-cladd-fg-softest border-b border-dashed text-right font-semibold text-cladd-fg"
|
||||||
|
onclick={() => (explain = !explain)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{pool.available === null ? "not set" : money(available)}
|
||||||
|
</button>
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">
|
||||||
|
{#if explain}
|
||||||
|
Firefly works this out from the amounts budgeted below. It can't be
|
||||||
|
edited here or in the API.
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="text-cladd-fg-soft">Budgeted</span>
|
||||||
|
<span class="num justify-self-end font-semibold text-cladd-fg"
|
||||||
|
>{money(pool.budgeted)}</span
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<div class="min-w-16 flex-1 basis-24">
|
||||||
|
<Meter max={available || pool.budgeted} segments={budgetedSegments} />
|
||||||
|
</div>
|
||||||
|
{#if available > 0}
|
||||||
|
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
|
||||||
|
>{percent(pool.budgeted / available)}
|
||||||
|
of available</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-cladd-fg-soft">Spent</span>
|
||||||
|
<span class="num justify-self-end font-semibold text-expense"
|
||||||
|
>{money(spent)}</span
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<div class="min-w-16 flex-1 basis-24">
|
||||||
|
<Meter max={pool.budgeted || spent} segments={spentSegments} />
|
||||||
|
</div>
|
||||||
|
{#if pool.budgeted > 0}
|
||||||
|
<span
|
||||||
|
class="num shrink-0 text-[12px] {left < 0
|
||||||
|
? "text-expense"
|
||||||
|
: "text-cladd-fg-soft"}"
|
||||||
|
>{left < 0
|
||||||
|
? `${money(-left)} overspent`
|
||||||
|
: `${money(left)} left`}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if allSpent > 0}
|
||||||
|
<p
|
||||||
|
class="mt-auto border-cladd-outline border-t pt-2 text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
All expenses in {pool.cur.code}:
|
||||||
|
<span class="num text-cladd-fg">{money(allSpent)}</span>.
|
||||||
|
{#if outside > 0}
|
||||||
|
<a class="text-brand hover:underline" href={noBudgetHref}
|
||||||
|
><span class="num">{money(outside)}</span>
|
||||||
|
of it has no budget{noBudgetCount
|
||||||
|
? ` (${noBudgetCount} transactions in all currencies)`
|
||||||
|
: ""}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
@@ -1,28 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { untrack } from "svelte";
|
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { all, invalidate } from "#lib/api/lists.js";
|
import { all, invalidate } from "#lib/api/lists.js";
|
||||||
import type { Budget, BudgetLimit, One } from "#lib/api/types.js";
|
import type {
|
||||||
|
Budget,
|
||||||
|
BudgetLimit,
|
||||||
|
One,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
import type { Lane, StackRow } from "#lib/components/charts/chart.js";
|
import BreakdownBox from "#lib/components/categories/BreakdownBox.svelte";
|
||||||
import Dimension from "#lib/components/dimension/Dimension.svelte";
|
import CurrencyTabs from "#lib/components/categories/CurrencyTabs.svelte";
|
||||||
import LimitLine from "#lib/components/dimension/LimitLine.svelte";
|
import GroupsBox from "#lib/components/categories/GroupsBox.svelte";
|
||||||
import LimitStrip from "#lib/components/dimension/LimitStrip.svelte";
|
import {
|
||||||
|
breakdown,
|
||||||
|
curOfSplit,
|
||||||
|
dayOf,
|
||||||
|
firstDay,
|
||||||
|
fromEntries,
|
||||||
|
inRange,
|
||||||
|
periodRanges,
|
||||||
|
rows as splitRows,
|
||||||
|
stepLabel,
|
||||||
|
} from "#lib/components/categories/sums.js";
|
||||||
|
import { tones, wash } from "#lib/components/charts/chart.js";
|
||||||
|
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||||
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
|
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import { Button } from "#lib/components/ui/button/index.js";
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
import { DimensionModel } from "#lib/dimension/model.svelte.js";
|
import { addDays, contains, today } from "#lib/dates.js";
|
||||||
import { budgetSpec } from "#lib/dimension/spec.js";
|
import { formatMoney, formatRange, toNumber } from "#lib/format.js";
|
||||||
import {
|
|
||||||
limitFacts,
|
|
||||||
limitsIn,
|
|
||||||
stripsOf,
|
|
||||||
withLimits,
|
|
||||||
} from "#lib/dimension/stats.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { longMonth } from "#lib/reports/board.js";
|
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
import { limitRange } from "./data.js";
|
import { limitRange } from "./data.js";
|
||||||
|
|
||||||
let { id, limitId }: { id: string; limitId?: string } = $props();
|
let { id, limitId }: { id: string; limitId?: string } = $props();
|
||||||
@@ -35,43 +48,166 @@
|
|||||||
queryFn: () => all<BudgetLimit>(`/v1/budgets/${id}/limits`),
|
queryFn: () => all<BudgetLimit>(`/v1/budgets/${id}/limits`),
|
||||||
queryKey: ["budget-limits", id],
|
queryKey: ["budget-limits", id],
|
||||||
}));
|
}));
|
||||||
const model = new DimensionModel(() => ({ spec: budgetSpec(id) }));
|
const groups = createQuery(() => ({
|
||||||
|
queryFn: () => all<TransactionGroup>(`/v1/budgets/${id}/transactions`),
|
||||||
|
queryKey: ["budget-transactions", id],
|
||||||
|
}));
|
||||||
|
|
||||||
const name = $derived(budget.data?.data.attributes.name ?? "");
|
const name = $derived(budget.data?.data.attributes.name ?? "");
|
||||||
const list = $derived(limits.data ?? []);
|
const sortedLimits = $derived(
|
||||||
const limit = $derived(list.find((l) => l.id === limitId));
|
[...(limits.data ?? [])].sort((a, b) =>
|
||||||
const shown = $derived(limitsIn(list, model.into));
|
(b.attributes.start ?? "").localeCompare(a.attributes.start ?? "")
|
||||||
const facts = $derived(
|
|
||||||
model.range
|
|
||||||
? limitFacts(shown, model.main, model.range, model.now).sort(
|
|
||||||
(a, b) =>
|
|
||||||
Number(b.code === limit?.attributes.currency_code) -
|
|
||||||
Number(a.code === limit?.attributes.currency_code)
|
|
||||||
)
|
)
|
||||||
: []
|
|
||||||
);
|
);
|
||||||
const strips = $derived(stripsOf(shown, model.main, model.months));
|
const limit = $derived(sortedLimits.find((l) => l.id === limitId));
|
||||||
|
const range = $derived(limit ? limitRange(limit) : null);
|
||||||
|
|
||||||
$effect(() => {
|
const allRows = $derived(
|
||||||
if (limit) {
|
splitRows(groups.data ?? [], (s) => s.budget_id === id)
|
||||||
const range = limitRange(limit);
|
);
|
||||||
untrack(() => period.set(range));
|
const scoped = $derived(
|
||||||
|
limit && range
|
||||||
|
? inRange(allRows, range).filter(
|
||||||
|
(r) => r.split.currency_code === limit.attributes.currency_code
|
||||||
|
)
|
||||||
|
: allRows
|
||||||
|
);
|
||||||
|
const spentRows = $derived(
|
||||||
|
scoped.filter((r) => r.split.type === "withdrawal")
|
||||||
|
);
|
||||||
|
const codes = $derived(
|
||||||
|
[...new Set(spentRows.map((r) => r.split.currency_code ?? ""))].filter(
|
||||||
|
Boolean
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const code = $derived(
|
||||||
|
limit?.attributes.currency_code ??
|
||||||
|
(codes.includes(page.url.searchParams.get("currency") ?? "")
|
||||||
|
? page.url.searchParams.get("currency")
|
||||||
|
: codes[0]) ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const inCur = $derived(
|
||||||
|
spentRows.filter((r) => r.split.currency_code === code)
|
||||||
|
);
|
||||||
|
const cur = $derived(
|
||||||
|
inCur[0]
|
||||||
|
? curOfSplit(inCur[0].split)
|
||||||
|
: {
|
||||||
|
code,
|
||||||
|
decimals: limit?.attributes.currency_decimal_places ?? 2,
|
||||||
|
symbol: limit?.attributes.currency_symbol ?? code,
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
const fmt = (value: number) =>
|
||||||
|
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
|
||||||
|
|
||||||
|
const visibleGroups = $derived.by(() => {
|
||||||
|
if (!range) {
|
||||||
|
return groups.data ?? [];
|
||||||
|
}
|
||||||
|
const ids = new Set(scoped.map((r) => r.group.id));
|
||||||
|
return (groups.data ?? []).filter((g) => ids.has(g.id));
|
||||||
});
|
});
|
||||||
$effect(() => {
|
|
||||||
const { start, end } = period;
|
const view = $derived(period.view);
|
||||||
untrack(() => {
|
const history = $derived.by(() => {
|
||||||
const range = limit && limitRange(limit);
|
const first = firstDay(inCur) ?? period.start;
|
||||||
if (range && (range.start !== start || range.end !== end)) {
|
const end = period.end > first ? period.end : today();
|
||||||
setTimeout(() =>
|
const ranges = periodRanges(end, view, 400)
|
||||||
goto(`/budgets/show/${id}?start=${start}&end=${end}`, {
|
.filter((r) => r.end >= first)
|
||||||
replace: true,
|
.reverse();
|
||||||
reset: false,
|
return ranges.map((r) => {
|
||||||
})
|
const spent = inRange(inCur, r).reduce(
|
||||||
|
(sum, row) => sum + toNumber(row.split.amount),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const own = sortedLimits.find(
|
||||||
|
(l) =>
|
||||||
|
l.attributes.currency_code === code &&
|
||||||
|
contains(r, l.attributes.start ?? "")
|
||||||
|
);
|
||||||
|
const amount = own ? toNumber(own.attributes.amount) : 0;
|
||||||
|
return {
|
||||||
|
format: fmt,
|
||||||
|
href: own ? `/budgets/show/${id}/${own.id}` : undefined,
|
||||||
|
key: r.start,
|
||||||
|
label: stepLabel(r, view),
|
||||||
|
values: amount
|
||||||
|
? {
|
||||||
|
left: Math.max(0, amount - spent),
|
||||||
|
over: Math.max(0, spent - amount),
|
||||||
|
spent: Math.min(spent, amount),
|
||||||
|
}
|
||||||
|
: { left: 0, over: 0, spent },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const historyKeys = [
|
||||||
|
{ color: "var(--ff-brand)", key: "spent", label: "Spent" },
|
||||||
|
{ color: wash("var(--ff-brand)", 28), key: "left", label: "Left" },
|
||||||
|
{ color: tones.expense, key: "over", label: "Overspent" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const burn = $derived.by(() => {
|
||||||
|
if (!(limit && range)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const amount = toNumber(limit.attributes.amount);
|
||||||
|
const last = range.end < today() ? range.end : today();
|
||||||
|
const byDay = new Map<string, number>();
|
||||||
|
for (const row of inCur) {
|
||||||
|
byDay.set(
|
||||||
|
dayOf(row),
|
||||||
|
(byDay.get(dayOf(row)) ?? 0) + toNumber(row.split.amount)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const points: { x: string; y: number }[] = [];
|
||||||
|
let left = amount;
|
||||||
|
for (let d = range.start; d <= last; d = addDays(d, 1)) {
|
||||||
|
left -= byDay.get(d) ?? 0;
|
||||||
|
points.push({ x: d, y: left });
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
area: true,
|
||||||
|
format: fmt,
|
||||||
|
key: "left",
|
||||||
|
label: "Left",
|
||||||
|
points,
|
||||||
|
strong: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: "var(--cladd-fg-softer)",
|
||||||
|
format: fmt,
|
||||||
|
key: "pace",
|
||||||
|
label: "Even pace",
|
||||||
|
points: [
|
||||||
|
{ x: range.start, y: amount },
|
||||||
|
{ x: range.end, y: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
const byCategory = $derived(
|
||||||
|
breakdown(inCur, (s) => ({
|
||||||
|
href: s.category_id ? `/categories/show/${s.category_id}` : undefined,
|
||||||
|
label: s.category_name ?? "(no category)",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const bySource = $derived(
|
||||||
|
breakdown(inCur, (s) => ({
|
||||||
|
href: s.source_id ? `/accounts/show/${s.source_id}` : undefined,
|
||||||
|
label: s.source_name ?? "",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const byDestination = $derived(
|
||||||
|
breakdown(inCur, (s) => ({
|
||||||
|
href: s.destination_id ? `/accounts/show/${s.destination_id}` : undefined,
|
||||||
|
label: s.destination_name ?? "",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
await api(`/v1/budgets/${id}`, { method: "DELETE" });
|
await api(`/v1/budgets/${id}`, { method: "DELETE" });
|
||||||
@@ -91,14 +227,16 @@
|
|||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: name || "Budget" }]}
|
crumbs={[{ href: "/budgets", label: "Budgets" }, { label: name || "Budget" }]}
|
||||||
subtitle={period.label}
|
subtitle={limit && range
|
||||||
|
? `${formatRange(range.start, range.end)} in ${limit.attributes.currency_code}`
|
||||||
|
: undefined}
|
||||||
title={name || "Budget"}
|
title={name || "Budget"}
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button
|
<Button
|
||||||
href="/budgets/edit/{id}"
|
href="/budgets/edit/{id}"
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Shift+E"
|
title="Edit budget (E)"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>Edit</Button
|
>Edit</Button
|
||||||
>
|
>
|
||||||
@@ -106,50 +244,111 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#snippet header(
|
<div class="flex flex-col gap-4">
|
||||||
lane: Lane,
|
{#if limit && range}
|
||||||
row: StackRow | undefined
|
<Box
|
||||||
)}
|
title="Chart for all transactions for budget "{name}" between {formatRange(
|
||||||
<span class="w-10 shrink-0 font-semibold text-cladd-fg">{lane.label}</span>
|
range.start,
|
||||||
{#if row}
|
range.end
|
||||||
<span class="text-cladd-fg-soft">{longMonth(row.key)}</span>
|
)} in {cur.code}"
|
||||||
<span class="num font-semibold text-cladd-fg"
|
|
||||||
>{lane.format((row.values.spent ?? 0) + (row.values.over ?? 0))}</span
|
|
||||||
>
|
|
||||||
{#if row.values.left !== undefined}
|
|
||||||
<span class="text-cladd-fg-soft"
|
|
||||||
>of
|
|
||||||
<span class="num"
|
|
||||||
>{lane.format((row.values.spent ?? 0) + row.values.left)}</span
|
|
||||||
>
|
|
||||||
limit</span
|
|
||||||
>
|
>
|
||||||
|
{#if burn[0]?.points.length}
|
||||||
|
<LineChart
|
||||||
|
label="Amount left in this budget"
|
||||||
|
marker={contains(range, today()) ? today() : undefined}
|
||||||
|
series={burn}
|
||||||
|
zero
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">Nothing spent yet.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
</Box>
|
||||||
|
{:else}
|
||||||
|
<Box title="Chart of all transactions for budget "{name}"">
|
||||||
|
{#snippet tools()}
|
||||||
|
<CurrencyTabs {codes} current={code} />
|
||||||
|
{/snippet}
|
||||||
|
{#if history.length > 0}
|
||||||
|
<StackedBars
|
||||||
|
format={fmt}
|
||||||
|
keys={historyKeys}
|
||||||
|
label="Spent per period against the budgeted amount"
|
||||||
|
rows={history}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">No expenses yet.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if lane.typical}
|
</Box>
|
||||||
<span class="flex items-center gap-1.5 text-cladd-fg-soft">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-3 border-cladd-fg-soft border-t-[1.5px] border-dotted"
|
|
||||||
></span>
|
|
||||||
typical
|
|
||||||
<span class="num">{lane.format(lane.typical)}</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Dimension
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
{header}
|
<BreakdownBox items={byCategory} title="Expenses by category" />
|
||||||
{model}
|
<BreakdownBox items={bySource} title="Expenses by asset account" />
|
||||||
noun="purchases"
|
<BreakdownBox
|
||||||
shape={(lanes) => withLimits(lanes, shown)}
|
items={byDestination}
|
||||||
>
|
title="Expenses by destination account"
|
||||||
{#snippet extra()}
|
/>
|
||||||
{#if !limits.isPending}
|
</div>
|
||||||
<LimitLine {facts} />
|
|
||||||
|
<div class="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_16rem]">
|
||||||
|
<GroupsBox
|
||||||
|
groups={visibleGroups}
|
||||||
|
loading={groups.isPending}
|
||||||
|
title="Transactions"
|
||||||
|
>
|
||||||
|
{#snippet links()}
|
||||||
|
{#if limit}
|
||||||
|
<a class="text-brand hover:underline" href="/budgets/show/{id}"
|
||||||
|
>Show everything</a
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet below()}
|
</GroupsBox>
|
||||||
<LimitStrip budgetId={id} cursor={model.focus} {strips} />
|
<nav
|
||||||
{/snippet}
|
aria-label="Budgeted amounts"
|
||||||
</Dimension>
|
class="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-2 2xl:grid-cols-1"
|
||||||
|
>
|
||||||
|
{#each sortedLimits as item (item.id)}
|
||||||
|
{@const r = limitRange(item)}
|
||||||
|
{@const amount = toNumber(item.attributes.amount)}
|
||||||
|
{@const spent = -(
|
||||||
|
fromEntries(item.attributes.spent).find(
|
||||||
|
(s) => s.code === item.attributes.currency_code
|
||||||
|
)?.amount ?? 0
|
||||||
|
)}
|
||||||
|
{@const money = (value: number) =>
|
||||||
|
formatMoney({
|
||||||
|
amount: value,
|
||||||
|
decimals: item.attributes.currency_decimal_places,
|
||||||
|
symbol: item.attributes.currency_symbol,
|
||||||
|
})}
|
||||||
|
<a
|
||||||
|
aria-current={item.id === limitId ? "page" : undefined}
|
||||||
|
class="cladd-surface cladd-outline flex flex-col gap-1 rounded-cladd-md px-3 py-2 text-[12px] {item.id ===
|
||||||
|
limitId
|
||||||
|
? "bg-row-cursor outline-2 outline-brand"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
href="/budgets/show/{id}/{item.id}"
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-cladd-fg"
|
||||||
|
>{formatRange(r.start, r.end)}</span
|
||||||
|
>
|
||||||
|
<span class="flex justify-between gap-2">
|
||||||
|
<span class="num text-expense">{money(spent)}</span>
|
||||||
|
<span class="num text-cladd-fg-soft">of {money(amount)}</span>
|
||||||
|
</span>
|
||||||
|
<Meter
|
||||||
|
height={6}
|
||||||
|
max={amount}
|
||||||
|
segments={spent > amount
|
||||||
|
? [
|
||||||
|
{ color: tones.warn, value: amount },
|
||||||
|
{ color: tones.expense, value: spent - amount },
|
||||||
|
]
|
||||||
|
: [{ color: "var(--ff-brand)", value: spent }]}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import { percent } from "#lib/format.js";
|
|
||||||
import { mergeTops, type Top, topMoney } from "./data.js";
|
|
||||||
|
|
||||||
const CENT = 0.005;
|
|
||||||
|
|
||||||
let { tops, onsort }: { tops: Top[]; onsort: () => void } = $props();
|
|
||||||
|
|
||||||
const shown = $derived(mergeTops(tops));
|
|
||||||
const free = $derived(
|
|
||||||
shown.some(
|
|
||||||
(top) =>
|
|
||||||
top.available !== null && Math.abs(top.available - top.budgeted) > CENT
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const columns = $derived(
|
|
||||||
[
|
|
||||||
free && "Available",
|
|
||||||
"Budgeted",
|
|
||||||
free && "Left to budget",
|
|
||||||
"Left in budgets",
|
|
||||||
"Spent",
|
|
||||||
"Without a budget",
|
|
||||||
].filter(Boolean) as string[]
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Box body="px-3 py-3">
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-[3rem_repeat(var(--cols),minmax(0,1fr))] sm:gap-y-2"
|
|
||||||
style:--cols={columns.length}
|
|
||||||
>
|
|
||||||
<span class="max-sm:hidden"></span>
|
|
||||||
{#each columns as label (label)}
|
|
||||||
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden">{label}</span>
|
|
||||||
{/each}
|
|
||||||
{#each shown as top, i (top.cur.code)}
|
|
||||||
<span
|
|
||||||
class="col-span-2 self-center font-semibold text-[13px] text-cladd-fg-soft sm:col-span-1 {i >
|
|
||||||
0
|
|
||||||
? "max-sm:mt-1 max-sm:border-cladd-outline max-sm:border-t max-sm:pt-3"
|
|
||||||
: ""}"
|
|
||||||
>{top.approx ? "≈" : ""}{top.cur.code}</span
|
|
||||||
>
|
|
||||||
{#if free}
|
|
||||||
{@render stat(
|
|
||||||
"Available",
|
|
||||||
top.available === null ? null : topMoney(top, top.available)
|
|
||||||
)}
|
|
||||||
{/if}
|
|
||||||
{@render stat(
|
|
||||||
"Budgeted",
|
|
||||||
top.budgeted ? topMoney(top, top.budgeted) : null
|
|
||||||
)}
|
|
||||||
{#if free}
|
|
||||||
{@const unallocated = (top.available ?? 0) - top.budgeted}
|
|
||||||
{@render stat(
|
|
||||||
"Left to budget",
|
|
||||||
top.available === null ? null : topMoney(top, unallocated),
|
|
||||||
unallocated < 0 ? "text-expense" : ""
|
|
||||||
)}
|
|
||||||
{/if}
|
|
||||||
{@const left = top.budgeted - top.inBudgets}
|
|
||||||
{@render stat(
|
|
||||||
"Left in budgets",
|
|
||||||
top.budgeted ? topMoney(top, left) : null,
|
|
||||||
left < 0 ? "text-expense" : ""
|
|
||||||
)}
|
|
||||||
{@render stat("Spent", topMoney(top, top.spent))}
|
|
||||||
<div class="min-w-0">
|
|
||||||
<span class="text-[12px] text-cladd-fg-soft sm:hidden"
|
|
||||||
>Without a budget</span
|
|
||||||
>
|
|
||||||
{#if top.outside > 0}
|
|
||||||
<button
|
|
||||||
class="block text-left hover:underline"
|
|
||||||
onclick={onsort}
|
|
||||||
title="Sort them into budgets t"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="num font-semibold text-[20px] text-brand tracking-[-0.02em] sm:text-[22px]"
|
|
||||||
>{topMoney(top, top.outside)}</span
|
|
||||||
>
|
|
||||||
{#if top.spent > 0}
|
|
||||||
<span class="num ml-1 text-[13px] text-cladd-fg-soft"
|
|
||||||
>{percent(top.outside / top.spent)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="num block font-semibold text-[20px] text-cladd-fg-softer sm:text-[22px]"
|
|
||||||
>0</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{#snippet stat(
|
|
||||||
label: string,
|
|
||||||
value: string | null,
|
|
||||||
tone = ""
|
|
||||||
)}
|
|
||||||
<div class="min-w-0">
|
|
||||||
<span class="text-[12px] text-cladd-fg-soft sm:hidden">{label}</span>
|
|
||||||
<span
|
|
||||||
class="num block font-semibold text-[20px] tracking-[-0.02em] sm:text-[22px] {value ===
|
|
||||||
null
|
|
||||||
? "text-cladd-fg-softer"
|
|
||||||
: tone || "text-cladd-fg"}"
|
|
||||||
>{value ?? "0"}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from "svelte";
|
|
||||||
import {
|
|
||||||
applyPatch,
|
|
||||||
type Entry,
|
|
||||||
type Option,
|
|
||||||
type Place,
|
|
||||||
samplesOf,
|
|
||||||
storedSet,
|
|
||||||
sumsOf,
|
|
||||||
targetsOf,
|
|
||||||
} from "#lib/budgets/triage.js";
|
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
|
||||||
import AutocompleteInput, {
|
|
||||||
type Option as Choice,
|
|
||||||
} from "#lib/components/inputs/AutocompleteInput.svelte";
|
|
||||||
import { Button } from "#lib/components/ui/button/index.js";
|
|
||||||
import { formatDate } from "#lib/format.js";
|
|
||||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
|
||||||
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
|
||||||
import type { Patch, Targets } from "#lib/transactions/mass.js";
|
|
||||||
import { showUrl } from "#lib/transactions/split.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
const DIGITS = 9;
|
|
||||||
const REFRESH_MS = 800;
|
|
||||||
|
|
||||||
interface Row {
|
|
||||||
entry?: Entry;
|
|
||||||
key: string;
|
|
||||||
place: Place;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Action {
|
|
||||||
done: number;
|
|
||||||
entries: string[];
|
|
||||||
error: string;
|
|
||||||
forget?: () => void;
|
|
||||||
kind: "move" | "ignore";
|
|
||||||
label: string;
|
|
||||||
row: string;
|
|
||||||
state: "busy" | "undoing" | "done";
|
|
||||||
targets: Targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
title,
|
|
||||||
noun,
|
|
||||||
places,
|
|
||||||
loading,
|
|
||||||
problem = "",
|
|
||||||
empty,
|
|
||||||
options,
|
|
||||||
pickKey,
|
|
||||||
active,
|
|
||||||
everything,
|
|
||||||
periodLabel,
|
|
||||||
ignoreKey,
|
|
||||||
assignPatch,
|
|
||||||
clearPatch,
|
|
||||||
create,
|
|
||||||
onassign,
|
|
||||||
hint,
|
|
||||||
onswitch,
|
|
||||||
onactivate,
|
|
||||||
onleave,
|
|
||||||
onchange,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
noun: string;
|
|
||||||
places: Place[];
|
|
||||||
loading: boolean;
|
|
||||||
problem?: string;
|
|
||||||
empty: string;
|
|
||||||
options: Option[];
|
|
||||||
pickKey: string;
|
|
||||||
active: boolean;
|
|
||||||
everything: boolean;
|
|
||||||
periodLabel: string;
|
|
||||||
ignoreKey: string;
|
|
||||||
assignPatch: (id: string) => Patch;
|
|
||||||
clearPatch: Patch;
|
|
||||||
create: (name: string) => Promise<Option>;
|
|
||||||
onassign?: (place: Place, option: Option) => () => void;
|
|
||||||
hint?: (place: Place) => string | null;
|
|
||||||
onswitch: () => void;
|
|
||||||
onactivate: () => void;
|
|
||||||
onleave: () => void;
|
|
||||||
onchange: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const store = untrack(() => storedSet(ignoreKey));
|
|
||||||
let ignored = $state(store.read());
|
|
||||||
let expanded = $state(new Set<string>());
|
|
||||||
let actions = $state<Action[]>([]);
|
|
||||||
let picking = $state<string | null>(null);
|
|
||||||
let pickText = $state("");
|
|
||||||
let pickRef = $state<HTMLInputElement | null>(null);
|
|
||||||
let pickError = $state("");
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
const taken = $derived(new Set(actions.flatMap((action) => action.entries)));
|
|
||||||
const latest = (key: string) =>
|
|
||||||
[...actions].reverse().find((action) => action.row === key);
|
|
||||||
const skipped = (place: Place) =>
|
|
||||||
actions.some(
|
|
||||||
(action) => action.kind === "ignore" && action.row === place.key
|
|
||||||
);
|
|
||||||
const left = (place: Place) =>
|
|
||||||
place.entries.filter((entry) => !taken.has(entry.key));
|
|
||||||
|
|
||||||
const shown = $derived(
|
|
||||||
places.filter((place) => !ignored.has(place.key) || skipped(place))
|
|
||||||
);
|
|
||||||
const rows = $derived<Row[]>(
|
|
||||||
shown.flatMap((place) => [
|
|
||||||
{ key: place.key, place },
|
|
||||||
...(expanded.has(place.key)
|
|
||||||
? place.entries.map((entry) => ({ entry, key: entry.key, place }))
|
|
||||||
: []),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
const open = $derived(
|
|
||||||
shown.filter((place) => !skipped(place) && left(place).length > 0)
|
|
||||||
);
|
|
||||||
const openCount = $derived(
|
|
||||||
open.reduce((sum, place) => sum + left(place).length, 0)
|
|
||||||
);
|
|
||||||
const hidden = $derived(
|
|
||||||
places.filter((place) => ignored.has(place.key) && !skipped(place)).length
|
|
||||||
);
|
|
||||||
const numbered = $derived(options.slice(0, DIGITS));
|
|
||||||
|
|
||||||
function settled(row: Row): boolean {
|
|
||||||
if (row.entry) {
|
|
||||||
return taken.has(row.entry.key) || skipped(row.place);
|
|
||||||
}
|
|
||||||
return skipped(row.place) || left(row.place).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexOf = (key: string) => rows.findIndex((row) => row.key === key);
|
|
||||||
const firstOpen = () =>
|
|
||||||
Math.max(
|
|
||||||
0,
|
|
||||||
rows.findIndex((row) => !settled(row))
|
|
||||||
);
|
|
||||||
|
|
||||||
function nextOpen(from: Row): Row | undefined {
|
|
||||||
const after = rows.slice(indexOf(from.key) + 1);
|
|
||||||
return after.find(
|
|
||||||
(row) =>
|
|
||||||
!settled(row) &&
|
|
||||||
(row.entry === undefined || (from.entry && row.place === from.place))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collapse(key: string) {
|
|
||||||
if (expanded.has(key)) {
|
|
||||||
const next = new Set(expanded);
|
|
||||||
next.delete(key);
|
|
||||||
expanded = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function changed() {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(onchange, REFRESH_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function record(action: Action, from: Row) {
|
|
||||||
actions = [...actions, action];
|
|
||||||
picking = null;
|
|
||||||
const next = nextOpen(from);
|
|
||||||
if (!from.entry) {
|
|
||||||
collapse(from.place.key);
|
|
||||||
}
|
|
||||||
if (next) {
|
|
||||||
nav.move(indexOf(next.key));
|
|
||||||
}
|
|
||||||
return actions.at(-1) as Action;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assign(row: Row, option: Option) {
|
|
||||||
if (settled(row)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const entries = row.entry ? [row.entry] : left(row.place);
|
|
||||||
const targets = targetsOf(entries);
|
|
||||||
const action = record(
|
|
||||||
{
|
|
||||||
done: 0,
|
|
||||||
entries: entries.map((entry) => entry.key),
|
|
||||||
error: "",
|
|
||||||
forget: row.entry ? undefined : onassign?.(row.place, option),
|
|
||||||
kind: "move",
|
|
||||||
label: option.name,
|
|
||||||
row: row.key,
|
|
||||||
state: "busy",
|
|
||||||
targets,
|
|
||||||
},
|
|
||||||
row
|
|
||||||
);
|
|
||||||
const errors = await applyPatch(targets, assignPatch(option.id), (done) => {
|
|
||||||
action.done = done;
|
|
||||||
});
|
|
||||||
action.state = "done";
|
|
||||||
action.error = errors[0] ?? "";
|
|
||||||
changed();
|
|
||||||
}
|
|
||||||
|
|
||||||
function ignore(row: Row) {
|
|
||||||
const { place } = row;
|
|
||||||
if (skipped(place)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ignored = new Set([...ignored, place.key]);
|
|
||||||
store.write(ignored);
|
|
||||||
record(
|
|
||||||
{
|
|
||||||
done: 0,
|
|
||||||
entries: [],
|
|
||||||
error: "",
|
|
||||||
kind: "ignore",
|
|
||||||
label: "",
|
|
||||||
row: place.key,
|
|
||||||
state: "done",
|
|
||||||
targets: new Map(),
|
|
||||||
},
|
|
||||||
{ key: place.key, place }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function undo(action: Action | undefined) {
|
|
||||||
if (action?.state !== "done") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const at = indexOf(action.row);
|
|
||||||
if (at >= 0) {
|
|
||||||
nav.move(at);
|
|
||||||
}
|
|
||||||
if (action.kind === "ignore") {
|
|
||||||
const next = new Set(ignored);
|
|
||||||
next.delete(action.row);
|
|
||||||
ignored = next;
|
|
||||||
store.write(next);
|
|
||||||
actions = actions.filter((item) => item !== action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
action.state = "undoing";
|
|
||||||
action.done = 0;
|
|
||||||
const errors = await applyPatch(action.targets, clearPatch, (done) => {
|
|
||||||
action.done = done;
|
|
||||||
});
|
|
||||||
action.forget?.();
|
|
||||||
if (errors.length > 0) {
|
|
||||||
action.state = "done";
|
|
||||||
action.error = errors[0];
|
|
||||||
} else {
|
|
||||||
actions = actions.filter((item) => item !== action);
|
|
||||||
}
|
|
||||||
changed();
|
|
||||||
}
|
|
||||||
|
|
||||||
const undoLast = () =>
|
|
||||||
undo([...actions].reverse().find((action) => action.state === "done"));
|
|
||||||
|
|
||||||
let shownScope = untrack(() => everything);
|
|
||||||
$effect(() => {
|
|
||||||
if (everything !== shownScope) {
|
|
||||||
shownScope = everything;
|
|
||||||
untrack(() => {
|
|
||||||
nav.cursor = -1;
|
|
||||||
expanded = new Set();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggle(row: Row) {
|
|
||||||
const { key } = row.place;
|
|
||||||
const next = new Set(expanded);
|
|
||||||
if (next.has(key)) {
|
|
||||||
next.delete(key);
|
|
||||||
} else {
|
|
||||||
next.add(key);
|
|
||||||
}
|
|
||||||
expanded = next;
|
|
||||||
nav.move(indexOf(row.entry && !next.has(key) ? key : row.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPick(row: Row) {
|
|
||||||
if (settled(row)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pickText = "";
|
|
||||||
pickError = "";
|
|
||||||
picking = row.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
pickRef?.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function picked(row: Row, choice: Choice) {
|
|
||||||
if (picking !== row.key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (choice.id) {
|
|
||||||
assign(row, { id: choice.id, name: choice.label });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
assign(row, await create(choice.label));
|
|
||||||
} catch (failure) {
|
|
||||||
pickError = failure instanceof Error ? failure.message : String(failure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = (query: string): Choice[] =>
|
|
||||||
fuzzyFilter(options, query, (option) => option.name, 40).map((option) => ({
|
|
||||||
id: option.id,
|
|
||||||
label: option.name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function show(row: Row) {
|
|
||||||
if (row.entry) {
|
|
||||||
goto(showUrl(row.entry.group.id));
|
|
||||||
} else if (row.place.accountId) {
|
|
||||||
goto(`/accounts/show/${row.place.accountId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = (action: (row: Row) => void) => () => {
|
|
||||||
const row = nav.current;
|
|
||||||
if (row) {
|
|
||||||
action(row);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const group = $derived(
|
|
||||||
`Sort into ${noun === "budget" ? "budgets" : "categories"}`
|
|
||||||
);
|
|
||||||
|
|
||||||
function bindings(): Binding[] {
|
|
||||||
return [
|
|
||||||
...numbered.map((option, i) => ({
|
|
||||||
group,
|
|
||||||
keys: String(i + 1),
|
|
||||||
label: `Move to ${option.name}`,
|
|
||||||
run: run((row) => assign(row, option)),
|
|
||||||
})),
|
|
||||||
{
|
|
||||||
group,
|
|
||||||
keys: pickKey,
|
|
||||||
label: `Move to another ${noun}`,
|
|
||||||
run: run(startPick),
|
|
||||||
},
|
|
||||||
{ group, keys: "n", label: "Ignore this place", run: run(ignore) },
|
|
||||||
{ group, keys: "u", label: "Undo the last move", run: undoLast },
|
|
||||||
{
|
|
||||||
group,
|
|
||||||
keys: "Enter",
|
|
||||||
label: "Show or hide the transactions",
|
|
||||||
run: run(toggle),
|
|
||||||
},
|
|
||||||
{ group, keys: "o", label: "Open", run: run(show) },
|
|
||||||
{
|
|
||||||
group,
|
|
||||||
keys: "v",
|
|
||||||
label: `Switch between ${periodLabel} and everything`,
|
|
||||||
run: onswitch,
|
|
||||||
},
|
|
||||||
{ group, keys: "Escape", label: "Stop sorting", run: onleave },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const nav: ListNav<Row> = new ListNav<Row>({
|
|
||||||
extra: bindings,
|
|
||||||
id: "triage",
|
|
||||||
items: () => rows,
|
|
||||||
key: (row) => row.key,
|
|
||||||
});
|
|
||||||
useKeys(() => (active ? nav.bindings() : []));
|
|
||||||
|
|
||||||
const ready = $derived(rows.length > 0);
|
|
||||||
const lost = $derived(nav.cursor < 0 || nav.cursor >= rows.length);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (active && ready && lost) {
|
|
||||||
untrack(() => nav.move(firstOpen()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (active) {
|
|
||||||
untrack(() => nav.reveal("nearest"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function click(index: number, event: MouseEvent) {
|
|
||||||
nav.click(index, event);
|
|
||||||
onactivate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function unignore() {
|
|
||||||
ignored = new Set();
|
|
||||||
store.write(ignored);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
body="p-0"
|
|
||||||
class="mt-4"
|
|
||||||
footer={hidden > 0 ? ignoredNote : undefined}
|
|
||||||
{title}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex min-h-11 flex-wrap items-center gap-x-4 gap-y-2 border-cladd-outline border-b px-3 py-2"
|
|
||||||
>
|
|
||||||
<p class="text-[14px] text-cladd-fg">
|
|
||||||
{#if loading}
|
|
||||||
<span class="text-cladd-fg-soft">Loading</span>
|
|
||||||
{:else if openCount === 0}
|
|
||||||
{empty}
|
|
||||||
{:else}
|
|
||||||
<span class="num font-semibold">{openCount}</span>
|
|
||||||
{openCount === 1 ? "transaction" : "transactions"}
|
|
||||||
in <span class="num font-semibold">{open.length}</span>
|
|
||||||
{open.length === 1 ? "place" : "places"}
|
|
||||||
{noun === "budget" ? "have no budget" : "have no category"}
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
<div class="ml-auto flex items-center gap-2">
|
|
||||||
<fieldset
|
|
||||||
aria-label="Which transactions"
|
|
||||||
class="cladd-cut flex h-7 items-center gap-0.5 rounded-cladd-sm p-0.5"
|
|
||||||
>
|
|
||||||
{#each [false, true] as all (all)}
|
|
||||||
<button
|
|
||||||
aria-pressed={everything === all}
|
|
||||||
class="h-6 rounded-[5px] px-2 text-[12px] {everything === all
|
|
||||||
? "cladd-surface cladd-outline font-semibold text-cladd-fg"
|
|
||||||
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
|
||||||
onclick={() => everything !== all && onswitch()}
|
|
||||||
title="Switch with v"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{all ? "Everything" : periodLabel}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</fieldset>
|
|
||||||
{#if openCount > 0}
|
|
||||||
{#if active}
|
|
||||||
<Button onclick={onleave} size="sm" variant="outline">
|
|
||||||
Stop sorting <span class="kbd">Esc</span>
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Button onclick={onactivate} size="sm">
|
|
||||||
Sort them <span class="kbd">t</span>
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if problem}
|
|
||||||
<p class="px-3 py-3 text-expense">{problem}</p>
|
|
||||||
{:else if rows.length > 0}
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-[13px]">
|
|
||||||
<thead>
|
|
||||||
<tr
|
|
||||||
class="h-8 border-cladd-outline border-b text-left text-[12px] text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
<th class="px-3 font-medium sm:w-[26%]">Place</th>
|
|
||||||
<th class="px-3 text-right font-medium sm:w-16">Count</th>
|
|
||||||
<th class="px-3 text-right font-medium sm:w-[14%]">Amount</th>
|
|
||||||
<th class="px-3 font-medium max-md:hidden">For example</th>
|
|
||||||
<th class="w-[22%] px-3 max-sm:hidden"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each rows as row, i (row.key)}
|
|
||||||
{@const done = settled(row)}
|
|
||||||
{@const cursor = active && nav.cursor === i}
|
|
||||||
<tr
|
|
||||||
class="h-8 scroll-mb-14 border-cladd-outline border-b {cursor
|
|
||||||
? "bg-row-cursor"
|
|
||||||
: "hover:bg-row-hover"} {done ? "text-cladd-fg-softer" : ""}"
|
|
||||||
id={nav.rowId(i)}
|
|
||||||
onclick={(event) => click(i, event)}
|
|
||||||
>
|
|
||||||
{#if row.entry}
|
|
||||||
{@const split = row.entry.split}
|
|
||||||
<td class="py-1 pr-3 pl-8">
|
|
||||||
<span class={done ? "" : "text-cladd-fg"}
|
|
||||||
>{split.description}</span
|
|
||||||
>
|
|
||||||
{@render result(row, "sm:hidden")}
|
|
||||||
</td>
|
|
||||||
<td class="num px-3 text-right text-cladd-fg-soft">
|
|
||||||
{formatDate(split.date, "short")}
|
|
||||||
</td>
|
|
||||||
<td class="px-3 text-right">
|
|
||||||
<Amount
|
|
||||||
amount={split.type === "withdrawal"
|
|
||||||
? -Number(split.amount)
|
|
||||||
: split.amount}
|
|
||||||
code={split.currency_code}
|
|
||||||
decimals={split.currency_decimal_places}
|
|
||||||
symbol={split.currency_symbol}
|
|
||||||
tone={done ? "neutral" : "auto"}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="max-w-0 truncate px-3 text-cladd-fg-soft max-md:hidden"
|
|
||||||
>
|
|
||||||
{split.type === "withdrawal" ? "from" : "to"}
|
|
||||||
{split.type === "withdrawal"
|
|
||||||
? split.source_name
|
|
||||||
: split.destination_name}
|
|
||||||
</td>
|
|
||||||
{:else}
|
|
||||||
{@const rest = done ? row.place.entries : left(row.place)}
|
|
||||||
<td class="py-1 pr-3 pl-3">
|
|
||||||
<span class="font-medium {done ? "" : "text-cladd-fg"}"
|
|
||||||
>{row.place.name || "(no name)"}</span
|
|
||||||
>
|
|
||||||
{@render result(row, "sm:hidden")}
|
|
||||||
</td>
|
|
||||||
<td class="num px-3 text-right">{rest.length}</td>
|
|
||||||
<td class="px-3 text-right">
|
|
||||||
<MoneyList
|
|
||||||
sums={sumsOf(rest)}
|
|
||||||
tone={done ? "neutral" : "auto"}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="max-w-0 truncate px-3 text-cladd-fg-soft max-md:hidden"
|
|
||||||
>
|
|
||||||
{samplesOf(rest).join(", ")}
|
|
||||||
</td>
|
|
||||||
{/if}
|
|
||||||
<td class="px-3 max-sm:hidden">{@render result(row, "")}</td>
|
|
||||||
</tr>
|
|
||||||
{#if cursor && !done}
|
|
||||||
<tr class="border-cladd-outline border-b bg-row-cursor/40">
|
|
||||||
<td class="px-3 py-2" colspan="5">
|
|
||||||
{@render strip(row)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{#snippet ignoredNote()}
|
|
||||||
<span class="text-cladd-fg-soft"
|
|
||||||
>{hidden}
|
|
||||||
{hidden === 1 ? "place is" : "places are"}
|
|
||||||
ignored.</span
|
|
||||||
>
|
|
||||||
<button class="text-brand hover:underline" onclick={unignore} type="button">
|
|
||||||
Show them again
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet result(
|
|
||||||
row: Row,
|
|
||||||
extra: string
|
|
||||||
)}
|
|
||||||
{@const item = latest(row.key)}
|
|
||||||
{#if item}
|
|
||||||
<div class="flex flex-wrap items-center gap-x-2 text-[12px] {extra}">
|
|
||||||
{#if item.state !== "done"}
|
|
||||||
<span class="num text-cladd-fg-soft"
|
|
||||||
>{item.state === "undoing"
|
|
||||||
? "Putting back"
|
|
||||||
: `Moving to ${item.label}`}
|
|
||||||
{item.done}
|
|
||||||
of {item.targets.size}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span class="text-cladd-fg">
|
|
||||||
{item.kind === "ignore"
|
|
||||||
? "Ignored"
|
|
||||||
: `${item.entries.length} moved to ${item.label}`}
|
|
||||||
</span>
|
|
||||||
{#if item.error}
|
|
||||||
<span class="text-expense">{item.error}</span>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
class="text-brand hover:underline"
|
|
||||||
onclick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
undo(item);
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Undo
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if !row.entry && hint?.(row.place)}
|
|
||||||
<div class="text-[12px] text-cladd-fg-soft {extra}">
|
|
||||||
Usually {hint(row.place)}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet strip(
|
|
||||||
row: Row
|
|
||||||
)}
|
|
||||||
{@const count = row.entry ? 1 : left(row.place).length}
|
|
||||||
{#if picking === row.key}
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<AutocompleteInput
|
|
||||||
aria-label="Pick a {noun}"
|
|
||||||
class="w-72 max-w-full"
|
|
||||||
free
|
|
||||||
onblur={() => (picking = null)}
|
|
||||||
onkeydown={(event) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
picking = null;
|
|
||||||
nav.move(nav.cursor);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onpick={(choice) => picked(row, choice)}
|
|
||||||
openOnFocus
|
|
||||||
placeholder="Type a {noun} name"
|
|
||||||
{search}
|
|
||||||
bind:ref={pickRef}
|
|
||||||
bind:value={pickText}
|
|
||||||
/>
|
|
||||||
<span class="text-[12px] text-cladd-fg-soft"
|
|
||||||
><span class="kbd">↵</span>
|
|
||||||
moves, a new name creates the {noun}</span
|
|
||||||
>
|
|
||||||
{#if pickError}
|
|
||||||
<span class="text-[12px] text-expense">{pickError}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
|
||||||
<span class="mr-1 text-cladd-fg-soft"
|
|
||||||
>Move {count === 1 ? "it" : `all ${count}`} to</span
|
|
||||||
>
|
|
||||||
{#each numbered as option, n (option.id)}
|
|
||||||
<Button onclick={() => assign(row, option)} size="sm" variant="outline">
|
|
||||||
<span class="kbd">{n + 1}</span>{option.name}
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
<Button onclick={() => startPick(row)} size="sm" variant="outline">
|
|
||||||
<span class="kbd">{pickKey}</span>Another {noun}
|
|
||||||
</Button>
|
|
||||||
{#if !row.entry}
|
|
||||||
<Button onclick={() => ignore(row)} size="sm" variant="ghost">
|
|
||||||
<span class="kbd">n</span>Ignore
|
|
||||||
</Button>
|
|
||||||
<Button onclick={() => toggle(row)} size="sm" variant="ghost">
|
|
||||||
<span class="kbd">↵</span>
|
|
||||||
{expanded.has(row.place.key) ? "Hide" : "Show"}
|
|
||||||
{count === 1 ? "it" : "them"}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
@@ -7,33 +7,22 @@ import type {
|
|||||||
Currency,
|
Currency,
|
||||||
InsightTotalEntry,
|
InsightTotalEntry,
|
||||||
Item,
|
Item,
|
||||||
Split,
|
Page,
|
||||||
|
TransactionGroup,
|
||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
import { placesOf, sumsOf, withoutBudget } from "#lib/budgets/triage.js";
|
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
amountIn,
|
|
||||||
type Cur,
|
type Cur,
|
||||||
fromEntries,
|
fromEntries,
|
||||||
list,
|
list,
|
||||||
type Sum,
|
type Sum,
|
||||||
type Sums,
|
type Sums,
|
||||||
} from "#lib/components/categories/sums.js";
|
} from "#lib/components/categories/sums.js";
|
||||||
import {
|
import { contains, daysBetween, type Range, today } from "#lib/dates.js";
|
||||||
contains,
|
import { toNumber } from "#lib/format.js";
|
||||||
daysBetween,
|
|
||||||
isDynamic,
|
|
||||||
type Range,
|
|
||||||
shiftPeriod,
|
|
||||||
today,
|
|
||||||
type ViewRange,
|
|
||||||
} from "#lib/dates.js";
|
|
||||||
import { formatMoney, toNumber } from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
|
|
||||||
export interface Line {
|
export interface Line {
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
approx?: boolean;
|
|
||||||
cur: Cur & { id: string };
|
cur: Cur & { id: string };
|
||||||
key: string;
|
key: string;
|
||||||
limit?: Item<BudgetLimit>;
|
limit?: Item<BudgetLimit>;
|
||||||
@@ -54,16 +43,6 @@ export interface Pool {
|
|||||||
spent: number;
|
spent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Top {
|
|
||||||
approx?: boolean;
|
|
||||||
available: number | null;
|
|
||||||
budgeted: number;
|
|
||||||
cur: Cur;
|
|
||||||
inBudgets: number;
|
|
||||||
outside: number;
|
|
||||||
spent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Pace {
|
export interface Pace {
|
||||||
expected: number;
|
expected: number;
|
||||||
fraction: number;
|
fraction: number;
|
||||||
@@ -79,9 +58,6 @@ export interface BudgetPage {
|
|||||||
total: Sum[];
|
total: Sum[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isOpen = (split: Split) =>
|
|
||||||
split.type === "withdrawal" && !split.budget_id;
|
|
||||||
|
|
||||||
export const day = (value: string | undefined) => (value ?? "").slice(0, 10);
|
export const day = (value: string | undefined) => (value ?? "").slice(0, 10);
|
||||||
|
|
||||||
export const limitRange = (limit: Item<BudgetLimit>): Range => ({
|
export const limitRange = (limit: Item<BudgetLimit>): Range => ({
|
||||||
@@ -122,20 +98,23 @@ export async function loadBudgetPage(
|
|||||||
currencies: Item<Currency>[]
|
currencies: Item<Currency>[]
|
||||||
): Promise<BudgetPage> {
|
): Promise<BudgetPage> {
|
||||||
const query = { end: range.end, start: range.start };
|
const query = { end: range.end, start: range.start };
|
||||||
const [budgets, limits, available, total, groups] = await Promise.all([
|
const [budgets, limits, available, total, noBudget, noBudgetPage] =
|
||||||
|
await Promise.all([
|
||||||
all<Budget>("/v1/budgets", query),
|
all<Budget>("/v1/budgets", query),
|
||||||
all<BudgetLimit>("/v1/budget-limits", query),
|
all<BudgetLimit>("/v1/budget-limits", query),
|
||||||
all<AvailableBudget>("/v1/available-budgets", query),
|
all<AvailableBudget>("/v1/available-budgets", query),
|
||||||
api<InsightTotalEntry[]>("/v1/insight/expense/total", { query }),
|
api<InsightTotalEntry[]>("/v1/insight/expense/total", { query }),
|
||||||
withoutBudget(range),
|
api<InsightTotalEntry[]>("/v1/insight/expense/no-budget", { query }),
|
||||||
|
api<Page<TransactionGroup>>("/v1/budgets/transactions-without-budget", {
|
||||||
|
query: { ...query, limit: 1, type: "withdrawal" },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const entries = placesOf(groups, isOpen).flatMap((place) => place.entries);
|
|
||||||
return {
|
return {
|
||||||
available,
|
available,
|
||||||
budgets,
|
budgets,
|
||||||
limits,
|
limits,
|
||||||
noBudget: sumsOf(entries),
|
noBudget: insightSums(noBudget, currencies),
|
||||||
noBudgetCount: entries.length,
|
noBudgetCount: noBudgetPage.meta.pagination?.total ?? 0,
|
||||||
total: insightSums(total, currencies),
|
total: insightSums(total, currencies),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -236,102 +215,6 @@ export function buildPools(
|
|||||||
return result.length > 0 ? result : [pool(primary)];
|
return result.length > 0 ? result : [pool(primary)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function topLines(
|
|
||||||
pools: Pool[],
|
|
||||||
total: Sum[],
|
|
||||||
noBudget: Sum[],
|
|
||||||
primary: string
|
|
||||||
): Top[] {
|
|
||||||
const curs = new Map<string, Cur>();
|
|
||||||
for (const item of [...pools.map((pool) => pool.cur), ...total]) {
|
|
||||||
curs.set(item.code, curs.get(item.code) ?? item);
|
|
||||||
}
|
|
||||||
return [...curs.values()]
|
|
||||||
.map((cur) => {
|
|
||||||
const pool = pools.find((item) => item.cur.code === cur.code);
|
|
||||||
return {
|
|
||||||
available: pool?.available ?? null,
|
|
||||||
budgeted: pool?.budgeted ?? 0,
|
|
||||||
cur,
|
|
||||||
inBudgets: -(pool?.spent ?? 0),
|
|
||||||
outside: -amountIn(noBudget, cur.code),
|
|
||||||
spent: -amountIn(total, cur.code),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((top) => top.budgeted || top.spent || top.available)
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
Number(b.cur.code === primary) - Number(a.cur.code === primary) ||
|
|
||||||
b.spent - a.spent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ratesOf(codes: string[]): number[] | null {
|
|
||||||
if (!display.active || codes.every((code) => code === display.code)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const rates = codes.map((code) => display.rate(code));
|
|
||||||
return rates.every((rate) => rate !== null) ? rates : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scaled = (values: number[], rates: number[]) =>
|
|
||||||
values.reduce((total, value, i) => total + value * rates[i], 0);
|
|
||||||
|
|
||||||
export function mergeLines(lines: Line[], range: Range): Line[] {
|
|
||||||
const rates = ratesOf(lines.map((line) => line.cur.code));
|
|
||||||
if (!rates) {
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
const limited = lines.find((line) => line.amount !== null);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
amount: limited
|
|
||||||
? scaled(
|
|
||||||
lines.map((line) => line.amount ?? 0),
|
|
||||||
rates
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
approx: true,
|
|
||||||
cur: { ...display.target, id: "" },
|
|
||||||
key: lines[0].key,
|
|
||||||
range: limited?.range ?? range,
|
|
||||||
spent: scaled(
|
|
||||||
lines.map((line) => line.spent),
|
|
||||||
rates
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeTops(tops: Top[]): Top[] {
|
|
||||||
const rates = ratesOf(tops.map((top) => top.cur.code));
|
|
||||||
if (!rates) {
|
|
||||||
return tops;
|
|
||||||
}
|
|
||||||
const sum = (pick: (top: Top) => number) => scaled(tops.map(pick), rates);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
approx: true,
|
|
||||||
available: tops.some((top) => top.available !== null)
|
|
||||||
? sum((top) => top.available ?? 0)
|
|
||||||
: null,
|
|
||||||
budgeted: sum((top) => top.budgeted),
|
|
||||||
cur: display.target,
|
|
||||||
inBudgets: sum((top) => top.inBudgets),
|
|
||||||
outside: sum((top) => top.outside),
|
|
||||||
spent: sum((top) => top.spent),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const lineMoney = (line: Line, amount: number) => {
|
|
||||||
const text = display.text([{ ...line.cur, amount }]);
|
|
||||||
return line.approx ? `≈${text}` : text;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const topMoney = (top: Top, amount: number) =>
|
|
||||||
`${top.approx ? "≈" : ""}${formatMoney({ amount, decimals: 0, symbol: top.cur.symbol })}`;
|
|
||||||
|
|
||||||
export function pace(line: Line, now = today()): Pace | null {
|
export function pace(line: Line, now = today()): Pace | null {
|
||||||
if (!(line.amount && contains(line.range, now))) {
|
if (!(line.amount && contains(line.range, now))) {
|
||||||
return null;
|
return null;
|
||||||
@@ -342,60 +225,16 @@ export function pace(line: Line, now = today()): Pace | null {
|
|||||||
return { expected, fraction: elapsed / total, over: -line.spent - expected };
|
return { expected, fraction: elapsed / total, over: -line.spent - expected };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projection(line: Line, now = today()): number | null {
|
export function perDay(line: Line, now = today()) {
|
||||||
if (!contains(line.range, now)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const total = daysBetween(line.range.start, line.range.end) + 1;
|
const total = daysBetween(line.range.start, line.range.end) + 1;
|
||||||
const elapsed = daysBetween(line.range.start, now) + 1;
|
const current = contains(line.range, now);
|
||||||
return (-line.spent * total) / elapsed;
|
const passed = current ? daysBetween(line.range.start, now) + 1 : total;
|
||||||
}
|
const left = current ? daysBetween(now, line.range.end) + 1 : total;
|
||||||
|
return {
|
||||||
export function safePerDay(line: Line, now = today()): number | null {
|
left: line.amount === null ? null : (line.amount + line.spent) / left,
|
||||||
if (line.amount === null || !contains(line.range, now)) {
|
past: line.range.end < now,
|
||||||
return null;
|
spent: line.spent / passed,
|
||||||
}
|
};
|
||||||
const left = daysBetween(now, line.range.end) + 1;
|
|
||||||
return Math.max(0, line.amount + line.spent) / left;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AVERAGE_OF = 3;
|
|
||||||
|
|
||||||
export function niceCeil(value: number) {
|
|
||||||
const step = 10 ** Math.max(0, Math.floor(Math.log10(value)) - 1);
|
|
||||||
return Math.ceil(value / step) * step;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function averages(
|
|
||||||
range: Range,
|
|
||||||
view: ViewRange
|
|
||||||
): Promise<Map<string, number>> {
|
|
||||||
const fixed = isDynamic(view) ? "1M" : view;
|
|
||||||
const ranges: Range[] = [];
|
|
||||||
let step = range;
|
|
||||||
for (let i = 0; i < AVERAGE_OF; i += 1) {
|
|
||||||
step = shiftPeriod(step, fixed, -1);
|
|
||||||
ranges.push(step);
|
|
||||||
}
|
|
||||||
const periods = await Promise.all(
|
|
||||||
ranges.map((item) =>
|
|
||||||
all<Budget>("/v1/budgets", { end: item.end, start: item.start })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const sums = new Map<string, number>();
|
|
||||||
for (const budget of periods.flat()) {
|
|
||||||
for (const sum of fromEntries(budget.attributes.spent)) {
|
|
||||||
const key = `${budget.id}:${sum.code}`;
|
|
||||||
sums.set(key, (sums.get(key) ?? 0) - sum.amount / AVERAGE_OF);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const out = new Map<string, number>();
|
|
||||||
for (const [key, value] of sums) {
|
|
||||||
if (value > 0) {
|
|
||||||
out.set(key, niceCeil(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPACES = /\s/g;
|
const SPACES = /\s/g;
|
||||||
|
|||||||
@@ -7,14 +7,6 @@ export function periodPath(base: string, start: string, end: string) {
|
|||||||
return `${base}/${start}/${end}?start=${start}&end=${end}`;
|
return `${base}/${start}/${end}?start=${start}&end=${end}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function keptParams() {
|
|
||||||
const params = new URLSearchParams(page.url.search);
|
|
||||||
params.delete("start");
|
|
||||||
params.delete("end");
|
|
||||||
const rest = params.toString();
|
|
||||||
return rest ? `&${rest}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePeriodPath(base: () => string) {
|
export function usePeriodPath(base: () => string) {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const { start, end } = page.params;
|
const { start, end } = page.params;
|
||||||
@@ -33,9 +25,7 @@ export function usePeriodPath(base: () => string) {
|
|||||||
const target = base();
|
const target = base();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (page.url.pathname.startsWith(`${target}/${from}`)) {
|
if (page.url.pathname.startsWith(`${target}/${from}`)) {
|
||||||
goto(`${periodPath(target, start, end)}${keptParams()}`, {
|
goto(periodPath(target, start, end), { replace: true });
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import BarChart from "#lib/components/charts/BarChart.svelte";
|
||||||
|
import { formatMoney } from "#lib/format.js";
|
||||||
|
import type { Breakdown } from "./sums.js";
|
||||||
|
|
||||||
|
const TOP = 8;
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
color = "var(--ff-expense)",
|
||||||
|
}: { title: string; items: Breakdown[]; color?: string } = $props();
|
||||||
|
|
||||||
|
function bars(item: Breakdown) {
|
||||||
|
const top = item.bars.slice(0, TOP);
|
||||||
|
const rest = item.bars.slice(TOP).reduce((sum, bar) => sum + bar.amount, 0);
|
||||||
|
const format = (value: number) =>
|
||||||
|
formatMoney({
|
||||||
|
amount: value,
|
||||||
|
decimals: item.cur.decimals,
|
||||||
|
symbol: item.cur.symbol,
|
||||||
|
});
|
||||||
|
const list = top.map((bar) => ({
|
||||||
|
format,
|
||||||
|
href: bar.href,
|
||||||
|
key: bar.label,
|
||||||
|
label: bar.label,
|
||||||
|
value: bar.amount,
|
||||||
|
}));
|
||||||
|
if (rest > 0) {
|
||||||
|
list.push({
|
||||||
|
format,
|
||||||
|
href: undefined,
|
||||||
|
key: "\u0000other",
|
||||||
|
label: `${item.bars.length - TOP} more`,
|
||||||
|
value: rest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box {title}>
|
||||||
|
{#if items.length === 0}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">Nothing in this range.</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each items as item (item.cur.code)}
|
||||||
|
{#if items.length > 1}
|
||||||
|
<h3 class="font-medium text-[12px] text-cladd-fg-soft">
|
||||||
|
{item.cur.code}
|
||||||
|
</h3>
|
||||||
|
{/if}
|
||||||
|
<BarChart
|
||||||
|
bars={bars(item)}
|
||||||
|
{color}
|
||||||
|
label={title}
|
||||||
|
labelWidth="minmax(5rem, 10rem)"
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
let { codes, current }: { codes: string[]; current: string } = $props();
|
||||||
|
|
||||||
|
function href(code: string) {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
url.searchParams.set("currency", code);
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if codes.length > 1}
|
||||||
|
<nav aria-label="Currency" class="flex gap-0.5 text-[12px]">
|
||||||
|
{#each codes as code (code)}
|
||||||
|
<a
|
||||||
|
aria-current={code === current ? "true" : undefined}
|
||||||
|
class="num rounded-cladd-sm px-1.5 py-0.5 {code === current
|
||||||
|
? "bg-row-selected font-semibold text-cladd-fg"
|
||||||
|
: "text-cladd-fg-soft hover:bg-row-hover"}"
|
||||||
|
data-sveltekit-noscroll
|
||||||
|
data-sveltekit-replacestate
|
||||||
|
href={href(code)}
|
||||||
|
>{code}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Amount, { type Tone } from "#lib/components/Amount.svelte";
|
import Amount, { type Tone } from "#lib/components/Amount.svelte";
|
||||||
import Money from "#lib/components/Money.svelte";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import type { Sum } from "./sums.js";
|
import type { Sum } from "./sums.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -10,8 +8,6 @@
|
|||||||
empty = "",
|
empty = "",
|
||||||
class: className = "",
|
class: className = "",
|
||||||
}: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props();
|
}: { sums: Sum[]; tone?: Tone; empty?: string; class?: string } = $props();
|
||||||
|
|
||||||
const merged = $derived(display.total(sums) !== null);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if sums.length === 0}
|
{#if sums.length === 0}
|
||||||
@@ -20,9 +16,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span class="inline-flex flex-wrap justify-end gap-x-2.5 {className}">
|
<span class="inline-flex flex-wrap justify-end gap-x-2.5 {className}">
|
||||||
{#if merged}
|
|
||||||
<Money parts={sums} {tone} />
|
|
||||||
{:else}
|
|
||||||
{#each sums as sum (sum.code)}
|
{#each sums as sum (sum.code)}
|
||||||
<Amount
|
<Amount
|
||||||
amount={sum.amount}
|
amount={sum.amount}
|
||||||
@@ -32,6 +25,5 @@
|
|||||||
{tone}
|
{tone}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1,21 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { api } from "#lib/api/client.js";
|
import { api } from "#lib/api/client.js";
|
||||||
import { invalidate } from "#lib/api/lists.js";
|
import { all, invalidate } from "#lib/api/lists.js";
|
||||||
import type { Category, One } from "#lib/api/types.js";
|
import type { Category, One, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
import {
|
import {
|
||||||
periodPath,
|
periodPath,
|
||||||
usePeriodPath,
|
usePeriodPath,
|
||||||
} from "#lib/components/budgets/period-path.svelte.js";
|
} from "#lib/components/budgets/period-path.svelte.js";
|
||||||
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
import Dimension from "#lib/components/dimension/Dimension.svelte";
|
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import { Button } from "#lib/components/ui/button/index.js";
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
import { DimensionModel } from "#lib/dimension/model.svelte.js";
|
import { formatDate, formatMoney } from "#lib/format.js";
|
||||||
import { categorySpec } from "#lib/dimension/spec.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import CurrencyTabs from "./CurrencyTabs.svelte";
|
||||||
|
import { currencies, FLOW_KEYS, flowRows } from "./flow.js";
|
||||||
|
import PeriodList from "./PeriodList.svelte";
|
||||||
|
import {
|
||||||
|
firstDay,
|
||||||
|
periodRanges,
|
||||||
|
rows as splitRows,
|
||||||
|
stepLabel,
|
||||||
|
stepRanges,
|
||||||
|
} from "./sums.js";
|
||||||
|
|
||||||
let { id, everything = false }: { id: string; everything?: boolean } =
|
let { id, everything = false }: { id: string; everything?: boolean } =
|
||||||
$props();
|
$props();
|
||||||
@@ -27,12 +38,49 @@
|
|||||||
queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
|
queryFn: () => api<One<Category>>(`/v1/categories/${id}`),
|
||||||
queryKey: ["category", id],
|
queryKey: ["category", id],
|
||||||
}));
|
}));
|
||||||
const name = $derived(category.data?.data.attributes.name ?? "");
|
const groups = createQuery(() => ({
|
||||||
const model = new DimensionModel(() => ({
|
queryFn: () => all<TransactionGroup>(`/v1/categories/${id}/transactions`),
|
||||||
everything,
|
queryKey: ["category-transactions", id],
|
||||||
spec: categorySpec(id),
|
|
||||||
}));
|
}));
|
||||||
const other = $derived(everything ? base : `${base}/all`);
|
|
||||||
|
const name = $derived(category.data?.data.attributes.name ?? "");
|
||||||
|
const own = $derived(
|
||||||
|
splitRows(groups.data ?? [], (s) => s.category_id === id)
|
||||||
|
);
|
||||||
|
const curs = $derived(currencies(own));
|
||||||
|
const cur = $derived(
|
||||||
|
curs.find((c) => c.code === page.url.searchParams.get("currency")) ??
|
||||||
|
curs[0]
|
||||||
|
);
|
||||||
|
const fmt = $derived((value: number) =>
|
||||||
|
formatMoney({ amount: value, decimals: cur?.decimals, symbol: cur?.symbol })
|
||||||
|
);
|
||||||
|
|
||||||
|
const periodRows = $derived(
|
||||||
|
cur
|
||||||
|
? flowRows(own, stepRanges(period.range), cur, (r) =>
|
||||||
|
r.start === r.end
|
||||||
|
? String(Number(r.start.slice(8)))
|
||||||
|
: formatDate(r.start, "short")
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const historyRows = $derived.by(() => {
|
||||||
|
if (!cur) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const first = firstDay(own) ?? period.start;
|
||||||
|
const ranges = periodRanges(period.end, period.view, 400)
|
||||||
|
.filter((r) => r.end >= first)
|
||||||
|
.reverse();
|
||||||
|
return flowRows(
|
||||||
|
own,
|
||||||
|
ranges,
|
||||||
|
cur,
|
||||||
|
(r) => stepLabel(r, period.view),
|
||||||
|
(r) => periodPath(base, r.start, r.end)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
async function remove() {
|
async function remove() {
|
||||||
await api(`/v1/categories/${id}`, { method: "DELETE" });
|
await api(`/v1/categories/${id}`, { method: "DELETE" });
|
||||||
@@ -47,12 +95,6 @@
|
|||||||
label: "Edit category",
|
label: "Edit category",
|
||||||
run: () => goto(`/categories/edit/${id}`),
|
run: () => goto(`/categories/edit/${id}`),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
group: "Category",
|
|
||||||
keys: "A",
|
|
||||||
label: everything ? "Show the current period" : "Show all time",
|
|
||||||
run: () => goto(other),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,17 +103,16 @@
|
|||||||
{ href: "/categories", label: "Categories" },
|
{ href: "/categories", label: "Categories" },
|
||||||
{ label: name || "Category" },
|
{ label: name || "Category" },
|
||||||
]}
|
]}
|
||||||
subtitle={everything ? "All time" : period.label}
|
subtitle={everything
|
||||||
|
? "All transactions"
|
||||||
|
: `Between ${formatDate(period.start)} and ${formatDate(period.end)}`}
|
||||||
title={name || "Category"}
|
title={name || "Category"}
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button href={other} size="sm" title="Shift+A" variant="outline"
|
|
||||||
>{everything ? "This period" : "All time"}</Button
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
href="/categories/edit/{id}"
|
href="/categories/edit/{id}"
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Shift+E"
|
title="Edit category (E)"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>Edit</Button
|
>Edit</Button
|
||||||
>
|
>
|
||||||
@@ -79,11 +120,66 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<Dimension
|
<div class="flex flex-col gap-4">
|
||||||
{model}
|
<div class="grid gap-4 {everything ? "" : "lg:grid-cols-2"}">
|
||||||
noun={model.dir === -1 ? "purchases" : "payments"}
|
{#if !everything}
|
||||||
onmonth={(range) =>
|
<Box
|
||||||
everything
|
title="Chart for all transactions for category "{name}" between {formatDate(
|
||||||
? goto(periodPath(base, range.start, range.end))
|
period.start
|
||||||
: period.set(range)}
|
)} and {formatDate(period.end)}"
|
||||||
/>
|
>
|
||||||
|
{#snippet tools()}
|
||||||
|
<CurrencyTabs
|
||||||
|
codes={curs.map((c) => c.code)}
|
||||||
|
current={cur?.code ?? ""}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
<StackedBars
|
||||||
|
format={fmt}
|
||||||
|
height={220}
|
||||||
|
keys={FLOW_KEYS}
|
||||||
|
label="Spent and earned in this period"
|
||||||
|
rows={periodRows}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
|
<Box title="Chart for all transactions for category "{name}"">
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if everything}
|
||||||
|
<CurrencyTabs
|
||||||
|
codes={curs.map((c) => c.code)}
|
||||||
|
current={cur?.code ?? ""}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<StackedBars
|
||||||
|
format={fmt}
|
||||||
|
height={220}
|
||||||
|
keys={FLOW_KEYS}
|
||||||
|
label="Spent and earned per period"
|
||||||
|
rows={historyRows}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PeriodList
|
||||||
|
groups={groups.data ?? []}
|
||||||
|
href={(r) => periodPath(base, r.start, r.end)}
|
||||||
|
keep={(s) => s.category_id === id}
|
||||||
|
loading={groups.isPending}
|
||||||
|
sidebar={!everything}
|
||||||
|
title="Transactions"
|
||||||
|
>
|
||||||
|
{#snippet links()}
|
||||||
|
{#if everything}
|
||||||
|
<a class="text-brand hover:underline" href={base}
|
||||||
|
>Show the current period and overview</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<a class="text-brand hover:underline" href="{base}/all"
|
||||||
|
>Show all transactions without grouping them by date</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PeriodList>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
type StackKey,
|
||||||
|
type StackRow,
|
||||||
|
tones,
|
||||||
|
} from "#lib/components/charts/chart.js";
|
||||||
|
import type { Range } from "#lib/dates.js";
|
||||||
|
import { formatMoney, toNumber } from "#lib/format.js";
|
||||||
|
import { type Cur, curOfSplit, inRange, type Row } from "./sums.js";
|
||||||
|
|
||||||
|
export const FLOW_KEYS: StackKey[] = [
|
||||||
|
{ color: tones.income, key: "earned", label: "Earned" },
|
||||||
|
{ color: tones.expense, key: "spent", label: "Spent" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function currencies(items: Row[]): Cur[] {
|
||||||
|
const found = new Map<string, Cur>();
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const { split } of items) {
|
||||||
|
const cur = curOfSplit(split);
|
||||||
|
found.set(cur.code, cur);
|
||||||
|
counts.set(cur.code, (counts.get(cur.code) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...found.values()].sort(
|
||||||
|
(a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flowRows(
|
||||||
|
items: Row[],
|
||||||
|
ranges: Range[],
|
||||||
|
cur: Cur,
|
||||||
|
label: (range: Range) => string,
|
||||||
|
href?: (range: Range) => string
|
||||||
|
): StackRow[] {
|
||||||
|
const own = items.filter((row) => row.split.currency_code === cur.code);
|
||||||
|
const format = (value: number) =>
|
||||||
|
formatMoney({ amount: value, decimals: cur.decimals, symbol: cur.symbol });
|
||||||
|
return ranges.map((range) => {
|
||||||
|
let spent = 0;
|
||||||
|
let earned = 0;
|
||||||
|
for (const { split } of inRange(own, range)) {
|
||||||
|
if (split.type === "withdrawal") {
|
||||||
|
spent -= toNumber(split.amount);
|
||||||
|
} else if (split.type === "deposit") {
|
||||||
|
earned += toNumber(split.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
format,
|
||||||
|
href: href?.(range),
|
||||||
|
key: range.start,
|
||||||
|
label: label(range),
|
||||||
|
values: { earned, spent },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
type StackRow,
|
type StackRow,
|
||||||
stack,
|
stack,
|
||||||
textWidth,
|
textWidth,
|
||||||
tones,
|
|
||||||
} from "./chart.js";
|
} from "./chart.js";
|
||||||
import Legend from "./Legend.svelte";
|
import Legend from "./Legend.svelte";
|
||||||
|
|
||||||
@@ -25,9 +24,6 @@
|
|||||||
const TILTED_CHARS = 16;
|
const TILTED_CHARS = 16;
|
||||||
const TILTED_CHAR_WIDTH = 4.9;
|
const TILTED_CHAR_WIDTH = 4.9;
|
||||||
const LABEL_GAP = 13;
|
const LABEL_GAP = 13;
|
||||||
const BARE_BOTTOM = 4;
|
|
||||||
const DOT_ROOM = 1.5;
|
|
||||||
const DOT_R = 3.5;
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
keys,
|
keys,
|
||||||
@@ -37,16 +33,6 @@
|
|||||||
axisFormat = (value: number) => compactMoney(value),
|
axisFormat = (value: number) => compactMoney(value),
|
||||||
label = "Bar chart",
|
label = "Bar chart",
|
||||||
active = $bindable(null),
|
active = $bindable(null),
|
||||||
selected = null,
|
|
||||||
reference = null,
|
|
||||||
partial = null,
|
|
||||||
dots,
|
|
||||||
dotColor = tones.income,
|
|
||||||
gutter,
|
|
||||||
legend: showLegend = true,
|
|
||||||
axis = true,
|
|
||||||
tilt = true,
|
|
||||||
keyboard = true,
|
|
||||||
onselect,
|
onselect,
|
||||||
}: {
|
}: {
|
||||||
keys: StackKey[];
|
keys: StackKey[];
|
||||||
@@ -56,63 +42,30 @@
|
|||||||
axisFormat?: (value: number) => string;
|
axisFormat?: (value: number) => string;
|
||||||
label?: string;
|
label?: string;
|
||||||
active?: string | null;
|
active?: string | null;
|
||||||
selected?: string | null;
|
onselect?: (row: StackRow) => void;
|
||||||
reference?: number | null;
|
|
||||||
partial?: string | null;
|
|
||||||
dots?: Record<string, number>;
|
|
||||||
dotColor?: string;
|
|
||||||
gutter?: number;
|
|
||||||
legend?: boolean;
|
|
||||||
axis?: boolean;
|
|
||||||
tilt?: boolean;
|
|
||||||
keyboard?: boolean;
|
|
||||||
onselect?: (row: StackRow, segment?: string) => void;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const uid = $props.id();
|
|
||||||
let width = $state(0);
|
let width = $state(0);
|
||||||
|
|
||||||
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
|
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
|
||||||
const yCount = $derived(Math.max(2, Math.floor((height - 60) / GAP_Y)));
|
const yCount = $derived(Math.max(2, Math.floor((height - 60) / GAP_Y)));
|
||||||
const stackTop = $derived(Math.max(0, ...stacks.map((s) => s.positive)));
|
|
||||||
const dotValues = $derived(
|
|
||||||
Object.values(dots ?? {}).filter(
|
|
||||||
(v) => v > 0 && (stackTop === 0 || v <= stackTop * DOT_ROOM)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const yBase = $derived(
|
const yBase = $derived(
|
||||||
scaleLinear()
|
scaleLinear()
|
||||||
.domain([
|
.domain([
|
||||||
Math.min(0, ...stacks.map((s) => s.negative)),
|
Math.min(0, ...stacks.map((s) => s.negative)),
|
||||||
Math.max(stackTop, reference ?? 0, ...dotValues) || 1,
|
Math.max(0, ...stacks.map((s) => s.positive)) || 1,
|
||||||
])
|
])
|
||||||
.nice(yCount)
|
.nice(yCount)
|
||||||
);
|
);
|
||||||
const yTicks = $derived(yBase.ticks(yCount));
|
const yTicks = $derived(yBase.ticks(yCount));
|
||||||
const left = $derived(
|
const left = $derived(
|
||||||
gutter ??
|
Math.ceil(Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10)
|
||||||
Math.ceil(
|
|
||||||
Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const step = $derived((width - left - RIGHT) / Math.max(1, rows.length));
|
const step = $derived((width - left - RIGHT) / Math.max(1, rows.length));
|
||||||
const crowded = $derived(
|
const tilted = $derived(
|
||||||
rows.some((r) => textWidth(r.label, 6.2) > step - 6) && step < 70
|
rows.some((r) => textWidth(r.label, 6.2) > step - 6) && step < 70
|
||||||
);
|
);
|
||||||
const tilted = $derived(tilt && crowded);
|
const bottom = $derived(tilted ? TILTED_BOTTOM : FLAT_BOTTOM);
|
||||||
const flatEvery = $derived(
|
|
||||||
crowded && !tilt
|
|
||||||
? Math.ceil(
|
|
||||||
(Math.max(...rows.map((r) => textWidth(r.label, 6.2))) + 6) / step
|
|
||||||
)
|
|
||||||
: 1
|
|
||||||
);
|
|
||||||
const bottom = $derived.by(() => {
|
|
||||||
if (!axis) {
|
|
||||||
return BARE_BOTTOM;
|
|
||||||
}
|
|
||||||
return tilted ? TILTED_BOTTOM : FLAT_BOTTOM;
|
|
||||||
});
|
|
||||||
const labelEvery = $derived(Math.max(1, Math.ceil(LABEL_GAP / step)));
|
const labelEvery = $derived(Math.max(1, Math.ceil(LABEL_GAP / step)));
|
||||||
const y = $derived(yBase.copy().range([height - bottom, TOP]));
|
const y = $derived(yBase.copy().range([height - bottom, TOP]));
|
||||||
const x = $derived(
|
const x = $derived(
|
||||||
@@ -135,13 +88,7 @@
|
|||||||
const activeIndex = $derived(
|
const activeIndex = $derived(
|
||||||
active ? rows.findIndex((r) => r.key === active) : -1
|
active ? rows.findIndex((r) => r.key === active) : -1
|
||||||
);
|
);
|
||||||
const selectedIndex = $derived(
|
const readRow = $derived(rows[activeIndex >= 0 ? activeIndex : 0]);
|
||||||
selected ? rows.findIndex((r) => r.key === selected) : -1
|
|
||||||
);
|
|
||||||
const readRow = $derived(
|
|
||||||
rows[Math.max(0, activeIndex >= 0 ? activeIndex : selectedIndex)]
|
|
||||||
);
|
|
||||||
const yTop = $derived(y.domain()[1]);
|
|
||||||
const fmt = (row: StackRow, value: number) => (row.format ?? format)(value);
|
const fmt = (row: StackRow, value: number) => (row.format ?? format)(value);
|
||||||
const legend = $derived<LegendItem[]>(
|
const legend = $derived<LegendItem[]>(
|
||||||
readRow
|
readRow
|
||||||
@@ -166,31 +113,17 @@
|
|||||||
: text;
|
: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(row: StackRow | undefined, segment?: string) {
|
function select(row: StackRow | undefined) {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (onselect) {
|
if (onselect) {
|
||||||
onselect(row, segment);
|
onselect(row);
|
||||||
} else if (row.href) {
|
} else if (row.href) {
|
||||||
goto(row.href);
|
goto(row.href);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentAt(event: MouseEvent, index: number) {
|
|
||||||
const found = stacks[index];
|
|
||||||
if (!found) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
||||||
const value = y.invert(event.clientY - box.top);
|
|
||||||
return found.segments.find(
|
|
||||||
(seg) =>
|
|
||||||
value >= Math.min(seg.from, seg.to) &&
|
|
||||||
value <= Math.max(seg.from, seg.to)
|
|
||||||
)?.key;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clickable = $derived(Boolean(onselect) || rows.some((r) => r.href));
|
const clickable = $derived(Boolean(onselect) || rows.some((r) => r.href));
|
||||||
|
|
||||||
function pick(event: PointerEvent) {
|
function pick(event: PointerEvent) {
|
||||||
@@ -203,9 +136,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onkeydown(event: KeyboardEvent) {
|
function onkeydown(event: KeyboardEvent) {
|
||||||
if (!keyboard) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
select(rows[activeIndex]);
|
select(rows[activeIndex]);
|
||||||
return;
|
return;
|
||||||
@@ -249,7 +179,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col gap-2">
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
{#if showLegend}
|
|
||||||
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
|
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
{#if keys.length > 1 && readRow}
|
{#if keys.length > 1 && readRow}
|
||||||
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
|
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
|
||||||
@@ -258,7 +187,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<Legend class="min-w-0 flex-1" items={legend} />
|
<Legend class="min-w-0 flex-1" items={legend} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
<div
|
<div
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-valuemax={Math.max(0, rows.length - 1)}
|
aria-valuemax={Math.max(0, rows.length - 1)}
|
||||||
@@ -266,45 +194,19 @@
|
|||||||
aria-valuenow={Math.max(0, activeIndex)}
|
aria-valuenow={Math.max(0, activeIndex)}
|
||||||
aria-valuetext={valueText}
|
aria-valuetext={valueText}
|
||||||
class="relative rounded-sm {clickable ? "cursor-pointer" : ""}"
|
class="relative rounded-sm {clickable ? "cursor-pointer" : ""}"
|
||||||
onblur={() => keyboard && (active = null)}
|
onblur={() => (active = null)}
|
||||||
onclick={(event) =>
|
onclick={() => select(rows[activeIndex])}
|
||||||
select(rows[activeIndex], segmentAt(event, activeIndex))}
|
onfocus={() => (active ??= rows[0]?.key ?? null)}
|
||||||
onfocus={() => keyboard && (active ??= rows[0]?.key ?? null)}
|
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
onpointerleave={() => (active = null)}
|
onpointerleave={() => (active = null)}
|
||||||
onpointermove={pick}
|
onpointermove={pick}
|
||||||
role="slider"
|
role="slider"
|
||||||
tabindex={keyboard ? 0 : -1}
|
tabindex="0"
|
||||||
style:height="{height}px"
|
style:height="{height}px"
|
||||||
bind:clientWidth={width}
|
bind:clientWidth={width}
|
||||||
>
|
>
|
||||||
{#if width > 0 && rows.length > 0}
|
{#if width > 0 && rows.length > 0}
|
||||||
<svg aria-hidden="true" class="block" {height} {width}>
|
<svg aria-hidden="true" class="block" {height} {width}>
|
||||||
{#if partial}
|
|
||||||
<defs>
|
|
||||||
<pattern
|
|
||||||
height="5"
|
|
||||||
id="{uid}-hatch"
|
|
||||||
patternTransform="rotate(45)"
|
|
||||||
patternUnits="userSpaceOnUse"
|
|
||||||
width="5"
|
|
||||||
>
|
|
||||||
<rect fill="var(--cladd-surface)" height="5" width="2" />
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
{/if}
|
|
||||||
{#if selectedIndex >= 0}
|
|
||||||
<rect
|
|
||||||
fill="var(--ff-row-cursor)"
|
|
||||||
fill-opacity="0.6"
|
|
||||||
height={height - bottom - TOP + 6}
|
|
||||||
rx="3"
|
|
||||||
width={x.step()}
|
|
||||||
x={(x(rows[selectedIndex].key) ?? 0) -
|
|
||||||
(x.step() - x.bandwidth()) / 2}
|
|
||||||
y={TOP - 6}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#each yTicks as tick (tick)}
|
{#each yTicks as tick (tick)}
|
||||||
<line
|
<line
|
||||||
stroke="var(--cladd-outline)"
|
stroke="var(--cladd-outline)"
|
||||||
@@ -337,17 +239,16 @@
|
|||||||
{@const dim = activeIndex >= 0 && activeIndex !== i}
|
{@const dim = activeIndex >= 0 && activeIndex !== i}
|
||||||
<g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}>
|
<g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}>
|
||||||
{#each s.segments as seg, j (seg.key)}
|
{#each s.segments as seg, j (seg.key)}
|
||||||
{@const d = segmentPath(
|
<path
|
||||||
|
d={segmentPath(
|
||||||
cx,
|
cx,
|
||||||
seg.from,
|
seg.from,
|
||||||
seg.to,
|
seg.to,
|
||||||
seg.outer,
|
seg.outer,
|
||||||
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
|
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
|
||||||
)}
|
)}
|
||||||
<path {d} style:fill={seg.color} />
|
style:fill={seg.color}
|
||||||
{#if partial === s.row.key}
|
/>
|
||||||
<path {d} fill="url(#{uid}-hatch)" />
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</g>
|
||||||
{#if capLabels && s.segments[0]}
|
{#if capLabels && s.segments[0]}
|
||||||
@@ -367,7 +268,7 @@
|
|||||||
</text>
|
</text>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if axis && tilted && i % labelEvery === 0}
|
{#if tilted && i % labelEvery === 0}
|
||||||
<text
|
<text
|
||||||
fill={activeIndex === i
|
fill={activeIndex === i
|
||||||
? "var(--cladd-fg)"
|
? "var(--cladd-fg)"
|
||||||
@@ -381,52 +282,20 @@
|
|||||||
Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH))
|
Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH))
|
||||||
)}
|
)}
|
||||||
</text>
|
</text>
|
||||||
{:else if axis && !tilted && i % flatEvery === 0}
|
{:else if !tilted}
|
||||||
<text
|
<text
|
||||||
fill={activeIndex === i || selectedIndex === i
|
fill={activeIndex === i
|
||||||
? "var(--cladd-fg)"
|
? "var(--cladd-fg)"
|
||||||
: "var(--cladd-fg-soft)"}
|
: "var(--cladd-fg-soft)"}
|
||||||
font-size="11"
|
font-size="11"
|
||||||
font-weight={selectedIndex === i ? 600 : 400}
|
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
x={cx}
|
x={cx}
|
||||||
y={height - 6}
|
y={height - 6}
|
||||||
>
|
>
|
||||||
{flatEvery > 1
|
{short(s.row.label, Math.floor(x.step() / 6.4))}
|
||||||
? s.row.label
|
|
||||||
: short(s.row.label, Math.floor(x.step() / 6.4))}
|
|
||||||
</text>
|
</text>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#if reference}
|
|
||||||
<line
|
|
||||||
stroke="var(--cladd-fg-soft)"
|
|
||||||
stroke-dasharray="2 3"
|
|
||||||
stroke-width="1.25"
|
|
||||||
x1={left}
|
|
||||||
x2={width - RIGHT}
|
|
||||||
y1={crisp(y(reference))}
|
|
||||||
y2={crisp(y(reference))}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#each rows as row (row.key)}
|
|
||||||
{@const value = dots?.[row.key] ?? 0}
|
|
||||||
{#if value > 0}
|
|
||||||
{@const cx = (x(row.key) ?? 0) + x.bandwidth() / 2}
|
|
||||||
{#if value > yTop}
|
|
||||||
<path d="M{cx - 4},{TOP - 1}l4,-6l4,6Z" style:fill={dotColor} />
|
|
||||||
{:else}
|
|
||||||
<circle
|
|
||||||
{cx}
|
|
||||||
cy={y(value)}
|
|
||||||
r={DOT_R}
|
|
||||||
stroke="var(--cladd-surface)"
|
|
||||||
stroke-width="1.5"
|
|
||||||
style:fill={dotColor}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { scaleLinear } from "d3-scale";
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import { compactMoney } from "#lib/format.js";
|
|
||||||
import { versus, versusText } from "#lib/reports/typical.js";
|
|
||||||
import Columns from "./Columns.svelte";
|
|
||||||
import {
|
|
||||||
type Lane,
|
|
||||||
type StackKey,
|
|
||||||
type StackRow,
|
|
||||||
stack,
|
|
||||||
textWidth,
|
|
||||||
tones,
|
|
||||||
} from "./chart.js";
|
|
||||||
import Legend from "./Legend.svelte";
|
|
||||||
|
|
||||||
const LANE_HEIGHT = 112;
|
|
||||||
const AXIS_HEIGHT = 18;
|
|
||||||
const TICK_GAP = 44;
|
|
||||||
|
|
||||||
let {
|
|
||||||
lanes,
|
|
||||||
cursor = null,
|
|
||||||
hover = $bindable(null),
|
|
||||||
partial = null,
|
|
||||||
pinned = false,
|
|
||||||
dotLabel = "in",
|
|
||||||
dotColor = tones.income,
|
|
||||||
title = (row: StackRow) => row.label,
|
|
||||||
label = "Months by currency",
|
|
||||||
legend = true,
|
|
||||||
moreIsGood = false,
|
|
||||||
header,
|
|
||||||
onselect,
|
|
||||||
}: {
|
|
||||||
lanes: Lane[];
|
|
||||||
cursor?: string | null;
|
|
||||||
hover?: string | null;
|
|
||||||
partial?: string | null;
|
|
||||||
pinned?: boolean;
|
|
||||||
dotLabel?: string;
|
|
||||||
moreIsGood?: boolean;
|
|
||||||
dotColor?: string;
|
|
||||||
title?: (row: StackRow) => string;
|
|
||||||
label?: string;
|
|
||||||
legend?: boolean;
|
|
||||||
header?: Snippet<[Lane, StackRow | undefined]>;
|
|
||||||
onselect?: (month: string, lane: Lane, segment?: string) => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const read = $derived(pinned ? cursor : (hover ?? cursor));
|
|
||||||
|
|
||||||
const sharedKeys = $derived.by(() => {
|
|
||||||
const seen = new Map<string, StackKey>();
|
|
||||||
for (const lane of lanes) {
|
|
||||||
for (const key of lane.keys) {
|
|
||||||
if (!seen.has(key.key)) {
|
|
||||||
seen.set(key.key, key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...seen.values()];
|
|
||||||
});
|
|
||||||
|
|
||||||
function widest(lane: Lane) {
|
|
||||||
const stacks = lane.rows.map((row) => stack(row, lane.keys));
|
|
||||||
const top = Math.max(
|
|
||||||
lane.typical ?? 0,
|
|
||||||
...stacks.map((s) => s.positive),
|
|
||||||
...Object.values(lane.dots ?? {})
|
|
||||||
);
|
|
||||||
const bottom = Math.min(0, ...stacks.map((s) => s.negative));
|
|
||||||
const height = lane.height ?? LANE_HEIGHT;
|
|
||||||
const count = Math.max(2, Math.floor((height - 60) / TICK_GAP));
|
|
||||||
const format = lane.axisFormat ?? ((v: number) => compactMoney(v));
|
|
||||||
const ticks = scaleLinear()
|
|
||||||
.domain([bottom, top || 1])
|
|
||||||
.nice(count)
|
|
||||||
.ticks(count);
|
|
||||||
return Math.max(...ticks.map((t) => textWidth(format(t))));
|
|
||||||
}
|
|
||||||
|
|
||||||
const gutter = $derived(
|
|
||||||
Math.ceil(Math.max(24, ...lanes.map((lane) => widest(lane)))) + 10
|
|
||||||
);
|
|
||||||
|
|
||||||
function rowOf(lane: Lane) {
|
|
||||||
return lane.rows.find((row) => row.key === read);
|
|
||||||
}
|
|
||||||
|
|
||||||
function totalOf(row: StackRow | undefined) {
|
|
||||||
return row ? Object.values(row.values).reduce((a, b) => a + b, 0) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tone(ratio: number | null) {
|
|
||||||
if (ratio === null || Math.abs(ratio) < 0.03) {
|
|
||||||
return "text-cladd-fg-soft";
|
|
||||||
}
|
|
||||||
return ratio > 0 === moreIsGood ? "text-income" : "text-expense";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet defaultHeader(
|
|
||||||
lane: Lane,
|
|
||||||
row: StackRow | undefined
|
|
||||||
)}
|
|
||||||
{@const value = totalOf(row)}
|
|
||||||
{@const sofar = row?.key === partial}
|
|
||||||
{@const ratio =
|
|
||||||
row && lane.typical && !sofar ? versus(value, lane.typical) : null}
|
|
||||||
{@const dot = row ? lane.dots?.[row.key] : undefined}
|
|
||||||
<span class="w-10 shrink-0 font-semibold text-cladd-fg">{lane.label}</span>
|
|
||||||
{#if row}
|
|
||||||
<span class="text-cladd-fg-soft">{title(row)}</span>
|
|
||||||
<span class="num font-semibold text-cladd-fg">{lane.format(value)}</span>
|
|
||||||
{#if sofar}
|
|
||||||
<span class="text-cladd-fg-soft">so far</span>
|
|
||||||
{:else if ratio !== null}
|
|
||||||
<span class={tone(ratio)}>{versusText(ratio)}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if lane.typical}
|
|
||||||
<span class="flex items-center gap-1.5 text-cladd-fg-soft">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-3 border-cladd-fg-soft border-t-[1.5px] border-dotted"
|
|
||||||
></span>
|
|
||||||
typical
|
|
||||||
<span class="num">{lane.format(lane.typical)}</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if dot}
|
|
||||||
<span class="flex items-center gap-1.5 text-cladd-fg-soft">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
class="size-2 rounded-full"
|
|
||||||
style:background={dotColor}
|
|
||||||
></span>
|
|
||||||
{dotLabel}
|
|
||||||
<span class="num text-cladd-fg">{lane.format(dot)}</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<figure aria-label={label} class="m-0 flex min-w-0 flex-col">
|
|
||||||
{#if legend && sharedKeys.length > 1}
|
|
||||||
<Legend
|
|
||||||
class="mb-2"
|
|
||||||
items={sharedKeys.map((k) => ({ ...k, shape: "rect" as const }))}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#each lanes as lane, i (lane.key)}
|
|
||||||
{@const last = i === lanes.length - 1}
|
|
||||||
<div class="min-w-0 {i > 0 ? "border-cladd-outline border-t pt-1.5" : ""}">
|
|
||||||
<div
|
|
||||||
class="flex min-h-6 flex-wrap items-baseline gap-x-3 gap-y-0.5 text-[12px]"
|
|
||||||
>
|
|
||||||
{@render (header ?? defaultHeader)(lane, rowOf(lane))}
|
|
||||||
</div>
|
|
||||||
<Columns
|
|
||||||
axis={last}
|
|
||||||
axisFormat={lane.axisFormat}
|
|
||||||
{dotColor}
|
|
||||||
dots={lane.dots}
|
|
||||||
format={lane.format}
|
|
||||||
{gutter}
|
|
||||||
height={(lane.height ?? LANE_HEIGHT) + (last ? AXIS_HEIGHT : 0)}
|
|
||||||
keyboard={false}
|
|
||||||
keys={lane.keys}
|
|
||||||
label="{label}: {lane.label}"
|
|
||||||
legend={false}
|
|
||||||
onselect={onselect
|
|
||||||
? (row, segment) => onselect(row.key, lane, segment)
|
|
||||||
: undefined}
|
|
||||||
{partial}
|
|
||||||
reference={lane.typical}
|
|
||||||
rows={lane.rows}
|
|
||||||
selected={cursor}
|
|
||||||
tilt={false}
|
|
||||||
bind:active={hover}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</figure>
|
|
||||||
@@ -39,18 +39,6 @@ export interface StackRow {
|
|||||||
values: Record<string, number>;
|
values: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Lane {
|
|
||||||
axisFormat?: (value: number) => string;
|
|
||||||
dots?: Record<string, number>;
|
|
||||||
format: (value: number) => string;
|
|
||||||
height?: number;
|
|
||||||
key: string;
|
|
||||||
keys: StackKey[];
|
|
||||||
label: string;
|
|
||||||
rows: StackRow[];
|
|
||||||
typical?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MeterSegment {
|
export interface MeterSegment {
|
||||||
color?: string;
|
color?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { DropdownMenu } from "bits-ui";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
Item,
|
||||||
|
Page,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
|
import {
|
||||||
|
canReconcile,
|
||||||
|
createUrl,
|
||||||
|
editUrl,
|
||||||
|
go,
|
||||||
|
openOld,
|
||||||
|
reconcileUrl,
|
||||||
|
showUrl,
|
||||||
|
} from "#lib/components/accounts/links.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
||||||
|
import { span } from "#lib/components/dashboard/data.js";
|
||||||
|
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
||||||
|
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
||||||
|
import { contains } from "#lib/dates.js";
|
||||||
|
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import {
|
||||||
|
groupTitle,
|
||||||
|
signedAmount,
|
||||||
|
signedForeign,
|
||||||
|
} from "#lib/transactions/split.js";
|
||||||
|
|
||||||
|
const LIMIT = 10;
|
||||||
|
const TRIGGER =
|
||||||
|
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1 rounded-cladd-sm px-2.5 font-semibold text-[12px]";
|
||||||
|
|
||||||
|
let {
|
||||||
|
account,
|
||||||
|
trend = [],
|
||||||
|
marker = -1,
|
||||||
|
}: { account: Item<Account>; trend?: number[]; marker?: number } = $props();
|
||||||
|
|
||||||
|
const a = $derived(account.attributes);
|
||||||
|
const cur = $derived({
|
||||||
|
code: a.currency_code,
|
||||||
|
decimals: a.currency_decimal_places,
|
||||||
|
symbol: a.currency_symbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = createQuery(() => ({
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api<Page<TransactionGroup>>(`/v1/accounts/${account.id}/transactions`, {
|
||||||
|
query: { ...span(period.start, period.end), limit: LIMIT },
|
||||||
|
}),
|
||||||
|
queryKey: ["account-tx", account.id, period.start, period.end, LIMIT],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rows = $derived(
|
||||||
|
(list.data?.data ?? [])
|
||||||
|
.flatMap((group) =>
|
||||||
|
group.attributes.transactions
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
(s.source_id === account.id || s.destination_id === account.id) &&
|
||||||
|
contains(period.range, s.date)
|
||||||
|
)
|
||||||
|
.map((split, i) => ({
|
||||||
|
foreign: signedForeign(split, account.id),
|
||||||
|
group,
|
||||||
|
key: `${group.id}-${i}`,
|
||||||
|
money: signedAmount(split, account.id),
|
||||||
|
split,
|
||||||
|
title:
|
||||||
|
group.attributes.transactions.length > 1
|
||||||
|
? `${groupTitle(group)}: ${split.description}`
|
||||||
|
: split.description,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.slice(0, LIMIT)
|
||||||
|
);
|
||||||
|
const change = $derived(toNumber(a.balance_difference));
|
||||||
|
const trendColor = $derived(
|
||||||
|
change < 0 ? "var(--ff-expense)" : "var(--ff-income)"
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box body="py-1" href={showUrl(account.id)} title={a.name}>
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if trend.length > 1}
|
||||||
|
<Sparkline color={trendColor} {marker} values={trend} width={72} />
|
||||||
|
{/if}
|
||||||
|
{#if change !== 0}
|
||||||
|
<Amount amount={change} signed {...cur} class="text-[12px]" />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<div class="transition-opacity {list.isPlaceholderData ? "opacity-60" : ""}">
|
||||||
|
{#if list.isPending}
|
||||||
|
<div class="h-14"></div>
|
||||||
|
{:else if list.isError}
|
||||||
|
<p class="px-3 py-2 text-expense">{list.error.message}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="px-3 py-3 text-cladd-fg-soft">
|
||||||
|
No transactions in this period.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex flex-col">
|
||||||
|
{#each rows as row (row.key)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="flex h-10 items-center gap-2 px-3 text-[12.5px] hover:bg-row-hover sm:h-7"
|
||||||
|
href="/transactions/show/{row.group.id}"
|
||||||
|
>
|
||||||
|
<span class="num w-12 shrink-0 text-cladd-fg-soft"
|
||||||
|
>{formatDate(row.split.date.slice(0, 10), "short")}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="min-w-0 flex-1 truncate text-cladd-fg"
|
||||||
|
title={row.title}
|
||||||
|
>{row.title}</span
|
||||||
|
>
|
||||||
|
{#if row.foreign}
|
||||||
|
<span class="num shrink-0 text-[11px] text-cladd-fg-soft"
|
||||||
|
>({formatMoney(row.foreign)})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<Amount
|
||||||
|
amount={row.money.amount}
|
||||||
|
code={row.money.code}
|
||||||
|
decimals={row.money.decimals}
|
||||||
|
symbol={row.money.symbol}
|
||||||
|
tone={row.money.tone}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="{TRIGGER} cladd-color-green text-cladd-primary"
|
||||||
|
>
|
||||||
|
Create <ChevronDown class="size-3.5" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenuContent align="start" class="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => go(createUrl("withdrawal", account.id))}
|
||||||
|
>New withdrawal</DropdownMenuItem
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={() => go(createUrl("deposit", account.id))}
|
||||||
|
>New deposit</DropdownMenuItem
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={() => go(createUrl("transfer", account.id))}
|
||||||
|
>New transfer</DropdownMenuItem
|
||||||
|
>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger class="{TRIGGER} ml-auto text-cladd-fg">
|
||||||
|
<span class="num"
|
||||||
|
>{formatMoney({ amount: a.current_balance, ...cur })}</span
|
||||||
|
>
|
||||||
|
<ChevronDown class="size-3.5" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
|
<DropdownMenuItem onSelect={() => go(showUrl(account.id))}
|
||||||
|
>Show</DropdownMenuItem
|
||||||
|
>
|
||||||
|
{#if canReconcile(account)}
|
||||||
|
<DropdownMenuItem onSelect={() => go(reconcileUrl(account.id))}
|
||||||
|
>Reconcile</DropdownMenuItem
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<DropdownMenuItem onSelect={() => go(editUrl(account.id))}
|
||||||
|
>Edit</DropdownMenuItem
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={() => openOld(showUrl(account.id))}
|
||||||
|
>Open in old UI</DropdownMenuItem
|
||||||
|
>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
|
||||||
import {
|
|
||||||
createQueries,
|
|
||||||
createQuery,
|
|
||||||
keepPreviousData,
|
|
||||||
} from "@tanstack/svelte-query";
|
|
||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import type {
|
|
||||||
Account,
|
|
||||||
Item,
|
|
||||||
Page,
|
|
||||||
TransactionGroup,
|
|
||||||
} from "#lib/api/types.js";
|
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
|
||||||
import { editUrl, showUrl } from "#lib/components/accounts/links.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
|
||||||
import { type Row, useColumn } from "#lib/dashboard/column.svelte.js";
|
|
||||||
import { addDays, today } from "#lib/dates.js";
|
|
||||||
import {
|
|
||||||
formatDate,
|
|
||||||
formatMoney,
|
|
||||||
type Money,
|
|
||||||
toNumber,
|
|
||||||
} from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
|
||||||
import { session } from "#lib/session.svelte.js";
|
|
||||||
import {
|
|
||||||
groupTitle,
|
|
||||||
signedAmount,
|
|
||||||
editUrl as txEditUrl,
|
|
||||||
showUrl as txShowUrl,
|
|
||||||
} from "#lib/transactions/split.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import {
|
|
||||||
accountsChartQuery,
|
|
||||||
accountsQuery,
|
|
||||||
byCurrency,
|
|
||||||
seriesPoints,
|
|
||||||
} from "./data.js";
|
|
||||||
|
|
||||||
const RECENT = 5;
|
|
||||||
const TREND_DAYS = 30;
|
|
||||||
const HISTORY_DAYS = 365;
|
|
||||||
|
|
||||||
const now = today();
|
|
||||||
let expanded = $state<string[]>([]);
|
|
||||||
|
|
||||||
const assets = createQuery(() => ({
|
|
||||||
...accountsQuery("asset", period.start, period.end),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
}));
|
|
||||||
const chart = createQuery(() =>
|
|
||||||
accountsChartQuery(addDays(now, -TREND_DAYS), now)
|
|
||||||
);
|
|
||||||
const recent = createQueries(() => ({
|
|
||||||
queries: expanded.map((id) => ({
|
|
||||||
queryFn: () =>
|
|
||||||
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
|
|
||||||
query: {
|
|
||||||
end: now,
|
|
||||||
limit: RECENT,
|
|
||||||
start: addDays(now, -HISTORY_DAYS),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
queryKey: ["account-recent", id, now],
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const shown = $derived.by(() => {
|
|
||||||
const list = (assets.data ?? [])
|
|
||||||
.filter((a) => a.attributes.active !== false)
|
|
||||||
.sort((a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0));
|
|
||||||
if (!("frontpageAccounts" in session.prefs)) {
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
const ids = new Set(session.frontpageAccounts);
|
|
||||||
return list.filter((a) => ids.has(a.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const trends = $derived(
|
|
||||||
new Map(
|
|
||||||
(chart.data ?? []).map((set) => [
|
|
||||||
set.label,
|
|
||||||
seriesPoints(set, false).map((p) => p.y),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
function sumSeries(series: number[][]): number[] {
|
|
||||||
const length = Math.max(0, ...series.map((s) => s.length));
|
|
||||||
return Array.from({ length }, (_, i) =>
|
|
||||||
series.reduce((sum, s) => sum + (s[i] ?? 0), 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const moneyOf = (a: Account, amount: Money["amount"]): Money => ({
|
|
||||||
amount,
|
|
||||||
code: a.currency_code,
|
|
||||||
decimals: a.currency_decimal_places ?? 2,
|
|
||||||
symbol: a.currency_symbol ?? a.currency_code,
|
|
||||||
});
|
|
||||||
|
|
||||||
const balances = $derived(
|
|
||||||
shown.map((a) => moneyOf(a.attributes, a.attributes.current_balance))
|
|
||||||
);
|
|
||||||
const changes = $derived(
|
|
||||||
shown.map((a) => moneyOf(a.attributes, a.attributes.balance_difference))
|
|
||||||
);
|
|
||||||
const total = $derived(display.total(balances));
|
|
||||||
const change = $derived(display.total(changes));
|
|
||||||
|
|
||||||
const sumOf = (
|
|
||||||
accounts: Item<Account>[],
|
|
||||||
pick: (a: Account) => string | null | undefined
|
|
||||||
) => accounts.reduce((s, a) => s + toNumber(pick(a.attributes)), 0);
|
|
||||||
|
|
||||||
const converted = (a: Item<Account>) =>
|
|
||||||
display.convert(
|
|
||||||
toNumber(a.attributes.current_balance),
|
|
||||||
a.attributes.currency_code
|
|
||||||
) ?? 0;
|
|
||||||
|
|
||||||
const groups = $derived(
|
|
||||||
total
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
accounts: shown.toSorted((a, b) => converted(b) - converted(a)),
|
|
||||||
balance: null,
|
|
||||||
change: null,
|
|
||||||
code: total.code,
|
|
||||||
trend: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: byCurrency(shown, (a) => a.attributes.currency_code ?? "").map(
|
|
||||||
([code, accounts]) => ({
|
|
||||||
accounts,
|
|
||||||
balance: moneyOf(
|
|
||||||
accounts[0].attributes,
|
|
||||||
sumOf(accounts, (a) => a.current_balance)
|
|
||||||
),
|
|
||||||
change: moneyOf(
|
|
||||||
accounts[0].attributes,
|
|
||||||
sumOf(accounts, (a) => a.balance_difference)
|
|
||||||
),
|
|
||||||
code,
|
|
||||||
trend: sumSeries(
|
|
||||||
accounts.map((a) => trends.get(a.attributes.name) ?? [])
|
|
||||||
),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const recentRows = $derived(
|
|
||||||
new Map(
|
|
||||||
expanded.map((id, i) => [
|
|
||||||
id,
|
|
||||||
(recent[i]?.data?.data ?? [])
|
|
||||||
.flatMap((group) =>
|
|
||||||
group.attributes.transactions
|
|
||||||
.filter((s) => s.source_id === id || s.destination_id === id)
|
|
||||||
.map((split, n) => ({
|
|
||||||
date: split.date.slice(0, 10),
|
|
||||||
group,
|
|
||||||
key: `acc-${id}-tx-${group.id}-${n}`,
|
|
||||||
money: signedAmount(split, id),
|
|
||||||
title:
|
|
||||||
group.attributes.transactions.length > 1
|
|
||||||
? `${groupTitle(group)}: ${split.description}`
|
|
||||||
: split.description,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
.slice(0, RECENT),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const accountKey = (id: string) => `acc-${id}`;
|
|
||||||
|
|
||||||
function expand(id: string) {
|
|
||||||
if (expanded.includes(id)) {
|
|
||||||
const first = recentRows.get(id)?.[0];
|
|
||||||
if (first) {
|
|
||||||
column.point(first.key);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expanded = [...expanded, id];
|
|
||||||
}
|
|
||||||
|
|
||||||
function fold(id: string) {
|
|
||||||
expanded = expanded.filter((e) => e !== id);
|
|
||||||
column.point(accountKey(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(id: string) {
|
|
||||||
if (expanded.includes(id)) {
|
|
||||||
fold(id);
|
|
||||||
} else {
|
|
||||||
expand(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const column = useColumn(1, () =>
|
|
||||||
groups.flatMap((g) =>
|
|
||||||
g.accounts.flatMap((account): Row[] => [
|
|
||||||
{
|
|
||||||
edit: () => goto(editUrl(account.id)),
|
|
||||||
expand: () => expand(account.id),
|
|
||||||
fold: () => fold(account.id),
|
|
||||||
key: accountKey(account.id),
|
|
||||||
open: () => goto(showUrl(account.id)),
|
|
||||||
},
|
|
||||||
...(recentRows.get(account.id) ?? []).map((tx) => ({
|
|
||||||
edit: () => goto(txEditUrl(tx.group.id)),
|
|
||||||
fold: () => fold(account.id),
|
|
||||||
key: tx.key,
|
|
||||||
open: () => goto(txShowUrl(tx.group.id)),
|
|
||||||
})),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const trendColor = (values: number[]) =>
|
|
||||||
(values.at(-1) ?? 0) < (values[0] ?? 0)
|
|
||||||
? "var(--ff-expense)"
|
|
||||||
: "var(--ff-income)";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet spark(
|
|
||||||
values: number[]
|
|
||||||
)}
|
|
||||||
{#if values.length > 1}
|
|
||||||
<Sparkline color={trendColor(values)} height={18} {values} width={56} />
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet balance(
|
|
||||||
money: Money
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
class="num font-medium {toNumber(money.amount) < 0
|
|
||||||
? "text-expense"
|
|
||||||
: "text-cladd-fg"}"
|
|
||||||
title={display.active && money.code !== display.code
|
|
||||||
? formatMoney(money)
|
|
||||||
: undefined}
|
|
||||||
>{display.text([money])}</span
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Box body="p-0" href="/accounts/asset" title="Accounts">
|
|
||||||
{#snippet tools()}
|
|
||||||
{#if total}
|
|
||||||
<span class="num font-semibold text-[13px] text-cladd-fg"
|
|
||||||
>{display.text(balances)}</span
|
|
||||||
>
|
|
||||||
{#if change?.amount}
|
|
||||||
<span
|
|
||||||
class="num text-[12px] {change.amount < 0
|
|
||||||
? "text-expense"
|
|
||||||
: "text-income"}"
|
|
||||||
>{display.text(changes, true)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
{#if shown.length === 0}
|
|
||||||
<p class="px-3 py-3 text-cladd-fg-soft">
|
|
||||||
{assets.isPending ? "" : "No accounts on the home screen."}
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<table
|
|
||||||
class="w-full border-collapse text-[13px] transition-opacity {assets.isPlaceholderData
|
|
||||||
? "opacity-60"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr class="h-7 text-[11px] text-cladd-fg-soft">
|
|
||||||
<th class="w-full pl-3 text-left font-normal" scope="col">Account</th>
|
|
||||||
<th class="px-2 text-right font-normal" scope="col">Balance</th>
|
|
||||||
<th class="px-2 text-right font-normal" scope="col">This period</th>
|
|
||||||
<th
|
|
||||||
class="hidden pr-3 text-right font-normal sm:table-cell"
|
|
||||||
scope="col"
|
|
||||||
>
|
|
||||||
{TREND_DAYS}
|
|
||||||
days
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
{#each groups as group (group.code)}
|
|
||||||
<tbody class="border-cladd-outline border-t">
|
|
||||||
{#if group.balance && group.change}
|
|
||||||
<tr class="h-7 bg-cladd-surface-next/60 font-semibold">
|
|
||||||
<th
|
|
||||||
class="pl-3 text-left text-[12px] text-cladd-fg"
|
|
||||||
scope="rowgroup"
|
|
||||||
>
|
|
||||||
{group.code}
|
|
||||||
</th>
|
|
||||||
<td class="whitespace-nowrap px-2 text-right">
|
|
||||||
{@render balance(group.balance)}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-2 text-right">
|
|
||||||
{#if toNumber(group.change.amount) !== 0}
|
|
||||||
<Amount {...group.change} signed />
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="hidden pr-3 sm:table-cell">
|
|
||||||
<div class="flex justify-end">
|
|
||||||
{@render spark(group.trend)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{#each group.accounts as account (account.id)}
|
|
||||||
{@const a = account.attributes}
|
|
||||||
{@const key = accountKey(account.id)}
|
|
||||||
{@const open = expanded.includes(account.id)}
|
|
||||||
<tr
|
|
||||||
class="h-7 border-cladd-outline/60 border-t {column.rowClass(
|
|
||||||
key
|
|
||||||
)}"
|
|
||||||
id={column.id(key)}
|
|
||||||
onclick={() => column.point(key)}
|
|
||||||
>
|
|
||||||
<td class="max-w-0 pl-1">
|
|
||||||
<div class="flex min-w-0 items-center">
|
|
||||||
<button
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-label="Last transactions of {a.name}"
|
|
||||||
class="grid size-6 shrink-0 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:text-cladd-fg"
|
|
||||||
onclick={() => toggle(account.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ChevronRight
|
|
||||||
class="size-3.5 transition-transform {open
|
|
||||||
? "rotate-90"
|
|
||||||
: ""}"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
class="truncate text-cladd-fg hover:underline"
|
|
||||||
href={showUrl(account.id)}
|
|
||||||
title={a.name}
|
|
||||||
>{a.name}</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-2 text-right">
|
|
||||||
{@render balance(moneyOf(a, a.current_balance))}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-2 text-right">
|
|
||||||
{#if toNumber(a.balance_difference) !== 0}
|
|
||||||
<Amount {...moneyOf(a, a.balance_difference)} signed />
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="hidden pr-3 sm:table-cell">
|
|
||||||
<div class="flex justify-end">
|
|
||||||
{@render spark(trends.get(a.name) ?? [])}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{#if open}
|
|
||||||
{#each recentRows.get(account.id) ?? [] as tx (tx.key)}
|
|
||||||
<tr
|
|
||||||
class="h-7 text-[12px] {column.rowClass(tx.key)}"
|
|
||||||
id={column.id(tx.key)}
|
|
||||||
onclick={() => column.point(tx.key)}
|
|
||||||
>
|
|
||||||
<td class="max-w-0 pl-7" colspan="2">
|
|
||||||
<a
|
|
||||||
class="flex min-w-0 items-baseline gap-2 hover:underline"
|
|
||||||
href={txShowUrl(tx.group.id)}
|
|
||||||
>
|
|
||||||
<span class="num w-12 shrink-0 text-cladd-fg-soft"
|
|
||||||
>{formatDate(tx.date, "short")}</span
|
|
||||||
>
|
|
||||||
<span class="truncate text-cladd-fg-soft" title={tx.title}
|
|
||||||
>{tx.title}</span
|
|
||||||
>
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-2 text-right">
|
|
||||||
<Amount {...tx.money} signed />
|
|
||||||
</td>
|
|
||||||
<td class="hidden sm:table-cell"></td>
|
|
||||||
</tr>
|
|
||||||
{:else}
|
|
||||||
<tr class="h-7 text-[12px]">
|
|
||||||
<td class="pl-7 text-cladd-fg-soft" colspan="4">
|
|
||||||
{recent[expanded.indexOf(account.id)]?.isPending
|
|
||||||
? ""
|
|
||||||
: "Nothing in the last year."}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
{/each}
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js";
|
import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js";
|
||||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||||
import { contains, today } from "#lib/dates.js";
|
import { contains, today } from "#lib/dates.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -17,7 +16,6 @@
|
|||||||
type Cur,
|
type Cur,
|
||||||
hasPrimary,
|
hasPrimary,
|
||||||
hrefWith,
|
hrefWith,
|
||||||
mergeable,
|
|
||||||
money,
|
money,
|
||||||
seriesPoints,
|
seriesPoints,
|
||||||
setCurrency,
|
setCurrency,
|
||||||
@@ -59,41 +57,34 @@
|
|||||||
|
|
||||||
interface Panel {
|
interface Panel {
|
||||||
cur: Cur;
|
cur: Cur;
|
||||||
label: string;
|
|
||||||
series: LineSeries[];
|
series: LineSeries[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
|
|
||||||
const merged = $derived(mergeable(sets.map((s) => curOf(s).code)));
|
|
||||||
|
|
||||||
function points(set: ChartSet): LinePoint[] {
|
|
||||||
const rate = merged ? (display.rate(curOf(set).code) ?? 1) : 1;
|
|
||||||
return seriesPoints(set, converted).map((p) => ({ x: p.x, y: p.y * rate }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sum(group: ChartSet[]): LinePoint[] {
|
function sum(group: ChartSet[]): LinePoint[] {
|
||||||
const totals = new Map<string, number>();
|
const totals = new Map<string, number>();
|
||||||
for (const set of group) {
|
for (const set of group) {
|
||||||
for (const p of points(set)) {
|
for (const p of seriesPoints(set, converted)) {
|
||||||
totals.set(p.x, (totals.get(p.x) ?? 0) + p.y);
|
totals.set(p.x, (totals.get(p.x) ?? 0) + p.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...totals.entries()].map(([x, y]) => ({ x, y }));
|
return [...totals.entries()].map(([x, y]) => ({ x, y }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function panel(cur: Cur, label: string, group: ChartSet[]): Panel {
|
const panels = $derived.by<Panel[]>(() => {
|
||||||
|
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
|
||||||
|
return byCurrency(sets, (s) => curOf(s).code).map(([, group]) => {
|
||||||
|
const cur = curOf(group[0]);
|
||||||
const format = money(cur);
|
const format = money(cur);
|
||||||
if (mode === "net-worth") {
|
if (mode === "net-worth") {
|
||||||
const included = group.filter((s) => !excluded.has(s.label));
|
const included = group.filter((s) => !excluded.has(s.label));
|
||||||
return {
|
return {
|
||||||
cur,
|
cur,
|
||||||
label,
|
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
area: true,
|
area: true,
|
||||||
format,
|
format,
|
||||||
key: "net-worth",
|
key: "net-worth",
|
||||||
label: `Net worth (${label})`,
|
label: `Net worth (${cur.code})`,
|
||||||
points: sum(included),
|
points: sum(included),
|
||||||
strong: true,
|
strong: true,
|
||||||
},
|
},
|
||||||
@@ -102,24 +93,16 @@
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
cur,
|
cur,
|
||||||
label,
|
|
||||||
series: group.map((s) => ({
|
series: group.map((s) => ({
|
||||||
area: group.length === 1,
|
area: group.length === 1,
|
||||||
format,
|
format,
|
||||||
key: s.label,
|
key: s.label,
|
||||||
label: s.label,
|
label: s.label,
|
||||||
points: points(s),
|
points: seriesPoints(s, converted),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
|
});
|
||||||
const panels = $derived<Panel[]>(
|
|
||||||
merged
|
|
||||||
? [panel(display.target, `≈${display.code}`, sets)]
|
|
||||||
: byCurrency(sets, (s) => curOf(s).code).map(([code, group]) =>
|
|
||||||
panel(curOf(group[0]), code, group)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const panelHeight = $derived(
|
const panelHeight = $derived(
|
||||||
Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length)))
|
Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length)))
|
||||||
);
|
);
|
||||||
@@ -178,10 +161,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each panels as panel (panel.cur.code)}
|
{#each panels as panel (panel.cur.code)}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
{#if panels.length > 1 || merged}
|
{#if panels.length > 1}
|
||||||
<span
|
<span
|
||||||
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||||
>{panel.label}</span
|
>{panel.cur.code}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<LineChart
|
<LineChart
|
||||||
@@ -204,7 +187,7 @@
|
|||||||
href="/accounts/asset"
|
href="/accounts/asset"
|
||||||
>View your asset accounts</a
|
>View your asset accounts</a
|
||||||
>
|
>
|
||||||
{#if converted && !merged}
|
{#if converted}
|
||||||
<span class="ml-auto text-cladd-fg-soft"
|
<span class="ml-auto text-cladd-fg-soft"
|
||||||
>Converted to your primary currency</span
|
>Converted to your primary currency</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,13 +11,10 @@
|
|||||||
} from "#lib/components/charts/chart.js";
|
} from "#lib/components/charts/chart.js";
|
||||||
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||||
import { toNumber } from "#lib/format.js";
|
import { toNumber } from "#lib/format.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import {
|
import {
|
||||||
byCurrency,
|
byCurrency,
|
||||||
type ChartSet,
|
type ChartSet,
|
||||||
type Cur,
|
|
||||||
mergeable,
|
|
||||||
money,
|
money,
|
||||||
setCurrency,
|
setCurrency,
|
||||||
span,
|
span,
|
||||||
@@ -47,50 +44,36 @@
|
|||||||
new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id]))
|
new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id]))
|
||||||
);
|
);
|
||||||
|
|
||||||
const sets = $derived(chart.data ?? []);
|
const groups = $derived(
|
||||||
const merged = $derived(mergeable(sets.map((s) => s.currency_code)));
|
byCurrency(chart.data ?? [], (s) => s.currency_code).map(([code, sets]) => {
|
||||||
|
const format = money(setCurrency(sets[0]));
|
||||||
function group(code: string, label: string, cur: Cur, list: ChartSet[]) {
|
const rows: StackRow[] = sets
|
||||||
const format = money(cur);
|
.map((s) => {
|
||||||
const totals = new Map<string, { budgeted: number; spent: number }>();
|
const budgeted = toNumber(s.entries.budgeted);
|
||||||
for (const s of list) {
|
const spent = Math.abs(toNumber(s.entries.spent));
|
||||||
const rate = merged ? (display.rate(s.currency_code) ?? 1) : 1;
|
const inside = budgeted > 0 ? Math.min(spent, budgeted) : spent;
|
||||||
const total = totals.get(s.label) ?? { budgeted: 0, spent: 0 };
|
const id = ids.get(s.label);
|
||||||
total.budgeted += toNumber(s.entries.budgeted) * rate;
|
|
||||||
total.spent += Math.abs(toNumber(s.entries.spent)) * rate;
|
|
||||||
totals.set(s.label, total);
|
|
||||||
}
|
|
||||||
const rows: StackRow[] = [...totals]
|
|
||||||
.map(([name, { budgeted, spent }]) => {
|
|
||||||
const id = ids.get(name);
|
|
||||||
return {
|
return {
|
||||||
format,
|
format,
|
||||||
href: id ? `/budgets/show/${id}` : undefined,
|
href: id ? `/budgets/show/${id}` : undefined,
|
||||||
key: name,
|
key: s.label,
|
||||||
label: name,
|
label: s.label,
|
||||||
note:
|
note:
|
||||||
budgeted > 0
|
budgeted > 0
|
||||||
? `${format(spent)} of ${format(budgeted)}`
|
? `${format(spent)} of ${format(budgeted)}`
|
||||||
: format(spent),
|
: format(spent),
|
||||||
values: {
|
values: {
|
||||||
left: budgeted > 0 ? Math.max(0, budgeted - spent) : 0,
|
left: Math.max(0, toNumber(s.entries.left)),
|
||||||
over: budgeted > 0 ? Math.max(0, spent - budgeted) : 0,
|
over: Math.max(0, Math.abs(toNumber(s.entries.overspent))),
|
||||||
spent: budgeted > 0 ? Math.min(spent, budgeted) : spent,
|
spent: inside,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((r) => r.values.spent + r.values.left + r.values.over > 0);
|
.filter((r) => r.values.spent + r.values.left + r.values.over > 0);
|
||||||
const limited = [...totals.values()].some((t) => t.budgeted > 0);
|
const limited = sets.some((x) => toNumber(x.entries.budgeted) > 0);
|
||||||
const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
|
const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
|
||||||
return { code, keys, label, limited, rows };
|
return { code, keys, limited, rows };
|
||||||
}
|
})
|
||||||
|
|
||||||
const groups = $derived(
|
|
||||||
merged
|
|
||||||
? [group(display.code, `≈${display.code}`, display.target, sets)]
|
|
||||||
: byCurrency(sets, (s) => s.currency_code).map(([code, list]) =>
|
|
||||||
group(code, code, setCurrency(list[0]), list)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const visible = $derived(groups.filter((g) => g.rows.length > 0));
|
const visible = $derived(groups.filter((g) => g.rows.length > 0));
|
||||||
</script>
|
</script>
|
||||||
@@ -118,9 +101,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each visible as group (group.code)}
|
{#each visible as group (group.code)}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
{#if visible.length > 1 || !group.limited || merged}
|
{#if visible.length > 1 || !group.limited}
|
||||||
<span class="text-[11px] text-cladd-fg-soft">
|
<span class="text-[11px] text-cladd-fg-soft">
|
||||||
<span class="font-semibold tracking-wide">{group.label}</span>
|
<span class="font-semibold tracking-wide">{group.code}</span>
|
||||||
{#if !group.limited}
|
{#if !group.limited}
|
||||||
· spent, no budget amount set in {group.code}
|
· spent, no budget amount set in {group.code}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,15 +6,12 @@
|
|||||||
import BarChart from "#lib/components/charts/BarChart.svelte";
|
import BarChart from "#lib/components/charts/BarChart.svelte";
|
||||||
import { type Bar, tones } from "#lib/components/charts/chart.js";
|
import { type Bar, tones } from "#lib/components/charts/chart.js";
|
||||||
import { toNumber } from "#lib/format.js";
|
import { toNumber } from "#lib/format.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import {
|
import {
|
||||||
axis,
|
axis,
|
||||||
byCurrency,
|
byCurrency,
|
||||||
type ChartSet,
|
type ChartSet,
|
||||||
type Cur,
|
|
||||||
hasPrimary,
|
hasPrimary,
|
||||||
mergeable,
|
|
||||||
money,
|
money,
|
||||||
proportional,
|
proportional,
|
||||||
setCurrency,
|
setCurrency,
|
||||||
@@ -37,43 +34,31 @@
|
|||||||
const ids = $derived(
|
const ids = $derived(
|
||||||
new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id]))
|
new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id]))
|
||||||
);
|
);
|
||||||
const sets = $derived(chart.data ?? []);
|
const converted = $derived(hasPrimary(chart.data ?? []));
|
||||||
const converted = $derived(hasPrimary(sets));
|
|
||||||
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
|
|
||||||
const merged = $derived(mergeable(sets.map((s) => curOf(s).code)));
|
|
||||||
|
|
||||||
function group(code: string, label: string, cur: Cur, list: ChartSet[]) {
|
const groups = $derived(
|
||||||
const totals = new Map<string, number>();
|
byCurrency(chart.data ?? [], (s) =>
|
||||||
for (const s of list) {
|
converted ? setPrimary(s).code : s.currency_code
|
||||||
|
).map(([code, sets]) => {
|
||||||
|
const cur = converted ? setPrimary(sets[0]) : setCurrency(sets[0]);
|
||||||
|
const bars: Bar[] = sets
|
||||||
|
.map((s) => {
|
||||||
const entries = (converted ? s.pc_entries : s.entries) as Record<
|
const entries = (converted ? s.pc_entries : s.entries) as Record<
|
||||||
string,
|
string,
|
||||||
string
|
string
|
||||||
>;
|
>;
|
||||||
const rate = merged ? (display.rate(curOf(s).code) ?? 1) : 1;
|
const id = ids.get(s.label);
|
||||||
const spent = Math.abs(toNumber(entries?.spent)) * rate;
|
|
||||||
totals.set(s.label, (totals.get(s.label) ?? 0) + spent);
|
|
||||||
}
|
|
||||||
const bars: Bar[] = [...totals]
|
|
||||||
.map(([name, value]) => {
|
|
||||||
const id = ids.get(name);
|
|
||||||
return {
|
return {
|
||||||
href: id ? `/categories/show/${id}` : NO_CATEGORY,
|
href: id ? `/categories/show/${id}` : NO_CATEGORY,
|
||||||
key: name,
|
key: s.label,
|
||||||
label: name,
|
label: s.label,
|
||||||
value,
|
value: Math.abs(toNumber(entries?.spent)),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((b) => b.value > 0)
|
.filter((b) => b.value > 0)
|
||||||
.sort((a, b) => b.value - a.value);
|
.sort((a, b) => b.value - a.value);
|
||||||
return { bars, code, cur, label };
|
return { bars, code, cur };
|
||||||
}
|
})
|
||||||
|
|
||||||
const groups = $derived(
|
|
||||||
merged
|
|
||||||
? [group(display.code, `≈${display.code}`, display.target, sets)]
|
|
||||||
: byCurrency(sets, (s) => curOf(s).code).map(([code, list]) =>
|
|
||||||
group(code, code, curOf(list[0]), list)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const visible = $derived(groups.filter((g) => g.bars.length > 0));
|
const visible = $derived(groups.filter((g) => g.bars.length > 0));
|
||||||
</script>
|
</script>
|
||||||
@@ -96,10 +81,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each visible as group (group.code)}
|
{#each visible as group (group.code)}
|
||||||
<div class="flex min-w-0 flex-col gap-1">
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
{#if visible.length > 1 || merged}
|
{#if visible.length > 1}
|
||||||
<span
|
<span
|
||||||
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||||
>Spent in {group.label}</span
|
>Spent in {group.code}</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<BarChart
|
<BarChart
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
import { isDebt, owed, stance } from "#lib/accounts/ledger.js";
|
import { showUrl } from "#lib/components/accounts/links.js";
|
||||||
import { editUrl, showUrl } from "#lib/components/accounts/links.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
import BarChart from "#lib/components/charts/BarChart.svelte";
|
||||||
import { formatMoney } from "#lib/format.js";
|
import { tones } from "#lib/components/charts/chart.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
import { toNumber } from "#lib/format.js";
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { accountsQuery, byCurrency, money } from "./data.js";
|
||||||
import { accountsQuery, byCurrency } from "./data.js";
|
|
||||||
|
|
||||||
const liabilities = createQuery(() => ({
|
const liabilities = createQuery(() => ({
|
||||||
...accountsQuery("liabilities", period.start, period.end),
|
...accountsQuery("liabilities", period.start, period.end),
|
||||||
@@ -18,138 +16,90 @@
|
|||||||
const groups = $derived(
|
const groups = $derived(
|
||||||
byCurrency(
|
byCurrency(
|
||||||
(liabilities.data ?? []).filter(
|
(liabilities.data ?? []).filter(
|
||||||
(a) => isDebt(a) && stance(a.attributes) !== "settled"
|
(a) =>
|
||||||
|
a.attributes.liability_type === "debt" &&
|
||||||
|
toNumber(a.attributes.debt_amount) !== 0
|
||||||
),
|
),
|
||||||
(a) => a.attributes.currency_code ?? ""
|
(a) => a.attributes.currency_code ?? ""
|
||||||
).map(([code, list]) => {
|
).map(([code, list]) => {
|
||||||
const first = list[0].attributes;
|
const first = list[0].attributes;
|
||||||
const cur = {
|
const format = money({
|
||||||
code,
|
code,
|
||||||
decimals: first.currency_decimal_places ?? 2,
|
decimals: first.currency_decimal_places ?? 2,
|
||||||
symbol: first.currency_symbol ?? code,
|
symbol: first.currency_symbol ?? code,
|
||||||
};
|
});
|
||||||
const rows = list
|
const bars = list
|
||||||
.map((a) => ({
|
.map((a) => {
|
||||||
id: a.id,
|
const due = Math.abs(toNumber(a.attributes.debt_amount));
|
||||||
key: `debt-${a.id}`,
|
const value =
|
||||||
name: a.attributes.name,
|
a.attributes.liability_direction === "credit" ? due : -due;
|
||||||
value: owed(a.attributes),
|
|
||||||
}))
|
|
||||||
.sort((x, y) => y.value - x.value);
|
|
||||||
return {
|
return {
|
||||||
code,
|
color: value > 0 ? tones.income : tones.expense,
|
||||||
cur,
|
href: showUrl(a.id),
|
||||||
largest: Math.max(...rows.map((r) => Math.abs(r.value))),
|
key: a.id,
|
||||||
net: rows.reduce((s, r) => s + r.value, 0),
|
label: a.attributes.name,
|
||||||
rows,
|
value,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
.sort((x, y) => y.value - x.value);
|
||||||
|
const owed = bars
|
||||||
const nets = $derived.by(() => {
|
.filter((b) => b.value > 0)
|
||||||
const total = display.total(
|
.reduce((s, b) => s + b.value, 0);
|
||||||
groups.map((g) => ({ ...g.cur, amount: g.net }))
|
const owe = bars
|
||||||
);
|
.filter((b) => b.value < 0)
|
||||||
if (total === null) {
|
.reduce((s, b) => s - b.value, 0);
|
||||||
return groups.map((g) => ({
|
return { bars, code, format, owe, owed };
|
||||||
code: g.code,
|
})
|
||||||
net: g.net,
|
|
||||||
text: formatMoney({ ...g.cur, amount: Math.abs(g.net) }),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
const text = formatMoney({ ...total, amount: Math.abs(total.amount) });
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
code: "all",
|
|
||||||
net: total.amount,
|
|
||||||
text: total.converted ? `≈${text}` : text,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
const column = useColumn(4, () =>
|
|
||||||
groups.flatMap((g) =>
|
|
||||||
g.rows.map((r) => ({
|
|
||||||
edit: () => goto(editUrl(r.id)),
|
|
||||||
key: r.key,
|
|
||||||
open: () => goto(showUrl(r.id)),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet more()}
|
|
||||||
<a
|
|
||||||
class="text-brand hover:underline dark:text-(--ff-series-1)"
|
|
||||||
href="/accounts/liabilities"
|
|
||||||
>See your liabilities</a
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#if groups.length > 0}
|
{#if groups.length > 0}
|
||||||
<Box body="p-0" footer={more} href="/accounts/liabilities" title="Debts">
|
<Box href="/accounts/liabilities" title="Debts with friends">
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 transition-opacity {liabilities.isPlaceholderData
|
class="flex flex-col gap-4 transition-opacity {liabilities.isPlaceholderData
|
||||||
? "opacity-60"
|
? "opacity-60"
|
||||||
: ""}"
|
: ""}"
|
||||||
>
|
>
|
||||||
{#each nets as g (g.code)}
|
{#each groups as group (group.code)}
|
||||||
{#if g.net !== 0}
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-[12px] text-cladd-fg-soft"
|
<div class="flex items-baseline gap-3 text-[12px]">
|
||||||
><span
|
<span
|
||||||
class="num font-semibold text-[15px] {g.net > 0
|
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||||
? "text-income"
|
>{group.code}</span
|
||||||
: "text-expense"}"
|
|
||||||
>{g.text}</span
|
|
||||||
>
|
>
|
||||||
{g.net > 0 ? "owed to you" : "owed by you"}</span
|
{#if group.owed > 0}
|
||||||
|
<span class="text-cladd-fg-soft"
|
||||||
|
>Owed to me
|
||||||
|
<span class="num text-income"
|
||||||
|
>{group.format(group.owed)}</span
|
||||||
|
></span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if group.owe > 0}
|
||||||
|
<span class="text-cladd-fg-soft"
|
||||||
|
>I owe
|
||||||
|
<span class="num text-expense"
|
||||||
|
>{group.format(group.owe)}</span
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<BarChart
|
||||||
|
bars={group.bars}
|
||||||
|
format={group.format}
|
||||||
|
label="Debts with friends in {group.code}"
|
||||||
|
labelWidth="minmax(5rem, 9rem)"
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<table class="w-full border-collapse text-[13px]">
|
{#snippet footer()}
|
||||||
{#each groups as g (g.code)}
|
|
||||||
<tbody class="border-cladd-outline border-t">
|
|
||||||
{#each g.rows as r, i (r.key)}
|
|
||||||
<tr
|
|
||||||
class="h-7 {i > 0
|
|
||||||
? "border-cladd-outline/60 border-t"
|
|
||||||
: ""} {column.rowClass(r.key)}"
|
|
||||||
id={column.id(r.key)}
|
|
||||||
onclick={() => column.point(r.key)}
|
|
||||||
>
|
|
||||||
<td class="w-full max-w-0 pl-3">
|
|
||||||
<a
|
<a
|
||||||
class="block truncate text-cladd-fg hover:underline"
|
class="text-brand dark:text-(--ff-series-1) hover:underline"
|
||||||
href={showUrl(r.id)}
|
href="/accounts/liabilities"
|
||||||
title={r.name}
|
>See your liabilities</a
|
||||||
>{r.name}</a
|
|
||||||
>
|
>
|
||||||
</td>
|
{/snippet}
|
||||||
<td class="px-2">
|
|
||||||
<div
|
|
||||||
class="flex h-1.5 w-16 overflow-hidden rounded-full bg-cladd-surface-next"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="h-full rounded-full {r.value > 0
|
|
||||||
? "bg-income"
|
|
||||||
: "bg-expense"}"
|
|
||||||
style:width="{(Math.abs(r.value) / g.largest) * 100}%"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="num whitespace-nowrap pr-3 pl-2 text-right font-medium {r.value >
|
|
||||||
0
|
|
||||||
? "text-income"
|
|
||||||
: "text-expense"}"
|
|
||||||
>
|
|
||||||
{formatMoney({ amount: r.value, ...g.cur }, true)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
{/each}
|
|
||||||
</table>
|
|
||||||
</Box>
|
</Box>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
|
||||||
import { all, lists } from "#lib/api/lists.js";
|
|
||||||
import type { Bill } from "#lib/api/types.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
|
||||||
import { addDays, today } from "#lib/dates.js";
|
|
||||||
import { formatDate, toNumber } from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { byCurrency, type Cur, rough } from "./data.js";
|
|
||||||
|
|
||||||
const DAYS = 7;
|
|
||||||
const HREF = `/subscriptions?horizon=${DAYS}`;
|
|
||||||
|
|
||||||
interface Due {
|
|
||||||
amount: number;
|
|
||||||
approx: boolean;
|
|
||||||
cur: Cur;
|
|
||||||
date: string;
|
|
||||||
edit: string;
|
|
||||||
href: string;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
piggy: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = today();
|
|
||||||
const end = addDays(now, DAYS);
|
|
||||||
const inWindow = (value: string | null | undefined) => {
|
|
||||||
const day = (value ?? "").slice(0, 10);
|
|
||||||
return day >= now && day <= end;
|
|
||||||
};
|
|
||||||
|
|
||||||
const bills = createQuery(() => ({
|
|
||||||
queryFn: () => all<Bill>("/v1/bills", { end, start: now }),
|
|
||||||
queryKey: ["bills-due", now, end],
|
|
||||||
}));
|
|
||||||
const piggies = createQuery(() => lists.piggyBanks());
|
|
||||||
|
|
||||||
const items = $derived.by<Due[]>(() => {
|
|
||||||
const due = (bills.data ?? [])
|
|
||||||
.filter((b) => b.attributes.active !== false)
|
|
||||||
.flatMap((b) => {
|
|
||||||
const a = b.attributes;
|
|
||||||
const expected = (a.pay_dates ?? []).filter(inWindow).sort();
|
|
||||||
const paid = (a.paid_dates ?? []).filter((p) => inWindow(p.date));
|
|
||||||
const min = toNumber(a.amount_min);
|
|
||||||
const max = toNumber(a.amount_max);
|
|
||||||
return expected.slice(paid.length).map((date) => ({
|
|
||||||
amount: (min + max) / 2,
|
|
||||||
approx: min !== max,
|
|
||||||
cur: {
|
|
||||||
code: a.currency_code ?? "",
|
|
||||||
decimals: a.currency_decimal_places ?? 2,
|
|
||||||
symbol: a.currency_symbol ?? "",
|
|
||||||
},
|
|
||||||
date: date.slice(0, 10),
|
|
||||||
edit: `/subscriptions/edit/${b.id}`,
|
|
||||||
href: `/subscriptions/show/${b.id}`,
|
|
||||||
key: `due-bill-${b.id}-${date.slice(0, 10)}`,
|
|
||||||
name: a.name ?? "",
|
|
||||||
piggy: false,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
const saving = (piggies.data ?? [])
|
|
||||||
.filter(
|
|
||||||
(p) =>
|
|
||||||
p.attributes.active !== false &&
|
|
||||||
toNumber(p.attributes.left_to_save) > 0 &&
|
|
||||||
inWindow(p.attributes.target_date)
|
|
||||||
)
|
|
||||||
.map((p) => {
|
|
||||||
const a = p.attributes;
|
|
||||||
return {
|
|
||||||
amount: toNumber(a.left_to_save),
|
|
||||||
approx: false,
|
|
||||||
cur: {
|
|
||||||
code: a.currency_code ?? "",
|
|
||||||
decimals: a.currency_decimal_places ?? 2,
|
|
||||||
symbol: a.currency_symbol ?? "",
|
|
||||||
},
|
|
||||||
date: (a.target_date ?? "").slice(0, 10),
|
|
||||||
edit: `/piggy-banks/edit/${p.id}`,
|
|
||||||
href: `/piggy-banks/show/${p.id}`,
|
|
||||||
key: `due-piggy-${p.id}`,
|
|
||||||
name: a.name ?? "",
|
|
||||||
piggy: true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return [...due, ...saving].sort(
|
|
||||||
(x, y) => x.date.localeCompare(y.date) || x.name.localeCompare(y.name)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const totals = $derived(
|
|
||||||
byCurrency(items, (i) => i.cur.code).map(([, list]) => ({
|
|
||||||
...list[0].cur,
|
|
||||||
amount: list.reduce((s, i) => s + i.amount, 0),
|
|
||||||
approx: list.some((i) => i.approx),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
function dayLabel(date: string) {
|
|
||||||
if (date === now) {
|
|
||||||
return "Today";
|
|
||||||
}
|
|
||||||
return date === addDays(now, 1) ? "Tomorrow" : formatDate(date, "day");
|
|
||||||
}
|
|
||||||
|
|
||||||
const column = useColumn(3, () =>
|
|
||||||
items.map((item) => ({
|
|
||||||
edit: () => goto(item.edit),
|
|
||||||
key: item.key,
|
|
||||||
open: () => goto(item.href),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet more()}
|
|
||||||
<a class="text-brand hover:underline dark:text-(--ff-series-1)" href={HREF}
|
|
||||||
>Go to your subscriptions</a
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Box body="p-0" footer={more} href={HREF} title="Next {DAYS} days">
|
|
||||||
{#if items.length === 0}
|
|
||||||
<p class="px-3 py-2.5 text-[12.5px] text-cladd-fg-soft">
|
|
||||||
{bills.isPending ? "" : `Nothing due in the next ${DAYS} days.`}
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="num flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 font-semibold text-[15px] text-cladd-fg"
|
|
||||||
>
|
|
||||||
{#if display.total(totals)}
|
|
||||||
<span
|
|
||||||
>{rough(
|
|
||||||
totals,
|
|
||||||
totals.some((t) => t.approx)
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
{#each totals as t (t.code)}
|
|
||||||
<span>{rough([t], t.approx)}</span>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<table class="w-full border-collapse text-[13px]">
|
|
||||||
<tbody>
|
|
||||||
{#each items as item (item.key)}
|
|
||||||
<tr
|
|
||||||
class="h-7 border-cladd-outline/60 border-t {column.rowClass(
|
|
||||||
item.key
|
|
||||||
)}"
|
|
||||||
id={column.id(item.key)}
|
|
||||||
onclick={() => column.point(item.key)}
|
|
||||||
>
|
|
||||||
<td class="whitespace-nowrap pr-2 pl-3 text-cladd-fg-soft">
|
|
||||||
{dayLabel(item.date)}
|
|
||||||
</td>
|
|
||||||
<td class="w-full max-w-0">
|
|
||||||
<a
|
|
||||||
class="block truncate text-cladd-fg hover:underline"
|
|
||||||
href={item.href}
|
|
||||||
title={item.name}
|
|
||||||
>{item.name}<span class="text-cladd-fg-soft"
|
|
||||||
>{item.piggy ? " · piggy bank" : ""}</span
|
|
||||||
></a
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="num whitespace-nowrap pr-3 pl-2 text-right text-cladd-fg"
|
|
||||||
>
|
|
||||||
{rough([{ ...item.cur, amount: item.amount }], item.approx)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
|
||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import type { Page, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import Money from "#lib/components/Money.svelte";
|
|
||||||
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
|
||||||
import { reviewed } from "#lib/dashboard/reviewed.svelte.js";
|
|
||||||
import { addDays, iso } from "#lib/dates.js";
|
|
||||||
import { formatDate, formatDateTime } from "#lib/format.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import {
|
|
||||||
editUrl,
|
|
||||||
groupTitle,
|
|
||||||
showUrl,
|
|
||||||
sumByCurrency,
|
|
||||||
} from "#lib/transactions/split.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
const FETCH = 200;
|
|
||||||
const SHOW = 10;
|
|
||||||
|
|
||||||
const since = reviewed.until;
|
|
||||||
const sinceDay = addDays(iso(new Date(since)), -1);
|
|
||||||
const searchHref = `/search?search=${encodeURIComponent(`created_at_after:${sinceDay}`)}`;
|
|
||||||
|
|
||||||
const at = (group: { attributes: TransactionGroup }) =>
|
|
||||||
group.attributes.created_at ?? "";
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const page = await api<Page<TransactionGroup>>("/v1/search/transactions", {
|
|
||||||
query: { limit: FETCH, query: `created_at_after:${sinceDay}` },
|
|
||||||
});
|
|
||||||
if (page.data.length < FETCH) {
|
|
||||||
reviewed.compact(page.data.map((g) => ({ at: at(g), id: g.id })));
|
|
||||||
}
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inbox = createQuery(() => ({
|
|
||||||
queryFn: load,
|
|
||||||
queryKey: ["dashboard-inbox", sinceDay],
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fetched = $derived(inbox.data?.data ?? []);
|
|
||||||
const entries = $derived(
|
|
||||||
fetched
|
|
||||||
.filter((g) => Date.parse(at(g)) > Date.parse(since))
|
|
||||||
.sort((a, b) => Date.parse(at(b)) - Date.parse(at(a)))
|
|
||||||
.map((group) => {
|
|
||||||
const [first] = group.attributes.transactions;
|
|
||||||
return {
|
|
||||||
at: at(group),
|
|
||||||
date: first?.date.slice(0, 10) ?? "",
|
|
||||||
group,
|
|
||||||
id: group.id,
|
|
||||||
money: sumByCurrency(group.attributes.transactions),
|
|
||||||
note: (first?.notes ?? "").trim().split("\n")[0],
|
|
||||||
title: groupTitle(group),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const shown = $derived(entries.slice(0, SHOW));
|
|
||||||
const fresh = $derived(entries.filter((e) => reviewed.isNew(e)).length);
|
|
||||||
|
|
||||||
const markAll = () => reviewed.all(entries);
|
|
||||||
|
|
||||||
useKeys(() => [
|
|
||||||
{
|
|
||||||
group: "Dashboard",
|
|
||||||
keys: "*",
|
|
||||||
label: "Mark all entries reviewed",
|
|
||||||
run: markAll,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const column = useColumn(2, () =>
|
|
||||||
shown.map((e) => ({
|
|
||||||
edit: () => goto(editUrl(e.id)),
|
|
||||||
key: `inbox-${e.id}`,
|
|
||||||
open: () => goto(showUrl(e.id)),
|
|
||||||
review: () => reviewed.toggle(e),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet actions()}
|
|
||||||
{#if fresh > 0}
|
|
||||||
<button
|
|
||||||
class="text-brand hover:underline dark:text-(--ff-series-1)"
|
|
||||||
onclick={markAll}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Mark all reviewed <span class="kbd">*</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if entries.length > SHOW}
|
|
||||||
<a class="ml-auto text-cladd-fg-soft hover:underline" href={searchHref}
|
|
||||||
>and {entries.length - SHOW} more</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
body="p-0"
|
|
||||||
footer={fresh > 0 || entries.length > SHOW ? actions : undefined}
|
|
||||||
href={searchHref}
|
|
||||||
title="Entered since you last looked"
|
|
||||||
>
|
|
||||||
{#snippet tools()}
|
|
||||||
{#if fresh > 0}
|
|
||||||
<span class="num text-[12px] text-cladd-fg-soft">{fresh} new</span>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
{#if entries.length === 0}
|
|
||||||
<p class="px-3 py-2.5 text-[12.5px] text-cladd-fg-soft">
|
|
||||||
{inbox.isPending ? "" : `Nothing new since ${formatDateTime(since)}.`}
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<table class="w-full border-collapse text-[13px]">
|
|
||||||
<tbody>
|
|
||||||
{#each shown as e, i (e.id)}
|
|
||||||
{@const key = `inbox-${e.id}`}
|
|
||||||
{@const isNew = reviewed.isNew(e)}
|
|
||||||
<tr
|
|
||||||
class="{i > 0
|
|
||||||
? "border-cladd-outline/60 border-t"
|
|
||||||
: ""} {column.rowClass(key)}"
|
|
||||||
id={column.id(key)}
|
|
||||||
onclick={() => column.point(key)}
|
|
||||||
>
|
|
||||||
<td class="w-7 py-1 pl-2 align-top">
|
|
||||||
<button
|
|
||||||
aria-label={isNew ? "Mark reviewed" : "Mark as new"}
|
|
||||||
aria-pressed={!isNew}
|
|
||||||
class="grid size-5 place-items-center rounded-full"
|
|
||||||
onclick={() => reviewed.toggle(e)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="size-2 rounded-full {isNew
|
|
||||||
? "bg-brand dark:bg-(--ff-series-1)"
|
|
||||||
: "border border-cladd-fg-softer"}"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="num w-12 whitespace-nowrap py-1 align-top text-cladd-fg-soft leading-5"
|
|
||||||
>
|
|
||||||
{formatDate(e.date, "short")}
|
|
||||||
</td>
|
|
||||||
<td class="w-full max-w-0 py-1 pl-2 align-top leading-5">
|
|
||||||
<a
|
|
||||||
class="block truncate hover:underline {isNew
|
|
||||||
? "font-semibold text-cladd-fg"
|
|
||||||
: "text-cladd-fg-soft"}"
|
|
||||||
href={showUrl(e.id)}
|
|
||||||
title={e.title}
|
|
||||||
>{e.title}</a
|
|
||||||
>
|
|
||||||
{#if e.note}
|
|
||||||
<p
|
|
||||||
class="truncate text-[12px] text-cladd-fg-soft"
|
|
||||||
title={e.note}
|
|
||||||
>
|
|
||||||
{e.note}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="whitespace-nowrap py-1 pr-3 pl-2 text-right align-top leading-5"
|
|
||||||
>
|
|
||||||
{#if e.money.length > 0}
|
|
||||||
<Money parts={e.money} tone={e.money[0].tone} />
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
@@ -8,15 +8,8 @@
|
|||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
import type { Component, Snippet } from "svelte";
|
import type { Component, Snippet } from "svelte";
|
||||||
import Meter from "#lib/components/charts/Meter.svelte";
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
import {
|
import { parse, periodOf, shiftPeriod, today } from "#lib/dates.js";
|
||||||
daysBetween,
|
|
||||||
parse,
|
|
||||||
periodOf,
|
|
||||||
shiftPeriod,
|
|
||||||
today,
|
|
||||||
} from "#lib/dates.js";
|
|
||||||
import { currentLocale, formatMoney } from "#lib/format.js";
|
import { currentLocale, formatMoney } from "#lib/format.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { ALL_ASSETS, reportPath } from "#lib/reports/params.js";
|
import { ALL_ASSETS, reportPath } from "#lib/reports/params.js";
|
||||||
import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js";
|
import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js";
|
||||||
@@ -50,49 +43,35 @@
|
|||||||
});
|
});
|
||||||
const year = $derived(today().slice(0, 4));
|
const year = $derived(today().slice(0, 4));
|
||||||
|
|
||||||
const absolute = (list: SummaryEntry[]) =>
|
const fmt = (e: SummaryEntry, abs = false) =>
|
||||||
list.map((e) => ({ ...e, amount: Math.abs(e.amount) }));
|
formatMoney({
|
||||||
|
amount: abs ? Math.abs(e.amount) : e.amount,
|
||||||
function lines(list: SummaryEntry[], abs = false): string[] {
|
code: e.code,
|
||||||
const parts = abs ? absolute(list) : list;
|
decimals: e.decimals,
|
||||||
return display.total(parts)
|
symbol: e.symbol,
|
||||||
? [display.text(parts)]
|
});
|
||||||
: parts.map((e) => formatMoney(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
function flow(list: SummaryEntry[] | undefined, sign: number) {
|
|
||||||
const parts = (list ?? []).map((e) => ({
|
|
||||||
...e,
|
|
||||||
amount: sign * Math.abs(e.amount),
|
|
||||||
}));
|
|
||||||
return display.text(display.total(parts) ? parts : parts.slice(0, 1), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compare(
|
function compare(
|
||||||
kind: string,
|
kind: string,
|
||||||
words: [string, string],
|
words: [string, string],
|
||||||
upIsGood = true
|
upIsGood = true
|
||||||
): Delta | null {
|
): Delta | null {
|
||||||
const sum = display.total(now[kind] ?? []);
|
const a = now[kind]?.[0];
|
||||||
const a = sum ?? now[kind]?.[0];
|
const b = a && before[kind]?.find((e) => e.code === a.code);
|
||||||
const b = sum
|
|
||||||
? display.total(before[kind] ?? [])
|
|
||||||
: a && before[kind]?.find((e) => e.code === a.code);
|
|
||||||
if (!(a && b) || a.amount === b.amount) {
|
if (!(a && b) || a.amount === b.amount) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const diff = a.amount - b.amount;
|
const diff = a.amount - b.amount;
|
||||||
const text = formatMoney({ ...a, amount: Math.abs(diff) });
|
|
||||||
return {
|
return {
|
||||||
good: diff > 0 === upIsGood,
|
good: diff > 0 === upIsGood,
|
||||||
text: `${sum?.converted ? "≈" : ""}${text} ${diff > 0 ? words[0] : words[1]}`,
|
text: `${fmt({ ...a, amount: diff }, true)} ${diff > 0 ? words[0] : words[1]}`,
|
||||||
up: diff > 0,
|
up: diff > 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const balance = $derived(now.balance ?? []);
|
const balance = $derived(now.balance ?? []);
|
||||||
const earned = $derived(now.earned?.length ? flow(now.earned, 1) : "");
|
const earned = $derived(now.earned?.[0]);
|
||||||
const spent = $derived(now.spent?.length ? flow(now.spent, -1) : "");
|
const spent = $derived(now.spent?.[0]);
|
||||||
const unpaid = $derived(now["bills-unpaid"] ?? []);
|
const unpaid = $derived(now["bills-unpaid"] ?? []);
|
||||||
const paid = $derived(now["bills-paid"] ?? []);
|
const paid = $derived(now["bills-paid"] ?? []);
|
||||||
const left = $derived(now["left-to-spend"] ?? []);
|
const left = $derived(now["left-to-spend"] ?? []);
|
||||||
@@ -102,23 +81,7 @@
|
|||||||
if (left.some((e) => e.noBudgets)) {
|
if (left.some((e) => e.noBudgets)) {
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
const sum = display.total(left);
|
return left.some((e) => e.amount > 0) ? "ok" : "over";
|
||||||
return (sum ? sum.amount > 0 : left.some((e) => e.amount > 0))
|
|
||||||
? "ok"
|
|
||||||
: "over";
|
|
||||||
});
|
|
||||||
const perDay = $derived.by(() => {
|
|
||||||
const sum = display.total(left);
|
|
||||||
if (!sum) {
|
|
||||||
return left[0]?.sub ?? "";
|
|
||||||
}
|
|
||||||
const from = today() < period.start ? period.start : today();
|
|
||||||
const days = daysBetween(from, period.end) + 1;
|
|
||||||
if (leftState === "none" || days < 1) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const amount = formatMoney({ ...sum, amount: sum.amount / days });
|
|
||||||
return `${sum.converted ? "≈" : ""}${amount}`;
|
|
||||||
});
|
});
|
||||||
const leftTitle = $derived(
|
const leftTitle = $derived(
|
||||||
leftState === "none"
|
leftState === "none"
|
||||||
@@ -134,11 +97,6 @@
|
|||||||
}[leftState]
|
}[leftState]
|
||||||
);
|
);
|
||||||
const paidShare = $derived.by(() => {
|
const paidShare = $derived.by(() => {
|
||||||
const done = paid.length ? display.total(absolute(paid))?.amount : 0;
|
|
||||||
const due = unpaid.length ? display.total(absolute(unpaid))?.amount : 0;
|
|
||||||
if (display.active && done !== undefined && due !== undefined) {
|
|
||||||
return { paid: done, total: done + due };
|
|
||||||
}
|
|
||||||
const code = unpaid[0]?.code ?? paid[0]?.code;
|
const code = unpaid[0]?.code ?? paid[0]?.code;
|
||||||
const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0);
|
const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0);
|
||||||
const u = Math.abs(unpaid.find((e) => e.code === code)?.amount ?? 0);
|
const u = Math.abs(unpaid.find((e) => e.code === code)?.amount ?? 0);
|
||||||
@@ -150,21 +108,20 @@
|
|||||||
list: SummaryEntry[],
|
list: SummaryEntry[],
|
||||||
abs = false
|
abs = false
|
||||||
)}
|
)}
|
||||||
{@const [main, ...rest] = lines(list, abs)}
|
{#if list.length === 0}
|
||||||
{#if main === undefined}
|
|
||||||
<div class="font-semibold text-[18px] text-cladd-fg-softer sm:text-[22px]">
|
<div class="font-semibold text-[18px] text-cladd-fg-softer sm:text-[22px]">
|
||||||
0
|
0
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="wrap-anywhere font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
|
class="truncate font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
|
||||||
>
|
>
|
||||||
{main}
|
{fmt(list[0], abs)}
|
||||||
</div>
|
</div>
|
||||||
{#if rest.length > 0}
|
{#if list.length > 1}
|
||||||
<div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
|
<div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
|
||||||
{#each rest as text (text)}
|
{#each list.slice(1) as e (e.code)}
|
||||||
<span>{text}</span>
|
<span>{fmt(e, abs)}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -185,7 +142,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<ArrowDown class="size-3.5 shrink-0" />
|
<ArrowDown class="size-3.5 shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span class="truncate"
|
||||||
><span class="num">{delta.text}</span>
|
><span class="num">{delta.text}</span>
|
||||||
<span class="text-cladd-fg-soft">than {prevName}</span></span
|
<span class="text-cladd-fg-soft">than {prevName}</span></span
|
||||||
>
|
>
|
||||||
@@ -228,10 +185,10 @@
|
|||||||
{#if earned || spent}
|
{#if earned || spent}
|
||||||
<div class="num flex flex-wrap gap-x-2 text-[12px]">
|
<div class="num flex flex-wrap gap-x-2 text-[12px]">
|
||||||
{#if earned}
|
{#if earned}
|
||||||
<span class="text-income">{earned}</span>
|
<span class="text-income">+{fmt(earned, true)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if spent}
|
{#if spent}
|
||||||
<span class="text-expense">{spent}</span>
|
<span class="text-expense">−{fmt(spent, true)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -246,21 +203,21 @@
|
|||||||
segments={[{ color: "var(--ff-series-3)", value: paidShare.paid }]}
|
segments={[{ color: "var(--ff-series-3)", value: paidShare.paid }]}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
|
<div class="truncate text-[12px] text-cladd-fg-soft">
|
||||||
<span>Paid:</span>
|
Paid:
|
||||||
{#each lines(paid, true) as text (text)}
|
<span class="num"
|
||||||
<span class="num">{text}</span>
|
>{paid.length ? paid.map((e) => fmt(e, true)).join(", ") : "0"}</span
|
||||||
{:else}
|
>
|
||||||
<span class="num">0</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet leftToSpend()}
|
{#snippet leftToSpend()}
|
||||||
{@render values(left)}
|
{@render values(left)}
|
||||||
{#if perDay}
|
{#if left[0]?.sub}
|
||||||
<div class="text-[12px] text-cladd-fg-soft">
|
<div class="truncate text-[12px] text-cladd-fg-soft">
|
||||||
{leftState === "none" ? "" : "Per day: "}<span class="num">{perDay}</span>
|
{leftState === "none" ? "" : "Per day: "}<span class="num"
|
||||||
|
>{left[0].sub}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{@render change(compare("left-to-spend", ["more", "less"]))}
|
{@render change(compare("left-to-spend", ["more", "less"]))}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { showUrl } from "#lib/components/accounts/links.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import BarChart from "#lib/components/charts/BarChart.svelte";
|
||||||
|
import { type Bar, tones } from "#lib/components/charts/chart.js";
|
||||||
|
import { toNumber } from "#lib/format.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import {
|
||||||
|
axis,
|
||||||
|
byCurrency,
|
||||||
|
currency,
|
||||||
|
type InsightEntry,
|
||||||
|
money,
|
||||||
|
proportional,
|
||||||
|
span,
|
||||||
|
} from "./data.js";
|
||||||
|
|
||||||
|
const MAX_BARS = 24;
|
||||||
|
const NARROW_BARS = 8;
|
||||||
|
|
||||||
|
let { kind }: { kind: "expense" | "revenue" } = $props();
|
||||||
|
|
||||||
|
const path = $derived(
|
||||||
|
kind === "expense"
|
||||||
|
? "/v1/insight/expense/expense"
|
||||||
|
: "/v1/insight/income/revenue"
|
||||||
|
);
|
||||||
|
const insight = createQuery(() => ({
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api<InsightEntry[]>(path, {
|
||||||
|
query: span(period.start, period.end),
|
||||||
|
}),
|
||||||
|
queryKey: ["insight", kind, period.start, period.end],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const title = $derived(
|
||||||
|
kind === "expense" ? "Expense accounts" : "Revenue accounts"
|
||||||
|
);
|
||||||
|
const verb = $derived(kind === "expense" ? "Spent" : "Earned");
|
||||||
|
|
||||||
|
function fold(bars: Bar[]): Bar[] {
|
||||||
|
if (bars.length <= MAX_BARS) {
|
||||||
|
return bars;
|
||||||
|
}
|
||||||
|
const rest = bars.slice(MAX_BARS - 1);
|
||||||
|
return [
|
||||||
|
...bars.slice(0, MAX_BARS - 1),
|
||||||
|
{
|
||||||
|
key: "other",
|
||||||
|
label: `Other (${rest.length})`,
|
||||||
|
value: rest.reduce((sum, b) => sum + b.value, 0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = $derived(
|
||||||
|
byCurrency(insight.data ?? [], (e) => e.currency_code).map(
|
||||||
|
([code, list]) => {
|
||||||
|
const cur = currency(code);
|
||||||
|
const bars = list
|
||||||
|
.map((e) => ({
|
||||||
|
href: e.id ? showUrl(e.id) : undefined,
|
||||||
|
key: e.id ?? e.name ?? "",
|
||||||
|
label: e.name ?? "",
|
||||||
|
value: Math.abs(toNumber(e.difference)),
|
||||||
|
}))
|
||||||
|
.filter((b) => b.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
return { bars: fold(bars), code, cur };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const visible = $derived(groups.filter((g) => g.bars.length > 0));
|
||||||
|
const wide = $derived(
|
||||||
|
visible.filter((g, i) => i === 0 || g.bars.length > NARROW_BARS)
|
||||||
|
);
|
||||||
|
const narrow = $derived(visible.filter((g) => !wide.includes(g)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet chart(
|
||||||
|
group: (typeof groups)[number],
|
||||||
|
height: number
|
||||||
|
)}
|
||||||
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
|
{#if visible.length > 1}
|
||||||
|
<span class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||||
|
>{verb}
|
||||||
|
in {group.code}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<BarChart
|
||||||
|
axisFormat={axis(group.cur)}
|
||||||
|
bars={group.bars}
|
||||||
|
color={kind === "expense" ? tones.expense : tones.income}
|
||||||
|
format={money(group.cur)}
|
||||||
|
{height}
|
||||||
|
label="{verb} per {kind} account in {group.code}"
|
||||||
|
name={verb}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<Box href="/accounts/{kind}" {title}>
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-5 transition-opacity {insight.isPlaceholderData
|
||||||
|
? "opacity-60"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
{#if insight.isPending}
|
||||||
|
<div class="h-60"></div>
|
||||||
|
{:else if insight.isError}
|
||||||
|
<p class="text-expense">{insight.error.message}</p>
|
||||||
|
{:else if visible.length === 0}
|
||||||
|
<p class="py-8 text-center text-cladd-fg-soft">
|
||||||
|
{kind === "expense"
|
||||||
|
? "Nothing spent in this period."
|
||||||
|
: "Nothing earned in this period."}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each [wide, narrow] as set, i (i)}
|
||||||
|
{#if set.length > 0}
|
||||||
|
<div
|
||||||
|
class="grid gap-x-6 gap-y-5 {i === 1
|
||||||
|
? "md:grid-cols-(--cols)"
|
||||||
|
: ""}"
|
||||||
|
style:--cols={proportional(set.map((g) => g.bars.length))}
|
||||||
|
>
|
||||||
|
{#each set as group (group.code)}
|
||||||
|
{@render chart(group, i === 0 ? 240 : 180)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<a
|
||||||
|
class="text-brand dark:text-(--ff-series-1) hover:underline"
|
||||||
|
href="/accounts/{kind}"
|
||||||
|
>See your {kind} accounts</a
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
|
||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import {
|
|
||||||
addDays,
|
|
||||||
daysBetween,
|
|
||||||
parse,
|
|
||||||
type Range,
|
|
||||||
shiftPeriod,
|
|
||||||
today,
|
|
||||||
} from "#lib/dates.js";
|
|
||||||
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
|
||||||
import { median } from "#lib/reports/typical.js";
|
|
||||||
import { byCurrency, currency, type InsightEntry, span } from "./data.js";
|
|
||||||
|
|
||||||
let { class: className = "" }: { class?: string } = $props();
|
|
||||||
|
|
||||||
const MONTHS = 6;
|
|
||||||
const MIN_MONTHS = 3;
|
|
||||||
const SUFFIX: Record<string, string> = {
|
|
||||||
few: "rd",
|
|
||||||
one: "st",
|
|
||||||
other: "th",
|
|
||||||
two: "nd",
|
|
||||||
};
|
|
||||||
const ordinals = new Intl.PluralRules("en-US", { type: "ordinal" });
|
|
||||||
|
|
||||||
const now = today();
|
|
||||||
const cutoff = $derived(period.end < now ? period.end : now);
|
|
||||||
const visible = $derived(cutoff >= period.start);
|
|
||||||
const ranges = $derived.by(() => {
|
|
||||||
const days = daysBetween(period.start, cutoff);
|
|
||||||
const list: Range[] = [{ end: cutoff, start: period.start }];
|
|
||||||
let { range } = period;
|
|
||||||
for (let i = 0; i < MONTHS; i += 1) {
|
|
||||||
range = shiftPeriod(range, period.view, -1);
|
|
||||||
const end = addDays(range.start, days);
|
|
||||||
list.push({ end: end < range.end ? end : range.end, start: range.start });
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
});
|
|
||||||
|
|
||||||
const pace = createQuery(() => ({
|
|
||||||
enabled: visible,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
queryFn: () =>
|
|
||||||
Promise.all(
|
|
||||||
ranges.map((r) =>
|
|
||||||
api<InsightEntry[]>("/v1/insight/expense/total", {
|
|
||||||
query: span(r.start, r.end),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
),
|
|
||||||
queryKey: ["dashboard-pace", ranges.map((r) => r.start).join(), cutoff],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const total = (list: InsightEntry[], code: string) =>
|
|
||||||
Math.abs(
|
|
||||||
list
|
|
||||||
.filter((e) => e.currency_code === code)
|
|
||||||
.reduce((s, e) => s + toNumber(e.difference), 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = $derived.by(() => {
|
|
||||||
const [current = [], ...past] = pace.data ?? [];
|
|
||||||
const codes = [
|
|
||||||
...new Set([current, ...past].flat().map((e) => e.currency_code)),
|
|
||||||
];
|
|
||||||
return byCurrency(codes, (code) => code)
|
|
||||||
.map(([code]) => {
|
|
||||||
const history = past
|
|
||||||
.map((list) => total(list, code))
|
|
||||||
.filter((v) => v > 0);
|
|
||||||
const typical = history.length >= MIN_MONTHS ? median(history) : null;
|
|
||||||
const spent = total(current, code);
|
|
||||||
const cur = currency(code);
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
format: (amount: number) => formatMoney({ amount, ...cur }),
|
|
||||||
spent,
|
|
||||||
typical,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((r) => r.spent > 0 || r.typical !== null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const title = $derived.by(() => {
|
|
||||||
if (period.view !== "1M") {
|
|
||||||
return `Spent by ${formatDate(cutoff, "short")}`;
|
|
||||||
}
|
|
||||||
const day = parse(cutoff).getDate();
|
|
||||||
return `Spent by the ${day}${SUFFIX[ordinals.select(day)] ?? "th"}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const all = $derived.by(() => {
|
|
||||||
const lists = pace.data ?? [];
|
|
||||||
const sums = lists.map((list) =>
|
|
||||||
display.total(
|
|
||||||
list.map((e) => ({ amount: e.difference, code: e.currency_code }))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (!display.active || lists.some((list, i) => list.length && !sums[i])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const [spent = 0, ...past] = sums.map((sum) => Math.abs(sum?.amount ?? 0));
|
|
||||||
const history = past.filter((v) => v > 0);
|
|
||||||
const approx = sums.some((sum) => sum?.converted);
|
|
||||||
return {
|
|
||||||
code: "All",
|
|
||||||
format: (amount: number) =>
|
|
||||||
`${approx ? "≈" : ""}${formatMoney({ ...display.target, amount, decimals: 0 })}`,
|
|
||||||
spent,
|
|
||||||
typical: history.length >= MIN_MONTHS ? median(history) : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const lines = $derived(all ? [all] : rows);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if visible && rows.length > 0}
|
|
||||||
<Box body="p-0" class={className} {title}>
|
|
||||||
{#snippet tools()}
|
|
||||||
<span class="text-[12px] text-cladd-fg-soft"
|
|
||||||
>Typical: median of {MONTHS} months</span
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
<table
|
|
||||||
class="w-full border-collapse text-[13px] transition-opacity {pace.isPlaceholderData
|
|
||||||
? "opacity-60"
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr class="h-7 text-[11px] text-cladd-fg-soft">
|
|
||||||
<th class="pl-3 text-left font-normal" scope="col">Currency</th>
|
|
||||||
<th class="px-2 text-right font-normal" scope="col">Spent</th>
|
|
||||||
<th class="px-2 text-right font-normal" scope="col">Typical</th>
|
|
||||||
<th class="px-2 text-right font-normal" scope="col">Difference</th>
|
|
||||||
<th class="hidden w-full pr-3 font-normal sm:table-cell" scope="col">
|
|
||||||
<span class="sr-only">Spent against typical</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each lines as r (r.code)}
|
|
||||||
{@const diff = r.typical === null ? null : r.spent - r.typical}
|
|
||||||
{@const scale = Math.max(r.spent, r.typical ?? 0) || 1}
|
|
||||||
<tr class="h-7 border-cladd-outline/60 border-t">
|
|
||||||
<th
|
|
||||||
class="pl-3 text-left font-semibold text-[12px] text-cladd-fg"
|
|
||||||
scope="row"
|
|
||||||
>
|
|
||||||
{r.code}
|
|
||||||
</th>
|
|
||||||
<td
|
|
||||||
class="num whitespace-nowrap px-2 text-right font-semibold text-cladd-fg"
|
|
||||||
>
|
|
||||||
{r.format(r.spent)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
{r.typical === null ? "n/a" : r.format(r.typical)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="num px-2 text-right sm:whitespace-nowrap {diff !== null &&
|
|
||||||
diff > 0
|
|
||||||
? "text-expense"
|
|
||||||
: "text-income"}"
|
|
||||||
>
|
|
||||||
{#if diff !== null}
|
|
||||||
{r.format(Math.abs(diff))}
|
|
||||||
{diff > 0 ? "over" : "under"}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="hidden pr-3 pl-2 sm:table-cell">
|
|
||||||
{#if r.typical !== null}
|
|
||||||
<div
|
|
||||||
class="relative h-2 min-w-24 max-w-80 rounded-full bg-cladd-surface-next"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute inset-y-0 left-0 rounded-full {diff !==
|
|
||||||
null && diff > 0
|
|
||||||
? "bg-expense"
|
|
||||||
: "bg-brand"}"
|
|
||||||
style:width="{(r.spent / scale) * 100}%"
|
|
||||||
></span>
|
|
||||||
<span
|
|
||||||
class="absolute -top-1 -bottom-1 w-0.5 -translate-x-1/2 rounded-full bg-cladd-fg"
|
|
||||||
style:left="{(r.typical / scale) * 100}%"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Box>
|
|
||||||
{/if}
|
|
||||||
@@ -4,8 +4,7 @@
|
|||||||
import type { PiggyBank } from "#lib/api/types.js";
|
import type { PiggyBank } from "#lib/api/types.js";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import Meter from "#lib/components/charts/Meter.svelte";
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
import { percent, toNumber } from "#lib/format.js";
|
import { formatMoney, percent, toNumber } from "#lib/format.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
|
|
||||||
const piggies = createQuery(() => lists.piggyBanks());
|
const piggies = createQuery(() => lists.piggyBanks());
|
||||||
|
|
||||||
@@ -19,14 +18,12 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fmt = (p: PiggyBank, amount: string | null | undefined) =>
|
const fmt = (p: PiggyBank, amount: string | null | undefined) =>
|
||||||
display.text([
|
formatMoney({
|
||||||
{
|
|
||||||
amount,
|
amount,
|
||||||
code: p.currency_code,
|
code: p.currency_code,
|
||||||
decimals: p.currency_decimal_places,
|
decimals: p.currency_decimal_places,
|
||||||
symbol: p.currency_symbol,
|
symbol: p.currency_symbol,
|
||||||
},
|
});
|
||||||
]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if saving.length > 0}
|
{#if saving.length > 0}
|
||||||
|
|||||||
@@ -4,11 +4,9 @@
|
|||||||
import type { Bill } from "#lib/api/types.js";
|
import type { Bill } from "#lib/api/types.js";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import Meter from "#lib/components/charts/Meter.svelte";
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
import { today } from "#lib/dates.js";
|
|
||||||
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { parseSummary, rough, span, summaryQuery } from "./data.js";
|
import { parseSummary, span, summaryQuery } from "./data.js";
|
||||||
|
|
||||||
const DUE_LIMIT = 6;
|
const DUE_LIMIT = 6;
|
||||||
|
|
||||||
@@ -38,30 +36,6 @@
|
|||||||
)
|
)
|
||||||
.filter((m) => m.total > 0)
|
.filter((m) => m.total > 0)
|
||||||
);
|
);
|
||||||
const lines = $derived.by(() => {
|
|
||||||
const paid = meters.map((m) => ({ ...m.cur, amount: m.paid }));
|
|
||||||
const left = meters.map((m) => ({ ...m.cur, amount: m.total - m.paid }));
|
|
||||||
const sumPaid = display.total(paid);
|
|
||||||
const sumLeft = display.total(left);
|
|
||||||
if (sumPaid && sumLeft) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: display.code,
|
|
||||||
left: display.text(left),
|
|
||||||
max: sumPaid.amount + sumLeft.amount,
|
|
||||||
paid: display.text(paid),
|
|
||||||
value: sumPaid.amount,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return meters.map((m, i) => ({
|
|
||||||
key: m.cur.code,
|
|
||||||
left: formatMoney(left[i]),
|
|
||||||
max: m.total,
|
|
||||||
paid: formatMoney(paid[i]),
|
|
||||||
value: m.paid,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
const inPeriod = (dates: (string | null | undefined)[]) =>
|
const inPeriod = (dates: (string | null | undefined)[]) =>
|
||||||
dates
|
dates
|
||||||
.map((d) => (d ?? "").slice(0, 10))
|
.map((d) => (d ?? "").slice(0, 10))
|
||||||
@@ -76,7 +50,7 @@
|
|||||||
(b.attributes.paid_dates ?? []).map((p) => p.date)
|
(b.attributes.paid_dates ?? []).map((p) => p.date)
|
||||||
).length;
|
).length;
|
||||||
const next = expected[paid];
|
const next = expected[paid];
|
||||||
if (!next || next >= today()) {
|
if (!next) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const amount =
|
const amount =
|
||||||
@@ -87,6 +61,16 @@
|
|||||||
})
|
})
|
||||||
.sort((x, y) => x.date.localeCompare(y.date))
|
.sort((x, y) => x.date.localeCompare(y.date))
|
||||||
);
|
);
|
||||||
|
const fmt = (
|
||||||
|
amount: number,
|
||||||
|
c: { code: string; symbol: string; decimals: number }
|
||||||
|
) =>
|
||||||
|
formatMoney({
|
||||||
|
amount,
|
||||||
|
code: c.code,
|
||||||
|
decimals: c.decimals,
|
||||||
|
symbol: c.symbol,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if (bills.data?.length ?? 0) > 0}
|
{#if (bills.data?.length ?? 0) > 0}
|
||||||
@@ -100,23 +84,25 @@
|
|||||||
<p class="text-cladd-fg-soft">Nothing expected in this period.</p>
|
<p class="text-cladd-fg-soft">Nothing expected in this period.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="flex flex-col gap-2">
|
<ul class="flex flex-col gap-2">
|
||||||
{#each lines as m (m.key)}
|
{#each meters as m (m.cur.code)}
|
||||||
<li class="flex flex-col gap-1">
|
<li class="flex flex-col gap-1">
|
||||||
<div
|
<div
|
||||||
class="flex items-baseline justify-between gap-2 text-[12px]"
|
class="flex items-baseline justify-between gap-2 text-[12px]"
|
||||||
>
|
>
|
||||||
<span class="text-cladd-fg-soft"
|
<span class="text-cladd-fg-soft"
|
||||||
><span class="num text-cladd-fg">{m.paid}</span>
|
><span class="num text-cladd-fg">{fmt(m.paid, m.cur)}</span>
|
||||||
paid</span
|
paid</span
|
||||||
>
|
>
|
||||||
<span class="text-cladd-fg-soft"
|
<span class="text-cladd-fg-soft"
|
||||||
><span class="num text-cladd-fg">{m.left}</span>
|
><span class="num text-cladd-fg"
|
||||||
|
>{fmt(m.total - m.paid, m.cur)}</span
|
||||||
|
>
|
||||||
to pay</span
|
to pay</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<Meter
|
<Meter
|
||||||
max={m.max}
|
max={m.total}
|
||||||
segments={[{ color: "var(--ff-series-3)", value: m.value }]}
|
segments={[{ color: "var(--ff-series-3)", value: m.paid }]}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -125,7 +111,7 @@
|
|||||||
{#if due.length > 0}
|
{#if due.length > 0}
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="pb-1 font-semibold text-[12px] text-cladd-fg-soft"
|
<span class="pb-1 font-semibold text-[12px] text-cladd-fg-soft"
|
||||||
>Not paid yet</span
|
>Still to pay this period</span
|
||||||
>
|
>
|
||||||
<ul class="flex flex-col">
|
<ul class="flex flex-col">
|
||||||
{#each due.slice(0, DUE_LIMIT) as d (d.bill.id)}
|
{#each due.slice(0, DUE_LIMIT) as d (d.bill.id)}
|
||||||
@@ -141,14 +127,12 @@
|
|||||||
>{d.bill.attributes.name}</span
|
>{d.bill.attributes.name}</span
|
||||||
>
|
>
|
||||||
<span class="num text-cladd-fg-soft"
|
<span class="num text-cladd-fg-soft"
|
||||||
>{rough([
|
>~{formatMoney({
|
||||||
{
|
|
||||||
amount: d.amount,
|
amount: d.amount,
|
||||||
code: d.bill.attributes.currency_code,
|
code: d.bill.attributes.currency_code,
|
||||||
decimals: d.bill.attributes.currency_decimal_places,
|
decimals: d.bill.attributes.currency_decimal_places,
|
||||||
symbol: d.bill.attributes.currency_symbol,
|
symbol: d.bill.attributes.currency_symbol,
|
||||||
},
|
})}</span
|
||||||
])}</span
|
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { api } from "#lib/api/client.js";
|
|||||||
import { all } from "#lib/api/lists.js";
|
import { all } from "#lib/api/lists.js";
|
||||||
import type { Account, BasicSummaryEntry } from "#lib/api/types.js";
|
import type { Account, BasicSummaryEntry } from "#lib/api/types.js";
|
||||||
import { addDays, today } from "#lib/dates.js";
|
import { addDays, today } from "#lib/dates.js";
|
||||||
import {
|
import { compactMoney, formatMoney, toNumber } from "#lib/format.js";
|
||||||
compactMoney,
|
|
||||||
formatMoney,
|
|
||||||
type Money,
|
|
||||||
toNumber,
|
|
||||||
} from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
import { period } from "#lib/period.svelte.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
|
||||||
@@ -95,19 +89,6 @@ export function byCurrency<T>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeable(codes: string[]): boolean {
|
|
||||||
return (
|
|
||||||
display.active &&
|
|
||||||
codes.some((code) => code !== display.code) &&
|
|
||||||
codes.every((code) => display.rate(code) !== null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rough(parts: Money[], approx = true): string {
|
|
||||||
const text = display.text(parts);
|
|
||||||
return approx && !text.startsWith("≈") ? `~${text}` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSummary(
|
export function parseSummary(
|
||||||
raw: Record<string, BasicSummaryEntry> | undefined
|
raw: Record<string, BasicSummaryEntry> | undefined
|
||||||
): Record<string, SummaryEntry[]> {
|
): Record<string, SummaryEntry[]> {
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import { setParam } from "#lib/components/categories/url.js";
|
|
||||||
import {
|
|
||||||
type Lane,
|
|
||||||
type StackRow,
|
|
||||||
tones,
|
|
||||||
} from "#lib/components/charts/chart.js";
|
|
||||||
import Lanes from "#lib/components/charts/Lanes.svelte";
|
|
||||||
import type { Range } from "#lib/dates.js";
|
|
||||||
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
|
|
||||||
import type { Facet } from "#lib/dimension/spec.js";
|
|
||||||
import { lanesOf } from "#lib/dimension/stats.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
|
||||||
import { longMonth, monthRange } from "#lib/reports/board.js";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import Headline from "./Headline.svelte";
|
|
||||||
import Inside from "./Inside.svelte";
|
|
||||||
import Ledger from "./Ledger.svelte";
|
|
||||||
|
|
||||||
const LANE_HEIGHTS = [180, 112, 88];
|
|
||||||
|
|
||||||
let {
|
|
||||||
model,
|
|
||||||
noun,
|
|
||||||
accountId,
|
|
||||||
shape = (list: Lane[]) => list,
|
|
||||||
onmonth = (month: Range) => period.set(month),
|
|
||||||
lead,
|
|
||||||
extra,
|
|
||||||
below,
|
|
||||||
header,
|
|
||||||
}: {
|
|
||||||
model: DimensionModel;
|
|
||||||
noun: string;
|
|
||||||
accountId?: string;
|
|
||||||
shape?: (lanes: Lane[]) => Lane[];
|
|
||||||
onmonth?: (range: Range) => void;
|
|
||||||
lead?: Snippet;
|
|
||||||
extra?: Snippet;
|
|
||||||
below?: Snippet;
|
|
||||||
header?: Snippet<[Lane, StackRow | undefined]>;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const height = $derived(
|
|
||||||
LANE_HEIGHTS[Math.min(model.codes.length, LANE_HEIGHTS.length) - 1] ?? 88
|
|
||||||
);
|
|
||||||
const lanes = $derived(
|
|
||||||
shape(
|
|
||||||
lanesOf(
|
|
||||||
model.flows,
|
|
||||||
model.months,
|
|
||||||
model.codes,
|
|
||||||
model.dir,
|
|
||||||
model.norms,
|
|
||||||
height
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const facets = $derived(model.spec.facets);
|
|
||||||
const facet = $derived(
|
|
||||||
facets.find((f) => f.id === page.url.searchParams.get("facet")) ?? facets[0]
|
|
||||||
);
|
|
||||||
const only = $derived.by(() => {
|
|
||||||
const value = page.url.searchParams.get("only") ?? "";
|
|
||||||
const cut = value.indexOf(":");
|
|
||||||
const found = facets.find((f) => f.id === value.slice(0, cut));
|
|
||||||
return found && cut > 0
|
|
||||||
? { facet: found, key: value.slice(cut + 1) }
|
|
||||||
: null;
|
|
||||||
});
|
|
||||||
const narrowed = $derived.by(() => {
|
|
||||||
if (!only) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const ids = new Set<string>();
|
|
||||||
let label = only.key;
|
|
||||||
for (const flow of model.periodFlows) {
|
|
||||||
const key = only.facet.of(flow).find((k) => k.key === only.key);
|
|
||||||
if (key) {
|
|
||||||
ids.add(flow.group);
|
|
||||||
({ label } = key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { groups: model.groups.filter((g) => ids.has(g.id)), label };
|
|
||||||
});
|
|
||||||
|
|
||||||
const pick = (f: Facet) =>
|
|
||||||
setParam("facet", f.id === facets[0]?.id ? "" : f.id);
|
|
||||||
const clear = () => setParam("only", "");
|
|
||||||
|
|
||||||
useKeys(() =>
|
|
||||||
only
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
group: "Inside",
|
|
||||||
keys: "Escape",
|
|
||||||
label: "Show all transactions again",
|
|
||||||
run: clear,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Headline {lead} {model} {noun} />
|
|
||||||
{@render extra?.()}
|
|
||||||
|
|
||||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]">
|
|
||||||
<Box title={model.everything ? "All months" : "13 months"}>
|
|
||||||
{#if lanes.length > 0}
|
|
||||||
<Lanes
|
|
||||||
cursor={model.focus}
|
|
||||||
dotColor={model.dir === -1 ? tones.income : tones.expense}
|
|
||||||
dotLabel={model.dir === -1 ? "in" : "out"}
|
|
||||||
{header}
|
|
||||||
label="Months by currency"
|
|
||||||
{lanes}
|
|
||||||
moreIsGood={model.dir === 1}
|
|
||||||
onselect={(month) => onmonth(monthRange(month))}
|
|
||||||
partial={model.partial}
|
|
||||||
title={(row) => longMonth(row.key)}
|
|
||||||
/>
|
|
||||||
{:else if !model.loading}
|
|
||||||
<p class="py-10 text-center text-[12px] text-cladd-fg-soft">
|
|
||||||
No spending or income here in these months.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{@render below?.()}
|
|
||||||
</Box>
|
|
||||||
<Inside
|
|
||||||
class="lg:[contain:size]"
|
|
||||||
current={only?.facet.id === facet.id ? only.key : null}
|
|
||||||
{facet}
|
|
||||||
{model}
|
|
||||||
onfacet={pick}
|
|
||||||
onnarrow={(f, row) =>
|
|
||||||
setParam(
|
|
||||||
"only",
|
|
||||||
only?.facet.id === f.id && only.key === row.key
|
|
||||||
? ""
|
|
||||||
: `${f.id}:${row.key}`
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<Ledger
|
|
||||||
{accountId}
|
|
||||||
empty={model.everything
|
|
||||||
? "No transactions."
|
|
||||||
: "No transactions in this period."}
|
|
||||||
groups={model.groups}
|
|
||||||
loading={model.loading}
|
|
||||||
{narrowed}
|
|
||||||
onclear={clear}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ArrowUpRight from "@lucide/svelte/icons/arrow-up-right";
|
|
||||||
import { untrack } from "svelte";
|
|
||||||
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
|
||||||
import { type FacetRow, fmt } from "#lib/dimension/stats.js";
|
|
||||||
import { percent } from "#lib/format.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
const GROUP = "Inside";
|
|
||||||
|
|
||||||
let {
|
|
||||||
rows,
|
|
||||||
loading,
|
|
||||||
active,
|
|
||||||
current,
|
|
||||||
marker,
|
|
||||||
label,
|
|
||||||
onnarrow,
|
|
||||||
onleave,
|
|
||||||
}: {
|
|
||||||
rows: FacetRow[];
|
|
||||||
loading: boolean;
|
|
||||||
active: boolean;
|
|
||||||
current: string | null;
|
|
||||||
marker: number;
|
|
||||||
label: string;
|
|
||||||
onnarrow: (row: FacetRow) => void;
|
|
||||||
onleave: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
function tint(i: number, row: FacetRow) {
|
|
||||||
if (active && nav.cursor === i) {
|
|
||||||
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
|
|
||||||
}
|
|
||||||
return row.key === current ? "bg-row-selected" : "hover:bg-row-hover";
|
|
||||||
}
|
|
||||||
|
|
||||||
const top = $derived(Math.max(...rows.map((row) => row.share), 0) || 1);
|
|
||||||
|
|
||||||
const nav: ListNav<FacetRow> = new ListNav<FacetRow>({
|
|
||||||
extra: () => [
|
|
||||||
{
|
|
||||||
group: GROUP,
|
|
||||||
keys: "Enter",
|
|
||||||
label: "Narrow the transactions to this row",
|
|
||||||
run: () => nav.current && onnarrow(nav.current),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: GROUP,
|
|
||||||
keys: "o",
|
|
||||||
label: "Open the page of this row",
|
|
||||||
run: () => nav.current?.href && goto(nav.current.href),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: GROUP,
|
|
||||||
keys: "Escape",
|
|
||||||
label: "Back to the transactions",
|
|
||||||
run: onleave,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
id: "facet",
|
|
||||||
items: () => rows,
|
|
||||||
key: (row) => row.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
useKeys(() => (active ? nav.bindings() : []));
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (active) {
|
|
||||||
untrack(() =>
|
|
||||||
nav.move(
|
|
||||||
Math.max(
|
|
||||||
0,
|
|
||||||
rows.findIndex((row) => row.key === current)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if rows.length === 0 && !loading}
|
|
||||||
<p class="px-3 py-6 text-center text-[12px] text-cladd-fg-soft">
|
|
||||||
Nothing in this period.
|
|
||||||
</p>
|
|
||||||
{:else if rows.length > 0}
|
|
||||||
<ul
|
|
||||||
aria-label={label}
|
|
||||||
class="grid min-h-0 flex-1 auto-rows-min grid-cols-[minmax(0,1fr)_auto_auto_auto] overflow-y-auto py-1 text-[12px] sm:grid-cols-[minmax(6rem,1fr)_minmax(2rem,7rem)_auto_auto_auto_auto]"
|
|
||||||
>
|
|
||||||
{#each rows as row, i (row.key)}
|
|
||||||
<li
|
|
||||||
class="col-span-full grid h-7 grid-cols-subgrid items-center gap-x-2.5 px-3 {tint(
|
|
||||||
i,
|
|
||||||
row
|
|
||||||
)}"
|
|
||||||
id={nav.rowId(i)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="truncate text-left text-cladd-fg hover:underline"
|
|
||||||
onclick={() => onnarrow(row)}
|
|
||||||
title={row.label}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{row.label}
|
|
||||||
</button>
|
|
||||||
<span class="relative h-2.5 max-sm:hidden">
|
|
||||||
<span
|
|
||||||
class="absolute inset-y-0 left-0 rounded-r-sm bg-brand"
|
|
||||||
style:width="max(1px, {(row.share / top) * 100}%)"
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
<span class="num truncate text-right text-cladd-fg">
|
|
||||||
{row.sums.map((sum) => fmt(sum.code, sum.amount, 0)).join(" + ")}
|
|
||||||
</span>
|
|
||||||
<span class="num w-9 text-right text-cladd-fg-soft"
|
|
||||||
>{percent(row.share)}</span
|
|
||||||
>
|
|
||||||
<span class="max-sm:hidden">
|
|
||||||
<Sparkline
|
|
||||||
color="var(--ff-brand)"
|
|
||||||
height={18}
|
|
||||||
{marker}
|
|
||||||
values={row.spark}
|
|
||||||
width={56}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{#if row.href}
|
|
||||||
<a
|
|
||||||
aria-label="Open {row.label}"
|
|
||||||
class="text-cladd-fg-softer hover:text-cladd-fg"
|
|
||||||
href={row.href}
|
|
||||||
>
|
|
||||||
<ArrowUpRight class="size-3.5" />
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<span></span>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import { daysBetween } from "#lib/dates.js";
|
|
||||||
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
|
|
||||||
import { counted, fmt, type Money } from "#lib/dimension/stats.js";
|
|
||||||
import { formatDate, formatNumber } from "#lib/format.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
|
||||||
import { monthOf } from "#lib/reports/board.js";
|
|
||||||
import { isApprox } from "#lib/reports/display.js";
|
|
||||||
import { versusText } from "#lib/reports/typical.js";
|
|
||||||
|
|
||||||
const WEEK = 7;
|
|
||||||
const SAME = 0.03;
|
|
||||||
|
|
||||||
let {
|
|
||||||
model,
|
|
||||||
noun,
|
|
||||||
lead,
|
|
||||||
}: { model: DimensionModel; noun: string; lead?: Snippet } = $props();
|
|
||||||
|
|
||||||
const facts = $derived(model.facts);
|
|
||||||
const when = $derived.by(() => {
|
|
||||||
if (!model.range) {
|
|
||||||
return "all time";
|
|
||||||
}
|
|
||||||
if (model.focus === monthOf(model.now) && period.isCurrent) {
|
|
||||||
return "this month";
|
|
||||||
}
|
|
||||||
return `in ${period.label}`;
|
|
||||||
});
|
|
||||||
const days = $derived.by(() => {
|
|
||||||
const start = model.range?.start ?? model.history.first;
|
|
||||||
const end =
|
|
||||||
model.range && model.range.end < model.now ? model.range.end : model.now;
|
|
||||||
return start ? daysBetween(start, end) + 1 : 0;
|
|
||||||
});
|
|
||||||
const perWeek = $derived(days >= WEEK ? (facts.count * WEEK) / days : null);
|
|
||||||
const main = $derived(model.norms[0]);
|
|
||||||
const typicals = $derived(model.norms.filter((n) => n.typical > 0));
|
|
||||||
const active = $derived(
|
|
||||||
model.typicalMonths.filter((month) =>
|
|
||||||
model.main.some((flow) => monthOf(flow.date) === month)
|
|
||||||
).length
|
|
||||||
);
|
|
||||||
const good = $derived(model.dir === 1);
|
|
||||||
|
|
||||||
function tone(ratio: number | null) {
|
|
||||||
if (ratio === null || Math.abs(ratio) < SAME) {
|
|
||||||
return "text-cladd-fg-soft";
|
|
||||||
}
|
|
||||||
return ratio > 0 === good ? "text-income" : "text-expense";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet amounts(
|
|
||||||
sums: Money[],
|
|
||||||
big: boolean
|
|
||||||
)}
|
|
||||||
{#each sums as sum, i (sum.code)}
|
|
||||||
{#if i > 0}
|
|
||||||
<span class="text-cladd-fg-soft">+</span>
|
|
||||||
{/if}
|
|
||||||
<span class="num font-semibold text-cladd-fg {big ? "text-[22px]" : ""}"
|
|
||||||
>{fmt(sum.code, sum.amount, 0)}</span
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 pb-3">
|
|
||||||
{#if lead}
|
|
||||||
{@render lead()}
|
|
||||||
{:else if !model.loading}
|
|
||||||
<p
|
|
||||||
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[14px] text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
{#if facts.count > 0}
|
|
||||||
{@render amounts(facts.sums, true)}
|
|
||||||
<span>{when}</span>
|
|
||||||
<span>· {counted(facts.count, noun)}</span>
|
|
||||||
{#if facts.median}
|
|
||||||
<span
|
|
||||||
>· median
|
|
||||||
<span class="num text-cladd-fg"
|
|
||||||
>{fmt(facts.median.code, facts.median.amount, 0)}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if perWeek !== null}
|
|
||||||
<span
|
|
||||||
>·
|
|
||||||
<span class="num text-cladd-fg"
|
|
||||||
>{formatNumber(perWeek, perWeek < 10 ? 1 : 0)}</span
|
|
||||||
>
|
|
||||||
per week</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span class="font-semibold text-[22px] text-cladd-fg"
|
|
||||||
>Nothing {when}</span
|
|
||||||
>
|
|
||||||
{#if model.history.last}
|
|
||||||
<span>· last {formatDate(model.history.last)}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#if !model.loading && main}
|
|
||||||
<p
|
|
||||||
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[13px] text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
{#if lead}
|
|
||||||
{#if facts.count > 0}
|
|
||||||
{@render amounts(facts.sums, false)}
|
|
||||||
<span>{when} ·</span>
|
|
||||||
{:else}
|
|
||||||
<span>nothing {when} ·</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if typicals.length > 0}
|
|
||||||
<span>typical month</span>
|
|
||||||
{@render amounts(
|
|
||||||
typicals.map((n) => ({ amount: n.typical, code: n.code })),
|
|
||||||
false
|
|
||||||
)}
|
|
||||||
{#if main.projected !== null}
|
|
||||||
<span
|
|
||||||
>· on pace for {isApprox(main.code) ? "" : "≈"}
|
|
||||||
<span class="num font-semibold text-cladd-fg"
|
|
||||||
>{fmt(main.code, main.projected, 0)}</span
|
|
||||||
>,</span
|
|
||||||
>
|
|
||||||
{:else if main.ratio !== null}
|
|
||||||
<span>·</span>
|
|
||||||
{/if}
|
|
||||||
{#if main.ratio !== null}
|
|
||||||
<span class="font-semibold {tone(main.ratio)}"
|
|
||||||
>{versusText(main.ratio)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{:else if model.typicalMonths.length > 0}
|
|
||||||
<span
|
|
||||||
>in {active} of the last {model.typicalMonths.length}
|
|
||||||
{model.typicalMonths.length === 1 ? "month" : "months"}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { DimensionModel } from "#lib/dimension/model.svelte.js";
|
|
||||||
import type { Facet } from "#lib/dimension/spec.js";
|
|
||||||
import { type FacetRow, facetRows } from "#lib/dimension/stats.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import FacetRows from "./FacetRows.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
model,
|
|
||||||
facet,
|
|
||||||
current,
|
|
||||||
onfacet,
|
|
||||||
onnarrow,
|
|
||||||
class: className = "",
|
|
||||||
}: {
|
|
||||||
model: DimensionModel;
|
|
||||||
facet: Facet;
|
|
||||||
current: string | null;
|
|
||||||
onfacet: (facet: Facet) => void;
|
|
||||||
onnarrow: (facet: Facet, row: FacetRow) => void;
|
|
||||||
class?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let focused = $state(false);
|
|
||||||
let section = $state<HTMLElement>();
|
|
||||||
|
|
||||||
const labelOf = (f: Facet) =>
|
|
||||||
model.dir === 1 && f.incomeLabel ? f.incomeLabel : f.label;
|
|
||||||
const rows = $derived(
|
|
||||||
facetRows(
|
|
||||||
facet,
|
|
||||||
model.periodMain,
|
|
||||||
model.main,
|
|
||||||
model.months,
|
|
||||||
model.codes,
|
|
||||||
model.weight
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const marker = $derived(model.months.indexOf(model.focus ?? ""));
|
|
||||||
|
|
||||||
useKeys(() =>
|
|
||||||
model.spec.facets.map((f, i) => ({
|
|
||||||
group: "Inside",
|
|
||||||
keys: String(i + 1),
|
|
||||||
label: `Inside: ${labelOf(f)}`,
|
|
||||||
run: () => {
|
|
||||||
onfacet(f);
|
|
||||||
focused = true;
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window
|
|
||||||
onpointerdown={(event) => {
|
|
||||||
if (focused && !section?.contains(event.target as Node)) {
|
|
||||||
focused = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section
|
|
||||||
aria-label="Inside"
|
|
||||||
class="cladd-surface cladd-outline flex min-w-0 flex-col rounded-cladd-lg {focused
|
|
||||||
? "outline-2 outline-brand"
|
|
||||||
: ""} {className}"
|
|
||||||
bind:this={section}
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
class="flex min-h-10 shrink-0 items-center gap-1 overflow-x-auto border-cladd-outline border-b px-2"
|
|
||||||
role="tablist"
|
|
||||||
>
|
|
||||||
{#each model.spec.facets as f, i (f.id)}
|
|
||||||
<button
|
|
||||||
aria-selected={f.id === facet.id}
|
|
||||||
class="flex h-7 shrink-0 items-center gap-1.5 rounded-cladd-sm px-2 text-[13px] {f.id ===
|
|
||||||
facet.id
|
|
||||||
? "bg-cladd-surface-next font-semibold text-cladd-fg"
|
|
||||||
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
|
||||||
onclick={() => onfacet(f)}
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span class="kbd">{i + 1}</span>
|
|
||||||
{labelOf(f)}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</header>
|
|
||||||
{#key facet.id}
|
|
||||||
<FacetRows
|
|
||||||
active={focused}
|
|
||||||
{current}
|
|
||||||
label={labelOf(facet)}
|
|
||||||
loading={model.loading}
|
|
||||||
{marker}
|
|
||||||
onleave={() => (focused = false)}
|
|
||||||
onnarrow={(row) => {
|
|
||||||
focused = false;
|
|
||||||
onnarrow(facet, row);
|
|
||||||
}}
|
|
||||||
{rows}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
</section>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import Pager from "#lib/components/transactions/Pager.svelte";
|
|
||||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
|
||||||
import { session } from "#lib/session.svelte.js";
|
|
||||||
import type { Group } from "#lib/transactions/split.js";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
|
|
||||||
let {
|
|
||||||
groups,
|
|
||||||
narrowed,
|
|
||||||
onclear,
|
|
||||||
loading = false,
|
|
||||||
accountId,
|
|
||||||
empty,
|
|
||||||
}: {
|
|
||||||
groups: Group[];
|
|
||||||
narrowed: { groups: Group[]; label: string } | null;
|
|
||||||
onclear: () => void;
|
|
||||||
loading?: boolean;
|
|
||||||
accountId?: string;
|
|
||||||
empty: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const shown = $derived(narrowed?.groups ?? groups);
|
|
||||||
const size = $derived(session.pageSize);
|
|
||||||
const pages = $derived(Math.max(1, Math.ceil(shown.length / size)));
|
|
||||||
const current = $derived(
|
|
||||||
Math.min(pages, Math.max(1, Number(page.url.searchParams.get("page")) || 1))
|
|
||||||
);
|
|
||||||
const slice = $derived(shown.slice((current - 1) * size, current * size));
|
|
||||||
|
|
||||||
function href(n: number) {
|
|
||||||
const url = new URL(page.url.href);
|
|
||||||
url.searchParams.set("page", String(n));
|
|
||||||
return url.pathname + url.search;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Box body="p-0" title="Transactions">
|
|
||||||
{#snippet tools()}
|
|
||||||
{#if narrowed}
|
|
||||||
<span class="text-[12px] text-cladd-fg-soft"
|
|
||||||
><span class="font-semibold text-cladd-fg">{narrowed.label}</span>
|
|
||||||
<span class="num"
|
|
||||||
>{narrowed.groups.length}
|
|
||||||
of {groups.length}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
aria-label="Show all {groups.length}"
|
|
||||||
class="kbd hover:text-cladd-fg"
|
|
||||||
onclick={onclear}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Esc
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="num text-[12px] text-cladd-fg-soft">{groups.length}</span>
|
|
||||||
{/if}
|
|
||||||
<Pager {href} keys page={current} {pages} />
|
|
||||||
{/snippet}
|
|
||||||
<TransactionTable
|
|
||||||
context={{ accountId }}
|
|
||||||
{empty}
|
|
||||||
groups={slice}
|
|
||||||
id="dimension-tx"
|
|
||||||
{loading}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { fmt, type LimitFact } from "#lib/dimension/stats.js";
|
|
||||||
import { percent } from "#lib/format.js";
|
|
||||||
|
|
||||||
let { facts }: { facts: LimitFact[] } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="-mt-1 flex flex-col gap-0.5 pb-3 text-[13px] text-cladd-fg-soft">
|
|
||||||
{#each facts as fact (fact.id)}
|
|
||||||
{@const expected =
|
|
||||||
fact.day === null ? null : (fact.amount * fact.day) / fact.days}
|
|
||||||
{@const left = fact.amount - fact.spent}
|
|
||||||
<p class="flex flex-wrap items-baseline gap-x-1.5">
|
|
||||||
<span
|
|
||||||
>limit
|
|
||||||
<span class="num font-semibold text-cladd-fg"
|
|
||||||
>{fmt(fact.code, fact.amount, 0)}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
>· spent
|
|
||||||
<span class="num text-cladd-fg"
|
|
||||||
>{percent(fact.amount > 0 ? fact.spent / fact.amount : 0)}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
{#if fact.day !== null && expected !== null}
|
|
||||||
<span>· day {fact.day} of {fact.days} →</span>
|
|
||||||
{#if fact.spent > expected}
|
|
||||||
<span class="font-semibold text-expense"
|
|
||||||
>{fmt(fact.code, fact.spent - expected, 0)}
|
|
||||||
over pace</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span class="font-semibold text-income">under pace</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if left >= 0}
|
|
||||||
<span
|
|
||||||
>·
|
|
||||||
<span class="num text-cladd-fg">{fmt(fact.code, left, 0)}</span>
|
|
||||||
left</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
>·
|
|
||||||
<span class="num font-semibold text-expense"
|
|
||||||
>{fmt(fact.code, -left, 0)}</span
|
|
||||||
>
|
|
||||||
overspent</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p>no limit for this period</p>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { fmt, type StripCell } from "#lib/dimension/stats.js";
|
|
||||||
import { longMonth, shortMonth } from "#lib/reports/board.js";
|
|
||||||
|
|
||||||
let {
|
|
||||||
strips,
|
|
||||||
budgetId,
|
|
||||||
cursor,
|
|
||||||
}: {
|
|
||||||
strips: Map<string, StripCell[]>;
|
|
||||||
budgetId: string;
|
|
||||||
cursor: string | null;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet meter(
|
|
||||||
cell: StripCell,
|
|
||||||
code: string
|
|
||||||
)}
|
|
||||||
{@const over = cell.spent > cell.amount}
|
|
||||||
<span
|
|
||||||
class="text-[11px] max-sm:hidden {cell.month === cursor
|
|
||||||
? "font-semibold text-cladd-fg"
|
|
||||||
: "text-cladd-fg-soft"}"
|
|
||||||
>{shortMonth(cell.month)}</span
|
|
||||||
>
|
|
||||||
<span class="relative h-1 w-full rounded-full bg-cladd-surface-next">
|
|
||||||
{#if cell.id}
|
|
||||||
<span
|
|
||||||
class="absolute inset-y-0 left-0 rounded-full {over
|
|
||||||
? "bg-expense"
|
|
||||||
: "bg-brand"}"
|
|
||||||
style:width="{Math.min(1, cell.spent / (cell.amount || 1)) * 100}%"
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="sr-only"
|
|
||||||
>{cell.id
|
|
||||||
? `${fmt(code, cell.spent, 0)} of ${fmt(code, cell.amount, 0)}`
|
|
||||||
: "no limit"}</span
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#each [...strips] as [code, cells] (code)}
|
|
||||||
<nav
|
|
||||||
aria-label="Limits in {code}"
|
|
||||||
class="mt-2 grid gap-1 border-cladd-outline border-t pt-2"
|
|
||||||
style:grid-template-columns="2.5rem repeat({cells.length}, minmax(0, 1fr))"
|
|
||||||
>
|
|
||||||
<span class="self-center font-semibold text-[12px] text-cladd-fg"
|
|
||||||
>{code}</span
|
|
||||||
>
|
|
||||||
{#each cells as cell (cell.month)}
|
|
||||||
{#if cell.id}
|
|
||||||
<a
|
|
||||||
class="flex flex-col items-center gap-1 rounded-cladd-sm px-1 py-0.5 hover:bg-row-hover {cell.month ===
|
|
||||||
cursor
|
|
||||||
? "bg-row-cursor"
|
|
||||||
: ""}"
|
|
||||||
href="/budgets/show/{budgetId}/{cell.id}"
|
|
||||||
title="{longMonth(cell.month)}: {fmt(code, cell.spent, 0)} of {fmt(
|
|
||||||
code,
|
|
||||||
cell.amount,
|
|
||||||
0
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{@render meter(cell, code)}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<span class="flex flex-col items-center gap-1 px-1 py-0.5">
|
|
||||||
{@render meter(cell, code)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
{/each}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { daysBetween } from "#lib/dates.js";
|
|
||||||
import { DimensionModel } from "#lib/dimension/model.svelte.js";
|
|
||||||
import { merchantSpec } from "#lib/dimension/spec.js";
|
|
||||||
import { counted, factsOf, fmt } from "#lib/dimension/stats.js";
|
|
||||||
import { formatDate, formatNumber } from "#lib/format.js";
|
|
||||||
import Dimension from "./Dimension.svelte";
|
|
||||||
|
|
||||||
let { id, everything = false }: { id: string; everything?: boolean } =
|
|
||||||
$props();
|
|
||||||
|
|
||||||
const model = new DimensionModel(() => ({
|
|
||||||
allTime: true,
|
|
||||||
everything,
|
|
||||||
spec: merchantSpec(id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const history = $derived(
|
|
||||||
factsOf(
|
|
||||||
model.main.filter((flow) => flow.date <= model.now),
|
|
||||||
model.codes
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const noun = $derived(model.dir === -1 ? "visits" : "payments");
|
|
||||||
const every = $derived(
|
|
||||||
history.count > 1
|
|
||||||
? daysBetween(history.first, history.last) / (history.count - 1)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
const bill = $derived(model.main.find((flow) => flow.bill)?.bill ?? null);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet lead()}
|
|
||||||
{#if !model.loading}
|
|
||||||
<p
|
|
||||||
class="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 text-[14px] text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
{#if history.count > 0}
|
|
||||||
{#each history.sums as sum, i (sum.code)}
|
|
||||||
{#if i > 0}
|
|
||||||
<span>+</span>
|
|
||||||
{/if}
|
|
||||||
<span class="num font-semibold text-[22px] text-cladd-fg"
|
|
||||||
>{fmt(sum.code, sum.amount, 0)}</span
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
<span>all time · {counted(history.count, noun)}</span>
|
|
||||||
{#if history.median}
|
|
||||||
<span
|
|
||||||
>· median
|
|
||||||
<span class="num text-cladd-fg"
|
|
||||||
>{fmt(history.median.code, history.median.amount, 0)}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if every !== null}
|
|
||||||
<span
|
|
||||||
>· every
|
|
||||||
<span class="num text-cladd-fg"
|
|
||||||
>{formatNumber(every, every < 10 ? 1 : 0)}</span
|
|
||||||
>
|
|
||||||
days</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<span
|
|
||||||
>· last
|
|
||||||
<span class="text-cladd-fg"
|
|
||||||
>{formatDate(history.last, "short")}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
{#if bill}
|
|
||||||
<span
|
|
||||||
>· subscription
|
|
||||||
<a
|
|
||||||
class="text-brand hover:underline"
|
|
||||||
href="/subscriptions/show/{bill.id}"
|
|
||||||
>{bill.name}</a
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span class="font-semibold text-[22px] text-cladd-fg"
|
|
||||||
>No {noun} yet</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Dimension accountId={id} {lead} {model} {noun} />
|
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
import Field from "#lib/components/inputs/Field.svelte";
|
import Field from "#lib/components/inputs/Field.svelte";
|
||||||
import { INPUT } from "#lib/components/inputs/styles.js";
|
import { INPUT } from "#lib/components/inputs/styles.js";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import Button from "#lib/components/ui/button/button.svelte";
|
|
||||||
import { formatMoney } from "#lib/format.js";
|
import { formatMoney } from "#lib/format.js";
|
||||||
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||||
import { ui } from "#lib/ui.svelte.js";
|
import { ui } from "#lib/ui.svelte.js";
|
||||||
@@ -52,16 +51,15 @@
|
|||||||
withdrawal: "withdrawal",
|
withdrawal: "withdrawal",
|
||||||
};
|
};
|
||||||
const FIELDS: [string, string, string][] = [
|
const FIELDS: [string, string, string][] = [
|
||||||
["d", "description", "description"],
|
["alt+d", "description", "description"],
|
||||||
["f", "source", "source account"],
|
["alt+f", "source", "source account"],
|
||||||
["t", "destination", "destination account"],
|
["alt+t", "destination", "destination account"],
|
||||||
["w", "date", "date"],
|
["alt+w", "date", "date"],
|
||||||
["m", "amount", "amount"],
|
["alt+m", "amount", "amount"],
|
||||||
["c", "category", "category"],
|
["alt+k", "category", "category"],
|
||||||
["b", "budget", "budget"],
|
["alt+b", "budget", "budget"],
|
||||||
["s", "bill", "subscription"],
|
["alt+g", "tags", "tags"],
|
||||||
["#", "tags", "tags"],
|
["alt+r", "notes", "notes"],
|
||||||
["r", "notes", "notes"],
|
|
||||||
];
|
];
|
||||||
const INLINE = new Set([
|
const INLINE = new Set([
|
||||||
"description",
|
"description",
|
||||||
@@ -105,7 +103,6 @@
|
|||||||
const originalType = start.type;
|
const originalType = start.type;
|
||||||
|
|
||||||
let form = $state<Form>(start);
|
let form = $state<Form>(start);
|
||||||
let lastSplit = start.splits[0].key;
|
|
||||||
let saved = $state(untrack(() => group));
|
let saved = $state(untrack(() => group));
|
||||||
let snapshot = JSON.stringify(start);
|
let snapshot = JSON.stringify(start);
|
||||||
let errors = $state<Errors>({});
|
let errors = $state<Errors>({});
|
||||||
@@ -206,17 +203,9 @@
|
|||||||
tick().then(() => focusField("description", false));
|
tick().then(() => focusField("description", false));
|
||||||
});
|
});
|
||||||
|
|
||||||
function splitOf(target: EventTarget | null): number | null {
|
|
||||||
const holder =
|
|
||||||
target instanceof Element ? target.closest("[data-split]") : null;
|
|
||||||
return holder ? Number(holder.getAttribute("data-split")) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentKey(): number {
|
function currentKey(): number {
|
||||||
const key = splitOf(document.activeElement) ?? lastSplit;
|
const holder = document.activeElement?.closest("[data-split]");
|
||||||
return form.splits.some((split) => split.key === key)
|
return Number(holder?.getAttribute("data-split") ?? form.splits[0].key);
|
||||||
? key
|
|
||||||
: form.splits[0].key;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusField(field: string, select = true) {
|
function focusField(field: string, select = true) {
|
||||||
@@ -462,25 +451,29 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: "Editor",
|
group: "Editor",
|
||||||
keys: "X",
|
input: true,
|
||||||
|
keys: "mod+alt+Backspace",
|
||||||
label: "Remove the current split",
|
label: "Remove the current split",
|
||||||
run: () => removeSplit(),
|
run: () => removeSplit(),
|
||||||
},
|
},
|
||||||
...TX_TYPES.map((type, index) => ({
|
...TX_TYPES.map((type, index) => ({
|
||||||
group: "Editor",
|
group: "Editor",
|
||||||
keys: `${index + 1}`,
|
input: true,
|
||||||
|
keys: `alt+${index + 1}`,
|
||||||
label: `Make it a ${type}`,
|
label: `Make it a ${type}`,
|
||||||
run: () => setType(type),
|
run: () => setType(type),
|
||||||
})),
|
})),
|
||||||
...FIELDS.map(([keys, field, label]) => ({
|
...FIELDS.map(([keys, field, label]) => ({
|
||||||
group: "Editor",
|
group: "Editor",
|
||||||
|
input: true,
|
||||||
keys,
|
keys,
|
||||||
label: `Go to ${label}`,
|
label: `Go to ${label}`,
|
||||||
run: () => focusField(field),
|
run: () => focusField(field),
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
group: "Editor",
|
group: "Editor",
|
||||||
keys: "T",
|
input: true,
|
||||||
|
keys: "alt+i",
|
||||||
label: "Go to the split transaction title",
|
label: "Go to the split transaction title",
|
||||||
run: () => document.getElementById("group-title")?.focus(),
|
run: () => document.getElementById("group-title")?.focus(),
|
||||||
},
|
},
|
||||||
@@ -526,11 +519,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div
|
<div class="flex flex-col gap-4" bind:this={root}>
|
||||||
class="flex flex-col gap-4"
|
|
||||||
onfocusin={(event) => (lastSplit = splitOf(event.target) ?? lastSplit)}
|
|
||||||
bind:this={root}
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
<div
|
<div
|
||||||
aria-label="Transaction type"
|
aria-label="Transaction type"
|
||||||
@@ -550,7 +539,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{TYPE_LABEL[type]}
|
{TYPE_LABEL[type]}
|
||||||
<span class="kbd hidden lg:inline-flex">{index + 1}</span>
|
<span class="kbd hidden lg:inline-flex"
|
||||||
|
>{prettyKeys(`alt+${index + 1}`)[0]}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -622,7 +613,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={errors.group_title?.[0]}
|
error={errors.group_title?.[0]}
|
||||||
for="group-title"
|
for="group-title"
|
||||||
keys="T"
|
keys="alt+i"
|
||||||
label="Description of the split transaction"
|
label="Description of the split transaction"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -635,29 +626,20 @@
|
|||||||
</Box>
|
</Box>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
|
||||||
<Attachments groupId={saved?.id ?? null} {journals} bind:queued />
|
|
||||||
{#if editing && journals.length}
|
|
||||||
<Links {journals} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="cladd-surface cladd-outline sticky bottom-0 z-20 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-cladd-lg px-3 py-2 sm:gap-x-4 sm:py-2.5 shadow-[0_-6px_16px_-12px_rgb(0_0_0/0.35)]"
|
class="cladd-surface cladd-outline sticky bottom-0 z-20 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-cladd-lg px-3 py-2 sm:gap-x-4 sm:py-2.5 shadow-[0_-6px_16px_-12px_rgb(0_0_0/0.35)]"
|
||||||
>
|
>
|
||||||
<Button
|
<button
|
||||||
class="h-9 px-3.5 text-[13px] sm:h-cladd-md"
|
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand inline-flex h-9 items-center gap-2 rounded-cladd-md px-3.5 font-semibold text-[13px] text-cladd-primary disabled:opacity-60 sm:h-cladd-md"
|
||||||
color="brand"
|
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
variant="fill"
|
type="button"
|
||||||
>
|
>
|
||||||
{editing ? "Update transaction" : "Save transaction"}
|
{editing ? "Update transaction" : "Save transaction"}
|
||||||
<span
|
<span class="kbd hidden sm:inline-flex"
|
||||||
class="kbd hidden bg-white/20! text-current! shadow-none! sm:inline-flex"
|
|
||||||
>{prettyKeys("mod+Enter")[0]}</span
|
>{prettyKeys("mod+Enter")[0]}</span
|
||||||
>
|
>
|
||||||
</Button>
|
</button>
|
||||||
<label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft">
|
<label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft">
|
||||||
<span class="hidden sm:inline">After saving</span>
|
<span class="hidden sm:inline">After saving</span>
|
||||||
<select
|
<select
|
||||||
@@ -703,4 +685,11 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Attachments groupId={saved?.id ?? null} {journals} bind:queued />
|
||||||
|
{#if editing && journals.length}
|
||||||
|
<Links {journals} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -10,21 +10,18 @@
|
|||||||
id,
|
id,
|
||||||
invalid = false,
|
invalid = false,
|
||||||
placeholder = "",
|
placeholder = "",
|
||||||
onpick,
|
|
||||||
}: {
|
}: {
|
||||||
value: Ref;
|
value: Ref;
|
||||||
search: (query: string) => Option[];
|
search: (query: string) => Option[];
|
||||||
id: string;
|
id: string;
|
||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onpick?: (option: Option) => void;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
{id}
|
{id}
|
||||||
{invalid}
|
{invalid}
|
||||||
{onpick}
|
|
||||||
openOnFocus
|
openOnFocus
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{search}
|
{search}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Field from "#lib/components/inputs/Field.svelte";
|
import Field from "#lib/components/inputs/Field.svelte";
|
||||||
import { INPUT } from "#lib/components/inputs/styles.js";
|
import { INPUT } from "#lib/components/inputs/styles.js";
|
||||||
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
|
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
|
||||||
|
import { prettyKeys } from "#lib/keys.svelte.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
import { session } from "#lib/session.svelte.js";
|
||||||
import AccountField from "./AccountField.svelte";
|
import AccountField from "./AccountField.svelte";
|
||||||
import type { Catalog } from "./catalog.svelte.js";
|
import type { Catalog } from "./catalog.svelte.js";
|
||||||
@@ -204,7 +205,9 @@
|
|||||||
>
|
>
|
||||||
<Trash class="size-3.5" />
|
<Trash class="size-3.5" />
|
||||||
Remove split
|
Remove split
|
||||||
<span class="kbd hidden lg:inline-flex">X</span>
|
<span class="kbd hidden lg:inline-flex"
|
||||||
|
>{prettyKeys("mod+alt+Backspace")[0]}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -213,7 +216,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("description")}
|
error={err("description")}
|
||||||
for={id("description")}
|
for={id("description")}
|
||||||
keys={keyHint("d")}
|
keys={keyHint("alt+d")}
|
||||||
label="Description"
|
label="Description"
|
||||||
>
|
>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
@@ -226,10 +229,10 @@
|
|||||||
bind:value={split.description}
|
bind:value={split.description}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{@render account("source", "Source account", "f")}
|
{@render account("source", "Source account", "alt+f")}
|
||||||
{@render account("destination", "Destination account", "t")}
|
{@render account("destination", "Destination account", "alt+t")}
|
||||||
{#if first}
|
{#if first}
|
||||||
<Field error={err("date")} for={id("date")} keys="w" label="Date">
|
<Field error={err("date")} for={id("date")} keys="alt+w" label="Date">
|
||||||
<DateInput
|
<DateInput
|
||||||
id={id("date")}
|
id={id("date")}
|
||||||
invalid={Boolean(err("date"))}
|
invalid={Boolean(err("date"))}
|
||||||
@@ -242,7 +245,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("amount")}
|
error={err("amount")}
|
||||||
for={id("amount")}
|
for={id("amount")}
|
||||||
keys={keyHint("m")}
|
keys={keyHint("alt+m")}
|
||||||
label="Amount"
|
label="Amount"
|
||||||
>
|
>
|
||||||
<AmountInput
|
<AmountInput
|
||||||
@@ -325,7 +328,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("budget_id")}
|
error={err("budget_id")}
|
||||||
for={id("budget")}
|
for={id("budget")}
|
||||||
keys={keyHint("b")}
|
keys={keyHint("alt+b")}
|
||||||
label="Budget"
|
label="Budget"
|
||||||
>
|
>
|
||||||
<RefField
|
<RefField
|
||||||
@@ -340,7 +343,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("category_name")}
|
error={err("category_name")}
|
||||||
for={id("category")}
|
for={id("category")}
|
||||||
keys={keyHint("c")}
|
keys={keyHint("alt+k")}
|
||||||
label="Category"
|
label="Category"
|
||||||
>
|
>
|
||||||
<AutocompleteInput
|
<AutocompleteInput
|
||||||
@@ -382,7 +385,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("tags")}
|
error={err("tags")}
|
||||||
for={id("tags")}
|
for={id("tags")}
|
||||||
keys={keyHint("#")}
|
keys={keyHint("alt+g")}
|
||||||
label="Tags"
|
label="Tags"
|
||||||
>
|
>
|
||||||
<TagsInput
|
<TagsInput
|
||||||
@@ -393,12 +396,7 @@
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{#if withdrawal}
|
{#if withdrawal}
|
||||||
<Field
|
<Field error={err("bill_id")} for={id("bill")} label="Subscription">
|
||||||
error={err("bill_id")}
|
|
||||||
for={id("bill")}
|
|
||||||
keys={keyHint("s")}
|
|
||||||
label="Subscription"
|
|
||||||
>
|
|
||||||
<RefField
|
<RefField
|
||||||
id={id("bill")}
|
id={id("bill")}
|
||||||
invalid={Boolean(err("bill_id"))}
|
invalid={Boolean(err("bill_id"))}
|
||||||
@@ -411,7 +409,7 @@
|
|||||||
<Field
|
<Field
|
||||||
error={err("notes")}
|
error={err("notes")}
|
||||||
for={id("notes")}
|
for={id("notes")}
|
||||||
keys={keyHint("r")}
|
keys={keyHint("alt+r")}
|
||||||
label="Notes"
|
label="Notes"
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
Page,
|
Page,
|
||||||
TransactionGroup,
|
TransactionGroup,
|
||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
import { learnedBudget } from "#lib/budgets/map.js";
|
|
||||||
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
import { formatMoney } from "#lib/format.js";
|
import { formatMoney } from "#lib/format.js";
|
||||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
@@ -235,24 +234,8 @@ export class Catalog {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
this.history.set(key, found);
|
this.history.set(key, found);
|
||||||
}
|
}
|
||||||
const learned = learnedBudget(accountId)?.id;
|
|
||||||
const budget = this.budgets.data?.find(
|
|
||||||
(item) => item.id === learned && item.attributes.active
|
|
||||||
);
|
|
||||||
if (!budget || type !== "withdrawal" || side !== "destination") {
|
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
return found.then(
|
|
||||||
(past) =>
|
|
||||||
past && {
|
|
||||||
split: {
|
|
||||||
...past.split,
|
|
||||||
budget_id: budget.id,
|
|
||||||
budget_name: budget.attributes.name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fromJournal(journalId: string): Promise<Recent | null> {
|
async fromJournal(journalId: string): Promise<Recent | null> {
|
||||||
const group = await api<One<TransactionGroup>>(
|
const group = await api<One<TransactionGroup>>(
|
||||||
|
|||||||
@@ -364,20 +364,15 @@ function splitPayload(form: Form, split: SplitForm, update: boolean) {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
const foreign = hasForeign(split) && split.foreignAmount.trim() !== "";
|
const foreign = hasForeign(split) && split.foreignAmount.trim() !== "";
|
||||||
let foreignAmount = update ? "0" : null;
|
|
||||||
if (foreign) {
|
|
||||||
foreignAmount = amountString(
|
|
||||||
split.foreignAmount,
|
|
||||||
split.foreignCurrency?.decimals ?? 2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...body,
|
...body,
|
||||||
...accountFields("source", sideOf(form, split, "source")),
|
...accountFields("source", sideOf(form, split, "source")),
|
||||||
...accountFields("destination", sideOf(form, split, "destination")),
|
...accountFields("destination", sideOf(form, split, "destination")),
|
||||||
amount: amountString(split.amount, split.currency?.decimals ?? 2),
|
amount: amountString(split.amount, split.currency?.decimals ?? 2),
|
||||||
currency_code: split.currency?.code,
|
currency_code: split.currency?.code,
|
||||||
foreign_amount: foreignAmount,
|
foreign_amount: foreign
|
||||||
|
? amountString(split.foreignAmount, split.foreignCurrency?.decimals ?? 2)
|
||||||
|
: null,
|
||||||
foreign_currency_code: foreign ? split.foreignCurrency?.code : null,
|
foreign_currency_code: foreign ? split.foreignCurrency?.code : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { Split } from "#lib/api/types.js";
|
import type { Split } from "#lib/api/types.js";
|
||||||
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
|
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
|
||||||
import { today } from "#lib/dates.js";
|
import { today } from "#lib/dates.js";
|
||||||
import { session } from "#lib/session.svelte.js";
|
|
||||||
import type { Catalog } from "./catalog.svelte.js";
|
import type { Catalog } from "./catalog.svelte.js";
|
||||||
import {
|
import {
|
||||||
type AccountRef,
|
|
||||||
type Cur,
|
|
||||||
emptyForm,
|
emptyForm,
|
||||||
KINDS,
|
KINDS,
|
||||||
OWN_SIDE,
|
OWN_SIDE,
|
||||||
@@ -16,9 +13,9 @@ import {
|
|||||||
toAccountRef,
|
toAccountRef,
|
||||||
} from "./model.js";
|
} from "./model.js";
|
||||||
|
|
||||||
export type Marker = "@" | "^" | ">" | "!" | "$" | "%" | "#" | "~";
|
export type Marker = "@" | "^" | ">" | "!" | "$" | "#" | "~";
|
||||||
|
|
||||||
const MULTI = new Set<string>(["@", "^", ">", "!", "$", "%"]);
|
const MULTI = new Set<string>(["@", "^", ">", "!", "$"]);
|
||||||
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
|
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
|
||||||
const WORD = /\S+/g;
|
const WORD = /\S+/g;
|
||||||
const NUMERIC = /^[\d(]/;
|
const NUMERIC = /^[\d(]/;
|
||||||
@@ -30,109 +27,21 @@ export interface Segment {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Equal {
|
|
||||||
code: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Quick {
|
export interface Quick {
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
amountText: string;
|
amountText: string;
|
||||||
currency: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
equal: Equal | null;
|
|
||||||
segments: Segment[];
|
segments: Segment[];
|
||||||
type: TxType;
|
type: TxType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledCodes = () =>
|
export function parseQuick(input: string): Quick {
|
||||||
new Set(
|
const segments: Segment[] = [];
|
||||||
session.currencies
|
const words: string[] = [];
|
||||||
.filter((item) => item.attributes.enabled)
|
let amount: number | null = null;
|
||||||
.map((item) => item.attributes.code)
|
let amountText = "";
|
||||||
);
|
|
||||||
|
|
||||||
interface Parse {
|
|
||||||
amount: number | null;
|
|
||||||
amountText: string;
|
|
||||||
coded: "amount" | "equal" | null;
|
|
||||||
currency: string;
|
|
||||||
current: Segment | null;
|
|
||||||
equal: Equal | null;
|
|
||||||
segments: Segment[];
|
|
||||||
words: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeCode(state: Parse, word: string, codes: Set<string>) {
|
|
||||||
const { coded } = state;
|
|
||||||
const code = word.toUpperCase();
|
|
||||||
state.coded = null;
|
|
||||||
if (!(coded && codes.has(code))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (coded === "amount") {
|
|
||||||
state.currency = code;
|
|
||||||
} else if (state.equal) {
|
|
||||||
state.equal.code = code;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeEqual(state: Parse, word: string) {
|
|
||||||
if (!word.startsWith("=") || state.equal) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
state.equal = { code: "", text: word.slice(1) };
|
|
||||||
state.coded = "equal";
|
|
||||||
state.current = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeMarker(state: Parse, word: string, start: number) {
|
|
||||||
const { current } = state;
|
|
||||||
if (MARKERS.has(word[0])) {
|
|
||||||
const segment = {
|
|
||||||
end: start + word.length,
|
|
||||||
marker: word[0] as Marker,
|
|
||||||
start,
|
|
||||||
value: word.slice(1),
|
|
||||||
};
|
|
||||||
state.segments.push(segment);
|
|
||||||
state.current = MULTI.has(segment.marker) ? segment : null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (current) {
|
|
||||||
current.value = `${current.value} ${word}`;
|
|
||||||
current.end = start + word.length;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function takeAmount(state: Parse, word: string) {
|
|
||||||
const value =
|
|
||||||
state.amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
|
|
||||||
if (value === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
state.amount = value;
|
|
||||||
state.amountText = word;
|
|
||||||
state.coded = "amount";
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseQuick(input: string, codes = enabledCodes()): Quick {
|
|
||||||
const state: Parse = {
|
|
||||||
amount: null,
|
|
||||||
amountText: "",
|
|
||||||
coded: null,
|
|
||||||
currency: "",
|
|
||||||
current: null,
|
|
||||||
equal: null,
|
|
||||||
segments: [],
|
|
||||||
words: [],
|
|
||||||
};
|
|
||||||
let deposit = false;
|
let deposit = false;
|
||||||
|
let current: Segment | null = null;
|
||||||
let first = true;
|
let first = true;
|
||||||
for (const match of input.matchAll(WORD)) {
|
for (const match of input.matchAll(WORD)) {
|
||||||
let [word] = match;
|
let [word] = match;
|
||||||
@@ -143,32 +52,42 @@ export function parseQuick(input: string, codes = enabledCodes()): Quick {
|
|||||||
start += 1;
|
start += 1;
|
||||||
}
|
}
|
||||||
first = false;
|
first = false;
|
||||||
const taken =
|
if (!word) {
|
||||||
!word ||
|
continue;
|
||||||
takeCode(state, word, codes) ||
|
|
||||||
takeEqual(state, word) ||
|
|
||||||
takeMarker(state, word, start) ||
|
|
||||||
takeAmount(state, word);
|
|
||||||
if (!taken) {
|
|
||||||
state.words.push(word);
|
|
||||||
}
|
}
|
||||||
|
if (MARKERS.has(word[0])) {
|
||||||
|
const segment = {
|
||||||
|
end: start + word.length,
|
||||||
|
marker: word[0] as Marker,
|
||||||
|
start,
|
||||||
|
value: word.slice(1),
|
||||||
|
};
|
||||||
|
segments.push(segment);
|
||||||
|
current = MULTI.has(segment.marker) ? segment : null;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const hasTarget = state.segments.some((segment) => segment.marker === ">");
|
if (current) {
|
||||||
|
current.value = `${current.value} ${word}`;
|
||||||
|
current.end = start + word.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value: number | null =
|
||||||
|
amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
|
||||||
|
if (value !== null) {
|
||||||
|
amount = value;
|
||||||
|
amountText = word;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
words.push(word);
|
||||||
|
}
|
||||||
|
const hasTarget = segments.some((segment) => segment.marker === ">");
|
||||||
let type: TxType = "withdrawal";
|
let type: TxType = "withdrawal";
|
||||||
if (hasTarget) {
|
if (hasTarget) {
|
||||||
type = "transfer";
|
type = "transfer";
|
||||||
} else if (deposit) {
|
} else if (deposit) {
|
||||||
type = "deposit";
|
type = "deposit";
|
||||||
}
|
}
|
||||||
return {
|
return { amount, amountText, description: words.join(" "), segments, type };
|
||||||
amount: state.amount,
|
|
||||||
amountText: state.amountText,
|
|
||||||
currency: state.currency,
|
|
||||||
description: state.words.join(" "),
|
|
||||||
equal: state.equal,
|
|
||||||
segments: state.segments,
|
|
||||||
type,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const markerValue = (quick: Quick, marker: Marker) =>
|
export const markerValue = (quick: Quick, marker: Marker) =>
|
||||||
@@ -239,77 +158,6 @@ function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) {
|
|||||||
: [`No asset account or liability called "${ownName}"`];
|
: [`No asset account or liability called "${ownName}"`];
|
||||||
}
|
}
|
||||||
|
|
||||||
function curOf(code: string | undefined): Cur | null {
|
|
||||||
const found = session.currencies.find(
|
|
||||||
(item) => item.attributes.code === code
|
|
||||||
);
|
|
||||||
return found
|
|
||||||
? {
|
|
||||||
code: found.attributes.code,
|
|
||||||
decimals: found.attributes.decimal_places ?? 2,
|
|
||||||
symbol: found.attributes.symbol,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMoney(
|
|
||||||
quick: Quick,
|
|
||||||
split: SplitForm,
|
|
||||||
own: AccountRef,
|
|
||||||
target: AccountRef | null
|
|
||||||
): string[] {
|
|
||||||
const native = own.currency?.code;
|
|
||||||
const { equal } = quick;
|
|
||||||
split.currency = own.currency;
|
|
||||||
split.amount = quick.amountText;
|
|
||||||
const first = quick.currency || native;
|
|
||||||
if (!native) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (!equal) {
|
|
||||||
return first === native
|
|
||||||
? []
|
|
||||||
: [`${own.name} is in ${native}: add =amount ${native}`];
|
|
||||||
}
|
|
||||||
const second =
|
|
||||||
equal.code || (first === native ? target?.currency?.code : native);
|
|
||||||
if (first === native && second && second !== native) {
|
|
||||||
split.foreignAmount = equal.text;
|
|
||||||
split.foreignCurrency = curOf(second);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (second === native && first !== native) {
|
|
||||||
split.amount = equal.text;
|
|
||||||
split.foreignAmount = quick.amountText;
|
|
||||||
split.foreignCurrency = curOf(first);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const example =
|
|
||||||
session.currencies.find(
|
|
||||||
(item) => item.attributes.enabled && item.attributes.code !== native
|
|
||||||
)?.attributes.code ?? "USD";
|
|
||||||
return [`Name the other currency, like =${equal.text || "520"} ${example}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBill(quick: Quick, catalog: Catalog, split: SplitForm) {
|
|
||||||
const name = markerValue(quick, "%");
|
|
||||||
if (!name) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (quick.type !== "withdrawal") {
|
|
||||||
return ["Only a withdrawal can pay a subscription"];
|
|
||||||
}
|
|
||||||
const wanted = name.toLowerCase();
|
|
||||||
const bill = (catalog.bills.data ?? []).find(
|
|
||||||
(item) => item.attributes.name?.toLowerCase() === wanted
|
|
||||||
);
|
|
||||||
if (!bill) {
|
|
||||||
return [`No subscription called "${name}"`];
|
|
||||||
}
|
|
||||||
split.bill = { id: bill.id, name: bill.attributes.name ?? name };
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLinks(
|
function resolveLinks(
|
||||||
quick: Quick,
|
quick: Quick,
|
||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
@@ -351,17 +199,10 @@ export function resolveQuick(
|
|||||||
split,
|
split,
|
||||||
counterparty ? recent[counterparty] : null
|
counterparty ? recent[counterparty] : null
|
||||||
);
|
);
|
||||||
problems.push(
|
problems.push(...links.problems);
|
||||||
...links.problems,
|
|
||||||
...resolveBill(quick, catalog, split),
|
|
||||||
...resolveMoney(
|
|
||||||
quick,
|
|
||||||
split,
|
|
||||||
split[own],
|
|
||||||
quick.type === "transfer" ? split[other] : null
|
|
||||||
)
|
|
||||||
);
|
|
||||||
split.tags = tagsOf(quick);
|
split.tags = tagsOf(quick);
|
||||||
|
split.amount = quick.amountText;
|
||||||
|
split.currency = split[own].currency;
|
||||||
split.description =
|
split.description =
|
||||||
quick.description ||
|
quick.description ||
|
||||||
split[other].name ||
|
split[other].name ||
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Money from "#lib/components/Money.svelte";
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
import { formatMoney } from "#lib/format.js";
|
import { formatMoney } from "#lib/format.js";
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import type { Total } from "./object-groups.js";
|
import type { Total } from "./object-groups.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -13,20 +12,24 @@
|
|||||||
tone?: "income" | "expense" | "plain";
|
tone?: "income" | "expense" | "plain";
|
||||||
empty?: string;
|
empty?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const merged = $derived(display.total(totals) !== null);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if totals.length === 0}
|
{#each totals as total (total.code)}
|
||||||
|
<div>
|
||||||
|
{#if tone === "plain"}
|
||||||
|
<span class="num">{formatMoney(total)}</span>
|
||||||
|
{:else}
|
||||||
|
<Amount
|
||||||
|
amount={total.amount}
|
||||||
|
code={total.code}
|
||||||
|
decimals={total.decimals}
|
||||||
|
symbol={total.symbol}
|
||||||
|
{tone}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
{#if empty}
|
{#if empty}
|
||||||
<span class="text-cladd-fg-softer">{empty}</span>
|
<span class="text-cladd-fg-softer">{empty}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if tone !== "plain"}
|
{/each}
|
||||||
<Money parts={totals} {tone} />
|
|
||||||
{:else if merged}
|
|
||||||
<div><span class="num">{display.text(totals)}</span></div>
|
|
||||||
{:else}
|
|
||||||
{#each totals as total (total.code)}
|
|
||||||
<div><span class="num">{formatMoney(total)}</span></div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Calendar from "@lucide/svelte/icons/calendar";
|
import Calendar from "@lucide/svelte/icons/calendar";
|
||||||
import { tick, untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import type { HTMLInputAttributes } from "svelte/elements";
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
import { addDays, today } from "#lib/dates.js";
|
import { addDays, today } from "#lib/dates.js";
|
||||||
import { formatDate } from "#lib/format.js";
|
import { formatDate } from "#lib/format.js";
|
||||||
@@ -11,21 +11,16 @@
|
|||||||
value = $bindable(""),
|
value = $bindable(""),
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
invalid = false,
|
invalid = false,
|
||||||
compact = false,
|
|
||||||
oncommit,
|
|
||||||
class: className = "",
|
class: className = "",
|
||||||
...rest
|
...rest
|
||||||
}: Omit<HTMLInputAttributes, "value"> & {
|
}: Omit<HTMLInputAttributes, "value"> & {
|
||||||
value?: string;
|
value?: string;
|
||||||
ref?: HTMLInputElement | null;
|
ref?: HTMLInputElement | null;
|
||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
compact?: boolean;
|
|
||||||
oncommit?: (value: string) => void;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let text = $state(value);
|
let text = $state(value);
|
||||||
let picker = $state<HTMLInputElement | null>(null);
|
let picker = $state<HTMLInputElement | null>(null);
|
||||||
let focused = $state(false);
|
|
||||||
|
|
||||||
const parsed = $derived(parseDate(text, today()));
|
const parsed = $derived(parseDate(text, today()));
|
||||||
const shown = $derived(parsed ?? value);
|
const shown = $derived(parsed ?? value);
|
||||||
@@ -34,9 +29,6 @@
|
|||||||
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}`
|
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}`
|
||||||
: ""
|
: ""
|
||||||
);
|
);
|
||||||
const display = $derived(
|
|
||||||
compact && !focused && shown ? formatDate(shown) : text
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const next = value;
|
const next = value;
|
||||||
@@ -55,33 +47,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function commit(next: string) {
|
|
||||||
text = next;
|
|
||||||
value = next;
|
|
||||||
oncommit?.(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus(
|
|
||||||
event: FocusEvent & { currentTarget: HTMLInputElement }
|
|
||||||
) {
|
|
||||||
focused = true;
|
|
||||||
const input = event.currentTarget;
|
|
||||||
if (compact) {
|
|
||||||
tick().then(() => input.select());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
function handleBlur() {
|
||||||
focused = false;
|
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
commit(parsed);
|
text = parsed;
|
||||||
|
value = parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKey(event: KeyboardEvent) {
|
function handleKey(event: KeyboardEvent) {
|
||||||
if (event.key === "Enter" && parsed) {
|
|
||||||
commit(parsed);
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
|
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
|
||||||
!(event.altKey || event.ctrlKey || event.metaKey)
|
!(event.altKey || event.ctrlKey || event.metaKey)
|
||||||
@@ -89,7 +62,8 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const step =
|
const step =
|
||||||
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
|
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
|
||||||
commit(addDays(parsed ?? value ?? today(), step));
|
value = addDays(parsed ?? value ?? today(), step);
|
||||||
|
text = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,20 +78,17 @@
|
|||||||
{...rest}
|
{...rest}
|
||||||
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
|
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="{INPUT} num {compact ? "pr-8" : "pr-28"}"
|
class="{INPUT} num pr-28"
|
||||||
onblur={handleBlur}
|
onblur={handleBlur}
|
||||||
onfocus={handleFocus}
|
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onkeydown={handleKey}
|
onkeydown={handleKey}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
value={display}
|
value={text}
|
||||||
>
|
>
|
||||||
{#if !compact}
|
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
|
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
|
||||||
>{label}</span
|
>{label}</span
|
||||||
>
|
>
|
||||||
{/if}
|
|
||||||
<button
|
<button
|
||||||
aria-label="Pick a date"
|
aria-label="Pick a date"
|
||||||
class="absolute inset-y-0 right-0 grid w-8 place-items-center text-cladd-fg-soft hover:text-cladd-fg"
|
class="absolute inset-y-0 right-0 grid w-8 place-items-center text-cladd-fg-soft hover:text-cladd-fg"
|
||||||
@@ -130,7 +101,10 @@
|
|||||||
<input
|
<input
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
|
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
|
||||||
onchange={({ currentTarget }) => commit(currentTarget.value)}
|
onchange={({ currentTarget }) => {
|
||||||
|
({ value } = currentTarget);
|
||||||
|
text = value;
|
||||||
|
}}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
type="date"
|
type="date"
|
||||||
{value}
|
{value}
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ export function setQuery(values: Record<string, string | null | undefined>) {
|
|||||||
url.searchParams.delete(key);
|
url.searchParams.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return goto(url, { replace: true, reset: false, state: page.state });
|
return goto(url, { replace: true, state: page.state });
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,6 @@
|
|||||||
|
|
||||||
const action = new Action();
|
const action = new Action();
|
||||||
const values = $state<Record<string, string>>({});
|
const values = $state<Record<string, string>>({});
|
||||||
const fields: Record<string, HTMLElement> = {};
|
|
||||||
|
|
||||||
const links = $derived(
|
const links = $derived(
|
||||||
(piggy.attributes.accounts ?? []).map((link) => {
|
(piggy.attributes.accounts ?? []).map((link) => {
|
||||||
@@ -51,10 +50,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fail(message: string, id = open[0]?.id ?? "") {
|
function fail(message: string) {
|
||||||
action.failed = true;
|
action.failed = true;
|
||||||
action.message = message;
|
action.message = message;
|
||||||
fields[id]?.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -66,11 +64,11 @@
|
|||||||
}
|
}
|
||||||
const amount = evalAmount(raw);
|
const amount = evalAmount(raw);
|
||||||
if (amount === null || amount <= 0) {
|
if (amount === null || amount <= 0) {
|
||||||
return fail("Enter an amount above zero", link.id);
|
return fail("Enter an amount above zero");
|
||||||
}
|
}
|
||||||
if (amount > link.max + 1e-9) {
|
if (amount > link.max + 1e-9) {
|
||||||
const max = formatMoney(piggyMoney(piggy.attributes, link.max));
|
const max = formatMoney(piggyMoney(piggy.attributes, link.max));
|
||||||
return fail(`${link.name} allows at most ${max}`, link.id);
|
return fail(`${link.name} allows at most ${max}`);
|
||||||
}
|
}
|
||||||
deltas[link.id] = mode === "add" ? amount : -amount;
|
deltas[link.id] = mode === "add" ? amount : -amount;
|
||||||
}
|
}
|
||||||
@@ -111,7 +109,6 @@
|
|||||||
onkeydown={onEscape}
|
onkeydown={onEscape}
|
||||||
bind:value={values[link.id]}
|
bind:value={values[link.id]}
|
||||||
{@attach (node) => {
|
{@attach (node) => {
|
||||||
fields[link.id] = node;
|
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
node.focus();
|
node.focus();
|
||||||
}
|
}
|
||||||
@@ -119,11 +116,7 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-[12px] text-cladd-fg-soft">
|
<span class="text-[12px] text-cladd-fg-soft">
|
||||||
up to
|
up to
|
||||||
<Amount
|
<Amount {...piggyMoney(piggy.attributes, link.max)} tone="neutral" />
|
||||||
{...piggyMoney(piggy.attributes, link.max)}
|
|
||||||
exact
|
|
||||||
tone="neutral"
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
|
||||||
import type { Item, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import Place from "./Place.svelte";
|
|
||||||
import SameDays from "./SameDays.svelte";
|
|
||||||
import { loadPlace, placeOf, visitOf, visitsIn } from "./usual.js";
|
|
||||||
|
|
||||||
const OWN = {
|
|
||||||
deposit: "destination",
|
|
||||||
transfer: "source",
|
|
||||||
withdrawal: "source",
|
|
||||||
} as const;
|
|
||||||
const TONE: Record<string, string> = {
|
|
||||||
deposit: "text-income",
|
|
||||||
transfer: "text-transfer",
|
|
||||||
withdrawal: "text-expense",
|
|
||||||
};
|
|
||||||
|
|
||||||
let { group }: { group: Item<TransactionGroup> } = $props();
|
|
||||||
|
|
||||||
const splits = $derived(group.attributes.transactions);
|
|
||||||
const first = $derived(splits[0]);
|
|
||||||
const day = $derived(first.date.slice(0, 10));
|
|
||||||
const currency = $derived({
|
|
||||||
code: first.currency_code,
|
|
||||||
decimals: first.currency_decimal_places,
|
|
||||||
symbol: first.currency_symbol,
|
|
||||||
});
|
|
||||||
const currencies = $derived(
|
|
||||||
[
|
|
||||||
currency,
|
|
||||||
{
|
|
||||||
code: first.foreign_currency_code,
|
|
||||||
decimals: first.foreign_currency_decimal_places,
|
|
||||||
symbol: first.foreign_currency_symbol,
|
|
||||||
},
|
|
||||||
].filter((option) => option.code)
|
|
||||||
);
|
|
||||||
const place = $derived(placeOf(splits));
|
|
||||||
const own = $derived.by(() => {
|
|
||||||
const side = OWN[first.type as keyof typeof OWN];
|
|
||||||
const id = side ? first[`${side}_id`] : null;
|
|
||||||
return side && id ? { id, name: first[`${side}_name`] ?? "" } : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const history = createQuery(() => ({
|
|
||||||
enabled: place !== null,
|
|
||||||
queryFn: () =>
|
|
||||||
place ? loadPlace(place, group.id, day) : Promise.resolve([]),
|
|
||||||
queryKey: ["receipt", "place", group.id, place?.id, day],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const best = $derived.by(() => {
|
|
||||||
if (!place) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const options = currencies.flatMap((option) => {
|
|
||||||
const current = visitOf(group, place, option.code ?? "");
|
|
||||||
return current
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
currency: option,
|
|
||||||
current,
|
|
||||||
past: visitsIn(history.data ?? [], place, option.code ?? ""),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
return options.reduce<(typeof options)[number] | null>(
|
|
||||||
(top, option) =>
|
|
||||||
top && top.past.length >= option.past.length ? top : option,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if best || own}
|
|
||||||
<div class="grid items-stretch gap-3 lg:grid-cols-2">
|
|
||||||
{#if place && best}
|
|
||||||
<Place
|
|
||||||
currency={best.currency}
|
|
||||||
current={best.current}
|
|
||||||
loading={history.isPending}
|
|
||||||
past={best.past}
|
|
||||||
{place}
|
|
||||||
tone={TONE[first.type] ?? "text-cladd-fg"}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if own}
|
|
||||||
<SameDays account={own} {currency} {day} groupId={group.id} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Paperclip from "@lucide/svelte/icons/paperclip";
|
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
|
||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import { all, lists } from "#lib/api/lists.js";
|
|
||||||
import type {
|
|
||||||
Attachment,
|
|
||||||
Item,
|
|
||||||
LinkType,
|
|
||||||
One,
|
|
||||||
Page,
|
|
||||||
PiggyBankEvent,
|
|
||||||
Split,
|
|
||||||
TransactionGroup,
|
|
||||||
TransactionLink,
|
|
||||||
} from "#lib/api/types.js";
|
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
|
||||||
import { formatNumber } from "#lib/format.js";
|
|
||||||
import {
|
|
||||||
journalId,
|
|
||||||
type Signed,
|
|
||||||
signedAmount,
|
|
||||||
} from "#lib/transactions/split.js";
|
|
||||||
|
|
||||||
interface Linked {
|
|
||||||
amount: Signed | null;
|
|
||||||
description: string;
|
|
||||||
group: string | null;
|
|
||||||
id: string;
|
|
||||||
notes: string;
|
|
||||||
phrase: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Event = Item<PiggyBankEvent> & {
|
|
||||||
links?: Record<string, { uri?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PIGGY_URI = /piggy-banks\/(\d+)/;
|
|
||||||
const BYTES_KB = 1024;
|
|
||||||
|
|
||||||
let { id, splits }: { id: string; splits: Split[] } = $props();
|
|
||||||
|
|
||||||
const journals = $derived(splits.map(journalId));
|
|
||||||
|
|
||||||
const attachments = createQuery(() => ({
|
|
||||||
queryFn: () =>
|
|
||||||
api<Page<Attachment>>(`/v1/transactions/${id}/attachments`, {
|
|
||||||
query: { limit: 500 },
|
|
||||||
}),
|
|
||||||
queryKey: ["transaction-attachments", id],
|
|
||||||
}));
|
|
||||||
const events = createQuery(() => ({
|
|
||||||
queryFn: () =>
|
|
||||||
api<Page<PiggyBankEvent>>(`/v1/transactions/${id}/piggy-bank-events`, {
|
|
||||||
query: { limit: 500 },
|
|
||||||
}),
|
|
||||||
queryKey: ["transaction-piggy-events", id],
|
|
||||||
}));
|
|
||||||
const piggies = createQuery(() => ({
|
|
||||||
...lists.piggyBanks(),
|
|
||||||
enabled: (events.data?.data.length ?? 0) > 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
async function loadLinks(ids: string[]): Promise<Linked[]> {
|
|
||||||
const [types, ...pages] = await Promise.all([
|
|
||||||
all<LinkType>("/v1/link-types"),
|
|
||||||
...ids.map((journal) =>
|
|
||||||
api<Page<TransactionLink>>(`/v1/transaction-journals/${journal}/links`)
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
const found = await Promise.all(
|
|
||||||
ids.flatMap((journal, index) =>
|
|
||||||
pages[index].data.map(async (link) => {
|
|
||||||
const { inward_id, outward_id, link_type_id, notes } =
|
|
||||||
link.attributes;
|
|
||||||
const source = inward_id === journal;
|
|
||||||
const other = source ? outward_id : inward_id;
|
|
||||||
const linkType = types.find((t) => t.id === link_type_id);
|
|
||||||
const group = await api<One<TransactionGroup>>(
|
|
||||||
`/v1/transaction-journals/${other}`
|
|
||||||
).catch(() => null);
|
|
||||||
const split = group?.data.attributes.transactions.find(
|
|
||||||
(s) => journalId(s) === other
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
amount: split ? signedAmount(split) : null,
|
|
||||||
description: split?.description ?? `#${other}`,
|
|
||||||
group: group?.data.id ?? null,
|
|
||||||
id: link.id,
|
|
||||||
notes: notes ?? "",
|
|
||||||
phrase:
|
|
||||||
(source
|
|
||||||
? linkType?.attributes.outward
|
|
||||||
: linkType?.attributes.inward) ?? "linked to",
|
|
||||||
};
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return [...new Map(found.map((link) => [link.id, link])).values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
const links = createQuery(() => ({
|
|
||||||
enabled: journals.length > 0,
|
|
||||||
queryFn: () => loadLinks(journals),
|
|
||||||
queryKey: ["transaction-links", id, journals.join(",")],
|
|
||||||
}));
|
|
||||||
|
|
||||||
async function removeLink(linkId: string) {
|
|
||||||
await api(`/v1/transaction-links/${linkId}`, { method: "DELETE" });
|
|
||||||
await links.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
function piggyOf(event: Event) {
|
|
||||||
const uri = Object.values(event.links ?? {}).find((l) => l?.uri)?.uri;
|
|
||||||
const piggyId = uri?.match(PIGGY_URI)?.[1];
|
|
||||||
const name = (piggies.data ?? []).find((p) => p.id === piggyId)?.attributes
|
|
||||||
.name;
|
|
||||||
return { id: piggyId, name: name ?? (piggyId ? `#${piggyId}` : "") };
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = $derived(attachments.data?.data ?? []);
|
|
||||||
const piggyEvents = $derived((events.data?.data ?? []) as Event[]);
|
|
||||||
const linked = $derived(links.data ?? []);
|
|
||||||
const count = $derived(
|
|
||||||
[files, piggyEvents, linked].filter((list) => list.length > 0).length
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if count > 0}
|
|
||||||
<div class="grid items-stretch gap-3 {count > 1 ? "lg:grid-cols-2" : ""}">
|
|
||||||
{#if linked.length}
|
|
||||||
<Box body="p-0" title="Transaction links">
|
|
||||||
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
|
|
||||||
{#each linked as link (link.id)}
|
|
||||||
<li class="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-1.5">
|
|
||||||
<span class="text-cladd-fg-soft">{link.phrase}</span>
|
|
||||||
{#if link.group}
|
|
||||||
<a
|
|
||||||
class="text-brand hover:underline"
|
|
||||||
href="/transactions/show/{link.group}"
|
|
||||||
>{link.description}</a
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span>{link.description}</span>
|
|
||||||
{/if}
|
|
||||||
{#if link.amount}
|
|
||||||
<Amount
|
|
||||||
amount={link.amount.amount}
|
|
||||||
code={link.amount.code}
|
|
||||||
decimals={link.amount.decimals}
|
|
||||||
symbol={link.amount.symbol}
|
|
||||||
tone={link.amount.tone}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if link.notes}
|
|
||||||
<span class="text-cladd-fg-soft">({link.notes})</span>
|
|
||||||
{/if}
|
|
||||||
<ConfirmButton
|
|
||||||
class="ml-auto"
|
|
||||||
confirmLabel="Press again to remove"
|
|
||||||
onconfirm={() => removeLink(link.id)}
|
|
||||||
>
|
|
||||||
Remove link
|
|
||||||
</ConfirmButton>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</Box>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if files.length}
|
|
||||||
<Box body="p-0" title="Attachments">
|
|
||||||
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
|
|
||||||
{#each files as file (file.id)}
|
|
||||||
<li class="flex flex-col gap-0.5 px-3 py-1.5">
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<Paperclip class="size-3.5 text-cladd-fg-soft" />
|
|
||||||
<a
|
|
||||||
class="min-w-0 truncate text-brand hover:underline"
|
|
||||||
data-sveltekit-reload
|
|
||||||
href="/attachments/view/{file.id}"
|
|
||||||
>{file.attributes.title || file.attributes.filename}</a
|
|
||||||
>
|
|
||||||
<span class="num text-cladd-fg-soft"
|
|
||||||
>{formatNumber((file.attributes.size ?? 0) / BYTES_KB, 1)}
|
|
||||||
KB</span
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
class="ml-auto text-[12px] text-cladd-fg-soft hover:text-cladd-fg hover:underline"
|
|
||||||
data-sveltekit-reload
|
|
||||||
href="/attachments/download/{file.id}"
|
|
||||||
>Download</a
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
{#if file.attributes.notes}
|
|
||||||
<span class="whitespace-pre-line text-[12px] text-cladd-fg-soft"
|
|
||||||
>{file.attributes.notes}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</Box>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if piggyEvents.length}
|
|
||||||
<Box body="p-0" title="Piggy banks">
|
|
||||||
<ul class="[&>li+li]:border-cladd-outline [&>li+li]:border-t">
|
|
||||||
{#each piggyEvents as piggyEvent (piggyEvent.id)}
|
|
||||||
{@const piggy = piggyOf(piggyEvent)}
|
|
||||||
<li class="flex items-center justify-between gap-3 px-3 py-1.5">
|
|
||||||
{#if piggy.id}
|
|
||||||
<a
|
|
||||||
class="text-brand hover:underline"
|
|
||||||
href="/piggy-banks/show/{piggy.id}"
|
|
||||||
>{piggy.name}</a
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span>Piggy bank</span>
|
|
||||||
{/if}
|
|
||||||
<Amount
|
|
||||||
amount={piggyEvent.attributes.amount}
|
|
||||||
code={piggyEvent.attributes.currency_code}
|
|
||||||
decimals={piggyEvent.attributes.currency_decimal_places}
|
|
||||||
symbol={piggyEvent.attributes.currency_symbol}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</Box>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, tick, untrack } from "svelte";
|
|
||||||
import type { Split } from "#lib/api/types.js";
|
|
||||||
import AccountField from "#lib/components/editor/AccountField.svelte";
|
|
||||||
import type { Catalog } from "#lib/components/editor/catalog.svelte.js";
|
|
||||||
import {
|
|
||||||
type AccountRef,
|
|
||||||
CASH,
|
|
||||||
emptyRef,
|
|
||||||
FREE_KIND,
|
|
||||||
KINDS,
|
|
||||||
OWN_SIDE,
|
|
||||||
type Ref,
|
|
||||||
SHARED,
|
|
||||||
type Side,
|
|
||||||
type TxType,
|
|
||||||
} from "#lib/components/editor/model.js";
|
|
||||||
import RefField from "#lib/components/editor/RefField.svelte";
|
|
||||||
import AmountInput from "#lib/components/inputs/AmountInput.svelte";
|
|
||||||
import AutocompleteInput from "#lib/components/inputs/AutocompleteInput.svelte";
|
|
||||||
import DateInput from "#lib/components/inputs/DateInput.svelte";
|
|
||||||
import { amountString, evalAmount } from "#lib/components/inputs/parse.js";
|
|
||||||
import { INPUT } from "#lib/components/inputs/styles.js";
|
|
||||||
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
|
|
||||||
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import type { Patch } from "#lib/transactions/mass.js";
|
|
||||||
import { FIELD_LABEL, type FieldName, type Row } from "./fields.js";
|
|
||||||
|
|
||||||
type Outcome = { patch: Patch; all: boolean } | { problem: string } | null;
|
|
||||||
|
|
||||||
let {
|
|
||||||
row,
|
|
||||||
split,
|
|
||||||
type,
|
|
||||||
catalog,
|
|
||||||
onsave,
|
|
||||||
oncancel,
|
|
||||||
}: {
|
|
||||||
row: Row;
|
|
||||||
split: Split;
|
|
||||||
type: TxType;
|
|
||||||
catalog: Catalog;
|
|
||||||
onsave: (patch: Patch, all: boolean) => Promise<string | null>;
|
|
||||||
oncancel: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const field = $derived(row.field);
|
|
||||||
const side = $derived<Side>(
|
|
||||||
row.field === "destination" ? "destination" : "source"
|
|
||||||
);
|
|
||||||
const decimals = (value: number | null | undefined) => value ?? 2;
|
|
||||||
const fixed = (value: string | null | undefined, places: number) =>
|
|
||||||
value ? Number(value).toFixed(places) : "";
|
|
||||||
|
|
||||||
function initialText() {
|
|
||||||
if (row.field === "category") {
|
|
||||||
return split.category_name ?? "";
|
|
||||||
}
|
|
||||||
return (row.field === "notes" ? split.notes : split.description) ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialAccount(): AccountRef {
|
|
||||||
const id = split[`${side}_id`] ?? null;
|
|
||||||
const kind = split[`${side}_type`] ?? "";
|
|
||||||
return kind === CASH
|
|
||||||
? { currency: null, id: null, kind: CASH, name: "" }
|
|
||||||
: { currency: null, id, kind, name: split[`${side}_name`] ?? "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialRef(): Ref {
|
|
||||||
const id = row.field === "bill" ? split.bill_id : split.budget_id;
|
|
||||||
const name = row.field === "bill" ? split.bill_name : split.budget_name;
|
|
||||||
return id ? { id, name: name ?? "" } : emptyRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
let root = $state<HTMLElement | null>(null);
|
|
||||||
const start = untrack(() => ({
|
|
||||||
account: initialAccount(),
|
|
||||||
amount: fixed(split.amount, decimals(split.currency_decimal_places)),
|
|
||||||
date: split.date.slice(0, 10),
|
|
||||||
foreign: fixed(
|
|
||||||
split.foreign_amount,
|
|
||||||
decimals(split.foreign_currency_decimal_places)
|
|
||||||
),
|
|
||||||
ref: initialRef(),
|
|
||||||
tags: [...(split.tags ?? [])],
|
|
||||||
text: initialText(),
|
|
||||||
}));
|
|
||||||
let text = $state(start.text);
|
|
||||||
let date = $state(start.date);
|
|
||||||
let amount = $state(start.amount);
|
|
||||||
let foreign = $state(start.foreign);
|
|
||||||
let account = $state<AccountRef>(start.account);
|
|
||||||
let ref = $state<Ref>(start.ref);
|
|
||||||
let tags = $state<string[]>(start.tags);
|
|
||||||
let saving = $state(false);
|
|
||||||
let problem = $state("");
|
|
||||||
|
|
||||||
const positive = (value: string) => (evalAmount(value) ?? 0) > 0;
|
|
||||||
|
|
||||||
function amountOutcome(): Outcome {
|
|
||||||
if (!positive(amount)) {
|
|
||||||
return { problem: "The amount must be a positive number" };
|
|
||||||
}
|
|
||||||
const patch: Patch = {
|
|
||||||
amount: amountString(amount, decimals(split.currency_decimal_places)),
|
|
||||||
};
|
|
||||||
const code = split.foreign_currency_code;
|
|
||||||
if (code && !foreign.trim() && type === "transfer") {
|
|
||||||
return { problem: `Enter the amount in ${code}` };
|
|
||||||
}
|
|
||||||
if (code && foreign.trim() && !positive(foreign)) {
|
|
||||||
return { problem: "The foreign amount must be a positive number" };
|
|
||||||
}
|
|
||||||
if (code) {
|
|
||||||
patch.foreign_amount = foreign.trim()
|
|
||||||
? amountString(foreign, decimals(split.foreign_currency_decimal_places))
|
|
||||||
: "0";
|
|
||||||
}
|
|
||||||
const same =
|
|
||||||
Number(patch.amount) === Number(split.amount) &&
|
|
||||||
(!code || Number(patch.foreign_amount) === Number(split.foreign_amount));
|
|
||||||
return same ? null : { all: false, patch };
|
|
||||||
}
|
|
||||||
|
|
||||||
function accountOutcome(): Outcome {
|
|
||||||
const name = account.name.trim();
|
|
||||||
const all = SHARED[type][side];
|
|
||||||
const other = split[`${side === "source" ? "destination" : "source"}_id`];
|
|
||||||
if (!(account.id || (name && FREE_KIND[type][side]))) {
|
|
||||||
return { problem: `Choose the ${FIELD_LABEL[row.field]}` };
|
|
||||||
}
|
|
||||||
if (account.id && !KINDS[type][side].includes(account.kind)) {
|
|
||||||
return {
|
|
||||||
problem: `A ${account.kind.toLowerCase()} can't be the ${side} of a ${type}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (account.id && account.id === other) {
|
|
||||||
return { problem: "Source and destination are the same account" };
|
|
||||||
}
|
|
||||||
const want =
|
|
||||||
side === OWN_SIDE[type]
|
|
||||||
? split.currency_code
|
|
||||||
: type === "transfer" &&
|
|
||||||
(split.foreign_currency_code ?? split.currency_code);
|
|
||||||
if (want && account.currency && account.currency.code !== want) {
|
|
||||||
return {
|
|
||||||
problem: `${account.name} is in ${account.currency.code}, this amount is in ${want}. Press e to change both`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (account.id === split[`${side}_id`]) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
all,
|
|
||||||
patch: account.id
|
|
||||||
? { [`${side}_id`]: account.id }
|
|
||||||
: { [`${side}_name`]: name },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function textOutcome(
|
|
||||||
key: "description" | "notes" | "category_name",
|
|
||||||
current: string | null | undefined
|
|
||||||
): Outcome {
|
|
||||||
const value = text.trim() || null;
|
|
||||||
return value === (current || null)
|
|
||||||
? null
|
|
||||||
: { all: false, patch: { [key]: value } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateOutcome(): Outcome {
|
|
||||||
if (!date) {
|
|
||||||
return { problem: "Enter a date like today, -1, 14.10 or 2026-10-14" };
|
|
||||||
}
|
|
||||||
return date === split.date.slice(0, 10)
|
|
||||||
? null
|
|
||||||
: { all: true, patch: { date: `${date}T${split.date.slice(11, 19)}` } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function refOutcome(key: "bill_id" | "budget_id"): Outcome {
|
|
||||||
if (!ref.id && ref.name.trim()) {
|
|
||||||
return { problem: `Pick an existing ${FIELD_LABEL[row.field]}` };
|
|
||||||
}
|
|
||||||
return (ref.id ?? null) === (split[key] ?? null)
|
|
||||||
? null
|
|
||||||
: { all: false, patch: { [key]: ref.id } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const OUTCOMES: Record<FieldName, () => Outcome> = {
|
|
||||||
amount: amountOutcome,
|
|
||||||
bill: () => refOutcome("bill_id"),
|
|
||||||
budget: () => refOutcome("budget_id"),
|
|
||||||
category: () => textOutcome("category_name", split.category_name),
|
|
||||||
date: dateOutcome,
|
|
||||||
description: () =>
|
|
||||||
text.trim()
|
|
||||||
? textOutcome("description", split.description)
|
|
||||||
: { problem: "Describe the transaction" },
|
|
||||||
destination: accountOutcome,
|
|
||||||
notes: () => textOutcome("notes", split.notes),
|
|
||||||
source: accountOutcome,
|
|
||||||
tags: () =>
|
|
||||||
JSON.stringify(tags) === JSON.stringify(split.tags ?? [])
|
|
||||||
? null
|
|
||||||
: { all: false, patch: { tags } },
|
|
||||||
};
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
if (saving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = OUTCOMES[row.field]();
|
|
||||||
if (result === null) {
|
|
||||||
oncancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ("problem" in result) {
|
|
||||||
({ problem } = result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving = true;
|
|
||||||
problem = (await onsave(result.patch, result.all)) ?? "";
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickSaves() {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (root?.contains(document.activeElement)) {
|
|
||||||
submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await tick();
|
|
||||||
const input = root?.querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
|
||||||
"input:not([type=date]):not([tabindex='-1']), textarea"
|
|
||||||
);
|
|
||||||
input?.focus();
|
|
||||||
if (row.field !== "notes") {
|
|
||||||
input?.select();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useKeys(() => [
|
|
||||||
{
|
|
||||||
group: "Field",
|
|
||||||
input: true,
|
|
||||||
keys: "Escape",
|
|
||||||
label: "Cancel the field",
|
|
||||||
run: oncancel,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Field",
|
|
||||||
input: true,
|
|
||||||
keys: "mod+Enter",
|
|
||||||
label: "Save the field",
|
|
||||||
run: submit,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const id = $derived(`receipt-${row.key.replace(":", "-")}`);
|
|
||||||
const BUTTON =
|
|
||||||
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1 rounded-cladd-sm px-2 font-semibold text-[12px] text-cladd-fg disabled:opacity-60";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form
|
|
||||||
aria-label="Edit {FIELD_LABEL[field]}"
|
|
||||||
class="min-w-0 flex-wrap items-center gap-x-2 gap-y-1 {field === "notes" ||
|
|
||||||
field === "description"
|
|
||||||
? "flex w-full"
|
|
||||||
: "inline-flex align-middle"}"
|
|
||||||
onsubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
submit();
|
|
||||||
}}
|
|
||||||
bind:this={root}
|
|
||||||
>
|
|
||||||
{#if field === "description"}
|
|
||||||
<input
|
|
||||||
aria-label="Description"
|
|
||||||
class="{INPUT} max-w-2xl flex-1 sm:text-[15px]"
|
|
||||||
{id}
|
|
||||||
bind:value={text}
|
|
||||||
>
|
|
||||||
{:else if field === "notes"}
|
|
||||||
<textarea
|
|
||||||
aria-label="Notes"
|
|
||||||
class="{INPUT} h-auto max-h-[60vh] min-h-24 w-full py-1.5 leading-relaxed [field-sizing:content]"
|
|
||||||
{id}
|
|
||||||
bind:value={text}
|
|
||||||
></textarea>
|
|
||||||
{:else if field === "category"}
|
|
||||||
<AutocompleteInput
|
|
||||||
aria-label="Category"
|
|
||||||
class="w-56"
|
|
||||||
free
|
|
||||||
{id}
|
|
||||||
onpick={pickSaves}
|
|
||||||
openOnFocus
|
|
||||||
placeholder="(no category)"
|
|
||||||
search={catalog.categoryOptions}
|
|
||||||
bind:value={text}
|
|
||||||
/>
|
|
||||||
{:else if field === "budget" || field === "bill"}
|
|
||||||
<div class="w-56">
|
|
||||||
<RefField
|
|
||||||
{id}
|
|
||||||
onpick={pickSaves}
|
|
||||||
placeholder={field === "bill" ? "(no subscription)" : "(no budget)"}
|
|
||||||
search={field === "bill" ? catalog.billOptions : catalog.budgetOptions}
|
|
||||||
bind:value={ref}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if field === "tags"}
|
|
||||||
<div class="w-72">
|
|
||||||
<TagsInput
|
|
||||||
{id}
|
|
||||||
placeholder="Comma or Enter between tags"
|
|
||||||
search={(query, taken) => catalog.tagOptions(query, taken)}
|
|
||||||
bind:tags
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if field === "date"}
|
|
||||||
<DateInput
|
|
||||||
aria-label="Date"
|
|
||||||
class="w-56"
|
|
||||||
{id}
|
|
||||||
placeholder="today, -1, 14.10"
|
|
||||||
bind:value={date}
|
|
||||||
/>
|
|
||||||
{:else if field === "amount"}
|
|
||||||
<AmountInput
|
|
||||||
aria-label="Amount"
|
|
||||||
class="w-40"
|
|
||||||
currency={split.currency_code ?? ""}
|
|
||||||
decimals={decimals(split.currency_decimal_places)}
|
|
||||||
{id}
|
|
||||||
bind:value={amount}
|
|
||||||
/>
|
|
||||||
{#if split.foreign_currency_code}
|
|
||||||
<AmountInput
|
|
||||||
aria-label="Amount in {split.foreign_currency_code}"
|
|
||||||
class="w-36"
|
|
||||||
currency={split.foreign_currency_code}
|
|
||||||
decimals={decimals(split.foreign_currency_decimal_places)}
|
|
||||||
bind:value={foreign}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<div class="w-72">
|
|
||||||
<AccountField
|
|
||||||
{catalog}
|
|
||||||
free={Boolean(FREE_KIND[type][side])}
|
|
||||||
{id}
|
|
||||||
kinds={KINDS[type][side]}
|
|
||||||
onpick={pickSaves}
|
|
||||||
placeholder={FREE_KIND[type][side] ? "a new name" : ""}
|
|
||||||
bind:value={account}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<span class="flex items-center gap-1.5">
|
|
||||||
<button class={BUTTON} disabled={saving} type="submit">
|
|
||||||
Save
|
|
||||||
<span class="kbd hidden lg:inline-flex"
|
|
||||||
>{field === "notes" ? prettyKeys("mod+Enter")[0] : "↵"}</span
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
<button class={BUTTON} onclick={oncancel} type="button">
|
|
||||||
Cancel <span class="kbd hidden lg:inline-flex">Esc</span>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
{#if saving || problem}
|
|
||||||
<span
|
|
||||||
class="text-[12px] {problem ? "text-expense" : "text-cladd-fg-soft"}"
|
|
||||||
role="status"
|
|
||||||
>{saving ? "Saving" : problem}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</form>
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { blocks, type Inline } from "./notes.js";
|
|
||||||
|
|
||||||
let { text }: { text: string } = $props();
|
|
||||||
|
|
||||||
const parsed = $derived(blocks(text));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet line(
|
|
||||||
parts: Inline[]
|
|
||||||
)}
|
|
||||||
{#each parts as part, index (index)}
|
|
||||||
{#if part.kind === "link"}
|
|
||||||
<a
|
|
||||||
class="break-all text-brand hover:underline"
|
|
||||||
href={part.href}
|
|
||||||
rel="noopener noreferrer nofollow"
|
|
||||||
target="_blank"
|
|
||||||
>{part.text}</a
|
|
||||||
>
|
|
||||||
{:else if part.kind === "bold"}
|
|
||||||
<strong class="font-semibold">{part.text}</strong>
|
|
||||||
{:else if part.kind === "code"}
|
|
||||||
<code class="rounded-[4px] bg-cladd-surface-next px-1 text-[0.92em]"
|
|
||||||
>{part.text}</code
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
{part.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<div class="flex max-w-[92ch] flex-col gap-2 break-words">
|
|
||||||
{#each parsed as block, index (index)}
|
|
||||||
{#if block.kind === "head"}
|
|
||||||
<p class="font-semibold">{@render line(block.line)}</p>
|
|
||||||
{:else if block.kind === "list"}
|
|
||||||
<svelte:element
|
|
||||||
class="flex flex-col gap-0.5 pl-5 {block.ordered
|
|
||||||
? "list-decimal"
|
|
||||||
: "list-disc"}"
|
|
||||||
this={block.ordered ? "ol" : "ul"}
|
|
||||||
>
|
|
||||||
{#each block.items as item, i (i)}
|
|
||||||
<li>{@render line(item)}</li>
|
|
||||||
{/each}
|
|
||||||
</svelte:element>
|
|
||||||
{:else}
|
|
||||||
<p>
|
|
||||||
{#each block.lines as row, i (i)}
|
|
||||||
{#if i > 0}
|
|
||||||
<br>
|
|
||||||
{/if}
|
|
||||||
{@render line(row)}
|
|
||||||
{/each}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import { parse } from "#lib/dates.js";
|
|
||||||
import { formatDate, type Money } from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import {
|
|
||||||
cadence,
|
|
||||||
type Place,
|
|
||||||
usualOf,
|
|
||||||
type Visit,
|
|
||||||
verdict,
|
|
||||||
} from "./usual.js";
|
|
||||||
|
|
||||||
const HEIGHT = 44;
|
|
||||||
const R_MAX = 15;
|
|
||||||
const R_MIN = 3;
|
|
||||||
const SPREAD = 2.4;
|
|
||||||
const RECENT = 6;
|
|
||||||
|
|
||||||
let {
|
|
||||||
place,
|
|
||||||
current,
|
|
||||||
past,
|
|
||||||
currency,
|
|
||||||
tone,
|
|
||||||
loading = false,
|
|
||||||
}: {
|
|
||||||
place: Place;
|
|
||||||
current: Visit;
|
|
||||||
past: Visit[];
|
|
||||||
currency: Omit<Money, "amount">;
|
|
||||||
tone: string;
|
|
||||||
loading?: boolean;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let width = $state(0);
|
|
||||||
let active = $state<Visit | null>(null);
|
|
||||||
|
|
||||||
const visits = $derived(
|
|
||||||
[current, ...past].sort((a, b) => a.date.localeCompare(b.date))
|
|
||||||
);
|
|
||||||
const usual = $derived(usualOf(current, past));
|
|
||||||
const said = $derived(usual ? verdict(usual.change) : null);
|
|
||||||
const span = $derived.by(() => {
|
|
||||||
const times = visits.map((visit) => parse(visit.date).getTime());
|
|
||||||
return { from: Math.min(...times), to: Math.max(...times) };
|
|
||||||
});
|
|
||||||
const biggest = $derived(Math.max(...visits.map((visit) => visit.amount), 1));
|
|
||||||
|
|
||||||
const largest = $derived(
|
|
||||||
Math.max(R_MIN + 3, Math.min(R_MAX, width / (visits.length * SPREAD)))
|
|
||||||
);
|
|
||||||
const recent = $derived(past.slice(0, RECENT));
|
|
||||||
|
|
||||||
const money = (amount: number) => display.text([{ ...currency, amount }]);
|
|
||||||
|
|
||||||
function x(visit: Visit) {
|
|
||||||
const room = Math.max(width - 2 * largest, 0);
|
|
||||||
const length = span.to - span.from;
|
|
||||||
const at = length ? (parse(visit.date).getTime() - span.from) / length : 1;
|
|
||||||
return largest + at * room;
|
|
||||||
}
|
|
||||||
|
|
||||||
const radius = (visit: Visit) =>
|
|
||||||
R_MIN + (largest - R_MIN) * Math.sqrt(visit.amount / biggest);
|
|
||||||
|
|
||||||
function stroke(visit: Visit) {
|
|
||||||
if (visit === current) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return active === visit ? 2 : 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = (visit: Visit) =>
|
|
||||||
`${formatDate(visit.date)}, ${visit.description}, ${money(visit.amount)}`;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
body="flex flex-col gap-2 pt-2.5"
|
|
||||||
href="/accounts/show/{place.id}"
|
|
||||||
title="{place.from ? "Same route" : "At this place"}{past.length
|
|
||||||
? `, last ${past.length}`
|
|
||||||
: ""}"
|
|
||||||
>
|
|
||||||
{#if loading}
|
|
||||||
<p class="px-3 pb-2.5 text-cladd-fg-soft">Loading visits...</p>
|
|
||||||
{:else if !past.length}
|
|
||||||
<p class="px-3 pb-2.5 text-cladd-fg-soft">
|
|
||||||
{place.from
|
|
||||||
? "First transfer on this route."
|
|
||||||
: `First time at ${place.name}.`}
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p class="flex flex-wrap items-baseline gap-x-1.5 px-3">
|
|
||||||
{#if usual}
|
|
||||||
<span class="text-cladd-fg-soft">usual</span>
|
|
||||||
<span class="num font-medium">{money(usual.median)}</span>
|
|
||||||
{#if usual.every !== null}
|
|
||||||
<span class="text-cladd-fg-softer">·</span>
|
|
||||||
<span>{cadence(usual.every)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if said}
|
|
||||||
<span class="text-cladd-fg-softer">·</span>
|
|
||||||
<span class={said.warn ? "text-warn" : "text-cladd-fg-soft"}
|
|
||||||
>this one {said.text}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span class="text-cladd-fg-soft"
|
|
||||||
>{past.length === 1 ? "One visit" : `${past.length} visits`}
|
|
||||||
before this one</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
<div class="px-3">
|
|
||||||
<div bind:clientWidth={width}>
|
|
||||||
{#if width}
|
|
||||||
<svg
|
|
||||||
aria-label="Visits by date, dot size is the amount"
|
|
||||||
class="block overflow-visible"
|
|
||||||
height={HEIGHT}
|
|
||||||
role="img"
|
|
||||||
{width}
|
|
||||||
>
|
|
||||||
<line
|
|
||||||
class="stroke-current text-cladd-fg-softest"
|
|
||||||
x1={largest}
|
|
||||||
x2={width - largest}
|
|
||||||
y1={HEIGHT / 2}
|
|
||||||
y2={HEIGHT / 2}
|
|
||||||
/>
|
|
||||||
{#each visits as visit (visit.group)}
|
|
||||||
{@const self = visit === current}
|
|
||||||
<a
|
|
||||||
aria-label={label(visit)}
|
|
||||||
href="/transactions/show/{visit.group}"
|
|
||||||
onblur={() => (active = null)}
|
|
||||||
onfocus={() => (active = visit)}
|
|
||||||
onpointerenter={() => (active = visit)}
|
|
||||||
onpointerleave={() => (active = null)}
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
class={self
|
|
||||||
? `fill-current ${tone}`
|
|
||||||
: `fill-cladd-surface stroke-current ${active === visit ? "text-cladd-fg" : "text-cladd-fg-soft"}`}
|
|
||||||
cx={x(visit)}
|
|
||||||
cy={HEIGHT / 2}
|
|
||||||
r={radius(visit)}
|
|
||||||
stroke-width={stroke(visit)}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="num flex items-baseline justify-between gap-3 px-3 text-[12px] text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
<span>{formatDate(visits[0].date)}</span>
|
|
||||||
<span>{formatDate(visits.at(-1)?.date)}</span>
|
|
||||||
</p>
|
|
||||||
<table class="mb-1 w-full border-cladd-outline border-t">
|
|
||||||
<tbody>
|
|
||||||
{#each recent as visit (visit.group)}
|
|
||||||
<tr
|
|
||||||
class="h-7 {active === visit ? "bg-row-hover" : ""}"
|
|
||||||
onpointerenter={() => (active = visit)}
|
|
||||||
onpointerleave={() => (active = null)}
|
|
||||||
>
|
|
||||||
<td class="num px-3 whitespace-nowrap text-cladd-fg-soft">
|
|
||||||
{formatDate(visit.date, "short")}
|
|
||||||
</td>
|
|
||||||
<td class="w-full max-w-0 truncate px-2">
|
|
||||||
<a class="hover:underline" href="/transactions/show/{visit.group}"
|
|
||||||
>{visit.description}</a
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="num px-3 text-right whitespace-nowrap">
|
|
||||||
{money(visit.amount)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
|
||||||
import { all } from "#lib/api/lists.js";
|
|
||||||
import type { Split, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import { addDays } from "#lib/dates.js";
|
|
||||||
import { formatDate, type Money } from "#lib/format.js";
|
|
||||||
import { display } from "#lib/money/display.svelte.js";
|
|
||||||
import { journalId, signedAmount, toneOf } from "#lib/transactions/split.js";
|
|
||||||
|
|
||||||
const DAYS = 2;
|
|
||||||
const AROUND = 5;
|
|
||||||
|
|
||||||
let {
|
|
||||||
account,
|
|
||||||
currency,
|
|
||||||
day,
|
|
||||||
groupId,
|
|
||||||
}: {
|
|
||||||
account: { id: string; name: string };
|
|
||||||
currency: Omit<Money, "amount">;
|
|
||||||
day: string;
|
|
||||||
groupId: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const start = $derived(addDays(day, -DAYS));
|
|
||||||
const end = $derived(addDays(day, DAYS));
|
|
||||||
|
|
||||||
const query = createQuery(() => ({
|
|
||||||
queryFn: () =>
|
|
||||||
all<TransactionGroup>(`/v1/accounts/${account.id}/transactions`, {
|
|
||||||
end,
|
|
||||||
start,
|
|
||||||
}),
|
|
||||||
queryKey: ["receipt", "same-days", account.id, start, end],
|
|
||||||
}));
|
|
||||||
|
|
||||||
function amountOn(split: Split) {
|
|
||||||
const signed = signedAmount(split, account.id);
|
|
||||||
const foreign =
|
|
||||||
split.foreign_currency_code === currency.code &&
|
|
||||||
split.currency_code !== currency.code;
|
|
||||||
if (!foreign) {
|
|
||||||
return signed;
|
|
||||||
}
|
|
||||||
const sign = Math.sign(signed.amount) || 1;
|
|
||||||
return {
|
|
||||||
...currency,
|
|
||||||
amount: sign * Number(split.foreign_amount),
|
|
||||||
tone: toneOf(split, sign < 0 ? -1 : 1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = $derived(
|
|
||||||
(query.data ?? [])
|
|
||||||
.flatMap((group) =>
|
|
||||||
group.attributes.transactions
|
|
||||||
.filter(
|
|
||||||
(split) =>
|
|
||||||
split.source_id === account.id ||
|
|
||||||
split.destination_id === account.id
|
|
||||||
)
|
|
||||||
.map((split) => ({
|
|
||||||
balance:
|
|
||||||
split.source_id === account.id
|
|
||||||
? split.source_balance_after
|
|
||||||
: split.destination_balance_after,
|
|
||||||
date: split.date.slice(0, 10),
|
|
||||||
description: split.description,
|
|
||||||
group: group.id,
|
|
||||||
key: journalId(split),
|
|
||||||
money: amountOn(split),
|
|
||||||
self: group.id === groupId,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
.reverse()
|
|
||||||
);
|
|
||||||
const shown = $derived.by(() => {
|
|
||||||
const first = rows.findIndex((row) => row.self);
|
|
||||||
const last = rows.findLastIndex((row) => row.self);
|
|
||||||
return first < 0
|
|
||||||
? rows.slice(0, 2 * AROUND + 1)
|
|
||||||
: rows.slice(Math.max(0, first - AROUND), last + AROUND + 1);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet more()}
|
|
||||||
<a
|
|
||||||
class="text-brand hover:underline"
|
|
||||||
href="/accounts/show/{account.id}/{start}/{end}"
|
|
||||||
>All {rows.length} on {account.name} from {formatDate(start, "short")} to
|
|
||||||
{formatDate(end, "short")}</a
|
|
||||||
>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<Box
|
|
||||||
body="p-0"
|
|
||||||
footer={shown.length < rows.length ? more : undefined}
|
|
||||||
href="/accounts/show/{account.id}/{start}/{end}"
|
|
||||||
title="Same days on {account.name}"
|
|
||||||
>
|
|
||||||
{#if query.isPending}
|
|
||||||
<p class="px-3 py-2.5 text-cladd-fg-soft">Loading {account.name}...</p>
|
|
||||||
{:else if query.error}
|
|
||||||
<p class="px-3 py-2.5 text-expense">{query.error.message}</p>
|
|
||||||
{:else}
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr
|
|
||||||
class="h-8 border-cladd-outline border-b text-left font-medium text-[12px] text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
<th class="px-3 font-medium">Date</th>
|
|
||||||
<th class="px-2 font-medium">Description</th>
|
|
||||||
<th class="px-2 text-right font-medium">Amount</th>
|
|
||||||
<th class="px-3 text-right font-medium whitespace-nowrap">
|
|
||||||
Balance after
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each shown as row (row.key)}
|
|
||||||
<tr
|
|
||||||
class="h-7 {row.self
|
|
||||||
? "bg-row-selected font-medium"
|
|
||||||
: "hover:bg-row-hover"}"
|
|
||||||
>
|
|
||||||
<td class="num px-3 whitespace-nowrap text-cladd-fg-soft">
|
|
||||||
{formatDate(row.date, "short")}
|
|
||||||
</td>
|
|
||||||
<td class="w-full max-w-0 truncate px-2">
|
|
||||||
{#if row.self}
|
|
||||||
{row.description}
|
|
||||||
{:else}
|
|
||||||
<a class="hover:underline" href="/transactions/show/{row.group}"
|
|
||||||
>{row.description}</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-2 text-right whitespace-nowrap">
|
|
||||||
<Amount
|
|
||||||
amount={row.money.amount}
|
|
||||||
code={row.money.code}
|
|
||||||
decimals={row.money.decimals}
|
|
||||||
symbol={row.money.symbol}
|
|
||||||
tone={row.money.tone}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td class="num px-3 text-right whitespace-nowrap">
|
|
||||||
{row.balance === null || row.balance === undefined
|
|
||||||
? ""
|
|
||||||
: display.text([{ ...currency, amount: row.balance }])}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import type { Split } from "#lib/api/types.js";
|
|
||||||
import {
|
|
||||||
isEditableType,
|
|
||||||
SHARED,
|
|
||||||
type Side,
|
|
||||||
} from "#lib/components/editor/model.js";
|
|
||||||
|
|
||||||
export type FieldName =
|
|
||||||
| "date"
|
|
||||||
| "description"
|
|
||||||
| "amount"
|
|
||||||
| "source"
|
|
||||||
| "destination"
|
|
||||||
| "category"
|
|
||||||
| "budget"
|
|
||||||
| "tags"
|
|
||||||
| "bill"
|
|
||||||
| "notes";
|
|
||||||
|
|
||||||
export interface Row {
|
|
||||||
field: FieldName;
|
|
||||||
index: number;
|
|
||||||
key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FIELD_LABEL: Record<FieldName, string> = {
|
|
||||||
amount: "amount",
|
|
||||||
bill: "subscription",
|
|
||||||
budget: "budget",
|
|
||||||
category: "category",
|
|
||||||
date: "date",
|
|
||||||
description: "description",
|
|
||||||
destination: "destination account",
|
|
||||||
notes: "notes",
|
|
||||||
source: "source account",
|
|
||||||
tags: "tags",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SIDES: Side[] = ["source", "destination"];
|
|
||||||
|
|
||||||
export const rowKey = (field: FieldName, index: number) => `${index}:${field}`;
|
|
||||||
|
|
||||||
export function rowsOf(splits: Split[]): Row[] {
|
|
||||||
const type = splits[0]?.type ?? "";
|
|
||||||
if (!isEditableType(type)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const many = splits.length > 1;
|
|
||||||
const withdrawal = type === "withdrawal";
|
|
||||||
const locked = splits.some((split) => split.reconciled);
|
|
||||||
const shared = SIDES.filter((side) => SHARED[type][side]);
|
|
||||||
const top: FieldName[] = many
|
|
||||||
? ["date", ...(locked ? [] : shared)]
|
|
||||||
: [
|
|
||||||
"date",
|
|
||||||
"description",
|
|
||||||
...(locked ? [] : (["amount", ...SIDES] as FieldName[])),
|
|
||||||
];
|
|
||||||
const own = (split: Split): FieldName[] => [
|
|
||||||
...(many ? (["description"] as const) : []),
|
|
||||||
...(many && !split.reconciled
|
|
||||||
? ["amount" as const, ...SIDES.filter((side) => !SHARED[type][side])]
|
|
||||||
: []),
|
|
||||||
"category",
|
|
||||||
...(withdrawal ? (["budget"] as const) : []),
|
|
||||||
"tags",
|
|
||||||
...(withdrawal ? (["bill"] as const) : []),
|
|
||||||
"notes",
|
|
||||||
];
|
|
||||||
return [
|
|
||||||
...top.map((field) => ({ field, index: 0 })),
|
|
||||||
...splits.flatMap((split, index) =>
|
|
||||||
own(split).map((field) => ({ field, index }))
|
|
||||||
),
|
|
||||||
].map((row) => ({ ...row, key: rowKey(row.field, row.index) }));
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import { all } from "#lib/api/lists.js";
|
|
||||||
import type {
|
|
||||||
Bill,
|
|
||||||
Item,
|
|
||||||
Page,
|
|
||||||
Split,
|
|
||||||
TransactionGroup,
|
|
||||||
} from "#lib/api/types.js";
|
|
||||||
import { addDays, addMonths } from "#lib/dates.js";
|
|
||||||
import { toNumber } from "#lib/format.js";
|
|
||||||
import { journalId } from "#lib/transactions/split.js";
|
|
||||||
|
|
||||||
const CENT = 0.005;
|
|
||||||
|
|
||||||
export interface Suggestion {
|
|
||||||
bill: Item<Bill>;
|
|
||||||
journal: string;
|
|
||||||
month: string;
|
|
||||||
paid: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const openSplits = (splits: Split[]) =>
|
|
||||||
splits.filter(
|
|
||||||
(split) =>
|
|
||||||
split.type === "withdrawal" && !split.bill_id && split.destination_id
|
|
||||||
);
|
|
||||||
|
|
||||||
function priceIn(split: Split, code: string): number | null {
|
|
||||||
if (split.currency_code === code) {
|
|
||||||
return Math.abs(toNumber(split.amount));
|
|
||||||
}
|
|
||||||
return split.foreign_currency_code === code && split.foreign_amount
|
|
||||||
? Math.abs(toNumber(split.foreign_amount))
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fits(bill: Bill, split: Split): boolean {
|
|
||||||
const price = priceIn(split, bill.currency_code ?? "");
|
|
||||||
return (
|
|
||||||
bill.active === true &&
|
|
||||||
price !== null &&
|
|
||||||
price >= toNumber(bill.amount_min) - CENT &&
|
|
||||||
price <= toNumber(bill.amount_max) + CENT &&
|
|
||||||
(bill.date ?? "").slice(0, 10) <= split.date.slice(0, 10)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = (bill: Item<Bill>) =>
|
|
||||||
toNumber(bill.attributes.amount_max) - toNumber(bill.attributes.amount_min);
|
|
||||||
|
|
||||||
async function lastDestination(billId: string): Promise<string | null> {
|
|
||||||
const page = await api<Page<TransactionGroup>>(
|
|
||||||
`/v1/bills/${billId}/transactions`,
|
|
||||||
{ query: { limit: 1 } }
|
|
||||||
);
|
|
||||||
return page.data[0]?.attributes.transactions[0]?.destination_id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function suggest(splits: Split[]): Promise<Suggestion[]> {
|
|
||||||
const open = openSplits(splits);
|
|
||||||
if (!open.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const month = `${open[0].date.slice(0, 7)}-01`;
|
|
||||||
const bills = await all<Bill>("/v1/bills", {
|
|
||||||
end: addDays(addMonths(month, 1), -1),
|
|
||||||
start: month,
|
|
||||||
});
|
|
||||||
const candidates = open.map((split) => ({
|
|
||||||
bills: bills.filter((bill) => fits(bill.attributes, split)),
|
|
||||||
split,
|
|
||||||
}));
|
|
||||||
const ids = [
|
|
||||||
...new Set(candidates.flatMap((c) => c.bills.map((bill) => bill.id))),
|
|
||||||
];
|
|
||||||
const last = new Map(
|
|
||||||
await Promise.all(
|
|
||||||
ids.map(async (id) => [id, await lastDestination(id)] as const)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return candidates.flatMap(({ bills: options, split }) => {
|
|
||||||
const [bill] = options
|
|
||||||
.filter((option) => last.get(option.id) === split.destination_id)
|
|
||||||
.sort((a, b) => width(a) - width(b));
|
|
||||||
if (!bill) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const paid = bill.attributes.paid_dates?.[0]?.date?.slice(0, 10) ?? null;
|
|
||||||
return [{ bill, journal: journalId(split), month, paid }];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
export type Inline =
|
|
||||||
| { kind: "text" | "bold" | "code"; text: string }
|
|
||||||
| { kind: "link"; text: string; href: string };
|
|
||||||
|
|
||||||
export type Block =
|
|
||||||
| { kind: "head"; line: Inline[] }
|
|
||||||
| { kind: "list"; ordered: boolean; items: Inline[][] }
|
|
||||||
| { kind: "para"; lines: Inline[][] };
|
|
||||||
|
|
||||||
const TOKEN =
|
|
||||||
/\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>()]*[^\s<>().,;:!?'"])/g;
|
|
||||||
const BULLET = /^\s*(?:[-*•]|(\d+)[.)])\s+(.*)$/;
|
|
||||||
const HEAD = /^#{1,6}\s+(.*)$/;
|
|
||||||
const LINES = /\r?\n/;
|
|
||||||
|
|
||||||
export function inline(text: string): Inline[] {
|
|
||||||
const out: Inline[] = [];
|
|
||||||
let last = 0;
|
|
||||||
for (const match of text.matchAll(TOKEN)) {
|
|
||||||
const [whole, bold, code, label, href, url] = match;
|
|
||||||
if (match.index > last) {
|
|
||||||
out.push({ kind: "text", text: text.slice(last, match.index) });
|
|
||||||
}
|
|
||||||
if (bold) {
|
|
||||||
out.push({ kind: "bold", text: bold });
|
|
||||||
} else if (code) {
|
|
||||||
out.push({ kind: "code", text: code });
|
|
||||||
} else {
|
|
||||||
out.push({ href: href ?? url, kind: "link", text: label ?? url });
|
|
||||||
}
|
|
||||||
last = match.index + whole.length;
|
|
||||||
}
|
|
||||||
if (last < text.length) {
|
|
||||||
out.push({ kind: "text", text: text.slice(last) });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blocks(notes: string): Block[] {
|
|
||||||
const result: Block[] = [];
|
|
||||||
let open: Block | null = null;
|
|
||||||
for (const raw of notes.split(LINES)) {
|
|
||||||
const line = raw.trimEnd();
|
|
||||||
const head = line.match(HEAD);
|
|
||||||
const bullet = line.match(BULLET);
|
|
||||||
if (!line.trim()) {
|
|
||||||
open = null;
|
|
||||||
} else if (head) {
|
|
||||||
result.push({ kind: "head", line: inline(head[1]) });
|
|
||||||
open = null;
|
|
||||||
} else if (bullet) {
|
|
||||||
const ordered = Boolean(bullet[1]);
|
|
||||||
if (open?.kind !== "list" || open.ordered !== ordered) {
|
|
||||||
open = { items: [], kind: "list", ordered };
|
|
||||||
result.push(open);
|
|
||||||
}
|
|
||||||
open.items.push(inline(bullet[2]));
|
|
||||||
} else {
|
|
||||||
if (open?.kind !== "para") {
|
|
||||||
open = { kind: "para", lines: [] };
|
|
||||||
result.push(open);
|
|
||||||
}
|
|
||||||
open.lines.push(inline(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import { api } from "#lib/api/client.js";
|
|
||||||
import type { Item, Page, Split, TransactionGroup } from "#lib/api/types.js";
|
|
||||||
import { addMonths, daysBetween } from "#lib/dates.js";
|
|
||||||
import { isCash } from "#lib/transactions/split.js";
|
|
||||||
|
|
||||||
const COUNT = 12;
|
|
||||||
const FETCH = 25;
|
|
||||||
const MIN_COUNT = 3;
|
|
||||||
const LOOKBACK_MONTHS = 60;
|
|
||||||
const PERCENT = 100;
|
|
||||||
const ABOUT = 5;
|
|
||||||
const NOTABLE = 15;
|
|
||||||
|
|
||||||
type Side = "source" | "destination";
|
|
||||||
|
|
||||||
export interface Place {
|
|
||||||
from: string | null;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
side: Side;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Visit {
|
|
||||||
amount: number;
|
|
||||||
date: string;
|
|
||||||
description: string;
|
|
||||||
group: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Usual {
|
|
||||||
change: number;
|
|
||||||
every: number | null;
|
|
||||||
median: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COUNTER: Record<string, Side> = {
|
|
||||||
deposit: "source",
|
|
||||||
transfer: "destination",
|
|
||||||
withdrawal: "destination",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CADENCE: [number, number, string][] = [
|
|
||||||
[0, 1.5, "daily"],
|
|
||||||
[5, 9, "weekly"],
|
|
||||||
[12, 17, "every two weeks"],
|
|
||||||
[26, 35, "monthly"],
|
|
||||||
[80, 100, "quarterly"],
|
|
||||||
[170, 195, "every half year"],
|
|
||||||
[345, 385, "yearly"],
|
|
||||||
];
|
|
||||||
|
|
||||||
export function placeOf(splits: Split[]): Place | null {
|
|
||||||
const [first] = splits;
|
|
||||||
const side = first ? COUNTER[first.type] : undefined;
|
|
||||||
const id = side ? first[`${side}_id`] : null;
|
|
||||||
if (!(side && id) || isCash(first[`${side}_type`])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
from: first.type === "transfer" ? first.source_id : null,
|
|
||||||
id,
|
|
||||||
name: first[`${side}_name`] ?? "",
|
|
||||||
side,
|
|
||||||
type: first.type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function priceIn(split: Split, code: string): number | null {
|
|
||||||
if (split.currency_code === code) {
|
|
||||||
return Math.abs(Number(split.amount));
|
|
||||||
}
|
|
||||||
return split.foreign_currency_code === code && split.foreign_amount
|
|
||||||
? Math.abs(Number(split.foreign_amount))
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function visitOf(
|
|
||||||
group: Item<TransactionGroup>,
|
|
||||||
place: Place,
|
|
||||||
code: string
|
|
||||||
): Visit | null {
|
|
||||||
const splits = group.attributes.transactions.filter(
|
|
||||||
(split) =>
|
|
||||||
split[`${place.side}_id`] === place.id &&
|
|
||||||
(!place.from || split.source_id === place.from)
|
|
||||||
);
|
|
||||||
const prices = splits.map((split) => priceIn(split, code));
|
|
||||||
if (!splits.length || prices.includes(null)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
amount: prices.reduce<number>((sum, price) => sum + (price ?? 0), 0),
|
|
||||||
date: splits[0].date.slice(0, 10),
|
|
||||||
description:
|
|
||||||
group.attributes.group_title || splits[0].description || `#${group.id}`,
|
|
||||||
group: group.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadPlace(
|
|
||||||
place: Place,
|
|
||||||
groupId: string,
|
|
||||||
end: string
|
|
||||||
): Promise<Item<TransactionGroup>[]> {
|
|
||||||
const page = place.from
|
|
||||||
? await api<Page<TransactionGroup>>("/v1/search/transactions", {
|
|
||||||
query: {
|
|
||||||
limit: FETCH,
|
|
||||||
query: `type:transfer source_account_id:${place.from} destination_account_id:${place.id} date_before:${end}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: await api<Page<TransactionGroup>>(
|
|
||||||
`/v1/accounts/${place.id}/transactions`,
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
end,
|
|
||||||
limit: FETCH,
|
|
||||||
start: addMonths(end, -LOOKBACK_MONTHS),
|
|
||||||
type: place.type,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return page.data.filter((group) => group.id !== groupId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const visitsIn = (
|
|
||||||
groups: Item<TransactionGroup>[],
|
|
||||||
place: Place,
|
|
||||||
code: string
|
|
||||||
) =>
|
|
||||||
groups
|
|
||||||
.map((group) => visitOf(group, place, code))
|
|
||||||
.filter((visit): visit is Visit => visit !== null)
|
|
||||||
.slice(0, COUNT);
|
|
||||||
|
|
||||||
function median(values: number[]) {
|
|
||||||
const sorted = [...values].sort((a, b) => a - b);
|
|
||||||
const mid = Math.floor(sorted.length / 2);
|
|
||||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usualOf(current: Visit, past: Visit[]): Usual | null {
|
|
||||||
if (past.length < MIN_COUNT) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const typical = median(past.map((visit) => visit.amount));
|
|
||||||
const dates = [current, ...past].map((visit) => visit.date).sort();
|
|
||||||
const gaps = dates.slice(1).map((date, i) => daysBetween(dates[i], date));
|
|
||||||
return {
|
|
||||||
change: typical ? Math.round((current.amount / typical - 1) * PERCENT) : 0,
|
|
||||||
every: gaps.length ? median(gaps) : null,
|
|
||||||
median: typical,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cadence(days: number): string {
|
|
||||||
return (
|
|
||||||
CADENCE.find(([low, high]) => days >= low && days <= high)?.[2] ??
|
|
||||||
`every ${Math.round(days)} days`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verdict(change: number) {
|
|
||||||
if (Math.abs(change) < ABOUT) {
|
|
||||||
return { text: "about usual", warn: false };
|
|
||||||
}
|
|
||||||
return change > 0
|
|
||||||
? { text: `${change}% above usual`, warn: change >= NOTABLE }
|
|
||||||
: { text: `${-change}% below usual`, warn: false };
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { displayCode, inDisplay } from "#lib/reports/display.js";
|
|
||||||
import { reportQueries } from "#lib/reports/queries.js";
|
import { reportQueries } from "#lib/reports/queries.js";
|
||||||
import {
|
import {
|
||||||
displayMoney,
|
|
||||||
drillQuery,
|
drillQuery,
|
||||||
money,
|
money,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -36,15 +34,12 @@
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
});
|
});
|
||||||
const sums = $derived.by(() => {
|
const sums = $derived.by(() => {
|
||||||
const into = displayCode(new Set(rows.map((row) => row.code)));
|
|
||||||
const map = new Map<string, { start: number; end: number }>();
|
const map = new Map<string, { start: number; end: number }>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const start = inDisplay(row.start, row.code, into);
|
const sum = map.get(row.code) ?? { end: 0, start: 0 };
|
||||||
const end = inDisplay(row.end, row.code, into);
|
sum.start += row.start;
|
||||||
const sum = map.get(start.code) ?? { end: 0, start: 0 };
|
sum.end += row.end;
|
||||||
sum.start += start.amount;
|
map.set(row.code, sum);
|
||||||
sum.end += end.amount;
|
|
||||||
map.set(start.code, sum);
|
|
||||||
}
|
}
|
||||||
const order = report.codes;
|
const order = report.codes;
|
||||||
return [...map.entries()].sort(
|
return [...map.entries()].sort(
|
||||||
@@ -78,17 +73,17 @@
|
|||||||
title="Account balances"
|
title="Account balances"
|
||||||
>
|
>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full min-w-[34rem] border-collapse text-[13px]">
|
<table class="w-full border-collapse text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-[12px] text-cladd-fg-soft">
|
<tr class="text-[12px] text-cladd-fg-soft">
|
||||||
<th class="h-7 w-full px-3 text-left font-medium">Name</th>
|
<th class="h-7 px-3 text-left font-medium">Name</th>
|
||||||
<th class="h-7 w-32 px-3 text-right font-medium">
|
<th class="h-7 px-3 text-right font-medium">
|
||||||
Balance at start of period
|
Balance at start of period
|
||||||
</th>
|
</th>
|
||||||
<th class="h-7 w-32 px-3 text-right font-medium">
|
<th class="h-7 px-3 text-right font-medium">
|
||||||
Balance at end of period
|
Balance at end of period
|
||||||
</th>
|
</th>
|
||||||
<th class="h-7 w-32 px-3 text-right font-medium">Difference</th>
|
<th class="h-7 px-3 text-right font-medium">Difference</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -100,7 +95,7 @@
|
|||||||
? "bg-row-selected"
|
? "bg-row-selected"
|
||||||
: ""}"
|
: ""}"
|
||||||
>
|
>
|
||||||
<td class="max-w-0 px-3">
|
<td class="max-w-[16rem] px-3">
|
||||||
<button
|
<button
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
||||||
@@ -111,14 +106,10 @@
|
|||||||
{row.name}
|
{row.name}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="num px-3 text-right">
|
<td class="num px-3 text-right">{money(row.code, row.start)}</td>
|
||||||
{displayMoney(row.code, row.start)}
|
<td class="num px-3 text-right">{money(row.code, row.end)}</td>
|
||||||
</td>
|
|
||||||
<td class="num px-3 text-right">
|
|
||||||
{displayMoney(row.code, row.end)}
|
|
||||||
</td>
|
|
||||||
<td class="num px-3 text-right {tone(row.end - row.start)}">
|
<td class="num px-3 text-right {tone(row.end - row.start)}">
|
||||||
{displayMoney(row.code, row.end - row.start, true)}
|
{money(row.code, row.end - row.start, true)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
|
||||||
|
import ArrowLeftRight from "@lucide/svelte/icons/arrow-left-right";
|
||||||
|
import ArrowRight from "@lucide/svelte/icons/arrow-right";
|
||||||
|
import Calculator from "@lucide/svelte/icons/calculator";
|
||||||
|
import Star from "@lucide/svelte/icons/star";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import type { Account } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import { addDays } from "#lib/dates.js";
|
||||||
|
import { formatDate, formatDateTime } from "#lib/format.js";
|
||||||
|
import { type AuditColumn, ledger } from "#lib/reports/audit.js";
|
||||||
|
import { reportQueries } from "#lib/reports/queries.js";
|
||||||
|
import { money, useReport } from "#lib/reports/report.svelte.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
account,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
columns,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
account: Account | undefined;
|
||||||
|
before: number | undefined;
|
||||||
|
after: number | undefined;
|
||||||
|
columns: AuditColumn[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const report = useReport();
|
||||||
|
const p = $derived(report.params);
|
||||||
|
const code = $derived(account?.currency_code ?? "");
|
||||||
|
const query = createQuery(() => reportQueries.ledger(id, p.start, p.end));
|
||||||
|
const rows = $derived(
|
||||||
|
query.data && before !== undefined
|
||||||
|
? ledger(query.data, id, code, before)
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
const show = (key: AuditColumn) => columns.includes(key);
|
||||||
|
const ICONS = {
|
||||||
|
deposit: ArrowRight,
|
||||||
|
"opening balance": Star,
|
||||||
|
reconciliation: Calculator,
|
||||||
|
transfer: ArrowLeftRight,
|
||||||
|
withdrawal: ArrowLeft,
|
||||||
|
} as const;
|
||||||
|
const META_DATES = [
|
||||||
|
"interest_date",
|
||||||
|
"book_date",
|
||||||
|
"process_date",
|
||||||
|
"due_date",
|
||||||
|
"payment_date",
|
||||||
|
"invoice_date",
|
||||||
|
] as const;
|
||||||
|
const dateColumns = $derived(META_DATES.filter((key) => show(key)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box body="p-0" href="/accounts/show/{id}" title={account?.name ?? `#${id}`}>
|
||||||
|
{#if !rows}
|
||||||
|
<p class="px-3 py-3 text-cladd-fg-soft">Loading</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="px-3 py-3">
|
||||||
|
No activity was recorded on account
|
||||||
|
<a
|
||||||
|
class="text-brand dark:text-[var(--ff-series-1)] hover:underline"
|
||||||
|
href="/accounts/show/{id}"
|
||||||
|
>{account?.name}</a
|
||||||
|
>
|
||||||
|
between {formatDate(p.start, "long")} and {formatDate(p.end, "long")}.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="px-3 py-2 text-[13px]">
|
||||||
|
Account balance of <strong>{account?.name}</strong> at the end of
|
||||||
|
{formatDate(addDays(p.start, -1), "long")}
|
||||||
|
was:
|
||||||
|
<span class="num font-semibold">{money(code, before ?? 0)}</span>
|
||||||
|
</p>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full border-collapse text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-[12px] text-cladd-fg-soft">
|
||||||
|
{#if show("icon")}
|
||||||
|
<th class="h-7 w-8 px-2"><span class="sr-only">Type</span></th>
|
||||||
|
{/if}
|
||||||
|
{#if show("description")}
|
||||||
|
<th class="h-7 px-3 font-medium">Description</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("balance_before")}
|
||||||
|
<th class="h-7 px-3 text-right font-medium">Balance before</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("amount")}
|
||||||
|
<th class="h-7 px-3 text-right font-medium">Amount</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("balance_after")}
|
||||||
|
<th class="h-7 px-3 text-right font-medium">Balance after</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("date")}
|
||||||
|
<th class="h-7 px-3 font-medium">Date</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("from")}
|
||||||
|
<th class="h-7 px-3 font-medium">From</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("to")}
|
||||||
|
<th class="h-7 px-3 font-medium">To</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("budget")}
|
||||||
|
<th class="h-7 px-3 font-medium">Budget</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("category")}
|
||||||
|
<th class="h-7 px-3 font-medium">Category</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("bill")}
|
||||||
|
<th class="h-7 px-3 font-medium">Subscription</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("created_at")}
|
||||||
|
<th class="h-7 px-3 font-medium">Created at</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("updated_at")}
|
||||||
|
<th class="h-7 px-3 font-medium">Updated at</th>
|
||||||
|
{/if}
|
||||||
|
{#if show("notes")}
|
||||||
|
<th class="h-7 px-3 font-medium">Notes</th>
|
||||||
|
{/if}
|
||||||
|
{#each dateColumns as key (key)}
|
||||||
|
<th class="h-7 px-3 font-medium">
|
||||||
|
{key.replace("_date", "").replace("process", "processing")}
|
||||||
|
date
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as row (row.split.transaction_journal_id)}
|
||||||
|
{@const s = row.split}
|
||||||
|
{@const Icon =
|
||||||
|
ICONS[s.type as keyof typeof ICONS] ?? ArrowLeftRight}
|
||||||
|
<tr
|
||||||
|
class="h-7 border-cladd-outline border-t align-top hover:bg-row-hover"
|
||||||
|
>
|
||||||
|
{#if show("icon")}
|
||||||
|
<td class="px-2 py-1.5 text-cladd-fg-soft">
|
||||||
|
<Icon aria-label={s.type} class="size-3.5" />
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("description")}
|
||||||
|
<td class="max-w-[22rem] px-3 py-1">
|
||||||
|
<a
|
||||||
|
class="hover:underline"
|
||||||
|
href="/transactions/show/{row.group}"
|
||||||
|
>
|
||||||
|
{#if row.title}
|
||||||
|
{row.title}
|
||||||
|
({s.description})
|
||||||
|
{:else}
|
||||||
|
{s.description}
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("balance_before")}
|
||||||
|
<td
|
||||||
|
class="num whitespace-nowrap px-3 py-1 text-right text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
{money(code, row.before)}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("amount")}
|
||||||
|
<td
|
||||||
|
class="num whitespace-nowrap px-3 py-1 text-right {row.amount <
|
||||||
|
0
|
||||||
|
? "text-expense"
|
||||||
|
: "text-income"}"
|
||||||
|
>
|
||||||
|
{money(code, row.amount)}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("balance_after")}
|
||||||
|
<td class="num whitespace-nowrap px-3 py-1 text-right">
|
||||||
|
{money(code, row.after)}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("date")}
|
||||||
|
<td class="num whitespace-nowrap px-3 py-1">
|
||||||
|
{formatDate(s.date, "medium")}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("from")}
|
||||||
|
<td class="max-w-[12rem] truncate px-3 py-1">
|
||||||
|
<a class="hover:underline" href="/accounts/show/{s.source_id}"
|
||||||
|
>{s.source_name}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("to")}
|
||||||
|
<td class="max-w-[12rem] truncate px-3 py-1">
|
||||||
|
<a
|
||||||
|
class="hover:underline"
|
||||||
|
href="/accounts/show/{s.destination_id}"
|
||||||
|
>{s.destination_name}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("budget")}
|
||||||
|
<td class="px-3 py-1">{s.budget_name ?? ""}</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("category")}
|
||||||
|
<td class="px-3 py-1">{s.category_name ?? ""}</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("bill")}
|
||||||
|
<td class="px-3 py-1">{s.bill_name ?? ""}</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("created_at")}
|
||||||
|
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
|
||||||
|
{formatDateTime(row.created)}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("updated_at")}
|
||||||
|
<td class="num whitespace-nowrap px-3 py-1 text-cladd-fg-soft">
|
||||||
|
{formatDateTime(row.updated)}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#if show("notes")}
|
||||||
|
<td
|
||||||
|
class="max-w-[24rem] whitespace-pre-line px-3 py-1 text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
{s.notes ?? ""}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
|
{#each dateColumns as key (key)}
|
||||||
|
<td class="num whitespace-nowrap px-3 py-1">
|
||||||
|
{formatDate(s[key] ?? null, "medium")}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="border-cladd-outline border-t px-3 py-2 text-[13px]">
|
||||||
|
Account balance of <strong>{account?.name}</strong> at the end of
|
||||||
|
{formatDate(p.end, "long")}
|
||||||
|
was:
|
||||||
|
<span class="num font-semibold">{money(code, after ?? 0)}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import {
|
||||||
|
AUDIT_COLUMNS,
|
||||||
|
type AuditColumn,
|
||||||
|
DEFAULT_AUDIT,
|
||||||
|
} from "#lib/reports/audit.js";
|
||||||
|
import { reportQueries } from "#lib/reports/queries.js";
|
||||||
|
import { navigate, useReport } from "#lib/reports/report.svelte.js";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import AuditAccount from "./AuditAccount.svelte";
|
||||||
|
|
||||||
|
const report = useReport();
|
||||||
|
const p = $derived(report.params);
|
||||||
|
const balances = createQuery(() => reportQueries.balances(p.start, p.end));
|
||||||
|
|
||||||
|
const columns = $derived.by(() => {
|
||||||
|
const raw = page.url.searchParams.get("cols");
|
||||||
|
if (raw === null) {
|
||||||
|
return DEFAULT_AUDIT;
|
||||||
|
}
|
||||||
|
const keys = new Set(raw.split(","));
|
||||||
|
return AUDIT_COLUMNS.map((c) => c.key).filter((key) => keys.has(key));
|
||||||
|
});
|
||||||
|
const accounts = $derived(
|
||||||
|
report.selected
|
||||||
|
.map((id) => ({ account: report.byId().get(id), id }))
|
||||||
|
.filter((a) => a.account)
|
||||||
|
.sort((a, b) =>
|
||||||
|
(a.account?.name ?? "").localeCompare(b.account?.name ?? "")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle(key: AuditColumn, on: boolean) {
|
||||||
|
const next = AUDIT_COLUMNS.map((c) => c.key).filter((k) =>
|
||||||
|
k === key ? on : columns.includes(k)
|
||||||
|
);
|
||||||
|
navigate({ cols: next.join(",") });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Box body="px-3 py-2" title="Options">
|
||||||
|
<fieldset class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<legend class="sr-only">Visible columns</legend>
|
||||||
|
{#each AUDIT_COLUMNS as column (column.key)}
|
||||||
|
<label class="flex h-7 items-center gap-1.5 text-[13px]">
|
||||||
|
<input
|
||||||
|
checked={columns.includes(column.key)}
|
||||||
|
class="size-3.5 accent-[var(--ff-brand)]"
|
||||||
|
onchange={(event) =>
|
||||||
|
toggle(column.key, event.currentTarget.checked)}
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
</Box>
|
||||||
|
{#if !report.accountsReady}
|
||||||
|
<p class="text-cladd-fg-soft">Loading</p>
|
||||||
|
{/if}
|
||||||
|
{#each accounts as item (item.id)}
|
||||||
|
<AuditAccount
|
||||||
|
account={item.account}
|
||||||
|
after={balances.data?.after.get(item.id)}
|
||||||
|
before={balances.data?.before.get(item.id)}
|
||||||
|
{columns}
|
||||||
|
id={item.id}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
import LineChart from "#lib/components/charts/LineChart.svelte";
|
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||||
import { contains, today } from "#lib/dates.js";
|
import { contains, today } from "#lib/dates.js";
|
||||||
import { formatDate } from "#lib/format.js";
|
import { formatDate } from "#lib/format.js";
|
||||||
import { displayCode, inDisplay, shownCode } from "#lib/reports/display.js";
|
|
||||||
import { reportQueries } from "#lib/reports/queries.js";
|
import { reportQueries } from "#lib/reports/queries.js";
|
||||||
import { money, useReport } from "#lib/reports/report.svelte.js";
|
import { money, useReport } from "#lib/reports/report.svelte.js";
|
||||||
import { modeAxis } from "#lib/reports/view.js";
|
import { modeAxis } from "#lib/reports/view.js";
|
||||||
@@ -32,16 +31,11 @@
|
|||||||
|
|
||||||
const charts = $derived.by(() => {
|
const charts = $derived.by(() => {
|
||||||
const byCode = new Map<string, LineSeries[]>();
|
const byCode = new Map<string, LineSeries[]>();
|
||||||
const sets = chart.data ?? [];
|
for (const set of chart.data ?? []) {
|
||||||
const into = displayCode(
|
const code = set.currency_code ?? "";
|
||||||
new Set(sets.map((set) => set.currency_code ?? ""))
|
|
||||||
);
|
|
||||||
for (const set of sets) {
|
|
||||||
const from = set.currency_code ?? "";
|
|
||||||
const code = shownCode(from, into);
|
|
||||||
const points = Object.entries(set.entries ?? {}).map(([x, y]) => ({
|
const points = Object.entries(set.entries ?? {}).map(([x, y]) => ({
|
||||||
x: x.slice(0, 10),
|
x: x.slice(0, 10),
|
||||||
y: inDisplay(Number(y), from, into).amount,
|
y: Number(y),
|
||||||
}));
|
}));
|
||||||
const list = byCode.get(code) ?? [];
|
const list = byCode.get(code) ?? [];
|
||||||
if (net && list.length > 0) {
|
if (net && list.length > 0) {
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
|
||||||
import { lists } from "#lib/api/lists.js";
|
|
||||||
import Lanes from "#lib/components/charts/Lanes.svelte";
|
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
|
||||||
import { today } from "#lib/dates.js";
|
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
|
||||||
import { period } from "#lib/period.svelte.js";
|
|
||||||
import {
|
|
||||||
buildBoard,
|
|
||||||
cursorOf,
|
|
||||||
grew,
|
|
||||||
longMonth,
|
|
||||||
mergedLane,
|
|
||||||
monthFacts,
|
|
||||||
monthOf,
|
|
||||||
monthRange,
|
|
||||||
shiftMonth,
|
|
||||||
spendingLanes,
|
|
||||||
} from "#lib/reports/board.js";
|
|
||||||
import { displayCode, displayFlows } from "#lib/reports/display.js";
|
|
||||||
import { exchanges } from "#lib/reports/flows.js";
|
|
||||||
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
|
||||||
import { ALL_ASSETS, type ReportParams, ymd } from "#lib/reports/params.js";
|
|
||||||
import {
|
|
||||||
createReport,
|
|
||||||
type Drill,
|
|
||||||
drillQuery,
|
|
||||||
navigate,
|
|
||||||
provideReport,
|
|
||||||
} from "#lib/reports/report.svelte.js";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import BoardTables from "./BoardTables.svelte";
|
|
||||||
import CurrencyTable from "./CurrencyTable.svelte";
|
|
||||||
import Headline from "./Headline.svelte";
|
|
||||||
import MoneyFlow from "./MoneyFlow.svelte";
|
|
||||||
import MonthPanel from "./MonthPanel.svelte";
|
|
||||||
import NetWorthLanes from "./NetWorthLanes.svelte";
|
|
||||||
import ReportBox from "./ReportBox.svelte";
|
|
||||||
import ReportRow from "./ReportRow.svelte";
|
|
||||||
import WhatGrew from "./WhatGrew.svelte";
|
|
||||||
|
|
||||||
const now = today();
|
|
||||||
const thisYear = now.slice(0, 4);
|
|
||||||
const thisMonth = monthOf(now);
|
|
||||||
|
|
||||||
const query = $derived(page.url.searchParams);
|
|
||||||
const view = $derived(query.get("view") === "month" ? "month" : "year");
|
|
||||||
const tables = $derived(query.has("tables"));
|
|
||||||
const merged = $derived(query.has("merged"));
|
|
||||||
const tab = $derived(query.get("tab") === "month" ? "month" : "grew");
|
|
||||||
|
|
||||||
const cursor = $derived(cursorOf(period.range, now));
|
|
||||||
const year = $derived(cursor.slice(0, 4));
|
|
||||||
const anchor = $derived(year === thisYear ? now : `${year}-12-31`);
|
|
||||||
const span = $derived(ledgerWindow(anchor));
|
|
||||||
const first = $derived(monthOf(period.first ?? "0000-01"));
|
|
||||||
|
|
||||||
const ledger = createQuery(() => ledgerQuery(anchor));
|
|
||||||
const assets = createQuery(() => lists.accounts("asset"));
|
|
||||||
const liabilities = createQuery(() => lists.accounts("liabilities"));
|
|
||||||
const own = $derived(
|
|
||||||
assets.data && liabilities.data
|
|
||||||
? [...assets.data, ...liabilities.data]
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
const ownIds = $derived(own?.map((a) => a.id) ?? []);
|
|
||||||
|
|
||||||
const range = $derived(
|
|
||||||
view === "month"
|
|
||||||
? monthRange(cursor)
|
|
||||||
: { end: `${year}-12-31`, start: `${year}-01-01` }
|
|
||||||
);
|
|
||||||
const params = $derived<ReportParams>({
|
|
||||||
accounts: ownIds,
|
|
||||||
end: range.end,
|
|
||||||
items: [],
|
|
||||||
start: range.start,
|
|
||||||
type: "default",
|
|
||||||
});
|
|
||||||
const mine = $derived(
|
|
||||||
ledger.data && own ? ownFlows(ledger.data, own) : undefined
|
|
||||||
);
|
|
||||||
const into = $derived(
|
|
||||||
mine ? displayCode(new Set(mine.map((flow) => flow.currency))) : null
|
|
||||||
);
|
|
||||||
const report = createReport(
|
|
||||||
() => params,
|
|
||||||
() => ledger.data,
|
|
||||||
() => into
|
|
||||||
);
|
|
||||||
provideReport(report);
|
|
||||||
|
|
||||||
const flows = $derived(mine && displayFlows(mine, into));
|
|
||||||
const board = $derived(
|
|
||||||
flows ? buildBoard(flows, span.months, year, report.codes, now) : undefined
|
|
||||||
);
|
|
||||||
const pcOk = $derived(
|
|
||||||
!!flows &&
|
|
||||||
flows.length > 0 &&
|
|
||||||
flows.every((flow) => flow.pc !== null) &&
|
|
||||||
(board?.codes.length ?? 0) > 1
|
|
||||||
);
|
|
||||||
const lanes = $derived.by(() => {
|
|
||||||
if (!board) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return pcOk && merged ? [mergedLane(board)] : spendingLanes(board);
|
|
||||||
});
|
|
||||||
const swaps = $derived.by(() => {
|
|
||||||
if (!(ledger.data && own)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const currency = new Map(
|
|
||||||
own.map((a) => [a.id, a.attributes.currency_code])
|
|
||||||
);
|
|
||||||
return exchanges(
|
|
||||||
ledger.data,
|
|
||||||
new Set(ownIds),
|
|
||||||
(id) => currency.get(id) ?? undefined
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const grewRows = $derived(board ? grew(board, cursor) : []);
|
|
||||||
const facts = $derived(board ? monthFacts(board, cursor) : []);
|
|
||||||
const loading = $derived(!board);
|
|
||||||
let hover = $state<string | null>(null);
|
|
||||||
|
|
||||||
function go(month: string, drill?: Drill | null) {
|
|
||||||
const target = monthRange(month);
|
|
||||||
navigate({
|
|
||||||
end: target.end,
|
|
||||||
start: target.start,
|
|
||||||
...(drill === undefined ? {} : drillQuery(drill)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(month: string) {
|
|
||||||
if (month > thisMonth) {
|
|
||||||
return thisMonth;
|
|
||||||
}
|
|
||||||
return month < first ? first : month;
|
|
||||||
}
|
|
||||||
|
|
||||||
function move(delta: number) {
|
|
||||||
const next = clamp(shiftMonth(cursor, delta));
|
|
||||||
if (next !== cursor) {
|
|
||||||
go(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelFor(month: string, item = "Spent") {
|
|
||||||
return view === "year" ? item : `${item}, ${longMonth(month)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drillFor(
|
|
||||||
month: string,
|
|
||||||
box: string,
|
|
||||||
extra: Partial<Drill> = {}
|
|
||||||
): Drill {
|
|
||||||
return {
|
|
||||||
at: view === "year" ? month : undefined,
|
|
||||||
box,
|
|
||||||
dir: -1,
|
|
||||||
...extra,
|
|
||||||
label: labelFor(month, extra.label),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMonth(month: string, segment?: string) {
|
|
||||||
const found = board?.catKeys.find(
|
|
||||||
(k) => k.key === segment && k.key !== "other"
|
|
||||||
);
|
|
||||||
const drill = drillFor(month, "months", {
|
|
||||||
dim: found ? "category" : undefined,
|
|
||||||
key: found?.key,
|
|
||||||
label: found?.label,
|
|
||||||
});
|
|
||||||
const same =
|
|
||||||
report.drill?.box === "months" &&
|
|
||||||
report.drill.key === drill.key &&
|
|
||||||
month === cursor;
|
|
||||||
go(month, same ? null : drill);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setView(next: "year" | "month") {
|
|
||||||
navigate({ ...drillQuery(null), view: next === "month" ? "month" : null });
|
|
||||||
}
|
|
||||||
|
|
||||||
function flag(name: string, on: boolean) {
|
|
||||||
navigate({ [name]: on ? "1" : null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const yearLinks = $derived.by(() => {
|
|
||||||
const dates = `${ymd(range.start)}/${ymd(range.end)}`;
|
|
||||||
const top = [...(board?.spending ?? [])]
|
|
||||||
.filter((flow) => flow.other.type === "Expense account")
|
|
||||||
.reduce((counts, flow) => {
|
|
||||||
counts.set(flow.other.id, (counts.get(flow.other.id) ?? 0) + 1);
|
|
||||||
return counts;
|
|
||||||
}, new Map<string, number>());
|
|
||||||
const busiest = [...top.entries()]
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(([id]) => id);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
href: `/reports/budget/${ALL_ASSETS}/allBudgets/${dates}`,
|
|
||||||
label: "Budget report",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: `/reports/category/${ALL_ASSETS}/allCategories/${dates}`,
|
|
||||||
label: "Category report",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: `/reports/tag/${ALL_ASSETS}/allTags/${dates}`,
|
|
||||||
label: "Tag report",
|
|
||||||
},
|
|
||||||
...(busiest.length > 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
href: `/reports/double/${ALL_ASSETS}/${busiest.join(",")}/${dates}`,
|
|
||||||
label: "Expense and revenue account report",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
href: `/reports/audit/${ALL_ASSETS}/${dates}`,
|
|
||||||
label: "Transaction history",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: `/reports/default/${ALL_ASSETS}/${dates}`,
|
|
||||||
label: "Default financial report",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
useKeys(() => [
|
|
||||||
{
|
|
||||||
group: "Report",
|
|
||||||
keys: "ArrowLeft",
|
|
||||||
label: "Previous month",
|
|
||||||
run: () => move(-1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Report",
|
|
||||||
keys: "ArrowRight",
|
|
||||||
label: "Next month",
|
|
||||||
run: () => move(1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Report",
|
|
||||||
keys: "[",
|
|
||||||
label: "Previous year",
|
|
||||||
run: () => move(-12),
|
|
||||||
},
|
|
||||||
{ group: "Report", keys: "]", label: "Next year", run: () => move(12) },
|
|
||||||
{
|
|
||||||
group: "Report",
|
|
||||||
keys: "Enter",
|
|
||||||
label: "Transactions of the month",
|
|
||||||
run: () => openMonth(cursor),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Report",
|
|
||||||
keys: "m",
|
|
||||||
label: tables ? "Hide tables by month" : "Show tables by month",
|
|
||||||
run: () => flag("tables", !tables),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Report",
|
|
||||||
keys: "g M",
|
|
||||||
label: "Month view",
|
|
||||||
run: () => setView("month"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: "Report",
|
|
||||||
keys: "g Y",
|
|
||||||
label: "Year view",
|
|
||||||
run: () => setView("year"),
|
|
||||||
},
|
|
||||||
...(pcOk
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
group: "Report",
|
|
||||||
keys: "c",
|
|
||||||
label: merged ? "One lane per currency" : "All in one currency",
|
|
||||||
run: () => flag("merged", !merged),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<PageHeader
|
|
||||||
crumbs={[{ label: "Reports" }]}
|
|
||||||
subtitle={view === "month" ? longMonth(cursor) : year}
|
|
||||||
title="Reports"
|
|
||||||
>
|
|
||||||
{#snippet actions()}
|
|
||||||
<nav aria-label="Report period" class="flex items-center gap-3 text-[13px]">
|
|
||||||
{#if `${Number(year) - 1}-12` >= first}
|
|
||||||
<button
|
|
||||||
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
|
|
||||||
onclick={() => move(-12)}
|
|
||||||
title="Previous year ["
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
‹ {Number(year) - 1}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if year < thisYear}
|
|
||||||
<button
|
|
||||||
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
|
|
||||||
onclick={() => move(12)}
|
|
||||||
title="Next year ]"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{Number(year) + 1}
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<span aria-hidden="true" class="text-cladd-fg-softest">|</span>
|
|
||||||
{#each [
|
|
||||||
{ key: "year" as const, label: "Year", shortcut: "g Y" },
|
|
||||||
{ key: "month" as const, label: "Month", shortcut: "g M" },
|
|
||||||
] as item (item.key)}
|
|
||||||
<button
|
|
||||||
aria-pressed={view === item.key}
|
|
||||||
class={view === item.key
|
|
||||||
? "font-semibold text-cladd-fg"
|
|
||||||
: "text-brand hover:underline dark:text-[var(--ff-series-1)]"}
|
|
||||||
onclick={() => setView(item.key)}
|
|
||||||
title="{item.label} view {item.shortcut}"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
{/snippet}
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
{#if ledger.error}
|
|
||||||
<p class="mb-4 text-expense">
|
|
||||||
Firefly III did not return the transactions: {ledger.error.message}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<Headline {board} month={view === "month" ? cursor : null} />
|
|
||||||
|
|
||||||
<ReportBox
|
|
||||||
body="px-3 pt-2 pb-3"
|
|
||||||
empty={lanes.length === 0}
|
|
||||||
emptyText="No spending or income in {year}."
|
|
||||||
id="months"
|
|
||||||
{loading}
|
|
||||||
title={merged && pcOk
|
|
||||||
? "Spent each month, all in one currency"
|
|
||||||
: "Spent each month"}
|
|
||||||
>
|
|
||||||
{#snippet tools()}
|
|
||||||
{#if pcOk}
|
|
||||||
<button
|
|
||||||
aria-pressed={merged}
|
|
||||||
class="text-[12px] text-brand hover:underline dark:text-[var(--ff-series-1)]"
|
|
||||||
onclick={() => flag("merged", !merged)}
|
|
||||||
title="Switch c"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{merged ? "One lane per currency" : "All in one currency"}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
<Lanes
|
|
||||||
{cursor}
|
|
||||||
dotLabel="earned"
|
|
||||||
label="Spent each month by currency"
|
|
||||||
{lanes}
|
|
||||||
onselect={(month, _lane, segment) => openMonth(month, segment)}
|
|
||||||
partial={board?.partial}
|
|
||||||
pinned={view === "month"}
|
|
||||||
title={(row) => longMonth(row.key)}
|
|
||||||
bind:hover
|
|
||||||
/>
|
|
||||||
</ReportBox>
|
|
||||||
|
|
||||||
<div class="flex gap-4 text-[13px] lg:hidden" role="tablist">
|
|
||||||
{#each [
|
|
||||||
{ key: "grew", label: "What grew" },
|
|
||||||
{ key: "month", label: longMonth(cursor) },
|
|
||||||
] as item (item.key)}
|
|
||||||
<button
|
|
||||||
aria-selected={tab === item.key}
|
|
||||||
class={tab === item.key
|
|
||||||
? "font-semibold text-cladd-fg"
|
|
||||||
: "text-brand dark:text-[var(--ff-series-1)]"}
|
|
||||||
onclick={() => navigate({ tab: item.key === "month" ? "month" : null })}
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReportRow class="lg:grid-cols-2">
|
|
||||||
<WhatGrew
|
|
||||||
class={tab === "grew" ? "" : "max-lg:hidden"}
|
|
||||||
codes={board?.codes.length ?? 0}
|
|
||||||
colors={board?.catKeys ?? []}
|
|
||||||
{cursor}
|
|
||||||
{loading}
|
|
||||||
monthDrill={(extra) => drillFor(cursor, "grew", extra)}
|
|
||||||
partial={cursor === board?.partial}
|
|
||||||
rows={grewRows}
|
|
||||||
/>
|
|
||||||
<MonthPanel
|
|
||||||
class={tab === "month" ? "" : "max-lg:hidden"}
|
|
||||||
{facts}
|
|
||||||
{loading}
|
|
||||||
month={cursor}
|
|
||||||
monthDrill={(extra) => drillFor(cursor, "month", extra)}
|
|
||||||
partial={cursor === board?.partial}
|
|
||||||
swaps={swaps.filter((swap) => monthOf(swap.date) === cursor)}
|
|
||||||
totals={view === "year"}
|
|
||||||
/>
|
|
||||||
</ReportRow>
|
|
||||||
|
|
||||||
{#if view === "month"}
|
|
||||||
<MoneyFlow switcher />
|
|
||||||
{:else if board}
|
|
||||||
<CurrencyTable {board} {swaps} />
|
|
||||||
<NetWorthLanes {cursor} groups={ledger.data ?? []} {own} {year} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if tables}
|
|
||||||
<BoardTables {view} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<nav
|
|
||||||
aria-label="More reports"
|
|
||||||
class="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-[12px] text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
|
|
||||||
onclick={() => flag("tables", !tables)}
|
|
||||||
title="Tables by month m"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tables ? "Hide tables by month" : "Tables by month"}
|
|
||||||
</button>
|
|
||||||
{#each yearLinks as link (link.href)}
|
|
||||||
<a
|
|
||||||
class="text-brand hover:underline dark:text-[var(--ff-series-1)]"
|
|
||||||
href={link.href}
|
|
||||||
>{link.label}</a
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { bucketOf, buckets, group, keys, total } from "#lib/reports/flows.js";
|
|
||||||
import { modeLabel, useReport } from "#lib/reports/report.svelte.js";
|
|
||||||
import { bucketLabel } from "#lib/reports/view.js";
|
|
||||||
import BudgetLimits from "./BudgetLimits.svelte";
|
|
||||||
import CurrencySwitch from "./CurrencySwitch.svelte";
|
|
||||||
import Matrix from "./Matrix.svelte";
|
|
||||||
import ReportBox from "./ReportBox.svelte";
|
|
||||||
import ReportRow from "./ReportRow.svelte";
|
|
||||||
import SpendingCalendar from "./SpendingCalendar.svelte";
|
|
||||||
import Subscriptions from "./Subscriptions.svelte";
|
|
||||||
import SumTable from "./SumTable.svelte";
|
|
||||||
|
|
||||||
let { view }: { view: "year" | "month" } = $props();
|
|
||||||
|
|
||||||
const report = useReport();
|
|
||||||
const flows = $derived(report.flows);
|
|
||||||
const income = $derived(flows?.filter((f) => f.dir === 1) ?? []);
|
|
||||||
const expenses = $derived(flows?.filter((f) => f.dir === -1) ?? []);
|
|
||||||
const withdrawals = $derived(expenses.filter((f) => f.type === "withdrawal"));
|
|
||||||
const deposits = $derived(income.filter((f) => f.type === "deposit"));
|
|
||||||
const periodCols = $derived(
|
|
||||||
buckets(report.params.start, report.params.end, "month").map((key) => ({
|
|
||||||
key,
|
|
||||||
label: bucketLabel(key, "month", true),
|
|
||||||
title: bucketLabel(key, "month"),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
const month = (date: string) => bucketOf(date, "month");
|
|
||||||
const NAMES = { budget: "Budget", category: "Category", tag: "Tag" };
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#snippet matrix(
|
|
||||||
id: string,
|
|
||||||
title: string,
|
|
||||||
dim: "budget" | "category" | "tag",
|
|
||||||
dir: 1 | -1,
|
|
||||||
list: typeof expenses
|
|
||||||
)}
|
|
||||||
<ReportBox
|
|
||||||
empty={list.length === 0}
|
|
||||||
{id}
|
|
||||||
loading={!flows}
|
|
||||||
title="{title} in {modeLabel(report.mode)}"
|
|
||||||
>
|
|
||||||
{#snippet tools()}
|
|
||||||
<CurrencySwitch />
|
|
||||||
{/snippet}
|
|
||||||
<Matrix
|
|
||||||
average={dim === "budget"}
|
|
||||||
box={id}
|
|
||||||
cols={periodCols}
|
|
||||||
{dim}
|
|
||||||
{dir}
|
|
||||||
nameLabel={NAMES[dim]}
|
|
||||||
rows={group(list, keys[dim], (f) => month(f.date))}
|
|
||||||
side={dir === 1 ? "in" : "out"}
|
|
||||||
/>
|
|
||||||
</ReportBox>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
{#if view === "year"}
|
|
||||||
{@render matrix("budget-periods", "Budgets", "budget", -1, withdrawals)}
|
|
||||||
{@render matrix(
|
|
||||||
"category-expenses",
|
|
||||||
"Categories, spent",
|
|
||||||
"category",
|
|
||||||
-1,
|
|
||||||
withdrawals
|
|
||||||
)}
|
|
||||||
{@render matrix(
|
|
||||||
"category-income",
|
|
||||||
"Categories, earned",
|
|
||||||
"category",
|
|
||||||
1,
|
|
||||||
deposits
|
|
||||||
)}
|
|
||||||
{@render matrix("tag-periods", "Tags, spent", "tag", -1, expenses)}
|
|
||||||
{:else}
|
|
||||||
<ReportRow class="lg:grid-cols-2">
|
|
||||||
<ReportBox
|
|
||||||
empty={expenses.length === 0}
|
|
||||||
id="expenses"
|
|
||||||
loading={!flows}
|
|
||||||
title="Expenses"
|
|
||||||
>
|
|
||||||
<SumTable
|
|
||||||
box="expenses"
|
|
||||||
columns={[{ kind: "out", label: "Total" }]}
|
|
||||||
counts
|
|
||||||
dim="other"
|
|
||||||
dir={-1}
|
|
||||||
footer={total(expenses)}
|
|
||||||
limit={10}
|
|
||||||
rows={group(expenses, keys.other)}
|
|
||||||
sort="out"
|
|
||||||
/>
|
|
||||||
</ReportBox>
|
|
||||||
<ReportBox
|
|
||||||
empty={income.length === 0}
|
|
||||||
id="income"
|
|
||||||
loading={!flows}
|
|
||||||
title="Revenue / income"
|
|
||||||
>
|
|
||||||
<SumTable
|
|
||||||
box="income"
|
|
||||||
columns={[{ kind: "in", label: "Total" }]}
|
|
||||||
counts
|
|
||||||
dim="other"
|
|
||||||
dir={1}
|
|
||||||
footer={total(income)}
|
|
||||||
limit={10}
|
|
||||||
rows={group(income, keys.other)}
|
|
||||||
sort="in"
|
|
||||||
/>
|
|
||||||
</ReportBox>
|
|
||||||
</ReportRow>
|
|
||||||
<ReportRow class="lg:grid-cols-12">
|
|
||||||
<BudgetLimits class="lg:col-span-7" />
|
|
||||||
<ReportBox
|
|
||||||
class="lg:col-span-5"
|
|
||||||
empty={expenses.length === 0}
|
|
||||||
id="tags"
|
|
||||||
loading={!flows}
|
|
||||||
title="Tags"
|
|
||||||
>
|
|
||||||
<SumTable
|
|
||||||
box="tags"
|
|
||||||
columns={[
|
|
||||||
{ kind: "out", label: "Spent" },
|
|
||||||
{ kind: "in", label: "Earned" },
|
|
||||||
]}
|
|
||||||
dim="tag"
|
|
||||||
footer={report.totals}
|
|
||||||
limit={10}
|
|
||||||
nameLabel="Tag"
|
|
||||||
rows={group(flows ?? [], keys.tag)}
|
|
||||||
/>
|
|
||||||
</ReportBox>
|
|
||||||
</ReportRow>
|
|
||||||
<Subscriptions />
|
|
||||||
{/if}
|
|
||||||
<SpendingCalendar />
|
|
||||||
</div>
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { formatRange, percent } from "#lib/format.js";
|
import { formatRange, percent } from "#lib/format.js";
|
||||||
import { inDisplay } from "#lib/reports/display.js";
|
|
||||||
import { NONE } from "#lib/reports/flows.js";
|
import { NONE } from "#lib/reports/flows.js";
|
||||||
import { reportQueries } from "#lib/reports/queries.js";
|
import { reportQueries } from "#lib/reports/queries.js";
|
||||||
import {
|
import {
|
||||||
@@ -37,31 +36,12 @@
|
|||||||
const withdrawals = flows.filter(
|
const withdrawals = flows.filter(
|
||||||
(f) => f.dir === -1 && f.type === "withdrawal"
|
(f) => f.dir === -1 && f.type === "withdrawal"
|
||||||
);
|
);
|
||||||
const merged = new Map<string, Line>();
|
const result: Line[] = limits.data.map((item) => {
|
||||||
for (const item of limits.data) {
|
|
||||||
const a = item.attributes;
|
const a = item.attributes;
|
||||||
const start = (a.start ?? p.start).slice(0, 10);
|
const start = (a.start ?? p.start).slice(0, 10);
|
||||||
const end = (a.end ?? p.end).slice(0, 10);
|
const end = (a.end ?? p.end).slice(0, 10);
|
||||||
const { amount, code } = inDisplay(
|
const code = a.currency_code ?? "";
|
||||||
Number(a.amount ?? 0),
|
const spent = withdrawals
|
||||||
a.currency_code ?? "",
|
|
||||||
report.into
|
|
||||||
);
|
|
||||||
const key = [a.budget_id, start, end, code].join("|");
|
|
||||||
const found = merged.get(key);
|
|
||||||
if (found) {
|
|
||||||
found.budgeted = (found.budgeted ?? 0) + amount;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
merged.set(key, {
|
|
||||||
budgeted: amount,
|
|
||||||
budgetId: String(a.budget_id),
|
|
||||||
code,
|
|
||||||
end,
|
|
||||||
key: item.id,
|
|
||||||
name:
|
|
||||||
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
|
|
||||||
spent: withdrawals
|
|
||||||
.filter(
|
.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.budget?.id === String(a.budget_id) &&
|
f.budget?.id === String(a.budget_id) &&
|
||||||
@@ -69,11 +49,19 @@
|
|||||||
f.date >= start &&
|
f.date >= start &&
|
||||||
f.date <= end
|
f.date <= end
|
||||||
)
|
)
|
||||||
.reduce((sum, f) => sum + f.amount, 0),
|
.reduce((sum, f) => sum + f.amount, 0);
|
||||||
|
return {
|
||||||
|
budgeted: Number(a.amount ?? 0),
|
||||||
|
budgetId: String(a.budget_id),
|
||||||
|
code,
|
||||||
|
end,
|
||||||
|
key: item.id,
|
||||||
|
name:
|
||||||
|
report.itemName("budget", String(a.budget_id)) ?? `#${a.budget_id}`,
|
||||||
|
spent,
|
||||||
start,
|
start,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
const result = [...merged.values()];
|
|
||||||
result.sort(
|
result.sort(
|
||||||
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
|
(a, b) => a.name.localeCompare(b.name) || a.start.localeCompare(b.start)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
modeLabel,
|
|
||||||
navigate,
|
|
||||||
useReport,
|
|
||||||
} from "#lib/reports/report.svelte.js";
|
|
||||||
|
|
||||||
const report = useReport();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if report.available.length > 1}
|
|
||||||
<div class="flex items-center gap-3 text-[12px]">
|
|
||||||
{#each report.available as mode (mode)}
|
|
||||||
<button
|
|
||||||
aria-pressed={report.mode === mode}
|
|
||||||
class={report.mode === mode
|
|
||||||
? "font-semibold text-cladd-fg"
|
|
||||||
: "text-brand hover:underline dark:text-[var(--ff-series-1)]"}
|
|
||||||
onclick={() => navigate({ cur: mode })}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{modeLabel(mode)}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { formatNumber } from "#lib/format.js";
|
|
||||||
import {
|
|
||||||
type Board,
|
|
||||||
longMonth,
|
|
||||||
monthOf,
|
|
||||||
shortMonth,
|
|
||||||
} from "#lib/reports/board.js";
|
|
||||||
import type { Exchange } from "#lib/reports/flows.js";
|
|
||||||
import {
|
|
||||||
drillQuery,
|
|
||||||
navigate,
|
|
||||||
useReport,
|
|
||||||
} from "#lib/reports/report.svelte.js";
|
|
||||||
import { typical } from "#lib/reports/typical.js";
|
|
||||||
import ReportBox from "./ReportBox.svelte";
|
|
||||||
|
|
||||||
let { board, swaps }: { board: Board; swaps: Exchange[] } = $props();
|
|
||||||
|
|
||||||
const report = useReport();
|
|
||||||
|
|
||||||
type Kind = "spent" | "earned" | "moved";
|
|
||||||
|
|
||||||
const LABELS: Record<Kind, string> = {
|
|
||||||
earned: "Earned",
|
|
||||||
moved: "Exchanged",
|
|
||||||
spent: "Spent",
|
|
||||||
};
|
|
||||||
|
|
||||||
const moved = $derived.by(() => {
|
|
||||||
const sums = new Map<string, Map<string, number>>();
|
|
||||||
const add = (code: string, month: string, value: number) => {
|
|
||||||
const byMonth = sums.get(code) ?? new Map<string, number>();
|
|
||||||
byMonth.set(month, (byMonth.get(month) ?? 0) + value);
|
|
||||||
sums.set(code, byMonth);
|
|
||||||
};
|
|
||||||
for (const swap of swaps) {
|
|
||||||
add(swap.to, monthOf(swap.date), swap.toAmount);
|
|
||||||
add(swap.from, monthOf(swap.date), -swap.fromAmount);
|
|
||||||
}
|
|
||||||
return sums;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sources = $derived({
|
|
||||||
earned: board.earned,
|
|
||||||
moved,
|
|
||||||
spent: board.spent,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = $derived(
|
|
||||||
board.codes.flatMap((code) =>
|
|
||||||
(["spent", "earned", "moved"] as Kind[])
|
|
||||||
.map((kind) => {
|
|
||||||
const byMonth = sources[kind].get(code);
|
|
||||||
return {
|
|
||||||
byMonth,
|
|
||||||
code,
|
|
||||||
kind,
|
|
||||||
total: board.months.reduce(
|
|
||||||
(sum, m) => sum + (byMonth?.get(m) ?? 0),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
typical:
|
|
||||||
kind === "moved" ? null : typical(byMonth, board.typicalMonths),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((row) => board.months.some((m) => row.byMonth?.get(m)))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const toneOf = (kind: Kind, value: number) => {
|
|
||||||
if (kind === "spent") {
|
|
||||||
return "text-expense";
|
|
||||||
}
|
|
||||||
if (kind === "earned") {
|
|
||||||
return "text-income";
|
|
||||||
}
|
|
||||||
return value < 0 ? "text-cladd-fg-soft" : "text-transfer";
|
|
||||||
};
|
|
||||||
|
|
||||||
const cell = (kind: Kind, value: number) =>
|
|
||||||
kind === "moved" ? formatNumber(value, 0, true) : formatNumber(value, 0);
|
|
||||||
|
|
||||||
function open(code: string, kind: Kind, month: string) {
|
|
||||||
const dir = kind === "spent" ? -1 : 1;
|
|
||||||
const current = report.drill;
|
|
||||||
const same =
|
|
||||||
current?.box === "currencies" &&
|
|
||||||
current.at === month &&
|
|
||||||
current.currency === code &&
|
|
||||||
current.dir === dir;
|
|
||||||
navigate(
|
|
||||||
drillQuery(
|
|
||||||
same
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
at: month,
|
|
||||||
box: "currencies",
|
|
||||||
currency: code,
|
|
||||||
dir,
|
|
||||||
label: `${LABELS[kind]} in ${code}, ${longMonth(month)}`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ReportBox
|
|
||||||
empty={rows.length === 0}
|
|
||||||
id="currencies"
|
|
||||||
loading={false}
|
|
||||||
title="By currency"
|
|
||||||
>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full border-collapse text-[13px]">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-[12px] text-cladd-fg-soft">
|
|
||||||
<th
|
|
||||||
class="sticky left-0 z-[1] h-7 bg-cladd-surface px-3 text-left font-medium"
|
|
||||||
></th>
|
|
||||||
{#each board.months as month (month)}
|
|
||||||
<th
|
|
||||||
class="h-7 px-2 text-right font-medium {month === board.partial
|
|
||||||
? "italic"
|
|
||||||
: ""}"
|
|
||||||
title={longMonth(month)}
|
|
||||||
>
|
|
||||||
{shortMonth(month)}
|
|
||||||
</th>
|
|
||||||
{/each}
|
|
||||||
<th class="h-7 whitespace-nowrap px-2 text-right font-medium">
|
|
||||||
Typical
|
|
||||||
</th>
|
|
||||||
<th class="h-7 px-3 text-right font-medium">Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each rows as row, i (`${row.code}-${row.kind}`)}
|
|
||||||
{@const first = i === 0 || rows[i - 1].code !== row.code}
|
|
||||||
<tr
|
|
||||||
class="h-7 hover:bg-row-hover {first
|
|
||||||
? "border-cladd-outline border-t-2"
|
|
||||||
: "border-cladd-outline border-t"}"
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="sticky left-0 z-[1] whitespace-nowrap bg-cladd-surface px-3 text-left font-normal"
|
|
||||||
scope="row"
|
|
||||||
>
|
|
||||||
<span class="inline-block w-10 font-semibold"
|
|
||||||
>{first ? row.code : ""}</span
|
|
||||||
>
|
|
||||||
<span class="text-cladd-fg-soft">{LABELS[row.kind]}</span>
|
|
||||||
</th>
|
|
||||||
{#each board.months as month (month)}
|
|
||||||
{@const value = row.byMonth?.get(month) ?? 0}
|
|
||||||
{@const active =
|
|
||||||
report.drill?.box === "currencies" &&
|
|
||||||
report.drill.at === month &&
|
|
||||||
report.drill.currency === row.code &&
|
|
||||||
report.drill.dir === (row.kind === "spent" ? -1 : 1)}
|
|
||||||
<td class="p-0 text-right">
|
|
||||||
{#if value && row.kind !== "moved"}
|
|
||||||
<button
|
|
||||||
class="num h-7 w-full whitespace-nowrap px-2 text-right hover:bg-row-cursor {toneOf(
|
|
||||||
row.kind,
|
|
||||||
value
|
|
||||||
)} {active ? "bg-row-selected font-semibold" : ""}"
|
|
||||||
onclick={() => open(row.code, row.kind, month)}
|
|
||||||
tabindex="-1"
|
|
||||||
title="{LABELS[row.kind]} in {row.code}, {longMonth(month)}"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{cell(row.kind, value)}
|
|
||||||
</button>
|
|
||||||
{:else if value}
|
|
||||||
<span
|
|
||||||
class="num block whitespace-nowrap px-2 {toneOf(
|
|
||||||
row.kind,
|
|
||||||
value
|
|
||||||
)}"
|
|
||||||
>{cell(row.kind, value)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
<td
|
|
||||||
class="num whitespace-nowrap px-2 text-right text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
{row.typical === null ? "" : formatNumber(row.typical, 0)}
|
|
||||||
</td>
|
|
||||||
<td class="num whitespace-nowrap px-3 text-right font-semibold">
|
|
||||||
{cell(row.kind, row.total)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</ReportBox>
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Box from "#lib/components/Box.svelte";
|
|
||||||
import {
|
|
||||||
type Board,
|
|
||||||
headlines,
|
|
||||||
longMonth,
|
|
||||||
ordinal,
|
|
||||||
shortMonth,
|
|
||||||
} from "#lib/reports/board.js";
|
|
||||||
import { money } from "#lib/reports/report.svelte.js";
|
|
||||||
import { versus, versusText } from "#lib/reports/typical.js";
|
|
||||||
|
|
||||||
let {
|
|
||||||
board,
|
|
||||||
month = null,
|
|
||||||
}: { board: Board | undefined; month?: string | null } = $props();
|
|
||||||
|
|
||||||
const rows = $derived(
|
|
||||||
board ? headlines(board, month ? [month] : board.months) : []
|
|
||||||
);
|
|
||||||
const year = $derived(board?.months[0]?.slice(0, 4) ?? "");
|
|
||||||
const period = $derived(month ? longMonth(month) : year);
|
|
||||||
const showPace = $derived(rows.some((row) => row.pace));
|
|
||||||
const byDay = $derived(
|
|
||||||
`Typical by the ${ordinal(Number(board?.now.slice(8, 10) ?? 0))}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const tone = (ratio: number | null) => {
|
|
||||||
if (ratio === null || Math.abs(ratio) < 0.03) {
|
|
||||||
return "text-cladd-fg-soft";
|
|
||||||
}
|
|
||||||
return ratio > 0 ? "text-expense" : "text-income";
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Box body="px-3 py-2">
|
|
||||||
{#if !board}
|
|
||||||
<p class="py-2 text-cladd-fg-soft">Loading</p>
|
|
||||||
{:else if rows.length === 0}
|
|
||||||
<p class="py-2 text-cladd-fg-soft">No spending or income in {period}.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="border-collapse text-[13px]">
|
|
||||||
<thead>
|
|
||||||
<tr
|
|
||||||
class="whitespace-nowrap text-left text-[12px] text-cladd-fg-soft"
|
|
||||||
>
|
|
||||||
<th class="w-12 pr-3 font-medium"></th>
|
|
||||||
<th class="pr-10 font-medium">Spent in {period}</th>
|
|
||||||
{#if showPace && !month}
|
|
||||||
<th class="pr-10 font-medium">
|
|
||||||
{shortMonth(board.partial ?? "")}
|
|
||||||
so far
|
|
||||||
</th>
|
|
||||||
{/if}
|
|
||||||
{#if showPace}
|
|
||||||
<th class="pr-10 font-medium">{byDay}</th>
|
|
||||||
{/if}
|
|
||||||
<th class="pr-10 font-medium">Typical month</th>
|
|
||||||
<th class="font-medium">Earned in {period}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each rows as row (row.code)}
|
|
||||||
{@const pace = row.pace}
|
|
||||||
{@const paceRatio = pace ? versus(pace.sofar, pace.typical) : null}
|
|
||||||
<tr class="h-9">
|
|
||||||
<th class="pr-3 text-left font-semibold" scope="row">
|
|
||||||
{row.code}
|
|
||||||
</th>
|
|
||||||
<td class="pr-10 sm:whitespace-nowrap">
|
|
||||||
<span class="num font-semibold text-[18px] tracking-[-0.01em]"
|
|
||||||
>{money(
|
|
||||||
row.code,
|
|
||||||
month && pace ? pace.sofar : row.spent,
|
|
||||||
false,
|
|
||||||
0
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
{#if month}
|
|
||||||
{@const ratio = pace
|
|
||||||
? paceRatio
|
|
||||||
: versus(row.spent, row.typical)}
|
|
||||||
<span
|
|
||||||
class="ml-2 whitespace-nowrap max-sm:ml-0 max-sm:block max-sm:text-[12px] {tone(
|
|
||||||
ratio
|
|
||||||
)}"
|
|
||||||
>{versusText(ratio)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
{#if showPace && !month}
|
|
||||||
<td class="pr-10 sm:whitespace-nowrap">
|
|
||||||
{#if pace}
|
|
||||||
<span class="num"
|
|
||||||
>{money(row.code, pace.sofar, false, 0)}</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="ml-2 whitespace-nowrap max-sm:ml-0 max-sm:block max-sm:text-[12px] {tone(
|
|
||||||
paceRatio
|
|
||||||
)}"
|
|
||||||
>{versusText(paceRatio)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
{/if}
|
|
||||||
{#if showPace}
|
|
||||||
<td class="num whitespace-nowrap pr-10">
|
|
||||||
{#if pace}
|
|
||||||
{money(row.code, pace.typical, false, 0)}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
{/if}
|
|
||||||
<td class="num whitespace-nowrap pr-10">
|
|
||||||
{money(row.code, row.typical, false, 0)}
|
|
||||||
</td>
|
|
||||||
<td class="num whitespace-nowrap text-income">
|
|
||||||
{#if row.earned}
|
|
||||||
{money(row.code, row.earned, false, 0)}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Box>
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from "@tanstack/svelte-query";
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
import { formatRange } from "#lib/format.js";
|
import { formatRange } from "#lib/format.js";
|
||||||
import { displayFlows } from "#lib/reports/display.js";
|
|
||||||
import {
|
import {
|
||||||
currencies,
|
currencies,
|
||||||
measureOf,
|
measureOf,
|
||||||
@@ -29,13 +28,10 @@
|
|||||||
const before = $derived(
|
const before = $derived(
|
||||||
earlier.data && report.accountsReady
|
earlier.data && report.accountsReady
|
||||||
? total(
|
? total(
|
||||||
displayFlows(
|
|
||||||
toFlows(
|
toFlows(
|
||||||
earlier.data,
|
earlier.data,
|
||||||
new Set(report.selected),
|
new Set(report.selected),
|
||||||
(id) => report.byId().get(id)?.currency_code
|
(id) => report.byId().get(id)?.currency_code
|
||||||
),
|
|
||||||
report.into
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
|
|||||||
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user