feat(accounts): merchants and payers as a league table
Expense and revenue accounts list this month per currency, share, visits, average, twelve months, typical and the change against it, last visit and category; quiet ones fold into one line behind i. 1 to 5 sort, ; groups by category with subtotals, x and s c set the category on all of a merchant's transactions in the period.
This commit is contained in:
1 parent
26903b0066
commit
fffb770cac
7 files changed
+1352
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||
import { lists } from "#lib/api/lists.js";
|
||||
import type { TransactionGroup } from "#lib/api/types.js";
|
||||
import { ratesQuery } from "#lib/dashboard/primary.svelte.js";
|
||||
import { today } from "#lib/dates.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { ledgerQuery, ledgerWindow, ownFlows } from "#lib/reports/ledger.js";
|
||||
import { pages } from "#lib/reports/queries.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 rates = createQuery(() => ({ ...ratesQuery(), enabled: true }));
|
||||
|
||||
const flows = $derived(
|
||||
ledger.data && accounts.data ? ownFlows(ledger.data, accounts.data) : []
|
||||
);
|
||||
const periodFlows = $derived.by(() => {
|
||||
if (inside) {
|
||||
return flows;
|
||||
}
|
||||
return extra.data && accounts.data
|
||||
? ownFlows(extra.data, accounts.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 primary = session.primary?.code ?? "";
|
||||
return board(accounts.data, flows, periodFlows, {
|
||||
day,
|
||||
kind: kind(),
|
||||
months: window.months.filter((m) => m <= month),
|
||||
past: typicalMonths(window.months, period.end, since),
|
||||
range: period.range,
|
||||
rate: (code) => rates.data?.[code]?.rate ?? (code === primary ? 1 : 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 || rates.isPending;
|
||||
},
|
||||
get stale() {
|
||||
return ledger.isFetching || extra.isPlaceholderData;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
months: string[];
|
||||
past: string[];
|
||||
range: Range;
|
||||
rate: (code: string) => number;
|
||||
}
|
||||
|
||||
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
|
||||
|
||||
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,
|
||||
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 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, (flow) => flow.currency);
|
||||
const nowBy = bucket(now, (flow) => flow.currency);
|
||||
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, (flow) => flow.currency);
|
||||
const yearBy = bucket(year, (flow) => flow.currency);
|
||||
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,
|
||||
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, number>();
|
||||
for (const line of merchants.flatMap((m) => m.lines)) {
|
||||
map.set(line.code, (map.get(line.code) ?? 0) + line.amount);
|
||||
}
|
||||
return [...map]
|
||||
.filter(([, amount]) => amount > 0)
|
||||
.map(([code, amount]) => ({ amount, base: 0, code, ratio: null }));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<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);
|
||||
return formatMoney({ ...cur, amount, decimals: decimals ?? cur.decimals });
|
||||
};
|
||||
|
||||
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}
|
||||
@@ -0,0 +1,705 @@
|
||||
<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,
|
||||
} 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 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: { amount: number; code: string }[]
|
||||
)}
|
||||
{#each list as t (t.code)}
|
||||
<div class="num">
|
||||
{formatMoney({ ...currency(t.code), amount: t.amount })}
|
||||
</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]"
|
||||
>{formatMoney({ ...currency(t.code), amount: t.amount })}</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>
|
||||
@@ -0,0 +1,72 @@
|
||||
<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>
|
||||
@@ -0,0 +1,46 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user