feat(budgets): top line per currency, sorting places into budgets, projections
Budgets show budgeted, left, spent and spent without a budget per currency, safe per day and where the month ends. t sorts expenses without a budget by place, 1 to 9 move a whole place into a budget, u undoes, and the editor and quick entry remember the place. A suggests empty amounts from three months. Categories get the same sorting.
This commit is contained in:
1 parent
68f64893f7
commit
cea51281cd
12 files changed
+1526
-205
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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]));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
budgetId,
|
budgetId,
|
||||||
range,
|
range,
|
||||||
editing,
|
editing,
|
||||||
|
suggested = null,
|
||||||
trap = true,
|
trap = true,
|
||||||
onedit,
|
onedit,
|
||||||
ondone,
|
ondone,
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
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;
|
||||||
@@ -33,7 +35,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
value = line.amount ? String(line.amount) : "";
|
value = String(line.amount || suggested || "");
|
||||||
problem = "";
|
problem = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,20 @@
|
|||||||
{@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
|
||||||
|
|||||||
@@ -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 { periodPath } from "./period-path.svelte.js";
|
import { keptParams, periodPath } from "./period-path.svelte.js";
|
||||||
|
|
||||||
const COUNT = 12;
|
const COUNT = 12;
|
||||||
|
|
||||||
@@ -24,7 +24,9 @@
|
|||||||
|
|
||||||
const before = $derived(walk(-1));
|
const before = $derived(walk(-1));
|
||||||
const after = $derived(walk(1));
|
const after = $derived(walk(1));
|
||||||
const href = (range: Range) => periodPath(base, range.start, range.end);
|
const kept = $derived(keptParams());
|
||||||
|
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;
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import { formatMoney, percent } from "#lib/format.js";
|
||||||
|
import type { Top } from "./data.js";
|
||||||
|
|
||||||
|
const CENT = 0.005;
|
||||||
|
|
||||||
|
let { tops, onsort }: { tops: Top[]; onsort: () => void } = $props();
|
||||||
|
|
||||||
|
const free = $derived(
|
||||||
|
tops.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[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const money = (top: Top, amount: number) =>
|
||||||
|
formatMoney({ amount, decimals: 0, symbol: top.cur.symbol });
|
||||||
|
</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 tops 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.cur.code}</span
|
||||||
|
>
|
||||||
|
{#if free}
|
||||||
|
{@render stat(
|
||||||
|
"Available",
|
||||||
|
top.available === null ? null : money(top, top.available)
|
||||||
|
)}
|
||||||
|
{/if}
|
||||||
|
{@render stat("Budgeted", top.budgeted ? money(top, top.budgeted) : null)}
|
||||||
|
{#if free}
|
||||||
|
{@const unallocated = (top.available ?? 0) - top.budgeted}
|
||||||
|
{@render stat(
|
||||||
|
"Left to budget",
|
||||||
|
top.available === null ? null : money(top, unallocated),
|
||||||
|
unallocated < 0 ? "text-expense" : ""
|
||||||
|
)}
|
||||||
|
{/if}
|
||||||
|
{@const left = top.budgeted - top.inBudgets}
|
||||||
|
{@render stat(
|
||||||
|
"Left in budgets",
|
||||||
|
top.budgeted ? money(top, left) : null,
|
||||||
|
left < 0 ? "text-expense" : ""
|
||||||
|
)}
|
||||||
|
{@render stat("Spent", money(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]"
|
||||||
|
>{money(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}
|
||||||
@@ -0,0 +1,673 @@
|
|||||||
|
<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}
|
||||||
|
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,18 +7,27 @@ import type {
|
|||||||
Currency,
|
Currency,
|
||||||
InsightTotalEntry,
|
InsightTotalEntry,
|
||||||
Item,
|
Item,
|
||||||
Page,
|
Split,
|
||||||
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 { contains, daysBetween, type Range, today } from "#lib/dates.js";
|
import {
|
||||||
|
contains,
|
||||||
|
daysBetween,
|
||||||
|
isDynamic,
|
||||||
|
type Range,
|
||||||
|
shiftPeriod,
|
||||||
|
today,
|
||||||
|
type ViewRange,
|
||||||
|
} from "#lib/dates.js";
|
||||||
import { toNumber } from "#lib/format.js";
|
import { toNumber } from "#lib/format.js";
|
||||||
|
|
||||||
export interface Line {
|
export interface Line {
|
||||||
@@ -43,6 +52,15 @@ export interface Pool {
|
|||||||
spent: number;
|
spent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Top {
|
||||||
|
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;
|
||||||
@@ -58,6 +76,9 @@ 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 => ({
|
||||||
@@ -98,23 +119,20 @@ 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, noBudget, noBudgetPage] =
|
const [budgets, limits, available, total, groups] = await Promise.all([
|
||||||
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 }),
|
||||||
api<InsightTotalEntry[]>("/v1/insight/expense/no-budget", { query }),
|
withoutBudget(range),
|
||||||
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: insightSums(noBudget, currencies),
|
noBudget: sumsOf(entries),
|
||||||
noBudgetCount: noBudgetPage.meta.pagination?.total ?? 0,
|
noBudgetCount: entries.length,
|
||||||
total: insightSums(total, currencies),
|
total: insightSums(total, currencies),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -215,6 +233,36 @@ 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -225,16 +273,60 @@ 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 perDay(line: Line, now = today()) {
|
export function projection(line: Line, now = today()): number | null {
|
||||||
|
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 current = contains(line.range, now);
|
const elapsed = daysBetween(line.range.start, now) + 1;
|
||||||
const passed = current ? daysBetween(line.range.start, now) + 1 : total;
|
return (-line.spent * total) / elapsed;
|
||||||
const left = current ? daysBetween(now, line.range.end) + 1 : total;
|
}
|
||||||
return {
|
|
||||||
left: line.amount === null ? null : (line.amount + line.spent) / left,
|
export function safePerDay(line: Line, now = today()): number | null {
|
||||||
past: line.range.end < now,
|
if (line.amount === null || !contains(line.range, now)) {
|
||||||
spent: line.spent / passed,
|
return null;
|
||||||
};
|
}
|
||||||
|
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,6 +7,14 @@ 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;
|
||||||
@@ -25,7 +33,9 @@ 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), { replace: true });
|
goto(`${periodPath(target, start, end)}${keptParams()}`, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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";
|
||||||
@@ -234,8 +235,24 @@ 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>>(
|
||||||
|
|||||||
@@ -3,38 +3,56 @@
|
|||||||
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 { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Budget, One } from "#lib/api/types.js";
|
||||||
|
import { learnBudget, learnedBudget } from "#lib/budgets/map.js";
|
||||||
|
import {
|
||||||
|
type Place,
|
||||||
|
placesOf,
|
||||||
|
refreshExcept,
|
||||||
|
type Scope,
|
||||||
|
withoutBudget,
|
||||||
|
} from "#lib/budgets/triage.js";
|
||||||
import Amount from "#lib/components/Amount.svelte";
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import {
|
import {
|
||||||
|
averages,
|
||||||
type BudgetRow,
|
type BudgetRow,
|
||||||
buildPools,
|
buildPools,
|
||||||
buildRows,
|
buildRows,
|
||||||
|
curOf,
|
||||||
|
isOpen,
|
||||||
type Line,
|
type Line,
|
||||||
loadBudgetPage,
|
loadBudgetPage,
|
||||||
pace,
|
pace,
|
||||||
perDay,
|
projection,
|
||||||
|
safePerDay,
|
||||||
|
saveLimit,
|
||||||
|
topLines,
|
||||||
} from "#lib/components/budgets/data.js";
|
} from "#lib/components/budgets/data.js";
|
||||||
import LimitCell, {
|
import LimitCell, {
|
||||||
type Move,
|
type Move,
|
||||||
} from "#lib/components/budgets/LimitCell.svelte";
|
} from "#lib/components/budgets/LimitCell.svelte";
|
||||||
import MeterBar from "#lib/components/budgets/MeterBar.svelte";
|
import MeterBar from "#lib/components/budgets/MeterBar.svelte";
|
||||||
import PeriodNav from "#lib/components/budgets/PeriodNav.svelte";
|
import PeriodNav from "#lib/components/budgets/PeriodNav.svelte";
|
||||||
import PoolBox from "#lib/components/budgets/PoolBox.svelte";
|
|
||||||
import {
|
import {
|
||||||
periodPath,
|
periodPath,
|
||||||
usePeriodPath,
|
usePeriodPath,
|
||||||
} from "#lib/components/budgets/period-path.svelte.js";
|
} from "#lib/components/budgets/period-path.svelte.js";
|
||||||
|
import TopLine from "#lib/components/budgets/TopLine.svelte";
|
||||||
|
import Triage from "#lib/components/budgets/Triage.svelte";
|
||||||
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
import ListMenu from "#lib/components/categories/ListMenu.svelte";
|
||||||
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
import MoneyList from "#lib/components/categories/MoneyList.svelte";
|
||||||
|
import { setParam } from "#lib/components/categories/url.js";
|
||||||
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 { formatMoney, formatRange } from "#lib/format.js";
|
import { formatMoney, formatRange } from "#lib/format.js";
|
||||||
import type { Binding } from "#lib/keys.svelte.js";
|
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
import { ListNav } from "#lib/list-nav.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 { oldUiUrl } from "#lib/spa-paths.js";
|
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
const PERIOD_WORDS: Record<string, string> = {
|
const PERIOD_WORDS: Record<string, string> = {
|
||||||
daily: "a day",
|
daily: "a day",
|
||||||
@@ -59,8 +77,15 @@
|
|||||||
? buildRows(query.data, period.range, session.currencies, primary)
|
? buildRows(query.data, period.range, session.currencies, primary)
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
const pools = $derived(
|
const tops = $derived(
|
||||||
query.data ? buildPools(query.data, rows, session.currencies, primary) : []
|
query.data
|
||||||
|
? topLines(
|
||||||
|
buildPools(query.data, rows, session.currencies, primary),
|
||||||
|
query.data.total,
|
||||||
|
query.data.noBudget,
|
||||||
|
primary
|
||||||
|
)
|
||||||
|
: []
|
||||||
);
|
);
|
||||||
const inactive = $derived(
|
const inactive = $derived(
|
||||||
query.data?.budgets.filter((b) => !b.attributes.active) ?? []
|
query.data?.budgets.filter((b) => !b.attributes.active) ?? []
|
||||||
@@ -72,6 +97,33 @@
|
|||||||
session.currencies.filter((c) => c.attributes.enabled)
|
session.currencies.filter((c) => c.attributes.enabled)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scope = $derived<Scope>(
|
||||||
|
page.url.searchParams.get("scope") === "all" ? "all" : "period"
|
||||||
|
);
|
||||||
|
const triageKey = $derived(
|
||||||
|
scope === "all"
|
||||||
|
? ["budget-triage", "all"]
|
||||||
|
: ["budget-triage", period.start, period.end]
|
||||||
|
);
|
||||||
|
const triage = createQuery(() => ({
|
||||||
|
enabled: period.ready,
|
||||||
|
queryFn: () => withoutBudget(scope === "all" ? null : period.range),
|
||||||
|
queryKey: triageKey,
|
||||||
|
}));
|
||||||
|
const places = $derived(triage.data ? placesOf(triage.data, isOpen) : []);
|
||||||
|
const options = $derived(
|
||||||
|
rows.map((row) => ({ id: row.budget.id, name: row.budget.attributes.name }))
|
||||||
|
);
|
||||||
|
const nowCurrent = $derived(period.isCurrent);
|
||||||
|
const endsLabel = $derived(
|
||||||
|
period.view === "1M" ? "Ends month at" : "Ends period at"
|
||||||
|
);
|
||||||
|
|
||||||
|
let triaging = $state(false);
|
||||||
|
let suggested = $state<Map<string, number> | null>(null);
|
||||||
|
let suggestNote = $state("");
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
let editKey = $state<string | null>(null);
|
let editKey = $state<string | null>(null);
|
||||||
let adding = $state<{ budgetId: string; code: string } | null>(null);
|
let adding = $state<{ budgetId: string; code: string } | null>(null);
|
||||||
let newName = $state("");
|
let newName = $state("");
|
||||||
@@ -114,6 +166,102 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const suggestionFor = (row: BudgetRow, line: Line) =>
|
||||||
|
line.amount === null
|
||||||
|
? (suggested?.get(`${row.budget.id}:${line.cur.code}`) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function linesOf(row: BudgetRow): Line[] {
|
||||||
|
const extra = addingLine(row);
|
||||||
|
const lines = extra ? [...row.lines, extra] : row.lines;
|
||||||
|
const prefix = `${row.budget.id}:`;
|
||||||
|
const more = [...(suggested?.keys() ?? [])]
|
||||||
|
.filter((key) => key.startsWith(prefix))
|
||||||
|
.map((key) => key.slice(prefix.length))
|
||||||
|
.filter((code) => !lines.some((line) => line.cur.code === code))
|
||||||
|
.map((code) => ({
|
||||||
|
amount: null,
|
||||||
|
cur: curOf(session.currencies, code),
|
||||||
|
key: `${prefix}${code}`,
|
||||||
|
range: period.range,
|
||||||
|
spent: 0,
|
||||||
|
}));
|
||||||
|
return [...lines, ...more];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = $derived(
|
||||||
|
rows.flatMap((row) =>
|
||||||
|
linesOf(row).flatMap((line) => {
|
||||||
|
const amount = suggestionFor(row, line);
|
||||||
|
return amount ? [{ amount, line, row }] : [];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function suggest() {
|
||||||
|
suggestNote = "Looking at the last 3 months";
|
||||||
|
try {
|
||||||
|
suggested = await averages(period.range, period.view);
|
||||||
|
suggestNote =
|
||||||
|
pending.length > 0
|
||||||
|
? ""
|
||||||
|
: "Every budget with spending already has an amount";
|
||||||
|
} catch (failure) {
|
||||||
|
suggestNote = failure instanceof Error ? failure.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard() {
|
||||||
|
suggested = null;
|
||||||
|
suggestNote = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSuggested() {
|
||||||
|
if (saving || pending.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = pending;
|
||||||
|
saving = true;
|
||||||
|
let saved = 0;
|
||||||
|
try {
|
||||||
|
await items.reduce(
|
||||||
|
(chain, item) =>
|
||||||
|
chain.then(async () => {
|
||||||
|
await saveLimit(
|
||||||
|
item.row.budget.id,
|
||||||
|
item.line,
|
||||||
|
item.amount,
|
||||||
|
period.range
|
||||||
|
);
|
||||||
|
saved += 1;
|
||||||
|
suggestNote = `Saving ${saved} of ${items.length}`;
|
||||||
|
}),
|
||||||
|
Promise.resolve()
|
||||||
|
);
|
||||||
|
await invalidate();
|
||||||
|
suggested = null;
|
||||||
|
suggestNote = `Saved ${saved} ${saved === 1 ? "amount" : "amounts"}`;
|
||||||
|
} catch (failure) {
|
||||||
|
suggestNote = failure instanceof Error ? failure.message : "Failed";
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBudget(name: string) {
|
||||||
|
const made = await api<One<Budget>>("/v1/budgets", {
|
||||||
|
body: { name },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return { id: made.data.id, name: made.data.attributes.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(place: Place, option: { id: string; name: string }) {
|
||||||
|
const before = learnedBudget(place.accountId);
|
||||||
|
learnBudget(place.accountId, option);
|
||||||
|
return () => learnBudget(place.accountId, before);
|
||||||
|
}
|
||||||
|
|
||||||
function done(index: number, move: Move) {
|
function done(index: number, move: Move) {
|
||||||
adding = null;
|
adding = null;
|
||||||
const next = rows[index + move];
|
const next = rows[index + move];
|
||||||
@@ -191,7 +339,38 @@
|
|||||||
key: (row) => row.budget.id,
|
key: (row) => row.budget.id,
|
||||||
open: (row) => goto(showHref(row)),
|
open: (row) => goto(showHref(row)),
|
||||||
});
|
});
|
||||||
nav.use();
|
|
||||||
|
useKeys(() => [
|
||||||
|
...(triaging ? [] : nav.bindings()),
|
||||||
|
{
|
||||||
|
group: "Budgets",
|
||||||
|
keys: "t",
|
||||||
|
label: triaging ? "Stop sorting" : "Sort expenses without a budget",
|
||||||
|
run: () => (triaging = !triaging),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Budgets",
|
||||||
|
keys: "A",
|
||||||
|
label: "Suggest empty amounts from the last 3 months",
|
||||||
|
run: suggest,
|
||||||
|
},
|
||||||
|
...(pending.length > 0 && !triaging
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
group: "Budgets",
|
||||||
|
keys: "mod+Enter",
|
||||||
|
label: "Save the suggested amounts",
|
||||||
|
run: saveSuggested,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Budgets",
|
||||||
|
keys: "Escape",
|
||||||
|
label: "Discard the suggested amounts",
|
||||||
|
run: discard,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
|
||||||
const money = (line: Line, amount: number) =>
|
const money = (line: Line, amount: number) =>
|
||||||
formatMoney({
|
formatMoney({
|
||||||
@@ -215,26 +394,31 @@
|
|||||||
<p class="text-expense">Could not load budgets: {query.error.message}</p>
|
<p class="text-expense">Could not load budgets: {query.error.message}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid gap-4 {pools.length > 1 ? "xl:grid-cols-2" : ""}">
|
<TopLine onsort={() => (triaging = true)} {tops} />
|
||||||
{#each pools as pool (pool.cur.code)}
|
|
||||||
<PoolBox
|
|
||||||
noBudget={query.data?.noBudget ?? []}
|
|
||||||
noBudgetCount={query.data?.noBudgetCount ?? 0}
|
|
||||||
{noBudgetHref}
|
|
||||||
{pool}
|
|
||||||
total={query.data?.total ?? []}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Box body="p-0" class="mt-4" title="Budgets">
|
<Box body="p-0" class="mt-4" title="Budgets">
|
||||||
{#snippet tools()}
|
{#snippet tools()}
|
||||||
<span class="text-[12px] text-cladd-fg-soft"
|
{#if pending.length > 0}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden"
|
||||||
|
>{pending.length}
|
||||||
|
suggested from the last 3 months</span
|
||||||
|
>
|
||||||
|
<Button disabled={saving} onclick={saveSuggested} size="sm">
|
||||||
|
Save {pending.length === 1 ? "it" : `all ${pending.length}`}
|
||||||
|
</Button>
|
||||||
|
<Button onclick={discard} size="sm" variant="ghost">Discard</Button>
|
||||||
|
{:else if suggestNote}
|
||||||
|
<span aria-live="polite" class="text-[12px] text-cladd-fg-soft"
|
||||||
|
>{suggestNote}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft max-sm:hidden"
|
||||||
><span class="kbd">e</span>
|
><span class="kbd">e</span>
|
||||||
set amount
|
set amount
|
||||||
<span class="kbd">Tab</span>
|
<span class="kbd">A</span>
|
||||||
next</span
|
suggest from the last 3 months</span
|
||||||
>
|
>
|
||||||
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-[13px] sm:min-w-[46rem]">
|
<table class="w-full text-[13px] sm:min-w-[46rem]">
|
||||||
@@ -246,7 +430,15 @@
|
|||||||
<th class="px-3 text-right font-medium">Budgeted</th>
|
<th class="px-3 text-right font-medium">Budgeted</th>
|
||||||
<th class="px-3 text-right font-medium">Spent</th>
|
<th class="px-3 text-right font-medium">Spent</th>
|
||||||
<th class="px-3 text-right font-medium max-sm:hidden">Left</th>
|
<th class="px-3 text-right font-medium max-sm:hidden">Left</th>
|
||||||
<th class="w-[30%] px-3 font-medium max-sm:hidden">Progress</th>
|
{#if nowCurrent}
|
||||||
|
<th class="px-3 text-right font-medium max-sm:hidden">Safe/day</th>
|
||||||
|
<th
|
||||||
|
class="whitespace-nowrap px-3 text-right font-medium max-md:hidden"
|
||||||
|
>
|
||||||
|
{endsLabel}
|
||||||
|
</th>
|
||||||
|
{/if}
|
||||||
|
<th class="w-[24%] px-3 font-medium max-sm:hidden">Progress</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<ListMenu
|
<ListMenu
|
||||||
@@ -292,18 +484,22 @@
|
|||||||
<tbody {...props}>
|
<tbody {...props}>
|
||||||
{#each rows as row, i (row.budget.id)}
|
{#each rows as row, i (row.budget.id)}
|
||||||
{@const extra = addingLine(row)}
|
{@const extra = addingLine(row)}
|
||||||
{@const lines = extra ? [...row.lines, extra] : row.lines}
|
{@const lines = linesOf(row)}
|
||||||
{#each lines as line, j (line.key)}
|
{#each lines as line, j (line.key)}
|
||||||
{@const p = pace(line)}
|
{@const p = pace(line)}
|
||||||
{@const day = perDay(line)}
|
{@const safe = safePerDay(line)}
|
||||||
|
{@const ends = projection(line)}
|
||||||
<tr
|
<tr
|
||||||
class="h-8 border-cladd-outline {j === lines.length - 1
|
class="h-8 border-cladd-outline {j === lines.length - 1
|
||||||
? "border-b"
|
? "border-b"
|
||||||
: ""} {nav.cursor === i
|
: ""} {nav.cursor === i && !triaging
|
||||||
? "bg-row-cursor"
|
? "bg-row-cursor"
|
||||||
: "hover:bg-row-hover"}"
|
: "hover:bg-row-hover"}"
|
||||||
id={j === 0 ? nav.rowId(i) : undefined}
|
id={j === 0 ? nav.rowId(i) : undefined}
|
||||||
onclick={(event) => nav.click(i, event)}
|
onclick={(event) => {
|
||||||
|
triaging = false;
|
||||||
|
nav.click(i, event);
|
||||||
|
}}
|
||||||
oncontextmenu={() => (nav.cursor = i)}
|
oncontextmenu={() => (nav.cursor = i)}
|
||||||
>
|
>
|
||||||
{#if j === 0}
|
{#if j === 0}
|
||||||
@@ -397,6 +593,7 @@
|
|||||||
editKey = line.key;
|
editKey = line.key;
|
||||||
}}
|
}}
|
||||||
range={period.range}
|
range={period.range}
|
||||||
|
suggested={suggestionFor(row, line)}
|
||||||
trap={line !== extra}
|
trap={line !== extra}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,13 +615,28 @@
|
|||||||
: "text-cladd-fg"}"
|
: "text-cladd-fg"}"
|
||||||
>{money(line, line.amount + line.spent)}</span
|
>{money(line, line.amount + line.spent)}</span
|
||||||
>
|
>
|
||||||
{#if !day.past && day.left !== null && day.left > 0}
|
|
||||||
<span class="num ml-1 text-[11px] text-cladd-fg-soft"
|
|
||||||
>{money(line, day.left)}/day</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
|
{#if nowCurrent}
|
||||||
|
<td class="num px-3 text-right max-sm:hidden">
|
||||||
|
{#if safe !== null}
|
||||||
|
<span
|
||||||
|
class={safe > 0 ? "text-cladd-fg" : "text-expense"}
|
||||||
|
>{money(line, safe)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="num px-3 text-right max-md:hidden">
|
||||||
|
{#if ends && line.spent !== 0}
|
||||||
|
<span
|
||||||
|
class={line.amount && ends > line.amount
|
||||||
|
? "text-expense"
|
||||||
|
: "text-cladd-fg-soft"}
|
||||||
|
>~{money(line, ends)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/if}
|
||||||
<td class="px-3 max-sm:hidden">
|
<td class="px-3 max-sm:hidden">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
{#if line.amount}
|
{#if line.amount}
|
||||||
@@ -468,7 +680,7 @@
|
|||||||
<td class="px-3 py-1.5 text-right">
|
<td class="px-3 py-1.5 text-right">
|
||||||
<MoneyList sums={query.data?.noBudget ?? []} />
|
<MoneyList sums={query.data?.noBudget ?? []} />
|
||||||
</td>
|
</td>
|
||||||
<td colspan="2"></td>
|
<td colspan={nowCurrent ? 4 : 2}></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr
|
||||||
class="border-cladd-outline border-t bg-cladd-surface-next/40 sm:hidden"
|
class="border-cladd-outline border-t bg-cladd-surface-next/40 sm:hidden"
|
||||||
@@ -506,6 +718,32 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Triage
|
||||||
|
active={triaging}
|
||||||
|
assignPatch={(id) => ({ budget_id: id })}
|
||||||
|
clearPatch={{ budget_id: 0 }}
|
||||||
|
create={createBudget}
|
||||||
|
empty={scope === "all"
|
||||||
|
? "Every expense has a budget."
|
||||||
|
: `Every expense in ${period.label} has a budget.`}
|
||||||
|
everything={scope === "all"}
|
||||||
|
hint={(place) => learnedBudget(place.accountId)?.name ?? null}
|
||||||
|
ignoreKey="ffui.budget-ignored"
|
||||||
|
loading={triage.isPending}
|
||||||
|
noun="budget"
|
||||||
|
onactivate={() => (triaging = true)}
|
||||||
|
onassign={remember}
|
||||||
|
onchange={() => refreshExcept(triageKey)}
|
||||||
|
onleave={() => (triaging = false)}
|
||||||
|
onswitch={() => setParam("scope", scope === "all" ? "" : "all")}
|
||||||
|
{options}
|
||||||
|
periodLabel={period.label}
|
||||||
|
pickKey="b"
|
||||||
|
{places}
|
||||||
|
problem={triage.isError ? `Could not load: ${triage.error.message}` : ""}
|
||||||
|
title="Expenses without a budget"
|
||||||
|
/>
|
||||||
|
|
||||||
{#snippet noBudgetLabel()}
|
{#snippet noBudgetLabel()}
|
||||||
<a class="text-brand italic hover:underline" href={noBudgetHref}
|
<a class="text-brand italic hover:underline" href={noBudgetHref}
|
||||||
>Expenses without a budget</a
|
>Expenses without a budget</a
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
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 { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Category, One } from "#lib/api/types.js";
|
||||||
|
import {
|
||||||
|
placesOf,
|
||||||
|
refreshExcept,
|
||||||
|
withoutCategory,
|
||||||
|
} from "#lib/budgets/triage.js";
|
||||||
import Box from "#lib/components/Box.svelte";
|
import Box from "#lib/components/Box.svelte";
|
||||||
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
|
import { periodPath } from "#lib/components/budgets/period-path.svelte.js";
|
||||||
|
import Triage from "#lib/components/budgets/Triage.svelte";
|
||||||
import {
|
import {
|
||||||
type CategoryRow,
|
type CategoryRow,
|
||||||
loadCategoryPage,
|
loadCategoryPage,
|
||||||
@@ -18,7 +25,7 @@
|
|||||||
import { Button } from "#lib/components/ui/button/index.js";
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
import { formatDate, percent } from "#lib/format.js";
|
import { formatDate, percent } from "#lib/format.js";
|
||||||
import { fuzzyFilter } from "#lib/fuzzy.js";
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
import type { Binding } from "#lib/keys.svelte.js";
|
import { type Binding, useKeys } from "#lib/keys.svelte.js";
|
||||||
import { ListNav } from "#lib/list-nav.svelte.js";
|
import { ListNav } from "#lib/list-nav.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";
|
||||||
@@ -98,6 +105,43 @@
|
|||||||
periodPath("/categories/list/no-category", period.start, period.end)
|
periodPath("/categories/list/no-category", period.start, period.end)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const everything = $derived(page.url.searchParams.get("scope") === "all");
|
||||||
|
const triageKey = $derived(
|
||||||
|
everything
|
||||||
|
? ["category-triage", "all"]
|
||||||
|
: ["category-triage", period.start, period.end]
|
||||||
|
);
|
||||||
|
const triage = createQuery(() => ({
|
||||||
|
enabled: period.ready,
|
||||||
|
queryFn: () => withoutCategory(everything ? null : period.range),
|
||||||
|
queryKey: triageKey,
|
||||||
|
}));
|
||||||
|
const places = $derived(
|
||||||
|
triage.data
|
||||||
|
? placesOf(
|
||||||
|
triage.data,
|
||||||
|
(split) =>
|
||||||
|
(split.type === "withdrawal" || split.type === "deposit") &&
|
||||||
|
!split.category_id
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const options = $derived(
|
||||||
|
(query.data?.rows ?? [])
|
||||||
|
.map((row) => ({ id: row.item.id, name: row.item.attributes.name }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
);
|
||||||
|
|
||||||
|
let triaging = $state(false);
|
||||||
|
|
||||||
|
async function createCategory(name: string) {
|
||||||
|
const made = await api<One<Category>>("/v1/categories", {
|
||||||
|
body: { name },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return { id: made.data.id, name: made.data.attributes.name };
|
||||||
|
}
|
||||||
|
|
||||||
let filterInput = $state<HTMLInputElement>();
|
let filterInput = $state<HTMLInputElement>();
|
||||||
let createInput = $state<HTMLInputElement>();
|
let createInput = $state<HTMLInputElement>();
|
||||||
let newName = $state("");
|
let newName = $state("");
|
||||||
@@ -163,7 +207,16 @@
|
|||||||
key: (row) => row.item.id,
|
key: (row) => row.item.id,
|
||||||
open: (row) => goto(showHref(row)),
|
open: (row) => goto(showHref(row)),
|
||||||
});
|
});
|
||||||
nav.use();
|
|
||||||
|
useKeys(() => [
|
||||||
|
...(triaging ? [] : nav.bindings()),
|
||||||
|
{
|
||||||
|
group: "Categories",
|
||||||
|
keys: "t",
|
||||||
|
label: triaging ? "Stop sorting" : "Sort transactions without a category",
|
||||||
|
run: () => (triaging = !triaging),
|
||||||
|
},
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader crumbs={[{ label: "Categories" }]} title="Categories">
|
<PageHeader crumbs={[{ label: "Categories" }]} title="Categories">
|
||||||
@@ -285,11 +338,14 @@
|
|||||||
{#each rows as row, i (row.item.id)}
|
{#each rows as row, i (row.item.id)}
|
||||||
<tr
|
<tr
|
||||||
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
|
class="h-7 border-cladd-outline border-b last:border-b-0 {nav.cursor ===
|
||||||
i
|
i && !triaging
|
||||||
? "bg-row-cursor"
|
? "bg-row-cursor"
|
||||||
: "hover:bg-row-hover"}"
|
: "hover:bg-row-hover"}"
|
||||||
id={nav.rowId(i)}
|
id={nav.rowId(i)}
|
||||||
onclick={(event) => nav.click(i, event)}
|
onclick={(event) => {
|
||||||
|
triaging = false;
|
||||||
|
nav.click(i, event);
|
||||||
|
}}
|
||||||
oncontextmenu={() => (nav.cursor = i)}
|
oncontextmenu={() => (nav.cursor = i)}
|
||||||
>
|
>
|
||||||
<td class="px-3">
|
<td class="px-3">
|
||||||
@@ -344,6 +400,30 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Triage
|
||||||
|
active={triaging}
|
||||||
|
assignPatch={(id) => ({ category_id: id })}
|
||||||
|
clearPatch={{ category_name: null }}
|
||||||
|
create={createCategory}
|
||||||
|
empty={everything
|
||||||
|
? "Every transaction has a category."
|
||||||
|
: `Every transaction in ${period.label} has a category.`}
|
||||||
|
{everything}
|
||||||
|
ignoreKey="ffui.category-ignored"
|
||||||
|
loading={triage.isPending}
|
||||||
|
noun="category"
|
||||||
|
onactivate={() => (triaging = true)}
|
||||||
|
onchange={() => refreshExcept(triageKey)}
|
||||||
|
onleave={() => (triaging = false)}
|
||||||
|
onswitch={() => setParam("scope", everything ? "" : "all")}
|
||||||
|
{options}
|
||||||
|
periodLabel={period.label}
|
||||||
|
pickKey="c"
|
||||||
|
{places}
|
||||||
|
problem={triage.isError ? `Could not load: ${triage.error.message}` : ""}
|
||||||
|
title="Transactions without a category"
|
||||||
|
/>
|
||||||
|
|
||||||
{#snippet bar(
|
{#snippet bar(
|
||||||
spent: number
|
spent: number
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user