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,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>
|
||||
Reference in new issue
Block a user