feat(dashboard): morning page, compact accounts, what the bot entered, next 7 days
The right column drops the box per account for one table per currency with sparklines and the last five transactions on l, a list of what was entered since the last look (x marks reviewed), bills and piggy bank dates in the next seven days, and debts with friends net per currency. The left column opens with spending so far against a typical month, and $ shows totals in UAH.
This commit is contained in:
1 parent
cea51281cd
commit
8c4e72666b
11 files changed
+1452
-312
No files matched your search
@@ -1,18 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||
import { showUrl } from "#lib/components/accounts/links.js";
|
||||
import { editUrl, showUrl } from "#lib/components/accounts/links.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import BarChart from "#lib/components/charts/BarChart.svelte";
|
||||
import { tones } from "#lib/components/charts/chart.js";
|
||||
import { toNumber } from "#lib/format.js";
|
||||
import { useColumn } from "#lib/dashboard/column.svelte.js";
|
||||
import {
|
||||
formatPrimary,
|
||||
inPrimaryTotal,
|
||||
ratesQuery,
|
||||
} from "#lib/dashboard/primary.svelte.js";
|
||||
import { formatMoney, toNumber } from "#lib/format.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import { accountsQuery, byCurrency, money } from "./data.js";
|
||||
import { goto } from "$app/navigation";
|
||||
import { accountsQuery, byCurrency } from "./data.js";
|
||||
|
||||
const liabilities = createQuery(() => ({
|
||||
...accountsQuery("liabilities", period.start, period.end),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
|
||||
const rates = createQuery(() => ratesQuery());
|
||||
|
||||
const groups = $derived(
|
||||
byCurrency(
|
||||
(liabilities.data ?? []).filter(
|
||||
@@ -23,83 +30,135 @@
|
||||
(a) => a.attributes.currency_code ?? ""
|
||||
).map(([code, list]) => {
|
||||
const first = list[0].attributes;
|
||||
const format = money({
|
||||
const cur = {
|
||||
code,
|
||||
decimals: first.currency_decimal_places ?? 2,
|
||||
symbol: first.currency_symbol ?? code,
|
||||
});
|
||||
const bars = list
|
||||
};
|
||||
const rows = list
|
||||
.map((a) => {
|
||||
const due = Math.abs(toNumber(a.attributes.debt_amount));
|
||||
const value =
|
||||
a.attributes.liability_direction === "credit" ? due : -due;
|
||||
return {
|
||||
color: value > 0 ? tones.income : tones.expense,
|
||||
href: showUrl(a.id),
|
||||
key: a.id,
|
||||
label: a.attributes.name,
|
||||
value,
|
||||
id: a.id,
|
||||
key: `debt-${a.id}`,
|
||||
name: a.attributes.name,
|
||||
value: a.attributes.liability_direction === "credit" ? due : -due,
|
||||
};
|
||||
})
|
||||
.sort((x, y) => y.value - x.value);
|
||||
const owed = bars
|
||||
.filter((b) => b.value > 0)
|
||||
.reduce((s, b) => s + b.value, 0);
|
||||
const owe = bars
|
||||
.filter((b) => b.value < 0)
|
||||
.reduce((s, b) => s - b.value, 0);
|
||||
return { bars, code, format, owe, owed };
|
||||
return {
|
||||
code,
|
||||
cur,
|
||||
largest: Math.max(...rows.map((r) => Math.abs(r.value))),
|
||||
net: rows.reduce((s, r) => s + r.value, 0),
|
||||
rows,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const nets = $derived.by(() => {
|
||||
const total = inPrimaryTotal(
|
||||
groups.map((g) => ({ amount: g.net, code: g.code })),
|
||||
rates.data
|
||||
);
|
||||
if (total === null) {
|
||||
return groups.map((g) => ({
|
||||
code: g.code,
|
||||
net: g.net,
|
||||
text: formatMoney({ amount: Math.abs(g.net), ...g.cur }),
|
||||
}));
|
||||
}
|
||||
return [{ code: "all", net: total, text: formatPrimary(Math.abs(total)) }];
|
||||
});
|
||||
|
||||
const column = useColumn(4, () =>
|
||||
groups.flatMap((g) =>
|
||||
g.rows.map((r) => ({
|
||||
edit: () => goto(editUrl(r.id)),
|
||||
key: r.key,
|
||||
open: () => goto(showUrl(r.id)),
|
||||
}))
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet more()}
|
||||
<a
|
||||
class="text-brand hover:underline dark:text-(--ff-series-1)"
|
||||
href="/accounts/liabilities"
|
||||
>See your liabilities</a
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
{#if groups.length > 0}
|
||||
<Box href="/accounts/liabilities" title="Debts with friends">
|
||||
<Box
|
||||
body="p-0"
|
||||
footer={more}
|
||||
href="/accounts/liabilities"
|
||||
title="Debts with friends"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-4 transition-opacity {liabilities.isPlaceholderData
|
||||
class="flex flex-wrap gap-x-4 gap-y-1 px-3 py-2 transition-opacity {liabilities.isPlaceholderData
|
||||
? "opacity-60"
|
||||
: ""}"
|
||||
>
|
||||
{#each groups as group (group.code)}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-baseline gap-3 text-[12px]">
|
||||
<span
|
||||
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||
>{group.code}</span
|
||||
{#each nets as g (g.code)}
|
||||
{#if g.net !== 0}
|
||||
<span class="text-[12px] text-cladd-fg-soft"
|
||||
><span
|
||||
class="num font-semibold text-[15px] {g.net > 0
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>{g.text}</span
|
||||
>
|
||||
{#if group.owed > 0}
|
||||
<span class="text-cladd-fg-soft"
|
||||
>Owed to me
|
||||
<span class="num text-income"
|
||||
>{group.format(group.owed)}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
{#if group.owe > 0}
|
||||
<span class="text-cladd-fg-soft"
|
||||
>I owe
|
||||
<span class="num text-expense"
|
||||
>{group.format(group.owe)}</span
|
||||
></span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<BarChart
|
||||
bars={group.bars}
|
||||
format={group.format}
|
||||
label="Debts with friends in {group.code}"
|
||||
labelWidth="minmax(5rem, 9rem)"
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</div>
|
||||
{g.net > 0 ? "owed to me" : "I owe"}</span
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<a
|
||||
class="text-brand dark:text-(--ff-series-1) hover:underline"
|
||||
href="/accounts/liabilities"
|
||||
>See your liabilities</a
|
||||
>
|
||||
{/snippet}
|
||||
<table class="w-full border-collapse text-[13px]">
|
||||
{#each groups as g (g.code)}
|
||||
<tbody class="border-cladd-outline border-t">
|
||||
{#each g.rows as r, i (r.key)}
|
||||
<tr
|
||||
class="h-7 {i > 0
|
||||
? "border-cladd-outline/60 border-t"
|
||||
: ""} {column.rowClass(r.key)}"
|
||||
id={column.id(r.key)}
|
||||
onclick={() => column.point(r.key)}
|
||||
>
|
||||
<td class="w-full max-w-0 pl-3">
|
||||
<a
|
||||
class="block truncate text-cladd-fg hover:underline"
|
||||
href={showUrl(r.id)}
|
||||
title={r.name}
|
||||
>{r.name}</a
|
||||
>
|
||||
</td>
|
||||
<td class="px-2">
|
||||
<div
|
||||
class="flex h-1.5 w-16 overflow-hidden rounded-full bg-cladd-surface-next"
|
||||
>
|
||||
<span
|
||||
class="h-full rounded-full {r.value > 0
|
||||
? "bg-income"
|
||||
: "bg-expense"}"
|
||||
style:width="{(Math.abs(r.value) / g.largest) * 100}%"
|
||||
></span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="num whitespace-nowrap pr-3 pl-2 text-right font-medium {r.value >
|
||||
0
|
||||
? "text-income"
|
||||
: "text-expense"}"
|
||||
>
|
||||
{formatMoney({ amount: r.value, ...g.cur }, true)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
{/each}
|
||||
</table>
|
||||
</Box>
|
||||
{/if}
|
||||
Reference in new issue
Block a user