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,506 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
AccountType,
|
||||||
|
Item,
|
||||||
|
One,
|
||||||
|
Page,
|
||||||
|
PiggyBank,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import {
|
||||||
|
fieldErrors,
|
||||||
|
SELECT_CLASS,
|
||||||
|
} from "#lib/components/categories/form.js";
|
||||||
|
import DateInput from "#lib/components/inputs/DateInput.svelte";
|
||||||
|
import Field from "#lib/components/inputs/Field.svelte";
|
||||||
|
import { INPUT } from "#lib/components/inputs/styles.js";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { Textarea } from "#lib/components/ui/textarea/index.js";
|
||||||
|
import { today } from "#lib/dates.js";
|
||||||
|
import { toNumber } from "#lib/format.js";
|
||||||
|
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import {
|
||||||
|
indexUrl,
|
||||||
|
LIABILITY_LABEL,
|
||||||
|
ROLE_LABEL,
|
||||||
|
showUrl,
|
||||||
|
TYPE_ONE,
|
||||||
|
} from "./links.js";
|
||||||
|
|
||||||
|
const DIRECTIONS = [
|
||||||
|
{ label: "I owe this debt to somebody else", value: "debit" },
|
||||||
|
{ label: "I am owed this debt", value: "credit" },
|
||||||
|
];
|
||||||
|
const PERIODS = [
|
||||||
|
{ label: "Per day", value: "daily" },
|
||||||
|
{ label: "Per week", value: "weekly" },
|
||||||
|
{ label: "Per month", value: "monthly" },
|
||||||
|
{ label: "Per quarter", value: "quarterly" },
|
||||||
|
{ label: "Per half year", value: "half-year" },
|
||||||
|
{ label: "Per year", value: "yearly" },
|
||||||
|
];
|
||||||
|
const CHECKBOX = "size-4 accent-[var(--ff-brand)]";
|
||||||
|
|
||||||
|
let { type, account }: { type: AccountType; account?: Item<Account> } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const initial = untrack(() => account)?.attributes;
|
||||||
|
const blank = (value: string | null | undefined) => value ?? "";
|
||||||
|
const positive = (value: string | null | undefined) =>
|
||||||
|
value ? String(Math.abs(toNumber(value))) : "";
|
||||||
|
|
||||||
|
let name = $state(blank(initial?.name));
|
||||||
|
let currency = $state(initial?.currency_code ?? session.primary?.code ?? "");
|
||||||
|
let role = $state<string>(initial?.account_role ?? "defaultAsset");
|
||||||
|
let liabilityType = $state<string>(initial?.liability_type ?? "debt");
|
||||||
|
let direction = $state<string>(initial?.liability_direction ?? "debit");
|
||||||
|
let interest = $state(blank(initial?.interest) || "0");
|
||||||
|
let interestPeriod = $state<string>(initial?.interest_period ?? "monthly");
|
||||||
|
let iban = $state(blank(initial?.iban));
|
||||||
|
let bic = $state(blank(initial?.bic));
|
||||||
|
let number = $state(blank(initial?.account_number));
|
||||||
|
let opening = $state(
|
||||||
|
untrack(() => type) === "liabilities"
|
||||||
|
? positive(initial?.opening_balance)
|
||||||
|
: blank(initial?.opening_balance)
|
||||||
|
);
|
||||||
|
let openingDate = $state(
|
||||||
|
blank(initial?.opening_balance_date).slice(0, 10) || today()
|
||||||
|
);
|
||||||
|
let virtualBalance = $state(blank(initial?.virtual_balance));
|
||||||
|
let netWorth = $state(initial?.include_net_worth ?? true);
|
||||||
|
let notes = $state(blank(initial?.notes));
|
||||||
|
let active = $state(initial?.active ?? true);
|
||||||
|
let cardType = $state(initial?.credit_card_type ?? "monthlyFull");
|
||||||
|
let paymentDate = $state(
|
||||||
|
blank(initial?.monthly_payment_date).slice(0, 10) || today()
|
||||||
|
);
|
||||||
|
let again = $state(false);
|
||||||
|
let nameInput = $state<HTMLInputElement>();
|
||||||
|
let errors = $state<Record<string, string>>({});
|
||||||
|
let busy = $state(false);
|
||||||
|
let saved = $state("");
|
||||||
|
|
||||||
|
const piggies = createQuery(() => ({
|
||||||
|
enabled: Boolean(account),
|
||||||
|
queryFn: () =>
|
||||||
|
api<Page<PiggyBank>>(`/v1/accounts/${account?.id}/piggy-banks`, {
|
||||||
|
query: { limit: 1 },
|
||||||
|
}),
|
||||||
|
queryKey: ["account-piggies", account?.id],
|
||||||
|
}));
|
||||||
|
const locked = $derived((piggies.data?.meta.pagination?.total ?? 0) > 0);
|
||||||
|
const enabled = $derived(
|
||||||
|
session.currencies.filter(
|
||||||
|
(c) => c.attributes.enabled || c.attributes.code === currency
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const hasCurrency = $derived(type === "asset" || type === "liabilities");
|
||||||
|
const isCard = $derived(type === "asset" && role === "ccAsset");
|
||||||
|
const openingLabel = $derived(
|
||||||
|
direction === "credit" ? "I am owed amount" : "I owe amount"
|
||||||
|
);
|
||||||
|
const submitLabel = $derived(
|
||||||
|
`${account ? "Update" : "Store new"} ${TYPE_ONE[type]}`
|
||||||
|
);
|
||||||
|
|
||||||
|
function assetFields(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
account_role: role,
|
||||||
|
virtual_balance: virtualBalance || null,
|
||||||
|
...(isCard
|
||||||
|
? { credit_card_type: cardType, monthly_payment_date: paymentDate }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function liabilityFields(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
interest: interest || "0",
|
||||||
|
interest_period: interestPeriod,
|
||||||
|
liability_direction: direction,
|
||||||
|
liability_type: liabilityType,
|
||||||
|
...(account && opening !== ""
|
||||||
|
? { liability_amount: opening, liability_start_date: openingDate }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function body(): Record<string, unknown> {
|
||||||
|
const hasOpening = opening !== "";
|
||||||
|
return {
|
||||||
|
account_number: number || null,
|
||||||
|
bic: bic || null,
|
||||||
|
iban: iban || null,
|
||||||
|
name,
|
||||||
|
notes: notes || null,
|
||||||
|
type,
|
||||||
|
...(account ? { active } : {}),
|
||||||
|
...(hasCurrency
|
||||||
|
? { currency_code: currency, include_net_worth: netWorth }
|
||||||
|
: {}),
|
||||||
|
...(hasOpening || account
|
||||||
|
? {
|
||||||
|
opening_balance: hasOpening ? opening : null,
|
||||||
|
opening_balance_date: hasOpening ? openingDate : null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(type === "asset" ? assetFields() : {}),
|
||||||
|
...(type === "liabilities" ? liabilityFields() : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToFrontpage(id: string) {
|
||||||
|
const ids = session.frontpageAccounts;
|
||||||
|
if (!("frontpageAccounts" in session.prefs) || ids.includes(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await session.setPreference("frontpageAccounts", [...ids, id].map(Number));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
name = "";
|
||||||
|
iban = "";
|
||||||
|
bic = "";
|
||||||
|
number = "";
|
||||||
|
opening = "";
|
||||||
|
virtualBalance = "";
|
||||||
|
notes = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(event?: Event) {
|
||||||
|
event?.preventDefault();
|
||||||
|
if (busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
errors = {};
|
||||||
|
saved = "";
|
||||||
|
try {
|
||||||
|
const result = await api<One<Account>>(
|
||||||
|
account ? `/v1/accounts/${account.id}` : "/v1/accounts",
|
||||||
|
{ body: body(), method: account ? "PUT" : "POST" }
|
||||||
|
);
|
||||||
|
if (!account && type === "asset") {
|
||||||
|
await addToFrontpage(result.data.id);
|
||||||
|
}
|
||||||
|
await invalidate();
|
||||||
|
if (again) {
|
||||||
|
saved = `Saved ${result.data.attributes.name}`;
|
||||||
|
if (!account) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await goto(showUrl(result.data.id));
|
||||||
|
} catch (failure) {
|
||||||
|
errors = fieldErrors(failure);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!account) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api(`/v1/accounts/${account.id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
await goto(indexUrl(type));
|
||||||
|
} catch (failure) {
|
||||||
|
errors = fieldErrors(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!untrack(() => account)) {
|
||||||
|
nameInput?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Form",
|
||||||
|
input: true,
|
||||||
|
keys: "mod+Enter",
|
||||||
|
label: submitLabel,
|
||||||
|
run: () => save(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-3" onsubmit={save}>
|
||||||
|
<div class="grid gap-3 lg:grid-cols-2">
|
||||||
|
<Box body="flex flex-col gap-3 p-3" title="Mandatory fields">
|
||||||
|
<Field error={errors.name} for="name" label="Name">
|
||||||
|
<input
|
||||||
|
autocomplete="off"
|
||||||
|
class={INPUT}
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
bind:this={nameInput}
|
||||||
|
bind:value={name}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
{#if hasCurrency}
|
||||||
|
<Field
|
||||||
|
error={errors.currency_code ?? errors.currency_id}
|
||||||
|
for="currency"
|
||||||
|
label="Currency"
|
||||||
|
>
|
||||||
|
{#if locked}
|
||||||
|
<p class="py-1.5 text-cladd-fg">
|
||||||
|
{currency}
|
||||||
|
<span class="text-cladd-fg-soft"
|
||||||
|
>· piggy banks are linked, so the currency stays</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<select class={SELECT_CLASS} id="currency" bind:value={currency}>
|
||||||
|
{#each enabled as cur (cur.id)}
|
||||||
|
<option value={cur.attributes.code}>
|
||||||
|
{cur.attributes.name}
|
||||||
|
({cur.attributes.symbol})
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
{#if type === "asset"}
|
||||||
|
<Field error={errors.account_role} for="role" label="Account role">
|
||||||
|
<select class={SELECT_CLASS} id="role" bind:value={role}>
|
||||||
|
{#each Object.entries(ROLE_LABEL) as [value, label] (value)}
|
||||||
|
<option {value}>{label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
{#if type === "liabilities"}
|
||||||
|
<Field
|
||||||
|
error={errors.liability_type}
|
||||||
|
for="liability-type"
|
||||||
|
label="Liability type"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class={SELECT_CLASS}
|
||||||
|
id="liability-type"
|
||||||
|
bind:value={liabilityType}
|
||||||
|
>
|
||||||
|
{#each Object.entries(LIABILITY_LABEL) as [value, label] (value)}
|
||||||
|
<option {value}>{label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
error={errors.liability_direction}
|
||||||
|
for="direction"
|
||||||
|
label="Liability in/out"
|
||||||
|
>
|
||||||
|
<select class={SELECT_CLASS} id="direction" bind:value={direction}>
|
||||||
|
{#each DIRECTIONS as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
error={errors.opening_balance ?? errors.liability_amount}
|
||||||
|
for="opening"
|
||||||
|
label={openingLabel}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="{INPUT} num text-right"
|
||||||
|
id="opening"
|
||||||
|
inputmode="decimal"
|
||||||
|
bind:value={opening}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
error={errors.opening_balance_date ?? errors.liability_start_date}
|
||||||
|
for="opening-date"
|
||||||
|
label="Start date of debt"
|
||||||
|
>
|
||||||
|
<DateInput id="opening-date" bind:value={openingDate} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field error={errors.interest} for="interest" label="Interest (%)">
|
||||||
|
<input
|
||||||
|
class="{INPUT} num text-right"
|
||||||
|
id="interest"
|
||||||
|
inputmode="decimal"
|
||||||
|
bind:value={interest}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
error={errors.interest_period}
|
||||||
|
for="interest-period"
|
||||||
|
label="Interest period"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class={SELECT_CLASS}
|
||||||
|
id="interest-period"
|
||||||
|
bind:value={interestPeriod}
|
||||||
|
>
|
||||||
|
{#each PERIODS as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
<Box body="flex flex-col gap-3 p-3" title="Optional fields">
|
||||||
|
<div class="grid gap-3 sm:grid-cols-[2fr_1fr]">
|
||||||
|
<Field error={errors.iban} for="iban" label="IBAN">
|
||||||
|
<input
|
||||||
|
autocomplete="off"
|
||||||
|
class="{INPUT} num"
|
||||||
|
id="iban"
|
||||||
|
bind:value={iban}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
<Field error={errors.bic} for="bic" label="BIC">
|
||||||
|
<input
|
||||||
|
autocomplete="off"
|
||||||
|
class="{INPUT} num"
|
||||||
|
id="bic"
|
||||||
|
maxlength="11"
|
||||||
|
bind:value={bic}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
error={errors.account_number}
|
||||||
|
for="number"
|
||||||
|
label={isCard ? "Credit card number" : "Account number"}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autocomplete="off"
|
||||||
|
class="{INPUT} num"
|
||||||
|
id="number"
|
||||||
|
bind:value={number}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
{#if type === "asset"}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
error={errors.opening_balance}
|
||||||
|
for="opening"
|
||||||
|
label="Opening balance"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="{INPUT} num text-right"
|
||||||
|
id="opening"
|
||||||
|
inputmode="decimal"
|
||||||
|
bind:value={opening}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
error={errors.opening_balance_date}
|
||||||
|
for="opening-date"
|
||||||
|
label="Opening balance date"
|
||||||
|
>
|
||||||
|
<DateInput id="opening-date" bind:value={openingDate} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
error={errors.virtual_balance}
|
||||||
|
for="virtual"
|
||||||
|
label="Virtual balance"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="{INPUT} num text-right"
|
||||||
|
id="virtual"
|
||||||
|
inputmode="decimal"
|
||||||
|
bind:value={virtualBalance}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
{#if hasCurrency}
|
||||||
|
<label class="flex items-center gap-2 text-[13px]">
|
||||||
|
<input class={CHECKBOX} type="checkbox" bind:checked={netWorth}>
|
||||||
|
Include in net worth
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if account}
|
||||||
|
<label class="flex items-center gap-2 text-[13px]">
|
||||||
|
<input class={CHECKBOX} type="checkbox" bind:checked={active}>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<Field error={errors.notes} for="notes" label="Notes">
|
||||||
|
<Textarea id="notes" rows={4} bind:value={notes} />
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isCard}
|
||||||
|
<div class="grid gap-3 lg:grid-cols-2">
|
||||||
|
<span class="hidden lg:block"></span>
|
||||||
|
<Box body="flex flex-col gap-3 p-3" title="Credit card options">
|
||||||
|
<Field
|
||||||
|
error={errors.credit_card_type}
|
||||||
|
for="card-type"
|
||||||
|
label="Credit card payment plan"
|
||||||
|
>
|
||||||
|
<select class={SELECT_CLASS} id="card-type" bind:value={cardType}>
|
||||||
|
<option value="monthlyFull">Full payment every month</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
error={errors.monthly_payment_date}
|
||||||
|
for="payment-date"
|
||||||
|
label="Credit card monthly payment date"
|
||||||
|
>
|
||||||
|
<DateInput id="payment-date" bind:value={paymentDate} />
|
||||||
|
</Field>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Box body="flex flex-wrap items-center gap-3 p-3" title="Options">
|
||||||
|
<Button disabled={busy} type="submit">{submitLabel}</Button>
|
||||||
|
<span class="flex gap-1 text-[12px] text-cladd-fg-soft">
|
||||||
|
{#each prettyKeys("mod+Enter") as key (key)}
|
||||||
|
<span class="kbd">{key}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
<label class="flex items-center gap-2 text-[13px]">
|
||||||
|
<input class={CHECKBOX} type="checkbox" bind:checked={again}>
|
||||||
|
{account
|
||||||
|
? "Return here after updating"
|
||||||
|
: "Return here to create another one"}
|
||||||
|
</label>
|
||||||
|
{#if busy}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">Saving</span>
|
||||||
|
{:else if errors.form}
|
||||||
|
<span class="text-[12px] text-expense" role="alert">{errors.form}</span>
|
||||||
|
{:else if saved}
|
||||||
|
<span class="text-[12px] text-income" role="status">{saved}</span>
|
||||||
|
{:else if Object.keys(errors).length > 0}
|
||||||
|
<span class="text-[12px] text-expense" role="alert"
|
||||||
|
>Some fields need fixing</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if account}
|
||||||
|
<span class="ml-auto">
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel="Press again: delete with its transactions"
|
||||||
|
onconfirm={remove}
|
||||||
|
>Delete</ConfirmButton
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { DropdownMenu } from "bits-ui";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all, invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Account, One, Page, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||||
|
import {
|
||||||
|
axis,
|
||||||
|
balanceDate,
|
||||||
|
type ChartSet,
|
||||||
|
currency,
|
||||||
|
hasPrimary,
|
||||||
|
money,
|
||||||
|
seriesPoints,
|
||||||
|
setPrimary,
|
||||||
|
span,
|
||||||
|
} from "#lib/components/dashboard/data.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import Pager from "#lib/components/transactions/Pager.svelte";
|
||||||
|
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||||
|
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
||||||
|
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
||||||
|
import { contains, daysBetween, today } from "#lib/dates.js";
|
||||||
|
import { formatMoney, formatRange } from "#lib/format.js";
|
||||||
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import Breakdown from "./Breakdown.svelte";
|
||||||
|
import {
|
||||||
|
canReconcile,
|
||||||
|
createUrl,
|
||||||
|
editUrl,
|
||||||
|
go,
|
||||||
|
indexUrl,
|
||||||
|
LIABILITY_LABEL,
|
||||||
|
openOld,
|
||||||
|
ROLE_LABEL,
|
||||||
|
reconcileUrl,
|
||||||
|
showUrl,
|
||||||
|
TYPE_TITLE,
|
||||||
|
typeOf,
|
||||||
|
} from "./links.js";
|
||||||
|
import PeriodList from "./PeriodList.svelte";
|
||||||
|
|
||||||
|
const SPECIAL = new Set(["initial-balance", "reconciliation"]);
|
||||||
|
const BUTTON =
|
||||||
|
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-fg";
|
||||||
|
|
||||||
|
let { id, everything = false }: { id: string; everything?: boolean } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let failure = $state("");
|
||||||
|
|
||||||
|
const until = $derived(balanceDate(period.start, period.end));
|
||||||
|
const account = createQuery(() => ({
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api<One<Account>>(`/v1/accounts/${id}`, {
|
||||||
|
query: everything ? { date: today() } : { date: until },
|
||||||
|
}),
|
||||||
|
queryKey: ["account", id, everything ? "all" : until],
|
||||||
|
}));
|
||||||
|
const a = $derived(account.data?.data.attributes);
|
||||||
|
const type = $derived(a ? typeOf(a) : "asset");
|
||||||
|
const item = $derived(account.data?.data);
|
||||||
|
|
||||||
|
const first = createQuery(() => ({
|
||||||
|
enabled: everything,
|
||||||
|
queryFn: async () => {
|
||||||
|
const head = await api<Page<TransactionGroup>>(
|
||||||
|
`/v1/accounts/${id}/transactions`,
|
||||||
|
{ query: { limit: 1 } }
|
||||||
|
);
|
||||||
|
const pages = head.meta.pagination?.total_pages ?? 1;
|
||||||
|
const last =
|
||||||
|
pages > 1
|
||||||
|
? await api<Page<TransactionGroup>>(
|
||||||
|
`/v1/accounts/${id}/transactions`,
|
||||||
|
{
|
||||||
|
query: { limit: 1, page: pages },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: head;
|
||||||
|
return (
|
||||||
|
last.data[0]?.attributes.transactions[0]?.date?.slice(0, 10) ?? today()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
queryKey: ["account-first", id],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const range = $derived(
|
||||||
|
everything
|
||||||
|
? { end: today(), start: first.data ?? today() }
|
||||||
|
: { end: period.end, start: period.start }
|
||||||
|
);
|
||||||
|
const step = $derived.by(() => {
|
||||||
|
const days = daysBetween(range.start, range.end);
|
||||||
|
if (days <= 92) {
|
||||||
|
return "1D";
|
||||||
|
}
|
||||||
|
if (days <= 730) {
|
||||||
|
return "1W";
|
||||||
|
}
|
||||||
|
return days <= 3000 ? "1M" : "1Y";
|
||||||
|
});
|
||||||
|
const chart = createQuery(() => ({
|
||||||
|
enabled: !everything || first.isSuccess,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api<ChartSet[]>("/v1/chart/account/overview", {
|
||||||
|
query: {
|
||||||
|
"accounts[]": id,
|
||||||
|
end: range.end > range.start ? range.end : range.start,
|
||||||
|
period: step,
|
||||||
|
start: range.start,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
queryKey: ["account-chart", id, range.start, range.end, step],
|
||||||
|
}));
|
||||||
|
const converted = $derived(hasPrimary(chart.data ?? []));
|
||||||
|
const series = $derived(
|
||||||
|
(chart.data ?? []).map((set) => ({
|
||||||
|
area: true,
|
||||||
|
format: money(converted ? setPrimary(set) : currency(set.currency_code)),
|
||||||
|
key: set.label,
|
||||||
|
label: set.label,
|
||||||
|
points: seriesPoints(set, converted),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const cur = $derived(
|
||||||
|
currency(a?.currency_code ?? session.primary?.code ?? "")
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageNo = $derived(
|
||||||
|
Number(page.url.searchParams.get("page") ?? "1") || 1
|
||||||
|
);
|
||||||
|
const dated = createQuery(() => ({
|
||||||
|
enabled: !everything,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
all<TransactionGroup>(
|
||||||
|
`/v1/accounts/${id}/transactions`,
|
||||||
|
span(period.start, period.end)
|
||||||
|
).then((list) =>
|
||||||
|
list.filter((g) =>
|
||||||
|
g.attributes.transactions.some((s) => contains(period.range, s.date))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
queryKey: ["account-tx-all", id, period.start, period.end],
|
||||||
|
}));
|
||||||
|
const paged = createQuery(() => ({
|
||||||
|
enabled: everything,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
|
||||||
|
query: { limit: session.pageSize, page: pageNo },
|
||||||
|
}),
|
||||||
|
queryKey: ["account-tx-page", id, pageNo, session.pageSize],
|
||||||
|
}));
|
||||||
|
const groups = $derived(
|
||||||
|
everything ? (paged.data?.data ?? []) : (dated.data ?? [])
|
||||||
|
);
|
||||||
|
const pages = $derived(paged.data?.meta.pagination?.total_pages ?? 1);
|
||||||
|
const total = $derived(
|
||||||
|
everything ? (paged.data?.meta.pagination?.total ?? 0) : groups.length
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!(a && SPECIAL.has(String(a.type)))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api<Page<TransactionGroup>>(`/v1/accounts/${id}/transactions`, {
|
||||||
|
query: { limit: 1 },
|
||||||
|
}).then((res) => {
|
||||||
|
const split = res.data[0]?.attributes.transactions[0];
|
||||||
|
const other =
|
||||||
|
split?.source_id === id ? split?.destination_id : split?.source_id;
|
||||||
|
goto(other ? showUrl(other) : "/", { replace: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = $derived(contains(range, today()) ? today() : undefined);
|
||||||
|
const subtitle = $derived.by(() => {
|
||||||
|
if (!a) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (type === "asset") {
|
||||||
|
return ROLE_LABEL[a.account_role ?? ""] ?? "";
|
||||||
|
}
|
||||||
|
if (type === "liabilities") {
|
||||||
|
return LIABILITY_LABEL[a.liability_type ?? ""] ?? "";
|
||||||
|
}
|
||||||
|
return TYPE_TITLE[type].replace(" accounts", " account");
|
||||||
|
});
|
||||||
|
const balance = $derived(
|
||||||
|
a ? formatMoney({ amount: a.current_balance, ...cur }) : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
function pageHref(n: number) {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
url.searchParams.set("page", String(n));
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
failure = "";
|
||||||
|
try {
|
||||||
|
await api(`/v1/accounts/${id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
goto(indexUrl(type));
|
||||||
|
} catch (e) {
|
||||||
|
failure = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Account",
|
||||||
|
keys: "E",
|
||||||
|
label: "Edit this account",
|
||||||
|
run: () => goto(editUrl(id)),
|
||||||
|
},
|
||||||
|
...(type === "asset"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
group: "Account",
|
||||||
|
keys: "R",
|
||||||
|
label: "Reconcile this account",
|
||||||
|
run: () => go(reconcileUrl(id)),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
group: "Account",
|
||||||
|
keys: "A",
|
||||||
|
label: everything ? "Show the current period" : "Show all transactions",
|
||||||
|
run: () => goto(everything ? showUrl(id) : `${showUrl(id)}/all`),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: indexUrl(type), label: TYPE_TITLE[type] },
|
||||||
|
...(everything
|
||||||
|
? [{ label: a?.name ?? "" }]
|
||||||
|
: [
|
||||||
|
{ href: `${showUrl(id)}/all`, label: a?.name ?? "" },
|
||||||
|
{ label: formatRange(period.start, period.end) },
|
||||||
|
]),
|
||||||
|
]}
|
||||||
|
{subtitle}
|
||||||
|
title={a?.name ?? "Account"}
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="{BUTTON} cladd-color-green text-cladd-primary"
|
||||||
|
>
|
||||||
|
Create <ChevronDown class="size-3.5" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
|
<DropdownMenuItem onSelect={() => go(createUrl("withdrawal", id))}
|
||||||
|
>New withdrawal</DropdownMenuItem
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={() => go(createUrl("deposit", id))}
|
||||||
|
>New deposit</DropdownMenuItem
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={() => go(createUrl("transfer", id))}
|
||||||
|
>New transfer</DropdownMenuItem
|
||||||
|
>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
<a class={BUTTON} href={editUrl(id)} title="Edit Shift+E">Edit</a>
|
||||||
|
{#if item && canReconcile(item)}
|
||||||
|
<a class={BUTTON} href={reconcileUrl(id)} title="Reconcile Shift+R"
|
||||||
|
>Reconcile</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
|
||||||
|
<button
|
||||||
|
class={BUTTON}
|
||||||
|
onclick={() => openOld(page.url.pathname)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Old UI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{#if failure}
|
||||||
|
<p class="pb-3 text-expense">{failure}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<Box
|
||||||
|
title={a
|
||||||
|
? `Chart for ${a.name} (${balance}) between ${formatRange(range.start, range.end)}`
|
||||||
|
: "Chart"}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="transition-opacity {chart.isPlaceholderData ? "opacity-60" : ""}"
|
||||||
|
>
|
||||||
|
{#if chart.isPending || (everything && first.isPending)}
|
||||||
|
<div class="h-[300px]"></div>
|
||||||
|
{:else if chart.isError}
|
||||||
|
<p class="text-expense">{chart.error.message}</p>
|
||||||
|
{:else if series.length === 0}
|
||||||
|
<p class="py-10 text-center text-cladd-fg-soft">No balance to draw.</p>
|
||||||
|
{:else}
|
||||||
|
<LineChart
|
||||||
|
axisFormat={axis(
|
||||||
|
converted ? setPrimary(chart.data?.[0] as ChartSet) : cur
|
||||||
|
)}
|
||||||
|
height={300}
|
||||||
|
label="Balance of {a?.name ?? "the account"}"
|
||||||
|
{marker}
|
||||||
|
{series}
|
||||||
|
toggle={false}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{#if !everything}
|
||||||
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
|
<Breakdown
|
||||||
|
by="category"
|
||||||
|
{groups}
|
||||||
|
kind="withdrawal"
|
||||||
|
title="Expenses by category"
|
||||||
|
/>
|
||||||
|
<Breakdown
|
||||||
|
by="budget"
|
||||||
|
{groups}
|
||||||
|
kind="withdrawal"
|
||||||
|
title="Expenses by budget"
|
||||||
|
/>
|
||||||
|
<Breakdown
|
||||||
|
by="category"
|
||||||
|
{groups}
|
||||||
|
kind="deposit"
|
||||||
|
title="Income by category"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if a?.notes}
|
||||||
|
<Box title="Notes">
|
||||||
|
<p class="whitespace-pre-wrap text-cladd-fg">{a.notes}</p>
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid gap-3 {everything
|
||||||
|
? ""
|
||||||
|
: "min-[1700px]:grid-cols-[minmax(0,1fr)_15rem]"}"
|
||||||
|
>
|
||||||
|
<Box body="p-0" title="Transactions">
|
||||||
|
{#snippet tools()}
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft"
|
||||||
|
>{total}
|
||||||
|
transactions</span
|
||||||
|
>
|
||||||
|
{#if everything}
|
||||||
|
<Pager href={pageHref} keys page={pageNo} {pages} />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<TransactionTable
|
||||||
|
context={{ accountId: id }}
|
||||||
|
empty={everything
|
||||||
|
? "No transactions."
|
||||||
|
: "No transactions in this period."}
|
||||||
|
{groups}
|
||||||
|
id="account-tx"
|
||||||
|
loading={everything ? paged.isFetching : dated.isFetching}
|
||||||
|
/>
|
||||||
|
{#snippet footer()}
|
||||||
|
{#if everything}
|
||||||
|
<a class="text-brand hover:underline" href={showUrl(id)}
|
||||||
|
>Show the current period and overview</a
|
||||||
|
>
|
||||||
|
<span class="ml-auto"
|
||||||
|
><Pager href={pageHref} page={pageNo} {pages} /></span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<a class="text-brand hover:underline" href="{showUrl(id)}/all"
|
||||||
|
>Show all transactions without grouping them by date</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
|
{#if !everything && a}
|
||||||
|
<div class="order-first min-w-0 min-[1700px]:order-none">
|
||||||
|
<PeriodList accountId={id} code={a.currency_code ?? ""} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
||||||
|
import ArrowUp from "@lucide/svelte/icons/arrow-up";
|
||||||
|
import Check from "@lucide/svelte/icons/check";
|
||||||
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
|
import X from "@lucide/svelte/icons/x";
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all, invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { Account, AccountType, Item, Page } from "#lib/api/types.js";
|
||||||
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
||||||
|
import {
|
||||||
|
balanceRange,
|
||||||
|
type ChartSet,
|
||||||
|
seriesPoints,
|
||||||
|
} from "#lib/components/dashboard/data.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
} from "#lib/components/ui/context-menu/index.js";
|
||||||
|
import { daysBetween } from "#lib/dates.js";
|
||||||
|
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
||||||
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import {
|
||||||
|
DIRECTION_LABEL,
|
||||||
|
editUrl,
|
||||||
|
formatIban,
|
||||||
|
go,
|
||||||
|
LIABILITY_LABEL,
|
||||||
|
openOld,
|
||||||
|
PERIOD_LABEL,
|
||||||
|
ROLE_LABEL,
|
||||||
|
reconcileUrl,
|
||||||
|
showUrl,
|
||||||
|
TYPE_ONE,
|
||||||
|
TYPE_TITLE,
|
||||||
|
} from "./links.js";
|
||||||
|
|
||||||
|
type Key =
|
||||||
|
| "name"
|
||||||
|
| "role"
|
||||||
|
| "liability"
|
||||||
|
| "direction"
|
||||||
|
| "interest"
|
||||||
|
| "number"
|
||||||
|
| "balance"
|
||||||
|
| "due"
|
||||||
|
| "active"
|
||||||
|
| "activity"
|
||||||
|
| "trend"
|
||||||
|
| "difference";
|
||||||
|
|
||||||
|
interface Column {
|
||||||
|
key: Key;
|
||||||
|
label: string;
|
||||||
|
num?: boolean;
|
||||||
|
wide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS: Record<Key, Column> = {
|
||||||
|
active: { key: "active", label: "Is active?" },
|
||||||
|
activity: { key: "activity", label: "Last activity", wide: true },
|
||||||
|
balance: { key: "balance", label: "Current balance", num: true },
|
||||||
|
difference: { key: "difference", label: "Balance difference", num: true },
|
||||||
|
direction: { key: "direction", label: "Liability in/out" },
|
||||||
|
due: { key: "due", label: "Amount due", num: true },
|
||||||
|
interest: { key: "interest", label: "Interest (period)", wide: true },
|
||||||
|
liability: { key: "liability", label: "Type of liability" },
|
||||||
|
name: { key: "name", label: "Name" },
|
||||||
|
number: { key: "number", label: "Account number", wide: true },
|
||||||
|
role: { key: "role", label: "Role", wide: true },
|
||||||
|
trend: { key: "trend", label: "In this period", num: true, wide: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAYOUT: Record<AccountType, Key[]> = {
|
||||||
|
asset: [
|
||||||
|
"name",
|
||||||
|
"role",
|
||||||
|
"number",
|
||||||
|
"balance",
|
||||||
|
"active",
|
||||||
|
"activity",
|
||||||
|
"trend",
|
||||||
|
"difference",
|
||||||
|
],
|
||||||
|
expense: ["name", "number", "balance", "active", "activity", "difference"],
|
||||||
|
liabilities: [
|
||||||
|
"name",
|
||||||
|
"liability",
|
||||||
|
"direction",
|
||||||
|
"interest",
|
||||||
|
"number",
|
||||||
|
"balance",
|
||||||
|
"due",
|
||||||
|
"active",
|
||||||
|
"trend",
|
||||||
|
"difference",
|
||||||
|
],
|
||||||
|
revenue: ["name", "number", "balance", "active", "activity", "difference"],
|
||||||
|
};
|
||||||
|
|
||||||
|
let { type, active = true }: { type: AccountType; active?: boolean } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let filterInput = $state<HTMLInputElement>();
|
||||||
|
let filter = $state(page.url.searchParams.get("filter") ?? "");
|
||||||
|
let menu = $state<Item<Account> | null>(null);
|
||||||
|
let armed = $state(false);
|
||||||
|
let failure = $state("");
|
||||||
|
|
||||||
|
const accounts = createQuery(() => ({
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
all<Account>("/v1/accounts", {
|
||||||
|
...balanceRange(period.start, period.end),
|
||||||
|
active: String(active),
|
||||||
|
type,
|
||||||
|
}),
|
||||||
|
queryKey: ["accounts-index", type, active, period.start, period.end],
|
||||||
|
}));
|
||||||
|
const others = createQuery(() => ({
|
||||||
|
queryFn: () =>
|
||||||
|
api<Page<Account>>("/v1/accounts", {
|
||||||
|
query: { active: String(!active), limit: 1, type },
|
||||||
|
}),
|
||||||
|
queryKey: ["accounts-count", type, !active],
|
||||||
|
}));
|
||||||
|
const charted = $derived(
|
||||||
|
(type === "asset" || type === "liabilities") && active
|
||||||
|
? (accounts.data ?? []).map((item) => item.id)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const trendChart = createQuery(() => ({
|
||||||
|
enabled: charted.length > 0 && period.start < period.end,
|
||||||
|
queryFn: () =>
|
||||||
|
api<ChartSet[]>("/v1/chart/account/overview", {
|
||||||
|
query: {
|
||||||
|
"accounts[]": charted,
|
||||||
|
end: period.end,
|
||||||
|
period: daysBetween(period.start, period.end) > 92 ? "1W" : "1D",
|
||||||
|
start: period.start,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
queryKey: ["accounts-trend", charted.join(","), period.start, period.end],
|
||||||
|
}));
|
||||||
|
const trends = $derived(
|
||||||
|
new Map(
|
||||||
|
(trendChart.data ?? []).map((set) => [
|
||||||
|
set.label,
|
||||||
|
seriesPoints(set, false).map((p) => p.y),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const otherCount = $derived(others.data?.meta.pagination?.total ?? 0);
|
||||||
|
|
||||||
|
const columns = $derived(LAYOUT[type].map((k) => COLUMNS[k]));
|
||||||
|
const sortParam = $derived(page.url.searchParams.get("sort") ?? "");
|
||||||
|
const sortKey = $derived(sortParam.replace("-", "") as Key | "");
|
||||||
|
const descending = $derived(sortParam.startsWith("-"));
|
||||||
|
|
||||||
|
function sortValue(item: Item<Account>, key: Key): string | number {
|
||||||
|
const a = item.attributes;
|
||||||
|
switch (key) {
|
||||||
|
case "name":
|
||||||
|
return a.name.toLowerCase();
|
||||||
|
case "role":
|
||||||
|
return ROLE_LABEL[a.account_role ?? ""] ?? "";
|
||||||
|
case "liability":
|
||||||
|
return a.liability_type ?? "";
|
||||||
|
case "direction":
|
||||||
|
return a.liability_direction ?? "";
|
||||||
|
case "interest":
|
||||||
|
return toNumber(a.interest);
|
||||||
|
case "number":
|
||||||
|
return formatIban(a);
|
||||||
|
case "balance":
|
||||||
|
return toNumber(a.current_balance);
|
||||||
|
case "due":
|
||||||
|
return toNumber(a.debt_amount);
|
||||||
|
case "active":
|
||||||
|
return a.active ? 1 : 0;
|
||||||
|
case "activity":
|
||||||
|
return a.last_activity ?? "";
|
||||||
|
default:
|
||||||
|
return toNumber(a.balance_difference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needle = $derived(filter.trim().toLowerCase());
|
||||||
|
const matching = $derived(
|
||||||
|
(accounts.data ?? []).filter((item) => {
|
||||||
|
if (!needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const a = item.attributes;
|
||||||
|
return [
|
||||||
|
a.name,
|
||||||
|
a.iban,
|
||||||
|
a.account_number,
|
||||||
|
ROLE_LABEL[a.account_role ?? ""],
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some((text) => String(text).toLowerCase().includes(needle));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const sorted = $derived.by(() => {
|
||||||
|
const list = [...matching];
|
||||||
|
if (!sortKey) {
|
||||||
|
return list.sort(
|
||||||
|
(x, y) => (x.attributes.order ?? 0) - (y.attributes.order ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const dir = descending ? -1 : 1;
|
||||||
|
return list.sort((x, y) => {
|
||||||
|
const a = sortValue(x, sortKey);
|
||||||
|
const b = sortValue(y, sortKey);
|
||||||
|
if (a === b) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (a > b ? 1 : -1) * dir;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const grouped = $derived(
|
||||||
|
sorted.some((item) => item.attributes.object_group_title)
|
||||||
|
);
|
||||||
|
const groups = $derived.by(() => {
|
||||||
|
if (!grouped) {
|
||||||
|
return [{ items: sorted, title: "" }];
|
||||||
|
}
|
||||||
|
const map = new Map<string, { items: Item<Account>[]; order: number }>();
|
||||||
|
for (const item of sorted) {
|
||||||
|
const name = item.attributes.object_group_title ?? "";
|
||||||
|
const entry = map.get(name) ?? {
|
||||||
|
items: [],
|
||||||
|
order: name ? (item.attributes.object_group_order ?? 0) : 1e9,
|
||||||
|
};
|
||||||
|
entry.items.push(item);
|
||||||
|
map.set(name, entry);
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort(([, a], [, b]) => a.order - b.order)
|
||||||
|
.map(([name, entry]) => ({
|
||||||
|
items: entry.items,
|
||||||
|
title: name || "Without group",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const rows = $derived(groups.flatMap((g) => g.items));
|
||||||
|
const index = $derived(new Map(rows.map((item, i) => [item.id, i])));
|
||||||
|
|
||||||
|
interface Total {
|
||||||
|
balance: number;
|
||||||
|
code: string;
|
||||||
|
decimals: number;
|
||||||
|
difference: number;
|
||||||
|
due: number;
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
const totals = $derived.by(() => {
|
||||||
|
const map = new Map<string, Total>();
|
||||||
|
for (const { attributes: a } of rows) {
|
||||||
|
const code = a.currency_code ?? "";
|
||||||
|
const t = map.get(code) ?? {
|
||||||
|
balance: 0,
|
||||||
|
code,
|
||||||
|
decimals: a.currency_decimal_places ?? 2,
|
||||||
|
difference: 0,
|
||||||
|
due: 0,
|
||||||
|
symbol: a.currency_symbol ?? code,
|
||||||
|
};
|
||||||
|
t.balance += toNumber(a.current_balance);
|
||||||
|
t.difference += toNumber(a.balance_difference);
|
||||||
|
t.due += toNumber(a.debt_amount);
|
||||||
|
map.set(code, t);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = $derived(
|
||||||
|
active ? TYPE_TITLE[type] : `${TYPE_TITLE[type]} (inactive)`
|
||||||
|
);
|
||||||
|
const createHref = $derived(`/accounts/create/${type}`);
|
||||||
|
|
||||||
|
const nav = new ListNav<Item<Account>>({
|
||||||
|
edit: (item) => goto(editUrl(item.id)),
|
||||||
|
extra: () => [
|
||||||
|
{
|
||||||
|
group: "Accounts",
|
||||||
|
keys: "/",
|
||||||
|
label: "Filter accounts",
|
||||||
|
run: () => filterInput?.focus(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Accounts",
|
||||||
|
keys: "c",
|
||||||
|
label: `Create a new ${TYPE_ONE[type]}`,
|
||||||
|
run: () => goto(createHref),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Accounts",
|
||||||
|
keys: "r",
|
||||||
|
label: "Reconcile account",
|
||||||
|
run: () => {
|
||||||
|
if (nav.current && type === "asset") {
|
||||||
|
go(reconcileUrl(nav.current.id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: "accounts",
|
||||||
|
items: () => rows,
|
||||||
|
key: (item) => item.id,
|
||||||
|
open: (item) => goto(showUrl(item.id)),
|
||||||
|
});
|
||||||
|
nav.use();
|
||||||
|
|
||||||
|
function onfilterkey(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && filter) {
|
||||||
|
filter = "";
|
||||||
|
onfilter();
|
||||||
|
} else if (event.key === "Enter" && rows[0]) {
|
||||||
|
event.preventDefault();
|
||||||
|
goto(showUrl(rows[0].id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ariaSort(key: Key) {
|
||||||
|
if (sortKey !== key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return descending ? "descending" : "ascending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortHref(key: Key): string {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
let next: string | null = key;
|
||||||
|
if (sortKey === key) {
|
||||||
|
next = descending ? null : `-${key}`;
|
||||||
|
}
|
||||||
|
if (next) {
|
||||||
|
url.searchParams.set("sort", next);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete("sort");
|
||||||
|
}
|
||||||
|
url.searchParams.set("start", period.start);
|
||||||
|
url.searchParams.set("end", period.end);
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onfilter() {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
if (filter) {
|
||||||
|
url.searchParams.set("filter", filter);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete("filter");
|
||||||
|
}
|
||||||
|
url.searchParams.set("start", period.start);
|
||||||
|
url.searchParams.set("end", period.end);
|
||||||
|
nav.cursor = -1;
|
||||||
|
goto(url, { replace: true, shallow: true, state: page.state });
|
||||||
|
}
|
||||||
|
|
||||||
|
function oncontextmenu(event: MouseEvent) {
|
||||||
|
const tr = (event.target as Element).closest("tr[data-id]");
|
||||||
|
armed = false;
|
||||||
|
menu = rows.find((r) => r.id === tr?.getAttribute("data-id")) ?? null;
|
||||||
|
if (menu) {
|
||||||
|
nav.cursor = index.get(menu.id) ?? -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(item: Item<Account>) {
|
||||||
|
failure = "";
|
||||||
|
try {
|
||||||
|
await api(`/v1/accounts/${item.id}`, { method: "DELETE" });
|
||||||
|
await invalidate();
|
||||||
|
} catch (e) {
|
||||||
|
failure = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(a: Account, value: string | number | null | undefined) {
|
||||||
|
return {
|
||||||
|
amount: value,
|
||||||
|
code: a.currency_code,
|
||||||
|
decimals: a.currency_decimal_places,
|
||||||
|
symbol: a.currency_symbol,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowClass(i: number) {
|
||||||
|
if (nav.cursor === i) {
|
||||||
|
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
|
||||||
|
}
|
||||||
|
return "hover:bg-row-hover";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[{ label: title }]}
|
||||||
|
subtitle={active ? undefined : "Archive"}
|
||||||
|
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[type]} c"
|
||||||
|
>
|
||||||
|
<Plus class="size-3.5" />
|
||||||
|
Create a new {TYPE_ONE[type]}
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Box body="p-0" {title}>
|
||||||
|
{#snippet tools()}
|
||||||
|
<input
|
||||||
|
aria-label="Filter accounts"
|
||||||
|
class="cladd-cut h-7 w-44 rounded-cladd-sm px-2 text-[12px] outline-none placeholder:text-cladd-fg-softer sm:w-60"
|
||||||
|
oninput={onfilter}
|
||||||
|
onkeydown={onfilterkey}
|
||||||
|
placeholder="Filter /"
|
||||||
|
type="search"
|
||||||
|
bind:this={filterInput}
|
||||||
|
bind:value={filter}
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{#if accounts.isPending}
|
||||||
|
<div class="h-40"></div>
|
||||||
|
{:else if accounts.isError}
|
||||||
|
<p class="p-3 text-expense">{accounts.error.message}</p>
|
||||||
|
{:else if (accounts.data?.length ?? 0) === 0}
|
||||||
|
<div class="flex flex-col items-center gap-3 px-3 py-10 text-center">
|
||||||
|
<p class="font-semibold text-[15px]">
|
||||||
|
{active
|
||||||
|
? `No ${TYPE_TITLE[type].toLowerCase()} yet`
|
||||||
|
: "Nothing archived"}
|
||||||
|
</p>
|
||||||
|
{#if active}
|
||||||
|
<a class="text-brand underline" href={createHref}
|
||||||
|
>Create a new {TYPE_ONE[type]}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger class="block overflow-x-auto" {oncontextmenu}>
|
||||||
|
<table
|
||||||
|
class="w-full min-w-[720px] border-collapse text-[13px] transition-opacity {accounts.isPlaceholderData
|
||||||
|
? "opacity-60"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<thead class="border-cladd-outline border-b text-left">
|
||||||
|
<tr class="h-8">
|
||||||
|
{#each columns as col (col.key)}
|
||||||
|
<th
|
||||||
|
aria-sort={ariaSort(col.key)}
|
||||||
|
class="whitespace-nowrap px-3 font-semibold text-[12px] text-cladd-fg-soft {col.num
|
||||||
|
? "text-right"
|
||||||
|
: ""} {col.wide ? "hidden md:table-cell" : ""}"
|
||||||
|
scope="col"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="inline-flex items-center gap-1 hover:text-cladd-fg"
|
||||||
|
href={sortHref(col.key)}
|
||||||
|
>{col.label}
|
||||||
|
{#if sortKey === col.key}
|
||||||
|
{#if descending}
|
||||||
|
<ArrowDown class="size-3" />
|
||||||
|
{:else}
|
||||||
|
<ArrowUp class="size-3" />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each groups as group (group.title)}
|
||||||
|
{#if grouped}
|
||||||
|
<tr class="h-8 border-cladd-outline border-t">
|
||||||
|
<th
|
||||||
|
class="px-3 text-left font-semibold text-[12px] text-cladd-fg"
|
||||||
|
colspan={columns.length}
|
||||||
|
scope="rowgroup"
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{#each group.items as item (item.id)}
|
||||||
|
{@const a = item.attributes}
|
||||||
|
{@const i = index.get(item.id) ?? -1}
|
||||||
|
<tr
|
||||||
|
class="h-7 border-cladd-outline/60 border-t {rowClass(i)}"
|
||||||
|
data-id={item.id}
|
||||||
|
id={nav.rowId(i)}
|
||||||
|
onclick={(event) => nav.click(i, event)}
|
||||||
|
>
|
||||||
|
{#each columns as col (col.key)}
|
||||||
|
<td
|
||||||
|
class="px-3 {col.key === "name"
|
||||||
|
? ""
|
||||||
|
: "whitespace-nowrap"} {col.num
|
||||||
|
? "text-right"
|
||||||
|
: ""} {col.wide ? "hidden md:table-cell" : ""}"
|
||||||
|
>
|
||||||
|
{#if col.key === "name"}
|
||||||
|
<a
|
||||||
|
class="block max-w-[22rem] truncate text-brand hover:underline"
|
||||||
|
href={showUrl(item.id)}
|
||||||
|
title={a.name}
|
||||||
|
>{a.name}</a
|
||||||
|
>
|
||||||
|
{:else if col.key === "role"}
|
||||||
|
{ROLE_LABEL[a.account_role ?? ""] ?? ""}
|
||||||
|
{:else if col.key === "liability"}
|
||||||
|
{LIABILITY_LABEL[a.liability_type ?? ""] ?? ""}
|
||||||
|
{:else if col.key === "direction"}
|
||||||
|
{DIRECTION_LABEL[a.liability_direction ?? ""] ?? ""}
|
||||||
|
{:else if col.key === "interest"}
|
||||||
|
<span class="num">{toNumber(a.interest)}%</span>
|
||||||
|
<span class="text-cladd-fg-soft"
|
||||||
|
>({PERIOD_LABEL[a.interest_period ?? ""] ??
|
||||||
|
a.interest_period})</span
|
||||||
|
>
|
||||||
|
{:else if col.key === "number"}
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft"
|
||||||
|
>{formatIban(a)}</span
|
||||||
|
>
|
||||||
|
{:else if col.key === "balance"}
|
||||||
|
<Amount {...money(a, a.current_balance)} />
|
||||||
|
{:else if col.key === "due"}
|
||||||
|
<Amount {...money(a, a.debt_amount)} tone="transfer" />
|
||||||
|
{:else if col.key === "active"}
|
||||||
|
{#if a.active}
|
||||||
|
<Check aria-label="Active" class="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<X
|
||||||
|
aria-label="Inactive"
|
||||||
|
class="size-3.5 text-cladd-fg-soft"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else if col.key === "trend"}
|
||||||
|
{@const values = trends.get(a.name) ?? []}
|
||||||
|
<span class="flex justify-end">
|
||||||
|
<Sparkline
|
||||||
|
color={toNumber(a.balance_difference) < 0
|
||||||
|
? "var(--ff-expense)"
|
||||||
|
: "var(--ff-income)"}
|
||||||
|
height={20}
|
||||||
|
{values}
|
||||||
|
width={88}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{:else if col.key === "activity"}
|
||||||
|
<span class="text-cladd-fg-soft"
|
||||||
|
>{a.last_activity
|
||||||
|
? formatDate(a.last_activity.slice(0, 10))
|
||||||
|
: "never"}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<Amount {...money(a, a.balance_difference)} />
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
{#if rows.length === 0}
|
||||||
|
<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>
|
||||||
|
{#if totals.length > 0}
|
||||||
|
<tfoot class="border-cladd-outline border-t-2">
|
||||||
|
{#each totals as t (t.code)}
|
||||||
|
<tr class="h-7">
|
||||||
|
{#each columns as col, c (col.key)}
|
||||||
|
<td
|
||||||
|
class="px-3 {col.num ? "text-right" : ""} {col.wide
|
||||||
|
? "hidden md:table-cell"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
{#if c === 0}
|
||||||
|
<span
|
||||||
|
class="font-semibold text-[12px] text-cladd-fg-soft"
|
||||||
|
>Total in {t.code}</span
|
||||||
|
>
|
||||||
|
{:else if col.key === "balance"}
|
||||||
|
<span class="num font-semibold"
|
||||||
|
>{formatMoney({ ...t, amount: t.balance })}</span
|
||||||
|
>
|
||||||
|
{:else if col.key === "due"}
|
||||||
|
<span class="num font-semibold"
|
||||||
|
>{formatMoney({ ...t, amount: t.due })}</span
|
||||||
|
>
|
||||||
|
{:else if col.key === "difference"}
|
||||||
|
<Amount
|
||||||
|
{...t}
|
||||||
|
amount={t.difference}
|
||||||
|
class="font-semibold"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tfoot>
|
||||||
|
{/if}
|
||||||
|
</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>
|
||||||
|
{#if type === "asset"}
|
||||||
|
<ContextMenuItem onSelect={() => go(reconcileUrl(target.id))}>
|
||||||
|
Reconcile <ContextMenuShortcut>r</ContextMenuShortcut>
|
||||||
|
</ContextMenuItem>
|
||||||
|
{/if}
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
if (armed) {
|
||||||
|
remove(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
armed = true;
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{armed
|
||||||
|
? `Press again to delete ${target.attributes.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 hover:underline" href={createHref}
|
||||||
|
>Create a new {TYPE_ONE[type]}</a
|
||||||
|
>
|
||||||
|
{#if failure}
|
||||||
|
<span class="text-expense">{failure}</span>
|
||||||
|
{/if}
|
||||||
|
{#if active && otherCount > 0}
|
||||||
|
<a
|
||||||
|
class="ml-auto text-cladd-fg-soft hover:underline"
|
||||||
|
href="/inactive-accounts/{type}"
|
||||||
|
>{otherCount}
|
||||||
|
inactive {otherCount === 1 ? "account" : "accounts"}</a
|
||||||
|
>
|
||||||
|
{:else if !active}
|
||||||
|
<a
|
||||||
|
class="ml-auto text-cladd-fg-soft hover:underline"
|
||||||
|
href="/accounts/{type}"
|
||||||
|
>Back to active {TYPE_TITLE[type].toLowerCase()}</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Split } from "#lib/api/types.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 {
|
||||||
|
byCurrency,
|
||||||
|
currency,
|
||||||
|
money,
|
||||||
|
} from "#lib/components/dashboard/data.js";
|
||||||
|
import type { Group } from "#lib/transactions/split.js";
|
||||||
|
|
||||||
|
const TOP = 6;
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
groups,
|
||||||
|
kind,
|
||||||
|
by,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
groups: Group[];
|
||||||
|
kind: "withdrawal" | "deposit";
|
||||||
|
by: "category" | "budget";
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function pick(split: Split): [string, string] {
|
||||||
|
if (by === "budget") {
|
||||||
|
return split.budget_id
|
||||||
|
? [split.budget_name ?? "", `/budgets/show/${split.budget_id}`]
|
||||||
|
: ["(no budget)", "/budgets/list/no-budget"];
|
||||||
|
}
|
||||||
|
return split.category_id
|
||||||
|
? [split.category_name ?? "", `/categories/show/${split.category_id}`]
|
||||||
|
: ["(no category)", "/categories/list/no-category"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const splits = $derived(
|
||||||
|
groups
|
||||||
|
.flatMap((g) => g.attributes.transactions)
|
||||||
|
.filter((s) => s.type === kind)
|
||||||
|
);
|
||||||
|
|
||||||
|
const sections = $derived(
|
||||||
|
byCurrency(splits, (s) => s.currency_code ?? "").map(([code, list]) => {
|
||||||
|
const sums = new Map<string, Bar>();
|
||||||
|
for (const split of list) {
|
||||||
|
const [label, href] = pick(split);
|
||||||
|
const bar = sums.get(label) ?? { href, key: label, label, value: 0 };
|
||||||
|
bar.value += Number(split.amount);
|
||||||
|
sums.set(label, bar);
|
||||||
|
}
|
||||||
|
const sorted = [...sums.values()].sort((a, b) => b.value - a.value);
|
||||||
|
const rest = sorted.slice(TOP);
|
||||||
|
const bars =
|
||||||
|
rest.length > 1
|
||||||
|
? [
|
||||||
|
...sorted.slice(0, TOP),
|
||||||
|
{
|
||||||
|
key: "other",
|
||||||
|
label: `Other (${rest.length})`,
|
||||||
|
value: rest.reduce((sum, b) => sum + b.value, 0),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: sorted;
|
||||||
|
return { bars, code, format: money(currency(code)) };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box {title}>
|
||||||
|
{#if sections.length === 0}
|
||||||
|
<p class="py-4 text-center text-cladd-fg-soft">
|
||||||
|
{kind === "withdrawal" ? "No expenses" : "No income"}
|
||||||
|
in this period.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each sections as section (section.code)}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#if sections.length > 1}
|
||||||
|
<span
|
||||||
|
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||||
|
>{section.code}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<BarChart
|
||||||
|
bars={section.bars}
|
||||||
|
color={kind === "withdrawal" ? tones.expense : tones.income}
|
||||||
|
format={section.format}
|
||||||
|
label="{title} in {section.code}"
|
||||||
|
labelWidth="minmax(5rem, 9rem)"
|
||||||
|
orientation="horizontal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import { tones } from "#lib/components/charts/chart.js";
|
||||||
|
import {
|
||||||
|
type ChartSet,
|
||||||
|
currency,
|
||||||
|
money,
|
||||||
|
} from "#lib/components/dashboard/data.js";
|
||||||
|
import {
|
||||||
|
isDynamic,
|
||||||
|
periodOf,
|
||||||
|
type Range,
|
||||||
|
shiftPeriod,
|
||||||
|
type ViewRange,
|
||||||
|
} from "#lib/dates.js";
|
||||||
|
import { toNumber } from "#lib/format.js";
|
||||||
|
import { period, periodLabel } from "#lib/period.svelte.js";
|
||||||
|
|
||||||
|
const COUNT = 12;
|
||||||
|
const STEPS = new Set(["1D", "1W", "1M", "3M", "6M", "1Y"]);
|
||||||
|
|
||||||
|
let { accountId, code }: { accountId: string; code: string } = $props();
|
||||||
|
|
||||||
|
const view = $derived<ViewRange>(
|
||||||
|
isDynamic(period.view) || !STEPS.has(period.view) ? "1M" : period.view
|
||||||
|
);
|
||||||
|
const blocks = $derived.by<Range[]>(() => {
|
||||||
|
const list = [periodOf(period.end, view)];
|
||||||
|
for (let i = 1; i < COUNT; i += 1) {
|
||||||
|
list.push(shiftPeriod(list[i - 1], view, -1));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
const oldest = $derived(blocks.at(-1)?.start ?? period.start);
|
||||||
|
const newest = $derived(blocks[0]?.end ?? period.end);
|
||||||
|
|
||||||
|
const flows = createQuery(() => ({
|
||||||
|
queryFn: () =>
|
||||||
|
api<ChartSet[]>("/v1/chart/balance/balance", {
|
||||||
|
query: {
|
||||||
|
"accounts[]": accountId,
|
||||||
|
end: newest,
|
||||||
|
period: view,
|
||||||
|
start: oldest,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
queryKey: ["account-flows", accountId, oldest, newest, view],
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface Flow {
|
||||||
|
code: string;
|
||||||
|
earned: number;
|
||||||
|
spent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byStart = $derived.by(() => {
|
||||||
|
const map = new Map<string, Flow[]>();
|
||||||
|
for (const set of flows.data ?? []) {
|
||||||
|
const kind = set.label === "earned" ? "earned" : "spent";
|
||||||
|
for (const [date, value] of Object.entries(set.entries)) {
|
||||||
|
const key = date.slice(0, 10);
|
||||||
|
const list = map.get(key) ?? [];
|
||||||
|
let flow = list.find((f) => f.code === set.currency_code);
|
||||||
|
if (!flow) {
|
||||||
|
flow = { code: set.currency_code, earned: 0, spent: 0 };
|
||||||
|
list.push(flow);
|
||||||
|
map.set(key, list);
|
||||||
|
}
|
||||||
|
flow[kind] += Math.abs(toNumber(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const list of map.values()) {
|
||||||
|
list.sort((a, b) => Number(b.code === code) - Number(a.code === code));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const scale = $derived(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
...[...byStart.values()].flatMap((list) =>
|
||||||
|
list
|
||||||
|
.filter((f) => f.code === code)
|
||||||
|
.map((f) => Math.max(f.earned, f.spent))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const isCurrent = (block: Range) =>
|
||||||
|
block.start === period.start && block.end === period.end;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box body="p-0 @container" title="Periods">
|
||||||
|
<ul
|
||||||
|
class="-mr-px grid grid-cols-1 overflow-hidden @xs:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-6"
|
||||||
|
>
|
||||||
|
{#each blocks as block (block.start)}
|
||||||
|
{@const list = (byStart.get(block.start) ?? []).filter(
|
||||||
|
(f) => f.earned || f.spent
|
||||||
|
)}
|
||||||
|
<li class="border-cladd-outline/60 border-r border-b">
|
||||||
|
<a
|
||||||
|
class="flex flex-col gap-1 px-3 py-2 hover:bg-row-hover {isCurrent(
|
||||||
|
block
|
||||||
|
)
|
||||||
|
? "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]"
|
||||||
|
: ""}"
|
||||||
|
href="/accounts/show/{accountId}/{block.start}/{block.end}"
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-[12px] text-cladd-fg"
|
||||||
|
>{periodLabel(block, view)}</span
|
||||||
|
>
|
||||||
|
{#if list.length === 0}
|
||||||
|
<span class="text-[12px] text-cladd-fg-softer"
|
||||||
|
>No transactions</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#each list as flow (flow.code)}
|
||||||
|
{@const fmt = money(currency(flow.code))}
|
||||||
|
<span
|
||||||
|
class="num flex flex-wrap justify-between gap-x-2 text-[12px]"
|
||||||
|
>
|
||||||
|
{#if flow.earned}
|
||||||
|
<span class="text-income">+{fmt(flow.earned)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if flow.spent}
|
||||||
|
<span class="ml-auto text-expense">−{fmt(flow.spent)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if flow.code === code}
|
||||||
|
<span aria-hidden="true" class="flex h-1 gap-0.5">
|
||||||
|
<span class="flex flex-1 justify-end">
|
||||||
|
<span
|
||||||
|
class="h-full rounded-l-full"
|
||||||
|
style:background={tones.income}
|
||||||
|
style:width="{(flow.earned / scale) * 100}%"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
<span class="flex-1">
|
||||||
|
<span
|
||||||
|
class="block h-full rounded-r-full"
|
||||||
|
style:background={tones.expense}
|
||||||
|
style:width="{(flow.spent / scale) * 100}%"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Check from "@lucide/svelte/icons/check";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all, invalidate } from "#lib/api/lists.js";
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
One,
|
||||||
|
Split,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import { currency, money } from "#lib/components/dashboard/data.js";
|
||||||
|
import DateInput from "#lib/components/inputs/DateInput.svelte";
|
||||||
|
import { INPUT } from "#lib/components/inputs/styles.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { Button } from "#lib/components/ui/button/index.js";
|
||||||
|
import { addDays } from "#lib/dates.js";
|
||||||
|
import { formatDate, toNumber } from "#lib/format.js";
|
||||||
|
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
|
import {
|
||||||
|
type Group,
|
||||||
|
groupTitle,
|
||||||
|
signedAmount,
|
||||||
|
signedForeign,
|
||||||
|
} from "#lib/transactions/split.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { indexUrl, showUrl, TYPE_TITLE } from "./links.js";
|
||||||
|
|
||||||
|
const MARGIN_DAYS = 3;
|
||||||
|
const CENT = 0.005;
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
amount: number;
|
||||||
|
cleared: boolean;
|
||||||
|
date: string;
|
||||||
|
group: Group;
|
||||||
|
inRange: boolean;
|
||||||
|
key: string;
|
||||||
|
split: Split;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, start, end }: { id: string; start: string; end: string } = $props();
|
||||||
|
|
||||||
|
let startInput = $state(untrack(() => start));
|
||||||
|
let endInput = $state(untrack(() => end));
|
||||||
|
let startText = $state("");
|
||||||
|
let endText = $state("");
|
||||||
|
let startTouched = $state(false);
|
||||||
|
let endTouched = $state(false);
|
||||||
|
let correct = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
|
let status = $state("");
|
||||||
|
let failure = $state("");
|
||||||
|
|
||||||
|
const account = createQuery(() => ({
|
||||||
|
queryFn: () => api<One<Account>>(`/v1/accounts/${id}`),
|
||||||
|
queryKey: ["account", id, "reconcile"],
|
||||||
|
}));
|
||||||
|
const a = $derived(account.data?.data.attributes);
|
||||||
|
const cur = $derived(currency(a?.currency_code ?? ""));
|
||||||
|
const fmt = $derived(money(cur));
|
||||||
|
|
||||||
|
const balanceAt = (date: string) => async () => {
|
||||||
|
const res = await api<One<Account>>(`/v1/accounts/${id}`, {
|
||||||
|
query: { date },
|
||||||
|
});
|
||||||
|
return toNumber(res.data.attributes.current_balance);
|
||||||
|
};
|
||||||
|
const before = createQuery(() => ({
|
||||||
|
queryFn: balanceAt(addDays(start, -1)),
|
||||||
|
queryKey: ["reconcile-balance", id, addDays(start, -1)],
|
||||||
|
staleTime: 0,
|
||||||
|
}));
|
||||||
|
const after = createQuery(() => ({
|
||||||
|
queryFn: balanceAt(end),
|
||||||
|
queryKey: ["reconcile-balance", id, end],
|
||||||
|
staleTime: 0,
|
||||||
|
}));
|
||||||
|
const list = createQuery(() => ({
|
||||||
|
queryFn: () =>
|
||||||
|
all<TransactionGroup>(`/v1/accounts/${id}/transactions`, {
|
||||||
|
end: addDays(end, MARGIN_DAYS),
|
||||||
|
start: addDays(start, -MARGIN_DAYS),
|
||||||
|
}),
|
||||||
|
queryKey: ["reconcile-list", id, start, end],
|
||||||
|
staleTime: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function net(split: Split): number {
|
||||||
|
const code = a?.currency_code;
|
||||||
|
if (split.currency_code !== code && split.foreign_currency_code === code) {
|
||||||
|
return signedForeign(split, id)?.amount ?? 0;
|
||||||
|
}
|
||||||
|
return signedAmount(split, id).amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = $derived<Row[]>(
|
||||||
|
(list.data ?? [])
|
||||||
|
.flatMap((group) =>
|
||||||
|
group.attributes.transactions
|
||||||
|
.filter((s) => s.source_id === id || s.destination_id === id)
|
||||||
|
.map((split) => {
|
||||||
|
const date = split.date.slice(0, 10);
|
||||||
|
return {
|
||||||
|
amount: net(split),
|
||||||
|
cleared: split.reconciled === true,
|
||||||
|
date,
|
||||||
|
group,
|
||||||
|
inRange: date >= start && date <= end,
|
||||||
|
key: split.transaction_journal_id ?? "",
|
||||||
|
split,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.sort((x, y) => y.split.date.localeCompare(x.split.date))
|
||||||
|
);
|
||||||
|
const open = $derived(rows.filter((r) => r.inRange && !r.cleared));
|
||||||
|
const later = $derived(rows.filter((r) => r.date > end));
|
||||||
|
const inside = $derived(rows.filter((r) => r.inRange));
|
||||||
|
const earlier = $derived(rows.filter((r) => r.date < start));
|
||||||
|
|
||||||
|
const nav = new ListNav<Row>({
|
||||||
|
extra: () => [
|
||||||
|
{
|
||||||
|
group: "Reconcile",
|
||||||
|
input: true,
|
||||||
|
keys: "mod+Enter",
|
||||||
|
label: "Store reconciliation",
|
||||||
|
run: () => store(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
id: "reconcile",
|
||||||
|
items: () => open,
|
||||||
|
key: (row) => row.key,
|
||||||
|
selectable: true,
|
||||||
|
});
|
||||||
|
nav.use();
|
||||||
|
|
||||||
|
useKeys(() => [
|
||||||
|
{
|
||||||
|
group: "Reconcile",
|
||||||
|
keys: "c",
|
||||||
|
label: "Toggle the correction",
|
||||||
|
run: () => {
|
||||||
|
correct = canCorrect && !correct;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const startBalance = $derived(
|
||||||
|
startTouched ? toNumber(startText) : (before.data ?? 0)
|
||||||
|
);
|
||||||
|
const endBalance = $derived(
|
||||||
|
endTouched ? toNumber(endText) : (after.data ?? 0)
|
||||||
|
);
|
||||||
|
const selected = $derived(open.filter((r) => nav.isSelected(r)));
|
||||||
|
const selectedSum = $derived(selected.reduce((s, r) => s + r.amount, 0));
|
||||||
|
const cleared = $derived(inside.filter((r) => r.cleared));
|
||||||
|
const clearedSum = $derived(cleared.reduce((s, r) => s + r.amount, 0));
|
||||||
|
const sum = $derived(startBalance + selectedSum + clearedSum);
|
||||||
|
const difference = $derived.by(() => {
|
||||||
|
const value = sum - endBalance;
|
||||||
|
return Math.abs(value) < CENT ? 0 : value;
|
||||||
|
});
|
||||||
|
const reconciliationAccounts = createQuery(() => ({
|
||||||
|
queryFn: () => all<Account>("/v1/accounts", { type: "reconciliation" }),
|
||||||
|
queryKey: ["accounts", "reconciliation"],
|
||||||
|
}));
|
||||||
|
const reconciliationId = $derived(
|
||||||
|
reconciliationAccounts.data?.find(
|
||||||
|
(r) => r.attributes.name === `${a?.name} reconciliation (${cur.code})`
|
||||||
|
)?.id
|
||||||
|
);
|
||||||
|
const canCorrect = $derived(Boolean(reconciliationId));
|
||||||
|
const correcting = $derived(correct && canCorrect && difference !== 0);
|
||||||
|
const ready = $derived(before.isSuccess && after.isSuccess && list.isSuccess);
|
||||||
|
|
||||||
|
function setDates(nextStart: string, nextEnd: string) {
|
||||||
|
if (!(nextStart && nextEnd) || nextStart > nextEnd) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTouched = false;
|
||||||
|
endTouched = false;
|
||||||
|
nav.clear();
|
||||||
|
goto(`/accounts/reconcile/${id}/index/${nextStart}/${nextEnd}`, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear(group: Group, journals: Set<string>) {
|
||||||
|
let splits = group.attributes.transactions;
|
||||||
|
let title = group.attributes.group_title;
|
||||||
|
if (title) {
|
||||||
|
const full = await api<One<TransactionGroup>>(
|
||||||
|
`/v1/transactions/${group.id}`
|
||||||
|
);
|
||||||
|
splits = full.data.attributes.transactions;
|
||||||
|
title = full.data.attributes.group_title || groupTitle(full.data);
|
||||||
|
}
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
transactions: splits.map((s) =>
|
||||||
|
journals.has(s.transaction_journal_id ?? "")
|
||||||
|
? {
|
||||||
|
reconciled: true,
|
||||||
|
transaction_journal_id: s.transaction_journal_id,
|
||||||
|
}
|
||||||
|
: { transaction_journal_id: s.transaction_journal_id }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (splits.length > 1) {
|
||||||
|
body.group_title = title;
|
||||||
|
}
|
||||||
|
await api(`/v1/transactions/${group.id}`, { body, method: "PUT" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function correction(other: string) {
|
||||||
|
const sides =
|
||||||
|
difference > 0
|
||||||
|
? { destination_id: other, source_id: id }
|
||||||
|
: { destination_id: id, source_id: other };
|
||||||
|
return {
|
||||||
|
apply_rules: false,
|
||||||
|
fire_webhooks: false,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
amount: Math.abs(difference).toFixed(cur.decimals),
|
||||||
|
currency_code: cur.code,
|
||||||
|
date: end,
|
||||||
|
description: `Reconciliation (${formatDate(start)} to ${formatDate(end)})`,
|
||||||
|
reconciled: true,
|
||||||
|
type: "reconciliation",
|
||||||
|
...sides,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function store() {
|
||||||
|
if (busy || !ready || (selected.length === 0 && !correcting)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
failure = "";
|
||||||
|
const byGroup = new Map<string, { group: Group; journals: Set<string> }>();
|
||||||
|
for (const row of selected) {
|
||||||
|
const entry = byGroup.get(row.group.id) ?? {
|
||||||
|
group: row.group,
|
||||||
|
journals: new Set<string>(),
|
||||||
|
};
|
||||||
|
entry.journals.add(row.key);
|
||||||
|
byGroup.set(row.group.id, entry);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let done = 0;
|
||||||
|
status = `Clearing ${selected.length} transactions`;
|
||||||
|
await Promise.all(
|
||||||
|
[...byGroup.values()].map(async ({ group, journals }) => {
|
||||||
|
await clear(group, journals);
|
||||||
|
done += 1;
|
||||||
|
status = `Cleared ${done} of ${byGroup.size}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (correcting) {
|
||||||
|
status = "Booking the correction";
|
||||||
|
await api("/v1/transactions", {
|
||||||
|
body: correction(reconciliationId ?? ""),
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
status = "Reconciliation stored";
|
||||||
|
await invalidate();
|
||||||
|
await goto(showUrl(id));
|
||||||
|
} catch (e) {
|
||||||
|
failure = e instanceof Error ? e.message : String(e);
|
||||||
|
status = "";
|
||||||
|
await list.refetch();
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowClass(row: Row) {
|
||||||
|
const i = open.indexOf(row);
|
||||||
|
if (i >= 0 && nav.cursor === i) {
|
||||||
|
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
|
||||||
|
}
|
||||||
|
if (nav.isSelected(row)) {
|
||||||
|
return "bg-row-selected";
|
||||||
|
}
|
||||||
|
return row.inRange ? "hover:bg-row-hover" : "text-cladd-fg-softer";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet marker(
|
||||||
|
text: string
|
||||||
|
)}
|
||||||
|
<tr class="h-7 border-cladd-outline border-t bg-cladd-surface-cut">
|
||||||
|
<td class="px-3 font-semibold text-[12px] text-cladd-fg-soft" colspan="6">
|
||||||
|
{text}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet line(
|
||||||
|
row: Row
|
||||||
|
)}
|
||||||
|
{@const i = open.indexOf(row)}
|
||||||
|
<tr
|
||||||
|
class="h-7 border-cladd-outline/60 border-t {rowClass(row)}"
|
||||||
|
id={i >= 0 ? nav.rowId(i) : undefined}
|
||||||
|
onclick={(event) => i >= 0 && nav.click(i, event)}
|
||||||
|
>
|
||||||
|
<td class="w-9 px-3">
|
||||||
|
{#if row.cleared}
|
||||||
|
<Check aria-label="Already reconciled" class="size-3.5 text-income" />
|
||||||
|
{:else if row.inRange}
|
||||||
|
<input
|
||||||
|
aria-label="Reconcile {row.split.description}"
|
||||||
|
checked={nav.isSelected(row)}
|
||||||
|
class="size-4 accent-[var(--ff-brand)]"
|
||||||
|
onchange={() => nav.toggle(row)}
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="num w-28 whitespace-nowrap px-3 text-cladd-fg-soft">
|
||||||
|
{formatDate(row.date)}
|
||||||
|
</td>
|
||||||
|
<td class="max-w-0 truncate px-3">
|
||||||
|
<a class="hover:underline" href="/transactions/show/{row.group.id}"
|
||||||
|
>{row.split.description}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td class="whitespace-nowrap px-3 text-right">
|
||||||
|
<Amount amount={row.amount} {...cur} />
|
||||||
|
</td>
|
||||||
|
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
|
||||||
|
{row.split.source_name}
|
||||||
|
</td>
|
||||||
|
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
|
||||||
|
{row.split.destination_name}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: indexUrl("asset"), label: TYPE_TITLE.asset },
|
||||||
|
{ href: showUrl(id), label: a?.name ?? "" },
|
||||||
|
{ label: "Reconcile" },
|
||||||
|
]}
|
||||||
|
title="Reconcile {a?.name ?? "account"}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if a && a.type !== "asset"}
|
||||||
|
<p class="text-expense">
|
||||||
|
You can only reconcile asset accounts.
|
||||||
|
<a class="text-brand underline" href={showUrl(id)}>Back to {a.name}</a>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_25rem]">
|
||||||
|
<Box body="flex flex-col p-0" title="Reconciliation range">
|
||||||
|
<table class="w-full text-[13px]">
|
||||||
|
<thead class="border-cladd-outline border-b text-left">
|
||||||
|
<tr class="h-8 text-[12px] text-cladd-fg-soft">
|
||||||
|
<th class="px-3 font-semibold" scope="col"></th>
|
||||||
|
<th class="px-3 font-semibold" scope="col">Date</th>
|
||||||
|
<th class="px-3 text-right font-semibold" scope="col">Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="h-11">
|
||||||
|
<th class="px-3 text-left font-semibold" scope="row">
|
||||||
|
Start balance
|
||||||
|
</th>
|
||||||
|
<td class="px-3">
|
||||||
|
<DateInput aria-label="Start date" bind:value={startInput} />
|
||||||
|
</td>
|
||||||
|
<td class="px-3">
|
||||||
|
<input
|
||||||
|
aria-label="Start balance"
|
||||||
|
class="{INPUT} num text-right"
|
||||||
|
inputmode="decimal"
|
||||||
|
oninput={(event) => {
|
||||||
|
startTouched = true;
|
||||||
|
startText = event.currentTarget.value;
|
||||||
|
}}
|
||||||
|
value={startTouched
|
||||||
|
? startText
|
||||||
|
: (before.data?.toFixed(cur.decimals) ?? "")}
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="h-11 border-cladd-outline/60 border-t">
|
||||||
|
<th class="px-3 text-left font-semibold" scope="row">
|
||||||
|
End balance
|
||||||
|
</th>
|
||||||
|
<td class="px-3">
|
||||||
|
<DateInput aria-label="End date" bind:value={endInput} />
|
||||||
|
</td>
|
||||||
|
<td class="px-3">
|
||||||
|
<input
|
||||||
|
aria-label="End balance"
|
||||||
|
class="{INPUT} num text-right"
|
||||||
|
inputmode="decimal"
|
||||||
|
oninput={(event) => {
|
||||||
|
endTouched = true;
|
||||||
|
endText = event.currentTarget.value;
|
||||||
|
}}
|
||||||
|
value={endTouched
|
||||||
|
? endText
|
||||||
|
: (after.data?.toFixed(cur.decimals) ?? "")}
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{#if startInput !== start || endInput !== end}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 border-cladd-outline border-t px-3 py-2"
|
||||||
|
>
|
||||||
|
<Button onclick={() => setDates(startInput, endInput)} size="sm"
|
||||||
|
>Update selection</Button
|
||||||
|
>
|
||||||
|
<span class="text-[12px] text-warn"
|
||||||
|
>Changing the range clears the ticks.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p
|
||||||
|
class="mt-auto border-cladd-outline border-t px-3 py-2 text-[12px] text-cladd-fg-soft"
|
||||||
|
>
|
||||||
|
Match the dates and balances to your bank statement, then tick the
|
||||||
|
transactions that appear on it.
|
||||||
|
<span class="kbd">x</span>
|
||||||
|
ticks the row under the cursor,
|
||||||
|
<span class="kbd">*</span>
|
||||||
|
ticks all.
|
||||||
|
</p>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box body="flex flex-col gap-3 p-3" title="Reconciliation options">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<span class="text-cladd-fg-soft">Difference</span>
|
||||||
|
<span
|
||||||
|
class="num font-semibold text-[20px] {difference === 0
|
||||||
|
? "text-income"
|
||||||
|
: "text-expense"}"
|
||||||
|
>{fmt(difference)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-0.5 text-[12px]">
|
||||||
|
<dt class="text-cladd-fg-soft">Submitted start balance</dt>
|
||||||
|
<dd class="num text-right">{fmt(startBalance)}</dd>
|
||||||
|
<dt class="text-cladd-fg-soft">
|
||||||
|
Selected transactions ({selected.length})
|
||||||
|
</dt>
|
||||||
|
<dd class="num text-right">{fmt(selectedSum)}</dd>
|
||||||
|
<dt class="text-cladd-fg-soft">
|
||||||
|
Already cleared transactions ({cleared.length})
|
||||||
|
</dt>
|
||||||
|
<dd class="num text-right">{fmt(clearedSum)}</dd>
|
||||||
|
<dt class="text-cladd-fg-soft">Sum of reconciliation</dt>
|
||||||
|
<dd class="num text-right">{fmt(sum)}</dd>
|
||||||
|
<dt class="text-cladd-fg-soft">Submitted end balance</dt>
|
||||||
|
<dd class="num text-right">{fmt(endBalance)}</dd>
|
||||||
|
</dl>
|
||||||
|
{#if difference !== 0}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">
|
||||||
|
Your Firefly III ledger has {difference > 0 ? "more" : "less"} money
|
||||||
|
in it than your bank says.
|
||||||
|
</p>
|
||||||
|
<fieldset class="m-0 flex flex-col gap-1.5 border-0 p-0 text-[12px]">
|
||||||
|
<legend class="sr-only">What to do with the difference</legend>
|
||||||
|
<label class="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
class="mt-0.5 accent-[var(--ff-brand)]"
|
||||||
|
name="correct"
|
||||||
|
type="radio"
|
||||||
|
value={false}
|
||||||
|
bind:group={correct}
|
||||||
|
>
|
||||||
|
Clear the selected transactions, but do not correct.
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
class="flex items-start gap-2 {canCorrect
|
||||||
|
? ""
|
||||||
|
: "text-cladd-fg-softer"}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="mt-0.5 accent-[var(--ff-brand)]"
|
||||||
|
disabled={!canCorrect}
|
||||||
|
name="correct"
|
||||||
|
type="radio"
|
||||||
|
value={true}
|
||||||
|
bind:group={correct}
|
||||||
|
>
|
||||||
|
Clear the selected transactions, and create a correction
|
||||||
|
{difference > 0 ? "removing" : "adding"}
|
||||||
|
{fmt(Math.abs(difference))}
|
||||||
|
{difference > 0 ? "from" : "to"}
|
||||||
|
this asset account.
|
||||||
|
<span class="kbd">c</span>
|
||||||
|
</label>
|
||||||
|
{#if !canCorrect && reconciliationAccounts.isSuccess}
|
||||||
|
<p class="text-cladd-fg-soft">
|
||||||
|
The first correction for this account has to be booked in
|
||||||
|
<a
|
||||||
|
class="text-brand underline"
|
||||||
|
data-sveltekit-reload
|
||||||
|
href="{page.url.pathname}?ffui=old"
|
||||||
|
>Firefly's own reconcile page</a
|
||||||
|
>: through the API, Firefly III cannot create the account that
|
||||||
|
holds corrections.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={busy || !ready || (selected.length === 0 && !correcting)}
|
||||||
|
onclick={store}
|
||||||
|
type="button"
|
||||||
|
>Store reconciliation</Button
|
||||||
|
>
|
||||||
|
<span class="flex gap-1">
|
||||||
|
{#each prettyKeys("mod+Enter") as key (key)}
|
||||||
|
<span class="kbd">{key}</span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if status}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft" role="status">{status}</p>
|
||||||
|
{/if}
|
||||||
|
{#if failure}
|
||||||
|
<p class="text-[12px] text-expense" role="alert">{failure}</p>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box body="p-0" class="mt-3" title="Transactions">
|
||||||
|
{#snippet tools()}
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft"
|
||||||
|
>{open.length}
|
||||||
|
to check, {cleared.length} already cleared</span
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
{#if list.isPending}
|
||||||
|
<div class="h-40"></div>
|
||||||
|
{:else if list.isError}
|
||||||
|
<p class="p-3 text-expense">{list.error.message}</p>
|
||||||
|
{:else}
|
||||||
|
<table class="w-full table-fixed border-collapse text-[13px]">
|
||||||
|
<colgroup>
|
||||||
|
<col class="w-9">
|
||||||
|
<col class="w-28">
|
||||||
|
<col>
|
||||||
|
<col class="w-36">
|
||||||
|
<col class="hidden md:table-column md:w-48">
|
||||||
|
<col class="hidden md:table-column md:w-48">
|
||||||
|
</colgroup>
|
||||||
|
<tbody>
|
||||||
|
{#each later as row (row.key)}
|
||||||
|
{@render line(row)}
|
||||||
|
{/each}
|
||||||
|
{@render marker(`End of reconcile period: ${formatDate(end)}`)}
|
||||||
|
{#each inside as row (row.key)}
|
||||||
|
{@render line(row)}
|
||||||
|
{/each}
|
||||||
|
{#if inside.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-4 text-center text-cladd-fg-soft" colspan="6">
|
||||||
|
No transactions in this range.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{@render marker(`Start of reconcile period: ${formatDate(start)}`)}
|
||||||
|
{#each earlier as row (row.key)}
|
||||||
|
{@render line(row)}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Account, AccountType, Item } from "#lib/api/types.js";
|
||||||
|
import { isSpaPath, oldUiUrl } from "#lib/spa-paths.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
export const TYPE_TITLE: Record<AccountType, string> = {
|
||||||
|
asset: "Asset accounts",
|
||||||
|
expense: "Expense accounts",
|
||||||
|
liabilities: "Liabilities",
|
||||||
|
revenue: "Revenue accounts",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TYPE_ONE: Record<AccountType, string> = {
|
||||||
|
asset: "asset account",
|
||||||
|
expense: "expense account",
|
||||||
|
liabilities: "liability",
|
||||||
|
revenue: "revenue account",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_LABEL: Record<string, string> = {
|
||||||
|
cashWalletAsset: "Cash wallet",
|
||||||
|
ccAsset: "Credit card",
|
||||||
|
defaultAsset: "Default asset account",
|
||||||
|
savingAsset: "Savings account",
|
||||||
|
sharedAsset: "Shared asset account",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIABILITY_LABEL: Record<string, string> = {
|
||||||
|
debt: "Debt",
|
||||||
|
loan: "Loan",
|
||||||
|
mortgage: "Mortgage",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DIRECTION_LABEL: Record<string, string> = {
|
||||||
|
credit: "Owed to me",
|
||||||
|
debit: "I owe",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PERIOD_LABEL: Record<string, string> = {
|
||||||
|
daily: "per day",
|
||||||
|
"half-year": "per half year",
|
||||||
|
monthly: "per month",
|
||||||
|
quarterly: "per quarter",
|
||||||
|
weekly: "per week",
|
||||||
|
yearly: "per year",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCOUNT_TYPES = new Set<string>([
|
||||||
|
"asset",
|
||||||
|
"expense",
|
||||||
|
"revenue",
|
||||||
|
"liabilities",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function typeOf(account: Account): AccountType {
|
||||||
|
const type = String(account.type ?? "asset");
|
||||||
|
if (ACCOUNT_TYPES.has(type)) {
|
||||||
|
return type as AccountType;
|
||||||
|
}
|
||||||
|
return ["loan", "debt", "mortgage", "liability"].includes(type)
|
||||||
|
? "liabilities"
|
||||||
|
: "asset";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showUrl = (id: string) => `/accounts/show/${id}`;
|
||||||
|
export const editUrl = (id: string) => `/accounts/edit/${id}`;
|
||||||
|
export const reconcileUrl = (id: string) => `/accounts/reconcile/${id}/index`;
|
||||||
|
export const indexUrl = (type: AccountType) => `/accounts/${type}`;
|
||||||
|
|
||||||
|
export function createUrl(
|
||||||
|
type: "withdrawal" | "deposit" | "transfer",
|
||||||
|
id: string
|
||||||
|
): string {
|
||||||
|
const side = type === "deposit" ? "destination" : "source";
|
||||||
|
return `/transactions/create/${type}?${side}=${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function go(href: string) {
|
||||||
|
if (isSpaPath(href.split("?")[0])) {
|
||||||
|
goto(href);
|
||||||
|
} else {
|
||||||
|
location.assign(href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openOld(href: string) {
|
||||||
|
location.assign(oldUiUrl(new URL(href, location.origin).href));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatIban(account: Account): string {
|
||||||
|
if (account.iban) {
|
||||||
|
return account.iban
|
||||||
|
.replace(/\s+/g, "")
|
||||||
|
.replace(/(.{4})/g, "$1 ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
return account.account_number ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canReconcile = (item: Item<Account>) =>
|
||||||
|
typeOf(item.attributes) === "asset";
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { compactMoney, formatNumber } from "#lib/format.js";
|
||||||
|
import BarRows from "./BarRows.svelte";
|
||||||
|
import Columns from "./Columns.svelte";
|
||||||
|
import type { Bar, StackRow } from "./chart.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
bars,
|
||||||
|
orientation = "vertical",
|
||||||
|
color = "var(--ff-series-1)",
|
||||||
|
name = "Amount",
|
||||||
|
height = 240,
|
||||||
|
format = (value: number) => formatNumber(value),
|
||||||
|
axisFormat = (value: number) => compactMoney(value),
|
||||||
|
label = "Bar chart",
|
||||||
|
labelWidth,
|
||||||
|
active = $bindable(null),
|
||||||
|
onselect,
|
||||||
|
}: {
|
||||||
|
bars: Bar[];
|
||||||
|
orientation?: "vertical" | "horizontal";
|
||||||
|
color?: string;
|
||||||
|
name?: string;
|
||||||
|
height?: number;
|
||||||
|
format?: (value: number) => string;
|
||||||
|
axisFormat?: (value: number) => string;
|
||||||
|
label?: string;
|
||||||
|
labelWidth?: string;
|
||||||
|
active?: string | null;
|
||||||
|
onselect?: (bar: Bar) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const keys = $derived([{ color, key: "value", label: name }]);
|
||||||
|
const rows = $derived<StackRow[]>(
|
||||||
|
bars.map((bar) => ({
|
||||||
|
color: bar.color,
|
||||||
|
format: bar.format,
|
||||||
|
href: bar.href,
|
||||||
|
key: bar.key,
|
||||||
|
label: bar.label,
|
||||||
|
note: bar.note,
|
||||||
|
values: { value: bar.value },
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const pick = $derived(
|
||||||
|
onselect
|
||||||
|
? (row: StackRow) => {
|
||||||
|
const bar = bars.find((b) => b.key === row.key);
|
||||||
|
if (bar) {
|
||||||
|
onselect(bar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if orientation === "horizontal"}
|
||||||
|
<BarRows {format} {keys} {label} {labelWidth} onselect={pick} {rows} />
|
||||||
|
{:else}
|
||||||
|
<Columns
|
||||||
|
{axisFormat}
|
||||||
|
{format}
|
||||||
|
{height}
|
||||||
|
{keys}
|
||||||
|
{label}
|
||||||
|
onselect={pick}
|
||||||
|
{rows}
|
||||||
|
bind:active
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatNumber } from "#lib/format.js";
|
||||||
|
import {
|
||||||
|
type Segment,
|
||||||
|
type StackKey,
|
||||||
|
type StackRow,
|
||||||
|
stack,
|
||||||
|
} from "./chart.js";
|
||||||
|
|
||||||
|
const GAP = 2;
|
||||||
|
const RADIUS = 4;
|
||||||
|
|
||||||
|
let {
|
||||||
|
keys,
|
||||||
|
rows,
|
||||||
|
format = (value: number) => formatNumber(value),
|
||||||
|
label = "Bar chart",
|
||||||
|
labelWidth = "minmax(6rem, 14rem)",
|
||||||
|
onselect,
|
||||||
|
}: {
|
||||||
|
keys: StackKey[];
|
||||||
|
rows: StackRow[];
|
||||||
|
format?: (value: number) => string;
|
||||||
|
label?: string;
|
||||||
|
labelWidth?: string;
|
||||||
|
onselect?: (row: StackRow) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let list = $state<HTMLElement>();
|
||||||
|
|
||||||
|
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
|
||||||
|
const lo = $derived(Math.min(0, ...stacks.map((s) => s.negative)));
|
||||||
|
const hi = $derived(Math.max(0, ...stacks.map((s) => s.positive)));
|
||||||
|
const span = $derived(hi - lo || 1);
|
||||||
|
const pos = (value: number) => ((value - lo) / span) * 100;
|
||||||
|
const total = (s: (typeof stacks)[number]) => s.positive + s.negative;
|
||||||
|
|
||||||
|
function box({ from, to, outer }: Segment, first: boolean) {
|
||||||
|
const up = to > from;
|
||||||
|
const start = pos(Math.min(from, to));
|
||||||
|
const size = (Math.abs(to - from) / span) * 100;
|
||||||
|
const inset = first ? 0 : GAP;
|
||||||
|
let radius = "0";
|
||||||
|
if (outer) {
|
||||||
|
radius = up
|
||||||
|
? `0 ${RADIUS}px ${RADIUS}px 0`
|
||||||
|
: `${RADIUS}px 0 0 ${RADIUS}px`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
left: up ? `calc(${start}% + ${inset}px)` : `${start}%`,
|
||||||
|
radius,
|
||||||
|
width: `max(1px, calc(${size}% - ${inset}px))`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const links = [
|
||||||
|
...(list?.querySelectorAll<HTMLElement>("[data-bar-link]") ?? []),
|
||||||
|
];
|
||||||
|
const index = links.indexOf(document.activeElement as HTMLElement);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const next = event.key === "ArrowDown" ? index + 1 : index - 1;
|
||||||
|
links[Math.max(0, Math.min(links.length - 1, next))]?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="@container">
|
||||||
|
<ul
|
||||||
|
aria-label={label}
|
||||||
|
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 text-[12px] @min-[30rem]:grid-cols-(--cols)"
|
||||||
|
bind:this={list}
|
||||||
|
style:--cols="{labelWidth} minmax(4rem, 1fr) auto"
|
||||||
|
>
|
||||||
|
{#each stacks as s (s.row.key)}
|
||||||
|
<li
|
||||||
|
class="col-span-2 grid grid-cols-subgrid items-center gap-y-1 rounded-sm px-1 py-1.5 focus-within:bg-row-cursor hover:bg-row-hover @min-[30rem]:col-span-3 @min-[30rem]:h-7 @min-[30rem]:py-0"
|
||||||
|
>
|
||||||
|
{#if s.row.href}
|
||||||
|
<a
|
||||||
|
class="col-start-1 row-start-1 truncate text-cladd-fg hover:underline focus-visible:outline-none"
|
||||||
|
data-bar-link
|
||||||
|
href={s.row.href}
|
||||||
|
{onkeydown}
|
||||||
|
title={s.row.label}
|
||||||
|
>{s.row.label}</a
|
||||||
|
>
|
||||||
|
{:else if onselect}
|
||||||
|
<button
|
||||||
|
class="col-start-1 row-start-1 truncate text-left text-cladd-fg hover:underline focus-visible:outline-none"
|
||||||
|
data-bar-link
|
||||||
|
onclick={() => onselect(s.row)}
|
||||||
|
{onkeydown}
|
||||||
|
title={s.row.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{s.row.label}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="col-start-1 row-start-1 truncate text-cladd-fg"
|
||||||
|
title={s.row.label}
|
||||||
|
>{s.row.label}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="relative col-span-2 row-start-2 h-3 @min-[30rem]:col-span-1 @min-[30rem]:col-start-2 @min-[30rem]:row-start-1"
|
||||||
|
>
|
||||||
|
{#if lo < 0}
|
||||||
|
<span
|
||||||
|
class="absolute -inset-y-2 w-px bg-cladd-outline"
|
||||||
|
style:left="{pos(0)}%"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
{#each s.segments as seg, j (seg.key)}
|
||||||
|
{@const b = box(
|
||||||
|
seg,
|
||||||
|
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
class="absolute inset-y-0"
|
||||||
|
title="{seg.label}: {(s.row.format ?? format)(seg.value)}"
|
||||||
|
style:background={seg.color}
|
||||||
|
style:border-radius={b.radius}
|
||||||
|
style:left={b.left}
|
||||||
|
style:width={b.width}
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="num col-start-2 row-start-1 text-right text-cladd-fg @min-[30rem]:col-start-3"
|
||||||
|
>{s.row.note ?? (s.row.format ?? format)(total(s))}</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { scaleBand, scaleLinear } from "d3-scale";
|
||||||
|
import { compactMoney, formatNumber } from "#lib/format.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import {
|
||||||
|
crisp,
|
||||||
|
type LegendItem,
|
||||||
|
moveIndex,
|
||||||
|
roundedBar,
|
||||||
|
type StackKey,
|
||||||
|
type StackRow,
|
||||||
|
stack,
|
||||||
|
textWidth,
|
||||||
|
} from "./chart.js";
|
||||||
|
import Legend from "./Legend.svelte";
|
||||||
|
|
||||||
|
const TOP = 14;
|
||||||
|
const RIGHT = 8;
|
||||||
|
const GAP_Y = 44;
|
||||||
|
const BAR_MAX = 24;
|
||||||
|
const GAP = 2;
|
||||||
|
const FLAT_BOTTOM = 22;
|
||||||
|
const TILTED_BOTTOM = 70;
|
||||||
|
const TILTED_CHARS = 16;
|
||||||
|
const TILTED_CHAR_WIDTH = 4.9;
|
||||||
|
const LABEL_GAP = 13;
|
||||||
|
|
||||||
|
let {
|
||||||
|
keys,
|
||||||
|
rows,
|
||||||
|
height = 240,
|
||||||
|
format = (value: number) => formatNumber(value),
|
||||||
|
axisFormat = (value: number) => compactMoney(value),
|
||||||
|
label = "Bar chart",
|
||||||
|
active = $bindable(null),
|
||||||
|
onselect,
|
||||||
|
}: {
|
||||||
|
keys: StackKey[];
|
||||||
|
rows: StackRow[];
|
||||||
|
height?: number;
|
||||||
|
format?: (value: number) => string;
|
||||||
|
axisFormat?: (value: number) => string;
|
||||||
|
label?: string;
|
||||||
|
active?: string | null;
|
||||||
|
onselect?: (row: StackRow) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let width = $state(0);
|
||||||
|
|
||||||
|
const stacks = $derived(rows.map((row) => ({ row, ...stack(row, keys) })));
|
||||||
|
const yCount = $derived(Math.max(2, Math.floor((height - 60) / GAP_Y)));
|
||||||
|
const yBase = $derived(
|
||||||
|
scaleLinear()
|
||||||
|
.domain([
|
||||||
|
Math.min(0, ...stacks.map((s) => s.negative)),
|
||||||
|
Math.max(0, ...stacks.map((s) => s.positive)) || 1,
|
||||||
|
])
|
||||||
|
.nice(yCount)
|
||||||
|
);
|
||||||
|
const yTicks = $derived(yBase.ticks(yCount));
|
||||||
|
const left = $derived(
|
||||||
|
Math.ceil(Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10)
|
||||||
|
);
|
||||||
|
const step = $derived((width - left - RIGHT) / Math.max(1, rows.length));
|
||||||
|
const tilted = $derived(
|
||||||
|
rows.some((r) => textWidth(r.label, 6.2) > step - 6) && step < 70
|
||||||
|
);
|
||||||
|
const bottom = $derived(tilted ? TILTED_BOTTOM : FLAT_BOTTOM);
|
||||||
|
const labelEvery = $derived(Math.max(1, Math.ceil(LABEL_GAP / step)));
|
||||||
|
const y = $derived(yBase.copy().range([height - bottom, TOP]));
|
||||||
|
const x = $derived(
|
||||||
|
scaleBand<string>()
|
||||||
|
.domain(rows.map((r) => r.key))
|
||||||
|
.range([left, Math.max(left + 1, width - RIGHT)])
|
||||||
|
.paddingInner(0.3)
|
||||||
|
.paddingOuter(0.15)
|
||||||
|
);
|
||||||
|
const barWidth = $derived(Math.max(2, Math.min(BAR_MAX, x.bandwidth())));
|
||||||
|
const zeroY = $derived(y(0));
|
||||||
|
const capLabels = $derived(
|
||||||
|
keys.length === 1 &&
|
||||||
|
stacks.every(
|
||||||
|
(s) =>
|
||||||
|
textWidth(fmt(s.row, s.positive + s.negative), 6.8) <= x.step() - 6
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeIndex = $derived(
|
||||||
|
active ? rows.findIndex((r) => r.key === active) : -1
|
||||||
|
);
|
||||||
|
const readRow = $derived(rows[activeIndex >= 0 ? activeIndex : 0]);
|
||||||
|
const fmt = (row: StackRow, value: number) => (row.format ?? format)(value);
|
||||||
|
const legend = $derived<LegendItem[]>(
|
||||||
|
readRow
|
||||||
|
? keys.map((k) => ({
|
||||||
|
color: (keys.length === 1 && readRow.color) || k.color,
|
||||||
|
key: k.key,
|
||||||
|
label: keys.length === 1 ? readRow.label : k.label,
|
||||||
|
shape: "rect",
|
||||||
|
value: fmt(readRow, readRow.values[k.key] ?? 0),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const valueText = $derived(
|
||||||
|
readRow
|
||||||
|
? `${readRow.label}: ${legend.map((l) => `${keys.length > 1 ? `${l.label} ` : ""}${l.value}`).join(", ")}`
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
function short(text: string, chars: number) {
|
||||||
|
return text.length > chars
|
||||||
|
? `${text.slice(0, Math.max(1, chars - 1))}…`
|
||||||
|
: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(row: StackRow | undefined) {
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (onselect) {
|
||||||
|
onselect(row);
|
||||||
|
} else if (row.href) {
|
||||||
|
goto(row.href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickable = $derived(Boolean(onselect) || rows.some((r) => r.href));
|
||||||
|
|
||||||
|
function pick(event: PointerEvent) {
|
||||||
|
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const offset = event.clientX - box.left - x.range()[0];
|
||||||
|
const index = Math.floor(
|
||||||
|
offset / x.step() - x.paddingOuter() + x.paddingInner() / 2
|
||||||
|
);
|
||||||
|
active = rows[Math.max(0, Math.min(rows.length - 1, index))]?.key ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
select(rows[activeIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape" && active) {
|
||||||
|
event.preventDefault();
|
||||||
|
active = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = moveIndex(event.key, activeIndex, rows.length);
|
||||||
|
if (next !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
active = rows[next].key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentPath(
|
||||||
|
cx: number,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
outer: boolean,
|
||||||
|
first: boolean
|
||||||
|
) {
|
||||||
|
const up = to > from;
|
||||||
|
let top = y(Math.max(from, to));
|
||||||
|
let bot = y(Math.min(from, to));
|
||||||
|
if (!first) {
|
||||||
|
if (up) {
|
||||||
|
bot -= GAP;
|
||||||
|
} else {
|
||||||
|
top += GAP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const h = Math.max(0, bot - top);
|
||||||
|
const xLeft = cx - barWidth / 2;
|
||||||
|
if (!outer) {
|
||||||
|
return `M${xLeft},${top}h${barWidth}v${h}h${-barWidth}Z`;
|
||||||
|
}
|
||||||
|
return roundedBar(xLeft, top, barWidth, h, up ? "top" : "bottom");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
|
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
|
{#if keys.length > 1 && readRow}
|
||||||
|
<span class="max-w-[16rem] truncate text-[12px] text-cladd-fg"
|
||||||
|
>{readRow.label}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<Legend class="min-w-0 flex-1" items={legend} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuemax={Math.max(0, rows.length - 1)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuenow={Math.max(0, activeIndex)}
|
||||||
|
aria-valuetext={valueText}
|
||||||
|
class="relative rounded-sm {clickable ? "cursor-pointer" : ""}"
|
||||||
|
onblur={() => (active = null)}
|
||||||
|
onclick={() => select(rows[activeIndex])}
|
||||||
|
onfocus={() => (active ??= rows[0]?.key ?? null)}
|
||||||
|
{onkeydown}
|
||||||
|
onpointerleave={() => (active = null)}
|
||||||
|
onpointermove={pick}
|
||||||
|
role="slider"
|
||||||
|
tabindex="0"
|
||||||
|
style:height="{height}px"
|
||||||
|
bind:clientWidth={width}
|
||||||
|
>
|
||||||
|
{#if width > 0 && rows.length > 0}
|
||||||
|
<svg aria-hidden="true" class="block" {height} {width}>
|
||||||
|
{#each yTicks as tick (tick)}
|
||||||
|
<line
|
||||||
|
stroke="var(--cladd-outline)"
|
||||||
|
x1={left}
|
||||||
|
x2={width - RIGHT}
|
||||||
|
y1={crisp(y(tick))}
|
||||||
|
y2={crisp(y(tick))}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
class="num"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
fill="var(--cladd-fg-soft)"
|
||||||
|
font-size="11"
|
||||||
|
text-anchor="end"
|
||||||
|
x={left - 8}
|
||||||
|
y={y(tick)}
|
||||||
|
>
|
||||||
|
{axisFormat(tick)}
|
||||||
|
</text>
|
||||||
|
{/each}
|
||||||
|
<line
|
||||||
|
stroke="var(--cladd-fg-softest)"
|
||||||
|
x1={left}
|
||||||
|
x2={width - RIGHT}
|
||||||
|
y1={crisp(zeroY)}
|
||||||
|
y2={crisp(zeroY)}
|
||||||
|
/>
|
||||||
|
{#each stacks as s, i (s.row.key)}
|
||||||
|
{@const cx = (x(s.row.key) ?? 0) + x.bandwidth() / 2}
|
||||||
|
{@const dim = activeIndex >= 0 && activeIndex !== i}
|
||||||
|
<g class="transition-opacity duration-100" opacity={dim ? 0.45 : 1}>
|
||||||
|
{#each s.segments as seg, j (seg.key)}
|
||||||
|
<path
|
||||||
|
d={segmentPath(
|
||||||
|
cx,
|
||||||
|
seg.from,
|
||||||
|
seg.to,
|
||||||
|
seg.outer,
|
||||||
|
j === 0 || s.segments[j - 1].value > 0 !== seg.value > 0
|
||||||
|
)}
|
||||||
|
style:fill={seg.color}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
{#if capLabels && s.segments[0]}
|
||||||
|
{@const text = fmt(s.row, s.segments[0].value)}
|
||||||
|
{#if text}
|
||||||
|
<text
|
||||||
|
class="num"
|
||||||
|
fill="var(--cladd-fg-soft)"
|
||||||
|
font-size="11"
|
||||||
|
text-anchor="middle"
|
||||||
|
x={cx}
|
||||||
|
y={s.segments[0].value > 0
|
||||||
|
? y(s.positive) - 5
|
||||||
|
: y(s.negative) + 13}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</text>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if tilted && i % labelEvery === 0}
|
||||||
|
<text
|
||||||
|
fill={activeIndex === i
|
||||||
|
? "var(--cladd-fg)"
|
||||||
|
: "var(--cladd-fg-soft)"}
|
||||||
|
font-size="11"
|
||||||
|
text-anchor="end"
|
||||||
|
transform="translate({cx + 3},{height - bottom + 12}) rotate(-40)"
|
||||||
|
>
|
||||||
|
{short(
|
||||||
|
s.row.label,
|
||||||
|
Math.min(TILTED_CHARS, Math.floor(cx / TILTED_CHAR_WIDTH))
|
||||||
|
)}
|
||||||
|
</text>
|
||||||
|
{:else if !tilted}
|
||||||
|
<text
|
||||||
|
fill={activeIndex === i
|
||||||
|
? "var(--cladd-fg)"
|
||||||
|
: "var(--cladd-fg-soft)"}
|
||||||
|
font-size="11"
|
||||||
|
text-anchor="middle"
|
||||||
|
x={cx}
|
||||||
|
y={height - 6}
|
||||||
|
>
|
||||||
|
{short(s.row.label, Math.floor(x.step() / 6.4))}
|
||||||
|
</text>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LegendItem } from "./chart.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
ontoggle,
|
||||||
|
widths = {},
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
items: LegendItem[];
|
||||||
|
ontoggle?: (key: string) => void;
|
||||||
|
widths?: Record<string, number>;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet content(
|
||||||
|
item: LegendItem
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="shrink-0 {item.shape === "rect"
|
||||||
|
? "size-2.5 rounded-[3px]"
|
||||||
|
: "h-0.5 w-3 rounded-full"}"
|
||||||
|
style:background={item.color}
|
||||||
|
></span>
|
||||||
|
{#if item.value !== undefined}
|
||||||
|
<span
|
||||||
|
class="num font-semibold text-cladd-fg"
|
||||||
|
style:min-width={widths[item.key] ? `${widths[item.key]}ch` : undefined}
|
||||||
|
>{item.value}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span class="truncate text-cladd-fg-soft">{item.label}</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<ul class="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] {className}">
|
||||||
|
{#each items as item (item.key)}
|
||||||
|
<li class="min-w-0 max-w-full">
|
||||||
|
{#if ontoggle}
|
||||||
|
<button
|
||||||
|
aria-pressed={!item.hidden}
|
||||||
|
class="flex min-w-0 max-w-full items-center gap-1.5 rounded-sm hover:text-cladd-fg {item.hidden
|
||||||
|
? "opacity-40"
|
||||||
|
: ""}"
|
||||||
|
onclick={() => ontoggle(item.key)}
|
||||||
|
title={item.hidden ? `Show ${item.label}` : `Hide ${item.label}`}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{@render content(item)}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="flex min-w-0 items-center gap-1.5"
|
||||||
|
>{@render content(item)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { bisector, extent } from "d3-array";
|
||||||
|
import { scaleLinear, scaleUtc } from "d3-scale";
|
||||||
|
import {
|
||||||
|
area as areaShape,
|
||||||
|
curveMonotoneX,
|
||||||
|
line as lineShape,
|
||||||
|
} from "d3-shape";
|
||||||
|
import { compactMoney, formatDate, formatNumber } from "#lib/format.js";
|
||||||
|
import {
|
||||||
|
crisp,
|
||||||
|
type LegendItem,
|
||||||
|
type LinePoint,
|
||||||
|
type LineSeries,
|
||||||
|
moveIndex,
|
||||||
|
seriesColor,
|
||||||
|
textWidth,
|
||||||
|
utc,
|
||||||
|
} from "./chart.js";
|
||||||
|
import Legend from "./Legend.svelte";
|
||||||
|
|
||||||
|
const TOP = 16;
|
||||||
|
const BOTTOM = 22;
|
||||||
|
const RIGHT = 14;
|
||||||
|
const GAP_Y = 44;
|
||||||
|
const GAP_X = 96;
|
||||||
|
const MIN_TICK_GAP = 70;
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
|
let {
|
||||||
|
series,
|
||||||
|
height = 240,
|
||||||
|
area = false,
|
||||||
|
marker,
|
||||||
|
markerLabel = "Today",
|
||||||
|
zero = false,
|
||||||
|
cursor = $bindable(null),
|
||||||
|
axisFormat = (value: number) => compactMoney(value),
|
||||||
|
xFormat = (value: string) => formatDate(value, "short"),
|
||||||
|
label = "Line chart",
|
||||||
|
toggle = true,
|
||||||
|
}: {
|
||||||
|
series: LineSeries[];
|
||||||
|
height?: number;
|
||||||
|
area?: boolean;
|
||||||
|
marker?: string;
|
||||||
|
markerLabel?: string;
|
||||||
|
zero?: boolean;
|
||||||
|
cursor?: string | null;
|
||||||
|
axisFormat?: (value: number) => string;
|
||||||
|
xFormat?: (x: string) => string;
|
||||||
|
label?: string;
|
||||||
|
toggle?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let width = $state(0);
|
||||||
|
let hidden = $state(new Set<string>());
|
||||||
|
|
||||||
|
const colored = $derived(
|
||||||
|
series.map((s, i) => ({ ...s, color: s.color ?? seriesColor(i) }))
|
||||||
|
);
|
||||||
|
const shown = $derived(colored.filter((s) => !hidden.has(s.key)));
|
||||||
|
const xs = $derived(
|
||||||
|
[...new Set(colored.flatMap((s) => s.points.map((p) => p.x)))].sort()
|
||||||
|
);
|
||||||
|
const times = $derived(xs.map((day) => utc(day).getTime()));
|
||||||
|
const lookup = $derived(
|
||||||
|
new Map(
|
||||||
|
colored.map((s) => [s.key, new Map(s.points.map((p) => [p.x, p.y]))])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const yDomain = $derived.by(() => {
|
||||||
|
const ys = shown.flatMap((s) => s.points.map((p) => p.y));
|
||||||
|
if (zero) {
|
||||||
|
ys.push(0);
|
||||||
|
}
|
||||||
|
const [lo = 0, hi = 0] = extent(ys);
|
||||||
|
if (lo === hi) {
|
||||||
|
const pad = Math.abs(lo) * 0.1 || 1;
|
||||||
|
return [lo - pad, hi + pad];
|
||||||
|
}
|
||||||
|
return [lo, hi];
|
||||||
|
});
|
||||||
|
const yCount = $derived(
|
||||||
|
Math.max(2, Math.floor((height - TOP - BOTTOM) / GAP_Y))
|
||||||
|
);
|
||||||
|
const y = $derived(
|
||||||
|
scaleLinear()
|
||||||
|
.domain(yDomain)
|
||||||
|
.nice(yCount)
|
||||||
|
.range([height - BOTTOM, TOP])
|
||||||
|
);
|
||||||
|
const yTicks = $derived(y.ticks(yCount));
|
||||||
|
const left = $derived(
|
||||||
|
Math.ceil(Math.max(24, ...yTicks.map((t) => textWidth(axisFormat(t)))) + 10)
|
||||||
|
);
|
||||||
|
const xDomain = $derived.by(() => {
|
||||||
|
const first = times[0] ?? 0;
|
||||||
|
const last = times.at(-1) ?? first;
|
||||||
|
return first === last ? [first - DAY, last + DAY] : [first, last];
|
||||||
|
});
|
||||||
|
const x = $derived(
|
||||||
|
scaleUtc()
|
||||||
|
.domain(xDomain)
|
||||||
|
.range([left, Math.max(left + 1, width - RIGHT)])
|
||||||
|
);
|
||||||
|
const xTicks = $derived.by(() => {
|
||||||
|
const count = Math.max(3, Math.floor((width - left - RIGHT) / GAP_X));
|
||||||
|
const candidates =
|
||||||
|
xs.length <= count
|
||||||
|
? xs
|
||||||
|
: [...new Set(x.ticks(count).map((d) => d.toISOString().slice(0, 10)))];
|
||||||
|
const kept: string[] = [];
|
||||||
|
let last = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const tick of candidates) {
|
||||||
|
const px = x(utc(tick));
|
||||||
|
if (px - last >= MIN_TICK_GAP) {
|
||||||
|
kept.push(tick);
|
||||||
|
last = px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
});
|
||||||
|
const baseline = $derived.by(() => {
|
||||||
|
const [lo, hi] = y.domain();
|
||||||
|
return y(Math.max(lo, Math.min(0, hi)));
|
||||||
|
});
|
||||||
|
const zeroInside = $derived(y.domain()[0] < 0 && y.domain()[1] > 0);
|
||||||
|
|
||||||
|
const at = (p: LinePoint) => x(utc(p.x));
|
||||||
|
const lineGen = $derived(
|
||||||
|
lineShape<LinePoint>()
|
||||||
|
.x(at)
|
||||||
|
.y((p) => y(p.y))
|
||||||
|
.curve(curveMonotoneX)
|
||||||
|
);
|
||||||
|
const areaGen = $derived(
|
||||||
|
areaShape<LinePoint>()
|
||||||
|
.x(at)
|
||||||
|
.y0(baseline)
|
||||||
|
.y1((p) => y(p.y))
|
||||||
|
.curve(curveMonotoneX)
|
||||||
|
);
|
||||||
|
|
||||||
|
const markerIndex = $derived(marker ? xs.indexOf(marker) : -1);
|
||||||
|
const restIndex = $derived(markerIndex >= 0 ? markerIndex : xs.length - 1);
|
||||||
|
const index = $derived(cursor ? xs.indexOf(cursor) : -1);
|
||||||
|
const readIndex = $derived(index >= 0 ? index : restIndex);
|
||||||
|
const readX = $derived(xs[readIndex]);
|
||||||
|
const readLabel = $derived.by(() => {
|
||||||
|
if (!readX) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (index < 0 && markerIndex >= 0) {
|
||||||
|
return markerLabel;
|
||||||
|
}
|
||||||
|
return formatDate(readX, "day");
|
||||||
|
});
|
||||||
|
|
||||||
|
const fmt = (s: LineSeries, value: number) =>
|
||||||
|
s.format ? s.format(value) : formatNumber(value);
|
||||||
|
const widths = $derived(
|
||||||
|
Object.fromEntries(
|
||||||
|
colored.map((s) => [
|
||||||
|
s.key,
|
||||||
|
Math.max(0, ...s.points.map((p) => fmt(s, p.y).length)),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const legend = $derived<LegendItem[]>(
|
||||||
|
colored.map((s) => {
|
||||||
|
const value = readX ? lookup.get(s.key)?.get(readX) : undefined;
|
||||||
|
return {
|
||||||
|
color: s.color,
|
||||||
|
hidden: hidden.has(s.key),
|
||||||
|
key: s.key,
|
||||||
|
label: s.label,
|
||||||
|
shape: "line",
|
||||||
|
value: value === undefined ? "" : fmt(s, value),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const valueText = $derived(
|
||||||
|
[readLabel, ...legend.map((l) => `${l.label} ${l.value}`)].join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
function flip(key: string) {
|
||||||
|
const next = new Set(hidden);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
hidden = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(event: PointerEvent) {
|
||||||
|
if (xs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const t = x.invert(event.clientX - box.left).getTime();
|
||||||
|
cursor = xs[bisector((v: number) => v).center(times, t)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape" && cursor) {
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = moveIndex(event.key, readIndex, xs.length);
|
||||||
|
if (next === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
cursor = xs[next];
|
||||||
|
}
|
||||||
|
|
||||||
|
function xAnchor(tick: string) {
|
||||||
|
const px = x(utc(tick));
|
||||||
|
if (px - left < 24) {
|
||||||
|
return "start";
|
||||||
|
}
|
||||||
|
return width - RIGHT - px < 24 ? "end" : "middle";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
|
<div class="flex min-h-5 flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||||
|
<span class="num w-[11ch] shrink-0 text-[12px] text-cladd-fg-soft"
|
||||||
|
>{readLabel}</span
|
||||||
|
>
|
||||||
|
<Legend
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
items={legend}
|
||||||
|
ontoggle={toggle && colored.length > 1 ? flip : undefined}
|
||||||
|
{widths}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuemax={Math.max(0, xs.length - 1)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuenow={readIndex}
|
||||||
|
aria-valuetext={valueText}
|
||||||
|
class="relative touch-pan-y rounded-sm"
|
||||||
|
onblur={() => (cursor = null)}
|
||||||
|
onfocus={() => (cursor ??= xs[restIndex] ?? null)}
|
||||||
|
{onkeydown}
|
||||||
|
onpointerleave={() => (cursor = null)}
|
||||||
|
onpointermove={pick}
|
||||||
|
role="slider"
|
||||||
|
tabindex="0"
|
||||||
|
style:height="{height}px"
|
||||||
|
bind:clientWidth={width}
|
||||||
|
>
|
||||||
|
{#if width > 0 && xs.length > 0}
|
||||||
|
<svg aria-hidden="true" class="block" {height} {width}>
|
||||||
|
{#each yTicks as tick (tick)}
|
||||||
|
<line
|
||||||
|
stroke="var(--cladd-outline)"
|
||||||
|
x1={left}
|
||||||
|
x2={width - RIGHT}
|
||||||
|
y1={crisp(y(tick))}
|
||||||
|
y2={crisp(y(tick))}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
class="num"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
fill="var(--cladd-fg-soft)"
|
||||||
|
font-size="11"
|
||||||
|
text-anchor="end"
|
||||||
|
x={left - 8}
|
||||||
|
y={y(tick)}
|
||||||
|
>
|
||||||
|
{axisFormat(tick)}
|
||||||
|
</text>
|
||||||
|
{/each}
|
||||||
|
{#if zeroInside}
|
||||||
|
<line
|
||||||
|
stroke="var(--cladd-fg-softest)"
|
||||||
|
x1={left}
|
||||||
|
x2={width - RIGHT}
|
||||||
|
y1={crisp(y(0))}
|
||||||
|
y2={crisp(y(0))}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#each xTicks as tick (tick)}
|
||||||
|
<text
|
||||||
|
fill="var(--cladd-fg-soft)"
|
||||||
|
font-size="11"
|
||||||
|
text-anchor={xAnchor(tick)}
|
||||||
|
x={x(utc(tick))}
|
||||||
|
y={height - 6}
|
||||||
|
>
|
||||||
|
{xFormat(tick)}
|
||||||
|
</text>
|
||||||
|
{/each}
|
||||||
|
{#if markerIndex >= 0}
|
||||||
|
{@const mx = crisp(x(utc(xs[markerIndex])))}
|
||||||
|
<line
|
||||||
|
stroke="var(--cladd-fg-softer)"
|
||||||
|
x1={mx}
|
||||||
|
x2={mx}
|
||||||
|
y1={TOP - 2}
|
||||||
|
y2={height - BOTTOM}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
fill="var(--cladd-fg-soft)"
|
||||||
|
font-size="10"
|
||||||
|
text-anchor={mx > width - 60 ? "end" : "middle"}
|
||||||
|
x={mx}
|
||||||
|
y={TOP - 5}
|
||||||
|
>
|
||||||
|
{markerLabel}
|
||||||
|
</text>
|
||||||
|
{/if}
|
||||||
|
{#each shown as s (s.key)}
|
||||||
|
{#if area || s.area}
|
||||||
|
<path
|
||||||
|
d={areaGen(s.points)}
|
||||||
|
fill-opacity="0.1"
|
||||||
|
style:fill={s.color}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<path
|
||||||
|
d={lineGen(s.points)}
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width={s.strong ? 2.5 : 2}
|
||||||
|
style:stroke={s.color}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{#if index >= 0}
|
||||||
|
{@const cx = crisp(x(utc(xs[index])))}
|
||||||
|
<line
|
||||||
|
stroke="var(--cladd-fg-soft)"
|
||||||
|
x1={cx}
|
||||||
|
x2={cx}
|
||||||
|
y1={TOP}
|
||||||
|
y2={height - BOTTOM}
|
||||||
|
/>
|
||||||
|
{#each shown as s (s.key)}
|
||||||
|
{@const value = lookup.get(s.key)?.get(xs[index])}
|
||||||
|
{#if value !== undefined}
|
||||||
|
<circle
|
||||||
|
cx={x(utc(xs[index]))}
|
||||||
|
cy={y(value)}
|
||||||
|
r="4"
|
||||||
|
stroke="var(--cladd-surface)"
|
||||||
|
stroke-width="2"
|
||||||
|
style:fill={s.color}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type MeterSegment, seriesColor, wash } from "./chart.js";
|
||||||
|
|
||||||
|
const GAP = 2;
|
||||||
|
|
||||||
|
let {
|
||||||
|
segments,
|
||||||
|
max,
|
||||||
|
height = 8,
|
||||||
|
track,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
segments: MeterSegment[];
|
||||||
|
max: number;
|
||||||
|
height?: number;
|
||||||
|
track?: string;
|
||||||
|
label?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const total = $derived(
|
||||||
|
segments.reduce((sum, s) => sum + Math.max(0, s.value), 0)
|
||||||
|
);
|
||||||
|
const scale = $derived(Math.max(max, total) || 1);
|
||||||
|
const parts = $derived(
|
||||||
|
segments
|
||||||
|
.filter((s) => s.value > 0)
|
||||||
|
.map((s, i) => ({
|
||||||
|
...s,
|
||||||
|
color: s.color ?? seriesColor(i),
|
||||||
|
share: (s.value / scale) * 100,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const trackColor = $derived(track ?? wash(parts[0]?.color ?? seriesColor(0)));
|
||||||
|
const title = $derived(
|
||||||
|
label ??
|
||||||
|
segments
|
||||||
|
.filter((s) => s.label)
|
||||||
|
.map((s) => s.label)
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex w-full overflow-hidden rounded-full"
|
||||||
|
{title}
|
||||||
|
style:background={trackColor}
|
||||||
|
style:gap="{GAP}px"
|
||||||
|
style:height="{height}px"
|
||||||
|
>
|
||||||
|
{#each parts as part, i (i)}
|
||||||
|
<span
|
||||||
|
class="h-full shrink-0 first:rounded-l-full last:rounded-r-full"
|
||||||
|
style:background={part.color}
|
||||||
|
style:width="calc({part.share}% - {i === 0 ? 0 : GAP}px)"
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { extent } from "d3-array";
|
||||||
|
import { scaleLinear } from "d3-scale";
|
||||||
|
import {
|
||||||
|
area as areaShape,
|
||||||
|
curveMonotoneX,
|
||||||
|
line as lineShape,
|
||||||
|
} from "d3-shape";
|
||||||
|
|
||||||
|
let {
|
||||||
|
values,
|
||||||
|
color = "var(--ff-series-1)",
|
||||||
|
width = 96,
|
||||||
|
height = 24,
|
||||||
|
marker = -1,
|
||||||
|
}: {
|
||||||
|
values: number[];
|
||||||
|
color?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
marker?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const PAD = 3;
|
||||||
|
|
||||||
|
const x = $derived(
|
||||||
|
scaleLinear()
|
||||||
|
.domain([0, Math.max(1, values.length - 1)])
|
||||||
|
.range([PAD, width - PAD])
|
||||||
|
);
|
||||||
|
const y = $derived.by(() => {
|
||||||
|
const [lo = 0, hi = 0] = extent(values);
|
||||||
|
const pad = lo === hi ? Math.abs(lo) * 0.1 || 1 : 0;
|
||||||
|
return scaleLinear()
|
||||||
|
.domain([lo - pad, hi + pad])
|
||||||
|
.range([height - PAD, PAD]);
|
||||||
|
});
|
||||||
|
const line = $derived(
|
||||||
|
lineShape<number>()
|
||||||
|
.x((_, i) => x(i))
|
||||||
|
.y((v) => y(v))
|
||||||
|
.curve(curveMonotoneX)(values) ?? ""
|
||||||
|
);
|
||||||
|
const fill = $derived(
|
||||||
|
areaShape<number>()
|
||||||
|
.x((_, i) => x(i))
|
||||||
|
.y0(height - PAD)
|
||||||
|
.y1((v) => y(v))
|
||||||
|
.curve(curveMonotoneX)(values) ?? ""
|
||||||
|
);
|
||||||
|
const end = $derived(
|
||||||
|
marker >= 0 && marker < values.length ? marker : values.length - 1
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if values.length > 1}
|
||||||
|
<svg aria-hidden="true" class="block shrink-0" {height} {width}>
|
||||||
|
<path d={fill} fill-opacity="0.1" style:fill={color} />
|
||||||
|
<path
|
||||||
|
d={line}
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
style:stroke={color}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={x(end)}
|
||||||
|
cy={y(values[end])}
|
||||||
|
r="2.5"
|
||||||
|
stroke="var(--cladd-surface)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
style:fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { compactMoney, formatNumber } from "#lib/format.js";
|
||||||
|
import BarRows from "./BarRows.svelte";
|
||||||
|
import Columns from "./Columns.svelte";
|
||||||
|
import type { StackKey, StackRow } from "./chart.js";
|
||||||
|
import Legend from "./Legend.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
keys,
|
||||||
|
rows,
|
||||||
|
orientation = "vertical",
|
||||||
|
height = 240,
|
||||||
|
format = (value: number) => formatNumber(value),
|
||||||
|
axisFormat = (value: number) => compactMoney(value),
|
||||||
|
label = "Stacked bar chart",
|
||||||
|
labelWidth,
|
||||||
|
active = $bindable(null),
|
||||||
|
onselect,
|
||||||
|
}: {
|
||||||
|
keys: StackKey[];
|
||||||
|
rows: StackRow[];
|
||||||
|
orientation?: "vertical" | "horizontal";
|
||||||
|
height?: number;
|
||||||
|
format?: (value: number) => string;
|
||||||
|
axisFormat?: (value: number) => string;
|
||||||
|
label?: string;
|
||||||
|
labelWidth?: string;
|
||||||
|
active?: string | null;
|
||||||
|
onselect?: (row: StackRow) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if orientation === "horizontal"}
|
||||||
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
|
{#if keys.length > 1}
|
||||||
|
<Legend items={keys.map((k) => ({ ...k, shape: "rect" as const }))} />
|
||||||
|
{/if}
|
||||||
|
<BarRows {format} {keys} {label} {labelWidth} {onselect} {rows} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Columns
|
||||||
|
{axisFormat}
|
||||||
|
{format}
|
||||||
|
{height}
|
||||||
|
{keys}
|
||||||
|
{label}
|
||||||
|
{onselect}
|
||||||
|
{rows}
|
||||||
|
bind:active
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
export interface LinePoint {
|
||||||
|
x: string;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineSeries {
|
||||||
|
area?: boolean;
|
||||||
|
color?: string;
|
||||||
|
format?: (value: number) => string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
points: LinePoint[];
|
||||||
|
strong?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bar {
|
||||||
|
color?: string;
|
||||||
|
format?: (value: number) => string;
|
||||||
|
href?: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
note?: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StackKey {
|
||||||
|
color: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StackRow {
|
||||||
|
color?: string;
|
||||||
|
format?: (value: number) => string;
|
||||||
|
href?: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
note?: string;
|
||||||
|
values: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeterSegment {
|
||||||
|
color?: string;
|
||||||
|
label?: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegendItem {
|
||||||
|
color: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
shape?: "line" | "rect";
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Side = "top" | "bottom" | "left" | "right";
|
||||||
|
|
||||||
|
const PALETTE_SIZE = 8;
|
||||||
|
|
||||||
|
export const OTHER_COLOR = "var(--cladd-fg-softest)";
|
||||||
|
|
||||||
|
export const tones = {
|
||||||
|
expense: "var(--ff-expense)",
|
||||||
|
income: "var(--ff-income)",
|
||||||
|
transfer: "var(--ff-transfer)",
|
||||||
|
warn: "var(--ff-warn)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function seriesColor(index: number): string {
|
||||||
|
return index < PALETTE_SIZE ? `var(--ff-series-${index + 1})` : OTHER_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wash(color: string, amount = 18): string {
|
||||||
|
return `color-mix(in oklch, ${color} ${amount}%, transparent)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function utc(iso: string): Date {
|
||||||
|
const [y, m, d] = iso.slice(0, 10).split("-").map(Number);
|
||||||
|
return new Date(Date.UTC(y, m - 1, d));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const crisp = (value: number) => Math.round(value) + 0.5;
|
||||||
|
|
||||||
|
export function textWidth(text: string, px = 6.6): number {
|
||||||
|
return text.length * px;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundedBar(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
side: Side,
|
||||||
|
radius = 4
|
||||||
|
): string {
|
||||||
|
const across = side === "top" || side === "bottom" ? w : h;
|
||||||
|
const along = side === "top" || side === "bottom" ? h : w;
|
||||||
|
const r = Math.max(0, Math.min(radius, across / 2, along));
|
||||||
|
const arc = (tx: number, ty: number) => `A${r},${r} 0 0 1 ${tx},${ty}`;
|
||||||
|
const right = x + w;
|
||||||
|
const bottom = y + h;
|
||||||
|
switch (side) {
|
||||||
|
case "top":
|
||||||
|
return `M${x},${bottom}V${y + r}${arc(x + r, y)}H${right - r}${arc(right, y + r)}V${bottom}Z`;
|
||||||
|
case "bottom":
|
||||||
|
return `M${right},${y}V${bottom - r}${arc(right - r, bottom)}H${x + r}${arc(x, bottom - r)}V${y}Z`;
|
||||||
|
case "right":
|
||||||
|
return `M${x},${y}H${right - r}${arc(right, y + r)}V${bottom - r}${arc(right - r, bottom)}H${x}Z`;
|
||||||
|
default:
|
||||||
|
return `M${right},${bottom}H${x + r}${arc(x, bottom - r)}V${y + r}${arc(x + r, y)}H${right}Z`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
color: string;
|
||||||
|
from: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
outer: boolean;
|
||||||
|
to: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stack(
|
||||||
|
row: StackRow,
|
||||||
|
keys: StackKey[]
|
||||||
|
): { segments: Segment[]; positive: number; negative: number } {
|
||||||
|
let positive = 0;
|
||||||
|
let negative = 0;
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row.values[key.key] ?? 0;
|
||||||
|
if (value === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const from = value > 0 ? positive : negative;
|
||||||
|
const to = from + value;
|
||||||
|
if (value > 0) {
|
||||||
|
positive = to;
|
||||||
|
} else {
|
||||||
|
negative = to;
|
||||||
|
}
|
||||||
|
segments.push({
|
||||||
|
color: (keys.length === 1 && row.color) || key.color,
|
||||||
|
from,
|
||||||
|
key: key.key,
|
||||||
|
label: key.label,
|
||||||
|
outer: false,
|
||||||
|
to,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const lastUp = segments.findLast((s) => s.value > 0);
|
||||||
|
const lastDown = segments.findLast((s) => s.value < 0);
|
||||||
|
for (const s of segments) {
|
||||||
|
s.outer = s === lastUp || s === lastDown;
|
||||||
|
}
|
||||||
|
return { negative, positive, segments };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveIndex(
|
||||||
|
key: string,
|
||||||
|
index: number,
|
||||||
|
count: number,
|
||||||
|
axis: "x" | "y" = "x"
|
||||||
|
): number | null {
|
||||||
|
const back = axis === "x" ? "ArrowLeft" : "ArrowUp";
|
||||||
|
const forward = axis === "x" ? "ArrowRight" : "ArrowDown";
|
||||||
|
const start = index < 0 ? count : index;
|
||||||
|
switch (key) {
|
||||||
|
case back:
|
||||||
|
return Math.max(0, start - 1);
|
||||||
|
case forward:
|
||||||
|
return Math.min(count - 1, index + 1);
|
||||||
|
case "PageUp":
|
||||||
|
return Math.max(0, start - 7);
|
||||||
|
case "PageDown":
|
||||||
|
return Math.min(count - 1, index + 7);
|
||||||
|
case "Home":
|
||||||
|
return 0;
|
||||||
|
case "End":
|
||||||
|
return count - 1;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { DropdownMenu } from "bits-ui";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
Item,
|
||||||
|
Page,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import Amount from "#lib/components/Amount.svelte";
|
||||||
|
import {
|
||||||
|
canReconcile,
|
||||||
|
createUrl,
|
||||||
|
editUrl,
|
||||||
|
go,
|
||||||
|
openOld,
|
||||||
|
reconcileUrl,
|
||||||
|
showUrl,
|
||||||
|
} from "#lib/components/accounts/links.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import Sparkline from "#lib/components/charts/Sparkline.svelte";
|
||||||
|
import { span } from "#lib/components/dashboard/data.js";
|
||||||
|
import DropdownMenuContent from "#lib/components/ui/dropdown-menu/dropdown-menu-content.svelte";
|
||||||
|
import DropdownMenuItem from "#lib/components/ui/dropdown-menu/dropdown-menu-item.svelte";
|
||||||
|
import { contains } from "#lib/dates.js";
|
||||||
|
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import {
|
||||||
|
groupTitle,
|
||||||
|
signedAmount,
|
||||||
|
signedForeign,
|
||||||
|
} from "#lib/transactions/split.js";
|
||||||
|
|
||||||
|
const LIMIT = 10;
|
||||||
|
const TRIGGER =
|
||||||
|
"cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1 rounded-cladd-sm px-2.5 font-semibold text-[12px]";
|
||||||
|
|
||||||
|
let {
|
||||||
|
account,
|
||||||
|
trend = [],
|
||||||
|
marker = -1,
|
||||||
|
}: { account: Item<Account>; trend?: number[]; marker?: number } = $props();
|
||||||
|
|
||||||
|
const a = $derived(account.attributes);
|
||||||
|
const cur = $derived({
|
||||||
|
code: a.currency_code,
|
||||||
|
decimals: a.currency_decimal_places,
|
||||||
|
symbol: a.currency_symbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = createQuery(() => ({
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api<Page<TransactionGroup>>(`/v1/accounts/${account.id}/transactions`, {
|
||||||
|
query: { ...span(period.start, period.end), limit: LIMIT },
|
||||||
|
}),
|
||||||
|
queryKey: ["account-tx", account.id, period.start, period.end, LIMIT],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rows = $derived(
|
||||||
|
(list.data?.data ?? [])
|
||||||
|
.flatMap((group) =>
|
||||||
|
group.attributes.transactions
|
||||||
|
.filter(
|
||||||
|
(s) =>
|
||||||
|
(s.source_id === account.id || s.destination_id === account.id) &&
|
||||||
|
contains(period.range, s.date)
|
||||||
|
)
|
||||||
|
.map((split, i) => ({
|
||||||
|
foreign: signedForeign(split, account.id),
|
||||||
|
group,
|
||||||
|
key: `${group.id}-${i}`,
|
||||||
|
money: signedAmount(split, account.id),
|
||||||
|
split,
|
||||||
|
title:
|
||||||
|
group.attributes.transactions.length > 1
|
||||||
|
? `${groupTitle(group)}: ${split.description}`
|
||||||
|
: split.description,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.slice(0, LIMIT)
|
||||||
|
);
|
||||||
|
const change = $derived(toNumber(a.balance_difference));
|
||||||
|
const trendColor = $derived(
|
||||||
|
change < 0 ? "var(--ff-expense)" : "var(--ff-income)"
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box body="py-1" href={showUrl(account.id)} title={a.name}>
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if trend.length > 1}
|
||||||
|
<Sparkline color={trendColor} {marker} values={trend} width={72} />
|
||||||
|
{/if}
|
||||||
|
{#if change !== 0}
|
||||||
|
<Amount amount={change} signed {...cur} class="text-[12px]" />
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<div class="transition-opacity {list.isPlaceholderData ? "opacity-60" : ""}">
|
||||||
|
{#if list.isPending}
|
||||||
|
<div class="h-14"></div>
|
||||||
|
{:else if list.isError}
|
||||||
|
<p class="px-3 py-2 text-expense">{list.error.message}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="px-3 py-3 text-cladd-fg-soft">
|
||||||
|
No transactions in this period.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex flex-col">
|
||||||
|
{#each rows as row (row.key)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="flex h-10 items-center gap-2 px-3 text-[12.5px] hover:bg-row-hover sm:h-7"
|
||||||
|
href="/transactions/show/{row.group.id}"
|
||||||
|
>
|
||||||
|
<span class="num w-12 shrink-0 text-cladd-fg-soft"
|
||||||
|
>{formatDate(row.split.date.slice(0, 10), "short")}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="min-w-0 flex-1 truncate text-cladd-fg"
|
||||||
|
title={row.title}
|
||||||
|
>{row.title}</span
|
||||||
|
>
|
||||||
|
{#if row.foreign}
|
||||||
|
<span class="num shrink-0 text-[11px] text-cladd-fg-soft"
|
||||||
|
>({formatMoney(row.foreign)})</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<Amount
|
||||||
|
amount={row.money.amount}
|
||||||
|
code={row.money.code}
|
||||||
|
decimals={row.money.decimals}
|
||||||
|
symbol={row.money.symbol}
|
||||||
|
tone={row.money.tone}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class="{TRIGGER} cladd-color-green text-cladd-primary"
|
||||||
|
>
|
||||||
|
Create <ChevronDown class="size-3.5" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenuContent align="start" class="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => go(createUrl("withdrawal", account.id))}
|
||||||
|
>New withdrawal</DropdownMenuItem
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={() => go(createUrl("deposit", account.id))}
|
||||||
|
>New deposit</DropdownMenuItem
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={() => go(createUrl("transfer", account.id))}
|
||||||
|
>New transfer</DropdownMenuItem
|
||||||
|
>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger class="{TRIGGER} ml-auto text-cladd-fg">
|
||||||
|
<span class="num"
|
||||||
|
>{formatMoney({ amount: a.current_balance, ...cur })}</span
|
||||||
|
>
|
||||||
|
<ChevronDown class="size-3.5" />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
|
<DropdownMenuItem onSelect={() => go(showUrl(account.id))}
|
||||||
|
>Show</DropdownMenuItem
|
||||||
|
>
|
||||||
|
{#if canReconcile(account)}
|
||||||
|
<DropdownMenuItem onSelect={() => go(reconcileUrl(account.id))}
|
||||||
|
>Reconcile</DropdownMenuItem
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<DropdownMenuItem onSelect={() => go(editUrl(account.id))}
|
||||||
|
>Edit</DropdownMenuItem
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={() => openOld(showUrl(account.id))}
|
||||||
|
>Open in old UI</DropdownMenuItem
|
||||||
|
>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import type { LinePoint, LineSeries } from "#lib/components/charts/chart.js";
|
||||||
|
import LineChart from "#lib/components/charts/LineChart.svelte";
|
||||||
|
import { contains, today } from "#lib/dates.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import {
|
||||||
|
accountsChartQuery,
|
||||||
|
accountsQuery,
|
||||||
|
axis,
|
||||||
|
byCurrency,
|
||||||
|
type ChartSet,
|
||||||
|
type Cur,
|
||||||
|
hasPrimary,
|
||||||
|
hrefWith,
|
||||||
|
money,
|
||||||
|
seriesPoints,
|
||||||
|
setCurrency,
|
||||||
|
setPrimary,
|
||||||
|
} from "./data.js";
|
||||||
|
|
||||||
|
const HEIGHT = 330;
|
||||||
|
const MIN_PANEL = 130;
|
||||||
|
|
||||||
|
const mode = $derived(
|
||||||
|
page.url.searchParams.get("chart") === "net-worth"
|
||||||
|
? "net-worth"
|
||||||
|
: "accounts"
|
||||||
|
);
|
||||||
|
const chart = createQuery(() => ({
|
||||||
|
...accountsChartQuery(period.start, period.end, mode === "net-worth"),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
}));
|
||||||
|
const assets = createQuery(() => ({
|
||||||
|
...accountsQuery("asset", period.start, period.end),
|
||||||
|
enabled: mode === "net-worth",
|
||||||
|
}));
|
||||||
|
const liabilities = createQuery(() => ({
|
||||||
|
...accountsQuery("liabilities", period.start, period.end),
|
||||||
|
enabled: mode === "net-worth",
|
||||||
|
}));
|
||||||
|
|
||||||
|
let cursor = $state<string | null>(null);
|
||||||
|
|
||||||
|
const sets = $derived(chart.data ?? []);
|
||||||
|
const converted = $derived(hasPrimary(sets));
|
||||||
|
const excluded = $derived(
|
||||||
|
new Set(
|
||||||
|
[...(assets.data ?? []), ...(liabilities.data ?? [])]
|
||||||
|
.filter((a) => a.attributes.include_net_worth === false)
|
||||||
|
.map((a) => a.attributes.name)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Panel {
|
||||||
|
cur: Cur;
|
||||||
|
series: LineSeries[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sum(group: ChartSet[]): LinePoint[] {
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const set of group) {
|
||||||
|
for (const p of seriesPoints(set, converted)) {
|
||||||
|
totals.set(p.x, (totals.get(p.x) ?? 0) + p.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...totals.entries()].map(([x, y]) => ({ x, y }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const panels = $derived.by<Panel[]>(() => {
|
||||||
|
const curOf = (s: ChartSet) => (converted ? setPrimary(s) : setCurrency(s));
|
||||||
|
return byCurrency(sets, (s) => curOf(s).code).map(([, group]) => {
|
||||||
|
const cur = curOf(group[0]);
|
||||||
|
const format = money(cur);
|
||||||
|
if (mode === "net-worth") {
|
||||||
|
const included = group.filter((s) => !excluded.has(s.label));
|
||||||
|
return {
|
||||||
|
cur,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
area: true,
|
||||||
|
format,
|
||||||
|
key: "net-worth",
|
||||||
|
label: `Net worth (${cur.code})`,
|
||||||
|
points: sum(included),
|
||||||
|
strong: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cur,
|
||||||
|
series: group.map((s) => ({
|
||||||
|
area: group.length === 1,
|
||||||
|
format,
|
||||||
|
key: s.label,
|
||||||
|
label: s.label,
|
||||||
|
points: seriesPoints(s, converted),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const panelHeight = $derived(
|
||||||
|
Math.max(MIN_PANEL, Math.floor(HEIGHT / Math.max(1, panels.length)))
|
||||||
|
);
|
||||||
|
const marker = $derived(
|
||||||
|
contains(period.range, today()) ? today() : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
function setMode(next: string) {
|
||||||
|
goto(hrefWith(page.url, { chart: next === "accounts" ? null : next }), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box href="/accounts/asset" title="Your accounts">
|
||||||
|
{#snippet tools()}
|
||||||
|
<fieldset
|
||||||
|
class="cladd-cut m-0 flex min-w-0 rounded-cladd-sm border-0 p-0.5 text-[12px]"
|
||||||
|
>
|
||||||
|
<legend class="sr-only">Chart shows</legend>
|
||||||
|
{#each [
|
||||||
|
["accounts", "Accounts"],
|
||||||
|
["net-worth", "Net worth"],
|
||||||
|
] as [value, text] (value)}
|
||||||
|
<button
|
||||||
|
aria-pressed={mode === value}
|
||||||
|
class="rounded-[5px] px-2 py-0.5 {mode === value
|
||||||
|
? "cladd-surface cladd-outline font-semibold text-cladd-fg"
|
||||||
|
: "text-cladd-fg-soft hover:text-cladd-fg"}"
|
||||||
|
onclick={() => setMode(value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
{/snippet}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-5 transition-opacity {chart.isPlaceholderData
|
||||||
|
? "opacity-60"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
{#if chart.isPending}
|
||||||
|
<div style:height="{HEIGHT}px"></div>
|
||||||
|
{:else if chart.isError}
|
||||||
|
<p class="text-expense">{chart.error.message}</p>
|
||||||
|
{:else if panels.length === 0}
|
||||||
|
<p class="py-10 text-center text-cladd-fg-soft">
|
||||||
|
No accounts to draw. Pick the accounts for this chart in
|
||||||
|
<a class="text-brand underline" href="/preferences">Preferences</a>.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each panels as panel (panel.cur.code)}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#if panels.length > 1}
|
||||||
|
<span
|
||||||
|
class="font-semibold text-[11px] text-cladd-fg-soft tracking-wide"
|
||||||
|
>{panel.cur.code}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<LineChart
|
||||||
|
axisFormat={axis(panel.cur)}
|
||||||
|
height={panelHeight}
|
||||||
|
label="{mode === "net-worth"
|
||||||
|
? "Net worth"
|
||||||
|
: "Account balances"} in {panel.cur.code}"
|
||||||
|
{marker}
|
||||||
|
series={panel.series}
|
||||||
|
bind:cursor
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<a class="text-brand hover:underline" href="/accounts/asset"
|
||||||
|
>View your asset accounts</a
|
||||||
|
>
|
||||||
|
{#if converted}
|
||||||
|
<span class="ml-auto text-cladd-fg-soft"
|
||||||
|
>Converted to your primary currency</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { lists } from "#lib/api/lists.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import {
|
||||||
|
type StackKey,
|
||||||
|
type StackRow,
|
||||||
|
tones,
|
||||||
|
wash,
|
||||||
|
} from "#lib/components/charts/chart.js";
|
||||||
|
import StackedBars from "#lib/components/charts/StackedBars.svelte";
|
||||||
|
import { toNumber } from "#lib/format.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import {
|
||||||
|
byCurrency,
|
||||||
|
type ChartSet,
|
||||||
|
money,
|
||||||
|
setCurrency,
|
||||||
|
span,
|
||||||
|
} from "./data.js";
|
||||||
|
|
||||||
|
const KEYS: StackKey[] = [
|
||||||
|
{ color: "var(--ff-series-1)", key: "spent", label: "Spent in budget" },
|
||||||
|
{
|
||||||
|
color: wash("var(--ff-series-1)", 24),
|
||||||
|
key: "left",
|
||||||
|
label: "Left to spend",
|
||||||
|
},
|
||||||
|
{ color: tones.expense, key: "over", label: "Overspent" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const chart = createQuery(() => ({
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api<ChartSet[]>("/v1/chart/budget/overview", {
|
||||||
|
query: span(period.start, period.end),
|
||||||
|
}),
|
||||||
|
queryKey: ["chart-budgets", period.start, period.end],
|
||||||
|
}));
|
||||||
|
const budgets = createQuery(() => lists.budgets());
|
||||||
|
|
||||||
|
const ids = $derived(
|
||||||
|
new Map((budgets.data ?? []).map((b) => [b.attributes.name, b.id]))
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = $derived(
|
||||||
|
byCurrency(chart.data ?? [], (s) => s.currency_code).map(([code, sets]) => {
|
||||||
|
const format = money(setCurrency(sets[0]));
|
||||||
|
const rows: StackRow[] = sets
|
||||||
|
.map((s) => {
|
||||||
|
const budgeted = toNumber(s.entries.budgeted);
|
||||||
|
const spent = Math.abs(toNumber(s.entries.spent));
|
||||||
|
const inside = budgeted > 0 ? Math.min(spent, budgeted) : spent;
|
||||||
|
const id = ids.get(s.label);
|
||||||
|
return {
|
||||||
|
format,
|
||||||
|
href: id ? `/budgets/show/${id}` : undefined,
|
||||||
|
key: s.label,
|
||||||
|
label: s.label,
|
||||||
|
note:
|
||||||
|
budgeted > 0
|
||||||
|
? `${format(spent)} of ${format(budgeted)}`
|
||||||
|
: format(spent),
|
||||||
|
values: {
|
||||||
|
left: Math.max(0, toNumber(s.entries.left)),
|
||||||
|
over: Math.max(0, Math.abs(toNumber(s.entries.overspent))),
|
||||||
|
spent: inside,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r) => r.values.spent + r.values.left + r.values.over > 0);
|
||||||
|
const limited = sets.some((x) => toNumber(x.entries.budgeted) > 0);
|
||||||
|
const keys = limited ? KEYS : [{ ...KEYS[0], label: "Spent" }];
|
||||||
|
return { code, keys, limited, rows };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const visible = $derived(groups.filter((g) => g.rows.length > 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box href="/budgets" title="Budgets and spending">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 transition-opacity {chart.isPlaceholderData
|
||||||
|
? "opacity-60"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
{#if chart.isPending}
|
||||||
|
<div class="h-24"></div>
|
||||||
|
{:else if chart.isError}
|
||||||
|
<p class="text-expense">{chart.error.message}</p>
|
||||||
|
{:else if visible.length === 0}
|
||||||
|
<p class="py-6 text-center text-cladd-fg-soft">
|
||||||
|
No budgets with a limit or spending in this period. Set an amount for a
|
||||||
|
budget on the
|
||||||
|
<a class="text-brand underline" href="/budgets">budgets page</a>.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each visible as group (group.code)}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#if visible.length > 1 || !group.limited}
|
||||||
|
<span class="text-[11px] text-cladd-fg-soft">
|
||||||
|
<span class="font-semibold tracking-wide">{group.code}</span>
|
||||||
|
{#if !group.limited}
|
||||||
|
· spent, no budget amount set in {group.code}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<StackedBars
|
||||||
|
keys={group.keys}
|
||||||
|
label="Budgets in {group.code}"
|
||||||
|
orientation="horizontal"
|
||||||
|
rows={group.rows}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<a class="text-brand hover:underline" href="/budgets">Go to your budgets</a>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { lists } from "#lib/api/lists.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,
|
||||||
|
type ChartSet,
|
||||||
|
hasPrimary,
|
||||||
|
money,
|
||||||
|
proportional,
|
||||||
|
setCurrency,
|
||||||
|
setPrimary,
|
||||||
|
span,
|
||||||
|
} from "./data.js";
|
||||||
|
|
||||||
|
const NO_CATEGORY = "/categories/list/no-category";
|
||||||
|
|
||||||
|
const chart = createQuery(() => ({
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () =>
|
||||||
|
api<ChartSet[]>("/v1/chart/category/overview", {
|
||||||
|
query: span(period.start, period.end),
|
||||||
|
}),
|
||||||
|
queryKey: ["chart-categories", period.start, period.end],
|
||||||
|
}));
|
||||||
|
const categories = createQuery(() => lists.categories());
|
||||||
|
|
||||||
|
const ids = $derived(
|
||||||
|
new Map((categories.data ?? []).map((c) => [c.attributes.name, c.id]))
|
||||||
|
);
|
||||||
|
const converted = $derived(hasPrimary(chart.data ?? []));
|
||||||
|
|
||||||
|
const groups = $derived(
|
||||||
|
byCurrency(chart.data ?? [], (s) =>
|
||||||
|
converted ? setPrimary(s).code : s.currency_code
|
||||||
|
).map(([code, sets]) => {
|
||||||
|
const cur = converted ? setPrimary(sets[0]) : setCurrency(sets[0]);
|
||||||
|
const bars: Bar[] = sets
|
||||||
|
.map((s) => {
|
||||||
|
const entries = (converted ? s.pc_entries : s.entries) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
const id = ids.get(s.label);
|
||||||
|
return {
|
||||||
|
href: id ? `/categories/show/${id}` : NO_CATEGORY,
|
||||||
|
key: s.label,
|
||||||
|
label: s.label,
|
||||||
|
value: Math.abs(toNumber(entries?.spent)),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((b) => b.value > 0)
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
return { bars, code, cur };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const visible = $derived(groups.filter((g) => g.bars.length > 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box href="/categories" title="Categories">
|
||||||
|
<div
|
||||||
|
class="grid gap-x-6 gap-y-5 transition-opacity md:grid-cols-(--cols) {chart.isPlaceholderData
|
||||||
|
? "opacity-60"
|
||||||
|
: ""}"
|
||||||
|
style:--cols={proportional(visible.map((g) => g.bars.length))}
|
||||||
|
>
|
||||||
|
{#if chart.isPending}
|
||||||
|
<div class="h-60"></div>
|
||||||
|
{:else if chart.isError}
|
||||||
|
<p class="text-expense">{chart.error.message}</p>
|
||||||
|
{:else if visible.length === 0}
|
||||||
|
<p class="py-10 text-center text-cladd-fg-soft">
|
||||||
|
Nothing spent in this period. Expenses show up here by category.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each visible as group (group.code)}
|
||||||
|
<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"
|
||||||
|
>Spent in {group.code}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<BarChart
|
||||||
|
axisFormat={axis(group.cur)}
|
||||||
|
bars={group.bars}
|
||||||
|
color={tones.expense}
|
||||||
|
format={money(group.cur)}
|
||||||
|
height={240}
|
||||||
|
label="Spent per category in {group.code}"
|
||||||
|
name="Spent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<a class="text-brand hover:underline" href="/categories"
|
||||||
|
>Go to your categories</a
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { 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 { period } from "#lib/period.svelte.js";
|
||||||
|
import { accountsQuery, byCurrency, money } from "./data.js";
|
||||||
|
|
||||||
|
const liabilities = createQuery(() => ({
|
||||||
|
...accountsQuery("liabilities", period.start, period.end),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const groups = $derived(
|
||||||
|
byCurrency(
|
||||||
|
(liabilities.data ?? []).filter(
|
||||||
|
(a) =>
|
||||||
|
a.attributes.liability_type === "debt" &&
|
||||||
|
toNumber(a.attributes.debt_amount) !== 0
|
||||||
|
),
|
||||||
|
(a) => a.attributes.currency_code ?? ""
|
||||||
|
).map(([code, list]) => {
|
||||||
|
const first = list[0].attributes;
|
||||||
|
const format = money({
|
||||||
|
code,
|
||||||
|
decimals: first.currency_decimal_places ?? 2,
|
||||||
|
symbol: first.currency_symbol ?? code,
|
||||||
|
});
|
||||||
|
const bars = 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,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.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 };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if groups.length > 0}
|
||||||
|
<Box href="/accounts/liabilities" title="Debts with friends">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-4 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
|
||||||
|
>
|
||||||
|
{#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>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<a class="text-brand hover:underline" href="/accounts/liabilities"
|
||||||
|
>See your liabilities</a
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowDown from "@lucide/svelte/icons/arrow-down";
|
||||||
|
import ArrowUp from "@lucide/svelte/icons/arrow-up";
|
||||||
|
import CalendarClock from "@lucide/svelte/icons/calendar-clock";
|
||||||
|
import ChartLine from "@lucide/svelte/icons/chart-line";
|
||||||
|
import Scale from "@lucide/svelte/icons/scale";
|
||||||
|
import Wallet from "@lucide/svelte/icons/wallet";
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import type { Component, Snippet } from "svelte";
|
||||||
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
|
import { parse, periodOf, shiftPeriod, today } from "#lib/dates.js";
|
||||||
|
import { currentLocale, formatMoney } from "#lib/format.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { ALL_ASSETS, reportPath } from "#lib/reports/params.js";
|
||||||
|
import { parseSummary, type SummaryEntry, summaryQuery } from "./data.js";
|
||||||
|
|
||||||
|
const CURRENCY_SUFFIX = /\s*\(.*\)$/;
|
||||||
|
|
||||||
|
interface Delta {
|
||||||
|
good: boolean;
|
||||||
|
text: string;
|
||||||
|
up: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = createQuery(() => ({
|
||||||
|
...summaryQuery(period.start, period.end),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
}));
|
||||||
|
const prevRange = $derived(shiftPeriod(period.range, period.view, -1));
|
||||||
|
const previous = createQuery(() =>
|
||||||
|
summaryQuery(prevRange.start, prevRange.end)
|
||||||
|
);
|
||||||
|
const now = $derived(parseSummary(current.data));
|
||||||
|
const before = $derived(parseSummary(previous.data));
|
||||||
|
const prevName = $derived.by(() => {
|
||||||
|
const month = periodOf(prevRange.start, "1M");
|
||||||
|
if (month.start === prevRange.start && month.end === prevRange.end) {
|
||||||
|
return new Intl.DateTimeFormat(currentLocale(), {
|
||||||
|
month: "short",
|
||||||
|
}).format(parse(prevRange.start));
|
||||||
|
}
|
||||||
|
return "the period before";
|
||||||
|
});
|
||||||
|
const year = $derived(today().slice(0, 4));
|
||||||
|
|
||||||
|
const fmt = (e: SummaryEntry, abs = false) =>
|
||||||
|
formatMoney({
|
||||||
|
amount: abs ? Math.abs(e.amount) : e.amount,
|
||||||
|
code: e.code,
|
||||||
|
decimals: e.decimals,
|
||||||
|
symbol: e.symbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
function compare(
|
||||||
|
kind: string,
|
||||||
|
words: [string, string],
|
||||||
|
upIsGood = true
|
||||||
|
): Delta | null {
|
||||||
|
const a = now[kind]?.[0];
|
||||||
|
const b = a && before[kind]?.find((e) => e.code === a.code);
|
||||||
|
if (!(a && b) || a.amount === b.amount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const diff = a.amount - b.amount;
|
||||||
|
return {
|
||||||
|
good: diff > 0 === upIsGood,
|
||||||
|
text: `${fmt({ ...a, amount: diff }, true)} ${diff > 0 ? words[0] : words[1]}`,
|
||||||
|
up: diff > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const balance = $derived(now.balance ?? []);
|
||||||
|
const earned = $derived(now.earned?.[0]);
|
||||||
|
const spent = $derived(now.spent?.[0]);
|
||||||
|
const unpaid = $derived(now["bills-unpaid"] ?? []);
|
||||||
|
const paid = $derived(now["bills-paid"] ?? []);
|
||||||
|
const left = $derived(now["left-to-spend"] ?? []);
|
||||||
|
const worth = $derived(now["net-worth"] ?? []);
|
||||||
|
|
||||||
|
const leftState = $derived.by(() => {
|
||||||
|
if (left.some((e) => e.noBudgets)) {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
return left.some((e) => e.amount > 0) ? "ok" : "over";
|
||||||
|
});
|
||||||
|
const leftTitle = $derived(
|
||||||
|
leftState === "none"
|
||||||
|
? (left.find((e) => e.noBudgets)?.title.replace(CURRENCY_SUFFIX, "") ??
|
||||||
|
"Spent")
|
||||||
|
: "Left to spend"
|
||||||
|
);
|
||||||
|
const leftColor = $derived(
|
||||||
|
{
|
||||||
|
none: "var(--ff-transfer)",
|
||||||
|
ok: "var(--ff-income)",
|
||||||
|
over: "var(--ff-expense)",
|
||||||
|
}[leftState]
|
||||||
|
);
|
||||||
|
const paidShare = $derived.by(() => {
|
||||||
|
const code = unpaid[0]?.code ?? paid[0]?.code;
|
||||||
|
const p = Math.abs(paid.find((e) => e.code === code)?.amount ?? 0);
|
||||||
|
const u = Math.abs(unpaid.find((e) => e.code === code)?.amount ?? 0);
|
||||||
|
return { paid: p, total: p + u };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet values(
|
||||||
|
list: SummaryEntry[],
|
||||||
|
abs = false
|
||||||
|
)}
|
||||||
|
{#if list.length === 0}
|
||||||
|
<div class="font-semibold text-[18px] text-cladd-fg-softer sm:text-[22px]">
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="truncate font-semibold text-[18px] text-cladd-fg tracking-[-0.02em] sm:text-[22px]"
|
||||||
|
>
|
||||||
|
{fmt(list[0], abs)}
|
||||||
|
</div>
|
||||||
|
{#if list.length > 1}
|
||||||
|
<div class="num flex flex-wrap gap-x-2 text-[12px] text-cladd-fg-soft">
|
||||||
|
{#each list.slice(1) as e (e.code)}
|
||||||
|
<span>{fmt(e, abs)}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet change(
|
||||||
|
delta: Delta | null
|
||||||
|
)}
|
||||||
|
{#if delta}
|
||||||
|
<div
|
||||||
|
class="hidden items-center gap-1 text-[12px] sm:flex {delta.good
|
||||||
|
? "text-income"
|
||||||
|
: "text-expense"}"
|
||||||
|
>
|
||||||
|
{#if delta.up}
|
||||||
|
<ArrowUp class="size-3.5 shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<ArrowDown class="size-3.5 shrink-0" />
|
||||||
|
{/if}
|
||||||
|
<span class="truncate"
|
||||||
|
><span class="num">{delta.text}</span>
|
||||||
|
<span class="text-cladd-fg-soft">than {prevName}</span></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet box(
|
||||||
|
title: string,
|
||||||
|
href: string,
|
||||||
|
icon: Component,
|
||||||
|
color: string,
|
||||||
|
body: Snippet
|
||||||
|
)}
|
||||||
|
{@const Icon = icon}
|
||||||
|
<section
|
||||||
|
class="cladd-surface cladd-outline flex min-w-0 gap-3 rounded-cladd-lg p-3 {current.isPlaceholderData
|
||||||
|
? "opacity-60"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="hidden size-10 shrink-0 place-items-center rounded-cladd-md text-white sm:grid"
|
||||||
|
style:background={color}
|
||||||
|
>
|
||||||
|
<Icon class="size-5" />
|
||||||
|
</span>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<a
|
||||||
|
class="truncate text-[12px] text-cladd-fg-soft hover:text-cladd-fg hover:underline"
|
||||||
|
{href}
|
||||||
|
>{title}</a
|
||||||
|
>
|
||||||
|
{@render body()}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet inOut()}
|
||||||
|
{@render values(balance)}
|
||||||
|
{#if earned || spent}
|
||||||
|
<div class="num flex flex-wrap gap-x-2 text-[12px]">
|
||||||
|
{#if earned}
|
||||||
|
<span class="text-income">+{fmt(earned, true)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if spent}
|
||||||
|
<span class="text-expense">−{fmt(spent, true)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{@render change(compare("balance", ["more", "less"]))}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet bills()}
|
||||||
|
{@render values(unpaid, true)}
|
||||||
|
{#if paidShare.total > 0}
|
||||||
|
<Meter
|
||||||
|
max={paidShare.total}
|
||||||
|
segments={[{ color: "var(--ff-series-3)", value: paidShare.paid }]}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="truncate text-[12px] text-cladd-fg-soft">
|
||||||
|
Paid:
|
||||||
|
<span class="num"
|
||||||
|
>{paid.length ? paid.map((e) => fmt(e, true)).join(", ") : "0"}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet leftToSpend()}
|
||||||
|
{@render values(left)}
|
||||||
|
{#if left[0]?.sub}
|
||||||
|
<div class="truncate text-[12px] text-cladd-fg-soft">
|
||||||
|
{leftState === "none" ? "" : "Per day: "}<span class="num"
|
||||||
|
>{left[0].sub}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{@render change(compare("left-to-spend", ["more", "less"]))}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet netWorth()}
|
||||||
|
{@render values(worth)}
|
||||||
|
{@render change(compare("net-worth", ["more", "less"]))}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||||
|
{@render box(
|
||||||
|
"In + out this period",
|
||||||
|
reportPath({
|
||||||
|
accounts: [ALL_ASSETS],
|
||||||
|
end: period.end,
|
||||||
|
items: [],
|
||||||
|
start: period.start,
|
||||||
|
type: "default",
|
||||||
|
}),
|
||||||
|
Scale,
|
||||||
|
"var(--ff-transfer)",
|
||||||
|
inOut
|
||||||
|
)}
|
||||||
|
{@render box(
|
||||||
|
"Subscriptions to pay",
|
||||||
|
"/subscriptions",
|
||||||
|
CalendarClock,
|
||||||
|
"var(--ff-series-3)",
|
||||||
|
bills
|
||||||
|
)}
|
||||||
|
{@render box(leftTitle, "/budgets", Wallet, leftColor, leftToSpend)}
|
||||||
|
{@render box(
|
||||||
|
"Net worth",
|
||||||
|
reportPath({
|
||||||
|
accounts: [ALL_ASSETS],
|
||||||
|
end: `${year}-12-31`,
|
||||||
|
items: [],
|
||||||
|
start: `${year}-01-01`,
|
||||||
|
type: "default",
|
||||||
|
}),
|
||||||
|
ChartLine,
|
||||||
|
"var(--ff-brand)",
|
||||||
|
netWorth
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { lists } from "#lib/api/lists.js";
|
||||||
|
import type { PiggyBank } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
|
import { formatMoney, percent, toNumber } from "#lib/format.js";
|
||||||
|
|
||||||
|
const piggies = createQuery(() => lists.piggyBanks());
|
||||||
|
|
||||||
|
const saving = $derived(
|
||||||
|
(piggies.data ?? [])
|
||||||
|
.filter((p) => toNumber(p.attributes.current_amount) > 0)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
toNumber(a.attributes.percentage) - toNumber(b.attributes.percentage)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const fmt = (p: PiggyBank, amount: string | null | undefined) =>
|
||||||
|
formatMoney({
|
||||||
|
amount,
|
||||||
|
code: p.currency_code,
|
||||||
|
decimals: p.currency_decimal_places,
|
||||||
|
symbol: p.currency_symbol,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if saving.length > 0}
|
||||||
|
<Box href="/piggy-banks" title="Piggy banks">
|
||||||
|
<ul class="flex flex-col gap-3">
|
||||||
|
{#each saving as item (item.id)}
|
||||||
|
{@const p = item.attributes}
|
||||||
|
{@const target = toNumber(p.target_amount)}
|
||||||
|
<li class="flex flex-col gap-1">
|
||||||
|
<div class="flex items-baseline gap-2 text-[12.5px]">
|
||||||
|
<a
|
||||||
|
class="min-w-0 flex-1 truncate font-semibold text-cladd-fg hover:underline"
|
||||||
|
href="/piggy-banks/show/{item.id}"
|
||||||
|
>{p.name}</a
|
||||||
|
>
|
||||||
|
<span class="num shrink-0 text-[12px] text-cladd-fg-soft">
|
||||||
|
<span class="text-cladd-fg">{fmt(p, p.current_amount)}</span>
|
||||||
|
{#if target > 0}
|
||||||
|
of {fmt(p, p.target_amount)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if target > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Meter
|
||||||
|
max={target}
|
||||||
|
segments={[{ value: toNumber(p.current_amount) }]}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="num w-10 shrink-0 text-right text-[11px] text-cladd-fg-soft"
|
||||||
|
>{percent(toNumber(p.percentage) / 100)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#snippet footer()}
|
||||||
|
<a class="text-brand hover:underline" href="/piggy-banks"
|
||||||
|
>Go to your piggy banks</a
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import { all } from "#lib/api/lists.js";
|
||||||
|
import type { Bill } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import Meter from "#lib/components/charts/Meter.svelte";
|
||||||
|
import { formatDate, formatMoney, toNumber } from "#lib/format.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { parseSummary, span, summaryQuery } from "./data.js";
|
||||||
|
|
||||||
|
const DUE_LIMIT = 6;
|
||||||
|
|
||||||
|
const summary = createQuery(() => ({
|
||||||
|
...summaryQuery(period.start, period.end),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
}));
|
||||||
|
const bills = createQuery(() => ({
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
queryFn: () => all<Bill>("/v1/bills", span(period.start, period.end)),
|
||||||
|
queryKey: ["bills", period.start, period.end],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const parsed = $derived(parseSummary(summary.data));
|
||||||
|
const meters = $derived(
|
||||||
|
(parsed["bills-paid"] ?? [])
|
||||||
|
.map((p) => {
|
||||||
|
const u = parsed["bills-unpaid"]?.find((e) => e.code === p.code);
|
||||||
|
const paid = Math.abs(p.amount);
|
||||||
|
const unpaid = Math.abs(u?.amount ?? 0);
|
||||||
|
return { cur: p, paid, total: paid + unpaid };
|
||||||
|
})
|
||||||
|
.concat(
|
||||||
|
(parsed["bills-unpaid"] ?? [])
|
||||||
|
.filter((u) => !parsed["bills-paid"]?.some((p) => p.code === u.code))
|
||||||
|
.map((u) => ({ cur: u, paid: 0, total: Math.abs(u.amount) }))
|
||||||
|
)
|
||||||
|
.filter((m) => m.total > 0)
|
||||||
|
);
|
||||||
|
const inPeriod = (dates: (string | null | undefined)[]) =>
|
||||||
|
dates
|
||||||
|
.map((d) => (d ?? "").slice(0, 10))
|
||||||
|
.filter((d) => d >= period.start && d <= period.end)
|
||||||
|
.sort();
|
||||||
|
const due = $derived(
|
||||||
|
(bills.data ?? [])
|
||||||
|
.filter((b) => b.attributes.active !== false)
|
||||||
|
.flatMap((b) => {
|
||||||
|
const expected = inPeriod(b.attributes.pay_dates ?? []);
|
||||||
|
const paid = inPeriod(
|
||||||
|
(b.attributes.paid_dates ?? []).map((p) => p.date)
|
||||||
|
).length;
|
||||||
|
const next = expected[paid];
|
||||||
|
if (!next) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const amount =
|
||||||
|
(toNumber(b.attributes.amount_min) +
|
||||||
|
toNumber(b.attributes.amount_max)) /
|
||||||
|
2;
|
||||||
|
return [{ amount, bill: b, date: next }];
|
||||||
|
})
|
||||||
|
.sort((x, y) => x.date.localeCompare(y.date))
|
||||||
|
);
|
||||||
|
const fmt = (
|
||||||
|
amount: number,
|
||||||
|
c: { code: string; symbol: string; decimals: number }
|
||||||
|
) =>
|
||||||
|
formatMoney({
|
||||||
|
amount,
|
||||||
|
code: c.code,
|
||||||
|
decimals: c.decimals,
|
||||||
|
symbol: c.symbol,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if (bills.data?.length ?? 0) > 0}
|
||||||
|
<Box href="/subscriptions" title="Subscriptions">
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 transition-opacity {summary.isPlaceholderData
|
||||||
|
? "opacity-60"
|
||||||
|
: ""}"
|
||||||
|
>
|
||||||
|
{#if meters.length === 0}
|
||||||
|
<p class="text-cladd-fg-soft">Nothing expected in this period.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="flex flex-col gap-2">
|
||||||
|
{#each meters as m (m.cur.code)}
|
||||||
|
<li class="flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
class="flex items-baseline justify-between gap-2 text-[12px]"
|
||||||
|
>
|
||||||
|
<span class="text-cladd-fg-soft"
|
||||||
|
><span class="num text-cladd-fg">{fmt(m.paid, m.cur)}</span>
|
||||||
|
paid</span
|
||||||
|
>
|
||||||
|
<span class="text-cladd-fg-soft"
|
||||||
|
><span class="num text-cladd-fg"
|
||||||
|
>{fmt(m.total - m.paid, m.cur)}</span
|
||||||
|
>
|
||||||
|
to pay</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<Meter
|
||||||
|
max={m.total}
|
||||||
|
segments={[{ color: "var(--ff-series-3)", value: m.paid }]}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if due.length > 0}
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="pb-1 font-semibold text-[12px] text-cladd-fg-soft"
|
||||||
|
>Still to pay this period</span
|
||||||
|
>
|
||||||
|
<ul class="flex flex-col">
|
||||||
|
{#each due.slice(0, DUE_LIMIT) as d (d.bill.id)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="-mx-1 flex h-10 items-center gap-2 rounded-sm px-1 text-[12.5px] sm:h-7 hover:bg-row-hover"
|
||||||
|
href="/subscriptions/show/{d.bill.id}"
|
||||||
|
>
|
||||||
|
<span class="num w-12 shrink-0 text-cladd-fg-soft"
|
||||||
|
>{formatDate(d.date, "short")}</span
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate"
|
||||||
|
>{d.bill.attributes.name}</span
|
||||||
|
>
|
||||||
|
<span class="num text-cladd-fg-soft"
|
||||||
|
>~{formatMoney({
|
||||||
|
amount: d.amount,
|
||||||
|
code: d.bill.attributes.currency_code,
|
||||||
|
decimals: d.bill.attributes.currency_decimal_places,
|
||||||
|
symbol: d.bill.attributes.currency_symbol,
|
||||||
|
})}</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if due.length > DUE_LIMIT}
|
||||||
|
<a
|
||||||
|
class="pt-1 text-[12px] text-cladd-fg-soft hover:underline"
|
||||||
|
href="/subscriptions"
|
||||||
|
>and {due.length - DUE_LIMIT} more</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#snippet footer()}
|
||||||
|
<a class="text-brand hover:underline" href="/subscriptions"
|
||||||
|
>Go to your subscriptions</a
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
</Box>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all } from "#lib/api/lists.js";
|
||||||
|
import type { Account, BasicSummaryEntry } from "#lib/api/types.js";
|
||||||
|
import { addDays, today } from "#lib/dates.js";
|
||||||
|
import { compactMoney, formatMoney, toNumber } from "#lib/format.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
|
||||||
|
export interface Cur {
|
||||||
|
code: string;
|
||||||
|
decimals: number;
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryEntry extends Cur {
|
||||||
|
amount: number;
|
||||||
|
noBudgets: boolean;
|
||||||
|
sub: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartSet {
|
||||||
|
currency_code: string;
|
||||||
|
currency_decimal_places: number;
|
||||||
|
currency_symbol?: string;
|
||||||
|
entries: Record<string, string | number>;
|
||||||
|
label: string;
|
||||||
|
pc_entries?: Record<string, string | number> | unknown[];
|
||||||
|
primary_currency_code?: string;
|
||||||
|
primary_currency_decimal_places?: number;
|
||||||
|
primary_currency_symbol?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsightEntry {
|
||||||
|
currency_code: string;
|
||||||
|
difference: string;
|
||||||
|
difference_float?: number;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARY_KEY = /^(.+)-in-([A-Z0-9]+)$/;
|
||||||
|
|
||||||
|
export function currency(code: string): Cur {
|
||||||
|
const found = session.currencies.find((c) => c.attributes.code === code);
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
decimals: found?.attributes.decimal_places ?? 2,
|
||||||
|
symbol: found?.attributes.symbol ?? code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setCurrency = (set: ChartSet): Cur => ({
|
||||||
|
...currency(set.currency_code),
|
||||||
|
...(set.currency_symbol ? { symbol: set.currency_symbol } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const money = (cur: Cur) => (value: number) =>
|
||||||
|
formatMoney({
|
||||||
|
amount: value,
|
||||||
|
code: cur.code,
|
||||||
|
decimals: cur.decimals,
|
||||||
|
symbol: cur.symbol,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const axis = (cur: Cur) => (value: number) =>
|
||||||
|
compactMoney(value, cur.symbol);
|
||||||
|
|
||||||
|
export const primaryCode = () => session.primary?.code ?? "";
|
||||||
|
|
||||||
|
export function byCurrency<T>(
|
||||||
|
items: T[],
|
||||||
|
code: (item: T) => string
|
||||||
|
): [string, T[]][] {
|
||||||
|
const groups = new Map<string, T[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = code(item);
|
||||||
|
const list = groups.get(key);
|
||||||
|
if (list) {
|
||||||
|
list.push(item);
|
||||||
|
} else {
|
||||||
|
groups.set(key, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const primary = primaryCode();
|
||||||
|
return [...groups.entries()].sort(
|
||||||
|
([a, la], [b, lb]) =>
|
||||||
|
Number(b === primary) - Number(a === primary) || lb.length - la.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSummary(
|
||||||
|
raw: Record<string, BasicSummaryEntry> | undefined
|
||||||
|
): Record<string, SummaryEntry[]> {
|
||||||
|
const result: Record<string, SummaryEntry[]> = {};
|
||||||
|
for (const [key, entry] of Object.entries(raw ?? {})) {
|
||||||
|
const match = key.match(SUMMARY_KEY);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const extra = entry as BasicSummaryEntry & {
|
||||||
|
no_available_budgets?: boolean;
|
||||||
|
};
|
||||||
|
const list = result[match[1]] ?? [];
|
||||||
|
list.push({
|
||||||
|
amount: toNumber(entry.monetary_value),
|
||||||
|
code: entry.currency_code ?? match[2],
|
||||||
|
decimals: entry.currency_decimal_places ?? 2,
|
||||||
|
noBudgets: extra.no_available_budgets === true,
|
||||||
|
sub: entry.sub_title ?? "",
|
||||||
|
symbol: entry.currency_symbol ?? match[2],
|
||||||
|
title: entry.title ?? "",
|
||||||
|
});
|
||||||
|
result[match[1]] = list;
|
||||||
|
}
|
||||||
|
const primary = primaryCode();
|
||||||
|
for (const list of Object.values(result)) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.code === primary) - Number(a.code === primary) ||
|
||||||
|
Math.abs(b.amount) - Math.abs(a.amount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPrimary(sets: ChartSet[]): boolean {
|
||||||
|
return (
|
||||||
|
session.prefs.convert_to_primary === true &&
|
||||||
|
sets.length > 0 &&
|
||||||
|
sets.every((s) => s.pc_entries && !Array.isArray(s.pc_entries))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upTo(end: string): string {
|
||||||
|
const now = today();
|
||||||
|
return end < now ? end : now;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodQuery(extra: Record<string, unknown> = {}) {
|
||||||
|
return { end: period.end, start: period.start, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefWith(
|
||||||
|
url: { href: string },
|
||||||
|
changes: Record<string, string | null>
|
||||||
|
): string {
|
||||||
|
const next = new URL(url.href);
|
||||||
|
for (const [name, value] of Object.entries(changes)) {
|
||||||
|
if (value === null) {
|
||||||
|
next.searchParams.delete(name);
|
||||||
|
} else {
|
||||||
|
next.searchParams.set(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.searchParams.set("start", period.start);
|
||||||
|
next.searchParams.set("end", period.end);
|
||||||
|
return next.pathname + next.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const summaryQuery = (start: string, end: string) => ({
|
||||||
|
queryFn: () =>
|
||||||
|
api<Record<string, BasicSummaryEntry>>("/v1/summary/basic", {
|
||||||
|
query: { end, start },
|
||||||
|
}),
|
||||||
|
queryKey: ["summary", start, end],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const accountsChartQuery = (
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
everything = false
|
||||||
|
) => ({
|
||||||
|
queryFn: () =>
|
||||||
|
api<ChartSet[]>("/v1/chart/account/overview", {
|
||||||
|
query: {
|
||||||
|
...span(start, end),
|
||||||
|
period: "1D",
|
||||||
|
preselected: everything ? "all" : undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
queryKey: ["chart-accounts", start, end, everything],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function seriesPoints(set: ChartSet, converted: boolean) {
|
||||||
|
const entries = (converted ? set.pc_entries : set.entries) as
|
||||||
|
| Record<string, string | number>
|
||||||
|
| undefined;
|
||||||
|
return Object.entries(entries ?? {}).map(([date, value]) => ({
|
||||||
|
x: date.slice(0, 10),
|
||||||
|
y: toNumber(value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPrimary(set: ChartSet): Cur {
|
||||||
|
return currency(set.primary_currency_code ?? set.currency_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function balanceDate(start: string, end: string): string {
|
||||||
|
const until = upTo(end);
|
||||||
|
return until < start ? end : until;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function balanceRange(start: string, end: string) {
|
||||||
|
const until = balanceDate(start, end);
|
||||||
|
return until > start ? { date: until, end: until, start } : { date: until };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function span(start: string, end: string) {
|
||||||
|
return { end: end > start ? end : addDays(start, 1), start };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountsQuery = (type: string, start: string, end: string) => ({
|
||||||
|
queryFn: () =>
|
||||||
|
all<Account>("/v1/accounts", { ...balanceRange(start, end), type }),
|
||||||
|
queryKey: ["accounts", type, start, end],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function proportional(counts: number[]): string {
|
||||||
|
return counts.map((n) => `minmax(0, ${Math.max(2, n)}fr)`).join(" ");
|
||||||
|
}
|
||||||
@@ -1,5 +1,96 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
|
||||||
|
import AccountBox from "#lib/components/dashboard/AccountBox.svelte";
|
||||||
|
import AccountsChart from "#lib/components/dashboard/AccountsChart.svelte";
|
||||||
|
import BudgetsChart from "#lib/components/dashboard/BudgetsChart.svelte";
|
||||||
|
import CategoriesChart from "#lib/components/dashboard/CategoriesChart.svelte";
|
||||||
|
import DebtsBox from "#lib/components/dashboard/DebtsBox.svelte";
|
||||||
|
import {
|
||||||
|
accountsChartQuery,
|
||||||
|
accountsQuery,
|
||||||
|
seriesPoints,
|
||||||
|
} from "#lib/components/dashboard/data.js";
|
||||||
|
import InfoBoxes from "#lib/components/dashboard/InfoBoxes.svelte";
|
||||||
|
import InsightBox from "#lib/components/dashboard/InsightBox.svelte";
|
||||||
|
import PiggyBanksBox from "#lib/components/dashboard/PiggyBanksBox.svelte";
|
||||||
|
import SubscriptionsBox from "#lib/components/dashboard/SubscriptionsBox.svelte";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { today } from "#lib/dates.js";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
|
||||||
|
const assets = createQuery(() => ({
|
||||||
|
...accountsQuery("asset", period.start, period.end),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
}));
|
||||||
|
const chart = createQuery(() => ({
|
||||||
|
...accountsChartQuery(period.start, period.end),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const frontpage = $derived.by(() => {
|
||||||
|
const list = (assets.data ?? [])
|
||||||
|
.filter((a) => a.attributes.active !== false)
|
||||||
|
.sort((a, b) => (a.attributes.order ?? 0) - (b.attributes.order ?? 0));
|
||||||
|
if (!("frontpageAccounts" in session.prefs)) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
const ids = new Set(session.frontpageAccounts);
|
||||||
|
return list.filter((a) => ids.has(a.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const trends = $derived(
|
||||||
|
new Map(
|
||||||
|
(chart.data ?? []).map((set) => {
|
||||||
|
const points = seriesPoints(set, false);
|
||||||
|
return [
|
||||||
|
set.label,
|
||||||
|
{
|
||||||
|
marker: points.findIndex((p) => p.x === today()),
|
||||||
|
values: points.map((p) => p.y),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (
|
||||||
|
assets.isSuccess &&
|
||||||
|
!assets.isPlaceholderData &&
|
||||||
|
assets.data.length === 0
|
||||||
|
) {
|
||||||
|
location.assign("/new-user");
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageHeader subtitle="What's playing?" title="Dashboard" />
|
<PageHeader subtitle="What's playing?" title="Dashboard" />
|
||||||
|
|
||||||
|
<InfoBoxes />
|
||||||
|
|
||||||
|
<div class="mt-3 grid gap-3 lg:grid-cols-12">
|
||||||
|
<div class="flex min-w-0 flex-col gap-3 lg:col-span-8">
|
||||||
|
<AccountsChart />
|
||||||
|
<BudgetsChart />
|
||||||
|
<CategoriesChart />
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-col gap-3 lg:col-span-4">
|
||||||
|
{#each frontpage as account (account.id)}
|
||||||
|
{@const trend = trends.get(account.attributes.name)}
|
||||||
|
<AccountBox
|
||||||
|
{account}
|
||||||
|
marker={trend?.marker ?? -1}
|
||||||
|
trend={trend?.values ?? []}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<SubscriptionsBox />
|
||||||
|
<PiggyBanksBox />
|
||||||
|
<DebtsBox />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col gap-3">
|
||||||
|
<InsightBox kind="expense" />
|
||||||
|
<InsightBox kind="revenue" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
redirect(307, "/accounts/asset");
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AccountType } from "#lib/api/types.js";
|
||||||
|
import AccountsIndex from "#lib/components/accounts/AccountsIndex.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const type = $derived(page.params.type as AccountType);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key type}
|
||||||
|
<AccountsIndex {type} />
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AccountType } from "#lib/api/types.js";
|
||||||
|
import AccountForm from "#lib/components/accounts/AccountForm.svelte";
|
||||||
|
import {
|
||||||
|
indexUrl,
|
||||||
|
TYPE_ONE,
|
||||||
|
TYPE_TITLE,
|
||||||
|
} from "#lib/components/accounts/links.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const type = $derived(page.params.type as AccountType);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: indexUrl(type), label: TYPE_TITLE[type] },
|
||||||
|
{ label: `Create a new ${TYPE_ONE[type]}` },
|
||||||
|
]}
|
||||||
|
title="Create a new {TYPE_ONE[type]}"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#key type}
|
||||||
|
<AccountForm {type} />
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import type { Account, One } from "#lib/api/types.js";
|
||||||
|
import AccountForm from "#lib/components/accounts/AccountForm.svelte";
|
||||||
|
import {
|
||||||
|
indexUrl,
|
||||||
|
showUrl,
|
||||||
|
TYPE_TITLE,
|
||||||
|
typeOf,
|
||||||
|
} from "#lib/components/accounts/links.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? "");
|
||||||
|
const account = createQuery(() => ({
|
||||||
|
queryFn: () => api<One<Account>>(`/v1/accounts/${id}`),
|
||||||
|
queryKey: ["account-edit", id],
|
||||||
|
staleTime: 0,
|
||||||
|
}));
|
||||||
|
const item = $derived(account.data?.data);
|
||||||
|
const type = $derived(item ? typeOf(item.attributes) : "asset");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
crumbs={[
|
||||||
|
{ href: indexUrl(type), label: TYPE_TITLE[type] },
|
||||||
|
{ href: showUrl(id), label: item?.attributes.name ?? "" },
|
||||||
|
{ label: "Edit" },
|
||||||
|
]}
|
||||||
|
title={item ? `Edit ${item.attributes.name}` : "Edit account"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if account.isError}
|
||||||
|
<p class="text-expense">{account.error.message}</p>
|
||||||
|
{:else if item}
|
||||||
|
{#key item.id}
|
||||||
|
<AccountForm account={item} {type} />
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Reconcile from "#lib/components/accounts/Reconcile.svelte";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? "");
|
||||||
|
const start = $derived(page.params.start ?? period.start);
|
||||||
|
const end = $derived(page.params.end ?? period.end);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key `${id}-${start}-${end}`}
|
||||||
|
<Reconcile {end} {id} {start} />
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import AccountShow from "#lib/components/accounts/AccountShow.svelte";
|
||||||
|
import { period } from "#lib/period.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? "");
|
||||||
|
const start = $derived(page.params.start);
|
||||||
|
const end = $derived(page.params.end);
|
||||||
|
|
||||||
|
let applied = "";
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const key = `${start}|${end}`;
|
||||||
|
if (start && end && key !== applied) {
|
||||||
|
applied = key;
|
||||||
|
period.set(start <= end ? { end, start } : { end: start, start: end });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const moved = period.start !== start || period.end !== end;
|
||||||
|
untrack(() => {
|
||||||
|
if (start && end && moved) {
|
||||||
|
goto(`/accounts/show/${id}`, { replace: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key id}
|
||||||
|
<AccountShow {id} />
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AccountShow from "#lib/components/accounts/AccountShow.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? "");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key id}
|
||||||
|
<AccountShow everything {id} />
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AccountType } from "#lib/api/types.js";
|
||||||
|
import AccountsIndex from "#lib/components/accounts/AccountsIndex.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const type = $derived(page.params.type as AccountType);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key type}
|
||||||
|
<AccountsIndex active={false} {type} />
|
||||||
|
{/key}
|
||||||
+16
-16
@@ -16,14 +16,14 @@
|
|||||||
--ff-row-hover: oklch(0.96 0.012 240);
|
--ff-row-hover: oklch(0.96 0.012 240);
|
||||||
--ff-row-cursor: oklch(0.93 0.03 240);
|
--ff-row-cursor: oklch(0.93 0.03 240);
|
||||||
--ff-row-selected: oklch(0.9 0.05 240);
|
--ff-row-selected: oklch(0.9 0.05 240);
|
||||||
--ff-series-1: oklch(0.57 0.105 240);
|
--ff-series-1: #1777b8;
|
||||||
--ff-series-2: oklch(0.62 0.13 160);
|
--ff-series-2: #cb6620;
|
||||||
--ff-series-3: oklch(0.7 0.14 70);
|
--ff-series-3: #008f7d;
|
||||||
--ff-series-4: oklch(0.58 0.15 20);
|
--ff-series-4: #b08000;
|
||||||
--ff-series-5: oklch(0.55 0.12 300);
|
--ff-series-5: #bc4b87;
|
||||||
--ff-series-6: oklch(0.65 0.1 200);
|
--ff-series-6: #3f842e;
|
||||||
--ff-series-7: oklch(0.6 0.13 110);
|
--ff-series-7: #6b4cae;
|
||||||
--ff-series-8: oklch(0.6 0.08 260);
|
--ff-series-8: #b63132;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cladd-color-brand {
|
.cladd-color-brand {
|
||||||
@@ -41,14 +41,14 @@
|
|||||||
--ff-row-hover: oklch(0.25 0.012 240);
|
--ff-row-hover: oklch(0.25 0.012 240);
|
||||||
--ff-row-cursor: oklch(0.29 0.03 240);
|
--ff-row-cursor: oklch(0.29 0.03 240);
|
||||||
--ff-row-selected: oklch(0.33 0.06 240);
|
--ff-row-selected: oklch(0.33 0.06 240);
|
||||||
--ff-series-1: oklch(0.7 0.1 240);
|
--ff-series-1: #348dcf;
|
||||||
--ff-series-2: oklch(0.72 0.12 160);
|
--ff-series-2: #d5753a;
|
||||||
--ff-series-3: oklch(0.8 0.13 75);
|
--ff-series-3: #0f9b89;
|
||||||
--ff-series-4: oklch(0.7 0.13 25);
|
--ff-series-4: #b68b16;
|
||||||
--ff-series-5: oklch(0.7 0.11 300);
|
--ff-series-5: #cd6199;
|
||||||
--ff-series-6: oklch(0.75 0.09 200);
|
--ff-series-6: #4e943e;
|
||||||
--ff-series-7: oklch(0.75 0.12 115);
|
--ff-series-7: #876cca;
|
||||||
--ff-series-8: oklch(0.72 0.07 260);
|
--ff-series-8: #c44844;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
Reference in new issue
Block a user