feat(dashboard,accounts,charts): v1 dashboard redrawn, account lists and pages, svg chart kit
This commit is contained in:
1 parent
62cd17ecde
commit
9e89829bae
36 files changed
+5678
-16
No files matched your search
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||
import { api } from "#lib/api/client.js";
|
||||
import { showUrl } from "#lib/components/accounts/links.js";
|
||||
import Box from "#lib/components/Box.svelte";
|
||||
import BarChart from "#lib/components/charts/BarChart.svelte";
|
||||
import { type Bar, tones } from "#lib/components/charts/chart.js";
|
||||
import { toNumber } from "#lib/format.js";
|
||||
import { period } from "#lib/period.svelte.js";
|
||||
import {
|
||||
axis,
|
||||
byCurrency,
|
||||
currency,
|
||||
type InsightEntry,
|
||||
money,
|
||||
proportional,
|
||||
span,
|
||||
} from "./data.js";
|
||||
|
||||
const MAX_BARS = 24;
|
||||
const NARROW_BARS = 8;
|
||||
|
||||
let { kind }: { kind: "expense" | "revenue" } = $props();
|
||||
|
||||
const path = $derived(
|
||||
kind === "expense"
|
||||
? "/v1/insight/expense/expense"
|
||||
: "/v1/insight/income/revenue"
|
||||
);
|
||||
const insight = createQuery(() => ({
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: () =>
|
||||
api<InsightEntry[]>(path, {
|
||||
query: span(period.start, period.end),
|
||||
}),
|
||||
queryKey: ["insight", kind, period.start, period.end],
|
||||
}));
|
||||
|
||||
const title = $derived(
|
||||
kind === "expense" ? "Expense accounts" : "Revenue accounts"
|
||||
);
|
||||
const verb = $derived(kind === "expense" ? "Spent" : "Earned");
|
||||
|
||||
function fold(bars: Bar[]): Bar[] {
|
||||
if (bars.length <= MAX_BARS) {
|
||||
return bars;
|
||||
}
|
||||
const rest = bars.slice(MAX_BARS - 1);
|
||||
return [
|
||||
...bars.slice(0, MAX_BARS - 1),
|
||||
{
|
||||
key: "other",
|
||||
label: `Other (${rest.length})`,
|
||||
value: rest.reduce((sum, b) => sum + b.value, 0),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const groups = $derived(
|
||||
byCurrency(insight.data ?? [], (e) => e.currency_code).map(
|
||||
([code, list]) => {
|
||||
const cur = currency(code);
|
||||
const bars = list
|
||||
.map((e) => ({
|
||||
href: e.id ? showUrl(e.id) : undefined,
|
||||
key: e.id ?? e.name ?? "",
|
||||
label: e.name ?? "",
|
||||
value: Math.abs(toNumber(e.difference)),
|
||||
}))
|
||||
.filter((b) => b.value > 0)
|
||||
.sort((a, b) => b.value - a.value);
|
||||
return { bars: fold(bars), code, cur };
|
||||
}
|
||||
)
|
||||
);
|
||||
const visible = $derived(groups.filter((g) => g.bars.length > 0));
|
||||
const wide = $derived(
|
||||
visible.filter((g, i) => i === 0 || g.bars.length > NARROW_BARS)
|
||||
);
|
||||
const narrow = $derived(visible.filter((g) => !wide.includes(g)));
|
||||
</script>
|
||||
|
||||
{#snippet chart(
|
||||
group: (typeof groups)[number],
|
||||
height: number
|
||||
)}
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
{#if visible.length > 1}
|
||||
<span class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||
>{verb}
|
||||
in {group.code}</span
|
||||
>
|
||||
{/if}
|
||||
<BarChart
|
||||
axisFormat={axis(group.cur)}
|
||||
bars={group.bars}
|
||||
color={kind === "expense" ? tones.expense : tones.income}
|
||||
format={money(group.cur)}
|
||||
{height}
|
||||
label="{verb} per {kind} account in {group.code}"
|
||||
name={verb}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Box href="/accounts/{kind}" {title}>
|
||||
<div
|
||||
class="flex flex-col gap-5 transition-opacity {insight.isPlaceholderData
|
||||
? "opacity-60"
|
||||
: ""}"
|
||||
>
|
||||
{#if insight.isPending}
|
||||
<div class="h-60"></div>
|
||||
{:else if insight.isError}
|
||||
<p class="text-expense">{insight.error.message}</p>
|
||||
{:else if visible.length === 0}
|
||||
<p class="py-8 text-center text-cladd-fg-soft">
|
||||
{kind === "expense"
|
||||
? "Nothing spent in this period."
|
||||
: "Nothing earned in this period."}
|
||||
</p>
|
||||
{:else}
|
||||
{#each [wide, narrow] as set, i (i)}
|
||||
{#if set.length > 0}
|
||||
<div
|
||||
class="grid gap-x-6 gap-y-5 {i === 1
|
||||
? "md:grid-cols-(--cols)"
|
||||
: ""}"
|
||||
style:--cols={proportional(set.map((g) => g.bars.length))}
|
||||
>
|
||||
{#each set as group (group.code)}
|
||||
{@render chart(group, i === 0 ? 240 : 180)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<a class="text-brand hover:underline" href="/accounts/{kind}"
|
||||
>See your {kind} accounts</a
|
||||
>
|
||||
{/snippet}
|
||||
</Box>
|
||||
Reference in new issue
Block a user