feat(currencies,preferences,export,misc): currencies, rates, attachments, link types, preferences, export, administrations, recurring and webhooks

This commit is contained in:
hh committed 2026-10-16 03:42:24 +00:00
1 parent 89bbf56062
commit f8a5fd6c6d
47 files changed
+4495 -7

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
label,
for: target,
problem,
help,
children,
}: {
label: string;
for?: string;
problem?: string;
help?: string;
children: Snippet;
} = $props();
</script>
<div class="@container">
<div class="grid gap-1 @lg:grid-cols-[11rem_minmax(0,1fr)] @lg:gap-3">
<label
class="font-medium text-[13px] text-cladd-fg @lg:pt-1.5 @lg:text-right"
for={target}
>{label}</label
>
<div class="flex min-w-0 flex-col gap-1">
{@render children()}
{#if problem}
<p class="text-[12px] text-expense">{problem}</p>
{:else if help}
<p class="text-[12px] text-cladd-fg-soft">{help}</p>
{/if}
</div>
</div>
</div>
+76
View File
@@ -0,0 +1,76 @@
<script generics="T" lang="ts">
import { ContextMenu } from "bits-ui";
import type { Snippet } from "svelte";
import ContextMenuContent from "#lib/components/ui/context-menu/context-menu-content.svelte";
import type { ListNav } from "#lib/list-nav.svelte.js";
let {
nav,
key,
head,
row,
menu,
empty = "Nothing here yet",
class: className = "",
}: {
nav: ListNav<T>;
key: (item: T) => string;
head: Snippet;
row: Snippet<[T, number]>;
menu?: Snippet<[T]>;
empty?: string;
class?: string;
} = $props();
</script>
{#snippet rows()}
{#each nav.items as item, i (key(item))}
<tr
class="h-7 border-cladd-outline/60 border-b last:border-b-0 {nav.cursor ===
i
? "bg-row-cursor"
: "hover:bg-row-hover"}"
id={nav.rowId(i)}
onclick={(event) => nav.click(i, event)}
oncontextmenu={() => (nav.cursor = i)}
>
{@render row(item, i)}
</tr>
{:else}
<tr>
<td class="px-2 py-3 text-cladd-fg-soft" colspan="99">{empty}</td>
</tr>
{/each}
{/snippet}
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px] {className}">
<thead>
<tr>
{@render head()}
</tr>
</thead>
{#if menu}
<ContextMenu.Root>
<ContextMenu.Trigger>
{#snippet child({
props,
})}
<tbody {...props}>
{@render rows()}
</tbody>
{/snippet}
</ContextMenu.Trigger>
<ContextMenuContent class="w-56">
{#if nav.current}
{@render menu(nav.current)}
{/if}
</ContextMenuContent>
</ContextMenu.Root>
{:else}
<tbody>
{@render rows()}
</tbody>
{/if}
</table>
</div>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import type { Arm } from "./action.svelte.js";
let {
arm,
id,
run,
label = "Delete",
}: { arm: Arm; id: string; run: () => unknown; label?: string } = $props();
const armed = $derived(arm.key === id);
</script>
<ContextMenuItem
onSelect={(event) => {
if (!armed) {
event.preventDefault();
}
arm.press(id, run);
}}
variant="destructive"
>
{armed ? "Press again to delete" : label}
<span class="ml-auto kbd">D</span>
</ContextMenuItem>
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
href,
label = "Open the old interface",
children,
}: { href: string; label?: string; children: Snippet } = $props();
</script>
<p class="text-[12px] text-cladd-fg-soft">
{@render children()}
<a class="text-brand hover:underline" data-sveltekit-reload {href}>{label}</a>
</p>
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Action } from "./action.svelte.js";
let { action, busy = "Saving" }: { action: Action; busy?: string } = $props();
</script>
{#if action.busy}
<span class="text-[12px] text-cladd-fg-soft">{busy}</span>
{:else if action.message}
<span
class="text-[12px] {action.failed ? "text-expense" : "text-income"}"
role="status"
>{action.message}</span
>
{/if}
+66
View File
@@ -0,0 +1,66 @@
import { ApiError } from "#lib/api/client.js";
const UNAUTHORIZED = 401;
const ARM_MS = 3000;
export function errorText(error: unknown): string {
if (error instanceof ApiError) {
if (error.status === UNAUTHORIZED) {
return "Only the owner can do this";
}
return Object.values(error.errors)[0]?.[0] ?? error.message;
}
return error instanceof Error ? error.message : String(error);
}
export class Action {
busy = $state(false);
message = $state("");
failed = $state(false);
errors = $state<Record<string, string[]>>({});
async run(task: () => Promise<unknown>, done = "Saved"): Promise<boolean> {
this.busy = true;
this.message = "";
this.failed = false;
this.errors = {};
try {
await task();
this.message = done;
return true;
} catch (error) {
this.failed = true;
this.message = errorText(error);
this.errors = error instanceof ApiError ? error.errors : {};
return false;
} finally {
this.busy = false;
}
}
field(name: string): string | undefined {
return this.errors[name]?.[0];
}
reset() {
this.message = "";
this.failed = false;
}
}
export class Arm {
key = $state<string | null>(null);
private timer: ReturnType<typeof setTimeout> | undefined;
press(key: string, run: () => unknown) {
clearTimeout(this.timer);
if (this.key === key) {
this.key = null;
return run();
}
this.key = key;
this.timer = setTimeout(() => {
this.key = null;
}, ARM_MS);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { ApiError, apiHeaders, apiUrl, type Query } from "#lib/api/client.js";
const FILENAME = /filename="?([^";]+)"?/;
const REVOKE_MS = 1000;
export async function download(
path: string,
query: Query,
fallback: string
): Promise<number> {
const response = await fetch(apiUrl(path, query), {
credentials: "same-origin",
headers: apiHeaders(false),
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as {
message?: string;
} | null;
throw new ApiError(response.status, body?.message ?? response.statusText);
}
const blob = await response.blob();
const name =
response.headers.get("Content-Disposition")?.match(FILENAME)?.[1] ??
fallback;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), REVOKE_MS);
return blob.size;
}
+45
View File
@@ -0,0 +1,45 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { Currency, One } from "#lib/api/types.js";
interface Me {
email: string;
role?: string | null;
}
const FOREVER = Number.POSITIVE_INFINITY;
export const misc = {
config: (key: string) => ({
queryFn: () =>
api<{ data: { value: unknown } }>(`/v1/configuration/${key}`).then(
(body) => body.data.value
),
queryKey: ["configuration", key],
staleTime: FOREVER,
}),
currencies: () => ({
queryFn: () => all<Currency>("/v1/currencies"),
queryKey: ["currencies"],
}),
me: () => ({
queryFn: () =>
api<One<Me>>("/v1/about/user").then((body) => body.data.attributes),
queryKey: ["about-user"],
staleTime: FOREVER,
}),
};
export const dateOnly = (value?: string | null) => value?.slice(0, 10);
export function sizeText(bytes: number | null | undefined): string {
const value = bytes ?? 0;
const units = ["B", "KB", "MB", "GB"];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
}
+11
View File
@@ -0,0 +1,11 @@
export const th =
"h-8 whitespace-nowrap border-cladd-outline border-b px-2 text-left font-semibold text-[12px] text-cladd-fg-soft";
export const thNum = `${th} text-right`;
export const td = "px-2 py-1 align-middle";
export const tdNum = `${td} num text-right`;
export const link = "text-brand hover:underline";
export const select =
"cladd-cut cladd-hoverable cladd-focusable-input h-cladd-md w-full min-w-0 rounded-cladd-md px-2 font-medium text-cladd-fg text-cladd-xs";
export const textarea =
"cladd-cut cladd-hoverable cladd-focusable-input min-h-24 w-full min-w-0 rounded-cladd-md px-2.5 py-1.5 font-medium text-cladd-fg text-cladd-xs";
export const muted = "text-[12px] text-cladd-fg-soft";
+14
View File
@@ -0,0 +1,14 @@
import { goto } from "$app/navigation";
import { page } from "$app/state";
export function setQuery(values: Record<string, string | null | undefined>) {
const url = new URL(page.url.href);
for (const [key, value] of Object.entries(values)) {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
}
return goto(url, { replace: true, state: page.state });
}
+4 -2
View File
@@ -36,10 +36,12 @@
await invalidate(); await invalidate();
} }
function cycleTheme() { async function cycleTheme() {
const order = ["system", "light", "dark"] as const; const order = ["system", "light", "dark"] as const;
const current = userPrefersMode.current ?? "system"; const current = userPrefersMode.current ?? "system";
setMode(order[(order.indexOf(current) + 1) % order.length]); const next = order[(order.indexOf(current) + 1) % order.length];
setMode(next);
await session.setPreference("darkMode", next === "system" ? "browser" : next);
} }
function focusSearch() { function focusSearch() {
+1 -4
View File
@@ -95,12 +95,9 @@ function toDate(value: string | Date): Date {
if (value instanceof Date) { if (value instanceof Date) {
return value; return value;
} }
if (value.length === 10) { const [y, m, d] = value.slice(0, 10).split("-").map(Number);
const [y, m, d] = value.split("-").map(Number);
return new Date(y, m - 1, d); return new Date(y, m - 1, d);
} }
return new Date(value);
}
export function formatDate( export function formatDate(
value: string | Date | null | undefined, value: string | Date | null | undefined,
+11
View File
@@ -1,3 +1,4 @@
import { setMode } from "mode-watcher";
import { import {
api, api,
keepAlive, keepAlive,
@@ -98,6 +99,15 @@ class Session {
configureFormat(this.locale, this.anonymous); configureFormat(this.locale, this.anonymous);
} }
applyTheme() {
const theme = this.prefs.darkMode;
if (theme === "light" || theme === "dark") {
setMode(theme);
} else if (theme === "browser") {
setMode("system");
}
}
async boot(): Promise<string | null> { async boot(): Promise<string | null> {
const state = await ping(); const state = await ping();
if (state !== "ok") { if (state !== "ok") {
@@ -113,6 +123,7 @@ class Session {
this.version = about.data?.version ?? ""; this.version = about.data?.version ?? "";
this.apiVersion = about.data?.api_version ?? ""; this.apiVersion = about.data?.api_version ?? "";
this.email = user.data.attributes.email; this.email = user.data.attributes.email;
this.applyTheme();
this.ready = true; this.ready = true;
keepAlive(); keepAlive();
return takeReturnPath(); return takeReturnPath();
+2 -1
View File
@@ -20,6 +20,7 @@ export const SPA_EXACT = [
"/recurring", "/recurring",
"/webhooks/index", "/webhooks/index",
"/settings/links", "/settings/links",
"/settings/links/create",
]; ];
export const SPA_PREFIXES = [ export const SPA_PREFIXES = [
@@ -41,10 +42,10 @@ export const SPA_PREFIXES = [
"/recurring/show/", "/recurring/show/",
"/webhooks/show/", "/webhooks/show/",
"/settings/links/show/", "/settings/links/show/",
"/settings/links/edit/",
]; ];
export const FIREFLY_PREFIXES = [ export const FIREFLY_PREFIXES = [
"/accounts/reconcile/",
"/attachments/download/", "/attachments/download/",
"/attachments/view/", "/attachments/view/",
"/transactions/convert/", "/transactions/convert/",
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import type { Item } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import OldUi from "#lib/components/misc/OldUi.svelte";
import { muted, td, th } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
import { type Group, groupQueries, roleText } from "./groups.js";
const query = createQuery(groupQueries.all);
const edit = (item: Item<Group>) => goto(`/administrations/edit/${item.id}`);
const nav: ListNav<Item<Group>> = new ListNav({
edit,
id: "administrations",
items: () => query.data ?? [],
key: (item) => item.id,
open: edit,
});
nav.use();
</script>
<PageHeader
crumbs={[{ label: "Financial administrations" }]}
subtitle="Overview"
title="Financial administrations"
/>
<div class="flex flex-col gap-3">
<OldUi href="/administrations?ffui=old">
Switching the active administration is not possible through the Firefly III
API.
</OldUi>
<Box body="p-0" title="Financial administrations">
{#if query.isPending}
<p class="p-3 {muted}">Loading administrations</p>
{:else if query.error}
<p class="p-3 text-expense">{errorText(query.error)}</p>
{:else}
<ListTable key={(item) => item.id} {nav}>
{#snippet head()}
<th class={th}>Title</th>
<th class={th}>Primary currency</th>
<th class={th}>Members</th>
<th class="{th} w-24">In use</th>
{/snippet}
{#snippet row(
item
)}
{@const g = item.attributes}
<td class={td}>
<a class="hover:underline" href="/administrations/edit/{item.id}"
>{g.title}</a
>
</td>
<td class={td}>
{g.primary_currency_name ?? ""}
({g.primary_currency_code})
</td>
<td class={td}>
{#if g.can_see_members}
{#each g.members ?? [] as member (member.user_id)}
<div>
{member.user_email}{member.you ? " (you)" : ""}
<span class="text-cladd-fg-soft"
>· {roleText(member.roles)}</span
>
</div>
{/each}
{:else}
<span class="text-cladd-fg-soft">Hidden</span>
{/if}
</td>
<td class={td}>{g.in_use ? "Yes" : "No"}</td>
{/snippet}
{#snippet menu(
item
)}
<ContextMenuItem onSelect={() => edit(item)}>
Edit
<span class="ml-auto kbd">e</span>
</ContextMenuItem>
{/snippet}
</ListTable>
{/if}
</Box>
</div>
@@ -0,0 +1,113 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import Box from "#lib/components/Box.svelte";
import { Action, errorText } from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import { misc } from "#lib/components/misc/queries.js";
import Status from "#lib/components/misc/Status.svelte";
import { muted, select } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import Input from "#lib/components/ui/input/input.svelte";
import { useKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { groupQueries } from "../../groups.js";
const id = $derived(page.params.id ?? "");
const query = createQuery(() => groupQueries.one(id));
const currencies = createQuery(misc.currencies);
const group = $derived(query.data?.data.attributes);
const enabled = $derived(
(currencies.data ?? [])
.filter(
(c) =>
c.attributes.enabled ||
c.attributes.code === group?.primary_currency_code
)
.sort((a, b) => a.attributes.name.localeCompare(b.attributes.name))
);
let name = $derived(group?.title ?? "");
let code = $derived(group?.primary_currency_code ?? "");
const save = new Action();
let element: HTMLFormElement | undefined = $state();
async function submit(event: SubmitEvent) {
event.preventDefault();
const ok = await save.run(() =>
api(`/v1/user-groups/${id}`, {
body: { primary_currency_code: code, title: name },
method: "PUT",
})
);
if (ok) {
await Promise.all([invalidate(), session.loadCurrencies()]);
await goto("/administrations");
}
}
const title = $derived(
group
? `Edit financial administration "${group.title}"`
: "Edit financial administration"
);
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: "Submit",
run: () => element?.requestSubmit(),
},
]);
</script>
<PageHeader
crumbs={[
{ href: "/administrations", label: "Financial administrations" },
{ label: title },
]}
subtitle={title}
title="Financial administrations"
/>
{#if group}
<form class="max-w-3xl" onsubmit={submit} bind:this={element}>
<Box {title}>
<div class="flex flex-col gap-3">
<Field for="title" label="Title" problem={save.field("title")}>
<Input id="title" required bind:value={name} />
</Field>
<Field
for="currency"
help="Changing the primary currency can take a while, because amounts may need to be converted."
label="Primary currency"
problem={save.field("primary_currency_code")}
>
<select class={select} id="currency" bind:value={code}>
{#each enabled as currency (currency.id)}
<option value={currency.attributes.code}>
{currency.attributes.name}
({currency.attributes.code})
</option>
{/each}
</select>
</Field>
</div>
{#snippet footer()}
<Button disabled={save.busy} size="sm" type="submit">Submit</Button>
<Status action={save} />
{/snippet}
</Box>
</form>
{:else if query.error}
<p class="text-expense">{errorText(query.error)}</p>
{:else}
<p class={muted}>Loading administration</p>
{/if}
+45
View File
@@ -0,0 +1,45 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { One, UserGroup } from "#lib/api/types.js";
export interface Member {
roles?: string[];
user_email: string;
user_id: string;
you?: boolean;
}
export type Group = UserGroup & {
members?: Member[];
primary_currency_name?: string;
};
const ROLES: Record<string, string> = {
full: "Full access",
mng_budgets: "Manage budgets",
mng_currencies: "Manage currencies",
mng_meta: "Manage classification and meta-data",
mng_piggies: "Manage piggy banks",
mng_recurring: "Manage recurring transactions",
mng_rules: "Manage rules",
mng_subscriptions: "Manage subscriptions",
mng_trx: "Manage transactions",
mng_webhooks: "Manage webhooks",
owner: "Owner",
ro: "Read-only",
view_reports: "View reports",
};
export const roleText = (roles: string[] = []) =>
roles.map((role) => ROLES[role] ?? role).join(", ");
export const groupQueries = {
all: () => ({
queryFn: () => all<Group>("/v1/user-groups"),
queryKey: ["user-groups"],
}),
one: (id: string) => ({
queryFn: () => api<One<Group>>(`/v1/user-groups/${id}`),
queryKey: ["user-group", id],
}),
};
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Attachment, Item } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import MenuDelete from "#lib/components/misc/MenuDelete.svelte";
import { dateOnly, sizeText } from "#lib/components/misc/queries.js";
import {
link,
muted,
td,
tdNum,
th,
thNum,
} from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import { formatDate } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
import AttachedTo from "./AttachedTo.svelte";
const query = createQuery(() => ({
queryFn: () => all<Attachment>("/v1/attachments"),
queryKey: ["attachments"],
}));
const attachments = $derived(
[...(query.data ?? [])].sort((a, b) =>
(b.attributes.created_at ?? "").localeCompare(
a.attributes.created_at ?? ""
)
)
);
const arm = new Arm();
let notes = $state<Record<string, string>>({});
const view = (item: Item<Attachment>) =>
window.open(`/attachments/view/${item.id}`, "_blank", "noopener");
const save = (item: Item<Attachment>) =>
location.assign(`/attachments/download/${item.id}`);
const edit = (item: Item<Attachment>) => goto(`/attachments/edit/${item.id}`);
async function remove(item: Item<Attachment>) {
try {
await api(`/v1/attachments/${item.id}`, { method: "DELETE" });
await invalidate();
} catch (error) {
notes = { ...notes, [item.id]: errorText(error) };
}
}
const nav: ListNav<Item<Attachment>> = new ListNav({
edit,
extra: () => [
{
group: "Attachments",
keys: "d",
label: "Download file",
run: () => nav.current && save(nav.current),
},
{
group: "Attachments",
keys: "D",
label: "Delete attachment (press twice)",
run: () => {
const item = nav.current;
if (item) {
arm.press(item.id, () => remove(item));
}
},
},
],
id: "attachments",
items: () => attachments,
key: (item) => item.id,
open: view,
});
nav.use();
</script>
<PageHeader crumbs={[{ label: "Attachments" }]} title="Attachments" />
<Box body="p-0" title="List of all attachments">
{#if query.isPending}
<p class="p-3 {muted}">Loading attachments</p>
{:else if query.error}
<p class="p-3 text-expense">{errorText(query.error)}</p>
{:else}
<ListTable
empty="There are no attachments yet. Add them to a transaction, account or budget when you edit it."
key={(item) => item.id}
{nav}
>
{#snippet head()}
<th class={th}>File name</th>
<th class={th}>Title</th>
<th class="{thNum} w-24">Size</th>
<th class="{th} hidden sm:table-cell w-40">Type</th>
<th class={th}>Attached to</th>
<th class={th}>Notes</th>
<th class="{th} hidden sm:table-cell w-28">Uploaded</th>
<th class={th}></th>
{/snippet}
{#snippet row(
item
)}
{@const a = item.attributes}
<td class={td}>
<a
class={link}
data-sveltekit-reload
href="/attachments/view/{item.id}"
rel="noopener"
target="_blank"
>{a.filename}</a
>
</td>
<td class={td}>{a.title ?? ""}</td>
<td class={tdNum}>{sizeText(a.size)}</td>
<td class="{td} hidden sm:table-cell text-cladd-fg-soft">{a.mime}</td>
<td class={td}>
<AttachedTo id={a.attachable_id} type={a.attachable_type} />
</td>
<td class="{td} whitespace-pre-line text-[12px] text-cladd-fg-soft">
{a.notes ?? ""}
</td>
<td class="{td} hidden sm:table-cell num">
{formatDate(dateOnly(a.created_at))}
</td>
<td class="{td} text-[12px]">
{#if arm.key === item.id}
<span class="text-expense">Press D again to delete</span>
{:else if notes[item.id]}
<span class="text-expense">{notes[item.id]}</span>
{/if}
</td>
{/snippet}
{#snippet menu(
item
)}
<ContextMenuItem onSelect={() => view(item)}>
View
<span class="ml-auto kbd">↵</span>
</ContextMenuItem>
<ContextMenuItem onSelect={() => save(item)}>
Download
<span class="ml-auto kbd">d</span>
</ContextMenuItem>
<ContextMenuItem onSelect={() => edit(item)}>
Edit
<span class="ml-auto kbd">e</span>
</ContextMenuItem>
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
{/snippet}
</ListTable>
{/if}
</Box>
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { One, TransactionGroup } from "#lib/api/types.js";
import { link } from "#lib/components/misc/styles.js";
interface Target {
href: string;
path: string;
title: string;
}
type Named = Record<string, unknown>;
let { type, id }: { type?: string | null; id?: string | null } = $props();
const TARGETS: Record<string, Target> = {
Account: { href: "/accounts/show/", path: "/v1/accounts/", title: "name" },
Bill: { href: "/subscriptions/show/", path: "/v1/bills/", title: "name" },
Budget: { href: "/budgets/show/", path: "/v1/budgets/", title: "name" },
Category: {
href: "/categories/show/",
path: "/v1/categories/",
title: "name",
},
PiggyBank: {
href: "/piggy-banks/show/",
path: "/v1/piggy-banks/",
title: "name",
},
Recurrence: {
href: "/recurring/show/",
path: "/v1/recurrences/",
title: "title",
},
Tag: { href: "/tags/show/", path: "/v1/tags/", title: "tag" },
};
async function resolve(): Promise<{ href: string; label: string }> {
if (type === "TransactionJournal") {
const group = await api<One<TransactionGroup>>(
`/v1/transaction-journals/${id}`
);
const split = group.data.attributes.transactions.find(
(s) => s.transaction_journal_id === id
);
return {
href: `/transactions/show/${group.data.id}`,
label:
split?.description ?? group.data.attributes.group_title ?? `#${id}`,
};
}
const target = TARGETS[type ?? ""];
const body = await api<One<Named>>(`${target.path}${id}`);
return {
href: `${target.href}${id}`,
label: String(body.data.attributes[target.title] ?? `#${id}`),
};
}
const known = $derived(
type === "TransactionJournal" || Boolean(TARGETS[type ?? ""])
);
const query = createQuery(() => ({
enabled: known && Boolean(id),
queryFn: resolve,
queryKey: ["attachable", type, id],
}));
</script>
{#if query.data}
<a class={link} href={query.data.href}>{query.data.label}</a>
{:else if query.error || !known}
<span class="text-cladd-fg-soft">{type} #{id}</span>
{/if}
@@ -0,0 +1,160 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Attachment, One } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { Action, errorText } from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import { sizeText } from "#lib/components/misc/queries.js";
import Status from "#lib/components/misc/Status.svelte";
import { link, muted, textarea } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import Input from "#lib/components/ui/input/input.svelte";
import { useKeys } from "#lib/keys.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import AttachedTo from "../../AttachedTo.svelte";
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
queryFn: () => api<One<Attachment>>(`/v1/attachments/${id}`),
queryKey: ["attachment", id],
}));
const attachment = $derived(query.data?.data.attributes);
let title = $derived(attachment?.title ?? "");
let notes = $derived(attachment?.notes ?? "");
const save = new Action();
const removal = new Action();
let element: HTMLFormElement | undefined = $state();
async function submit(event: SubmitEvent) {
event.preventDefault();
const cleanNotes = notes.trim();
const cleanTitle = title.trim();
const kept = !cleanNotes && Boolean(attachment?.notes);
const ok = await save.run(async () => {
await api(`/v1/attachments/${id}`, {
body: {
...(cleanNotes ? { notes: cleanNotes } : {}),
...(cleanTitle ? { title: cleanTitle } : {}),
},
method: "PUT",
});
await invalidate();
});
if (ok && kept) {
save.message =
"Saved. The old notes stay: Firefly III cannot remove notes through its API";
}
}
async function remove() {
const ok = await removal.run(
() => api(`/v1/attachments/${id}`, { method: "DELETE" }),
"Deleted"
);
if (ok) {
await invalidate();
await goto("/attachments");
}
}
const pageTitle = $derived(
attachment ? `Edit attachment "${attachment.filename}"` : "Edit attachment"
);
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: "Update attachment",
run: () => element?.requestSubmit(),
},
{
group: "Attachments",
keys: "d",
label: "Download file",
run: () => location.assign(`/attachments/download/${id}`),
},
]);
</script>
<PageHeader
crumbs={[
{ href: "/attachments", label: "Attachments" },
{ label: pageTitle },
]}
title={pageTitle}
/>
{#if attachment}
<form class="max-w-3xl" onsubmit={submit} bind:this={element}>
<Box title="Attachment">
<div class="flex flex-col gap-3">
<Field label="File name">
<p class="pt-1.5">
<a
class={link}
data-sveltekit-reload
href="/attachments/view/{id}"
rel="noopener"
target="_blank"
>{attachment.filename}</a
>
<a
class="ml-3 text-[12px] {link}"
data-sveltekit-reload
href="/attachments/download/{id}"
>Download</a
>
</p>
</Field>
<Field label="Type">
<p class="pt-1.5">{attachment.mime}</p>
</Field>
<Field label="Size">
<p class="num pt-1.5">{sizeText(attachment.size)}</p>
</Field>
<Field label="Attached to">
<p class="pt-1.5">
<AttachedTo
id={attachment.attachable_id}
type={attachment.attachable_type}
/>
</p>
</Field>
<Field for="title" label="Title" problem={save.field("title")}>
<Input id="title" bind:value={title} />
</Field>
<Field for="notes" label="Notes" problem={save.field("notes")}>
<textarea
class={textarea}
id="notes"
rows="5"
bind:value={notes}
></textarea>
</Field>
</div>
{#snippet footer()}
<Button disabled={save.busy} size="sm" type="submit"
>Update attachment</Button
>
<Status action={save} />
<span class="ml-auto flex items-center gap-2">
<Status action={removal} busy="Deleting" />
<ConfirmButton onconfirm={remove}>Delete attachment</ConfirmButton>
</span>
{/snippet}
</Box>
</form>
{:else if query.error}
<p class="text-expense">{errorText(query.error)}</p>
{:else}
<p class={muted}>Loading attachment</p>
{/if}
+213
View File
@@ -0,0 +1,213 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { ApiError, api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Currency, Item } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import MenuDelete from "#lib/components/misc/MenuDelete.svelte";
import { misc } from "#lib/components/misc/queries.js";
import { muted, td, tdNum, th, thNum } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import { ListNav } from "#lib/list-nav.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
const CONFLICT = 409;
const query = createQuery(misc.currencies);
const me = createQuery(misc.me);
const owner = $derived(me.data?.role === "owner");
const rank = (c: Currency) => {
if (c.primary) {
return 0;
}
return c.enabled ? 1 : 2;
};
const currencies = $derived(
[...(query.data ?? [])].sort(
(a, b) =>
rank(a.attributes) - rank(b.attributes) ||
a.attributes.code.localeCompare(b.attributes.code)
)
);
let notes = $state<Record<string, string>>({});
let pending = $state<Record<string, boolean>>({});
const arm = new Arm();
async function act(item: Item<Currency>, path: string, method = "POST") {
notes = { ...notes, [item.id]: "" };
try {
await api(`/v1/currencies/${item.attributes.code}${path}`, { method });
await Promise.all([invalidate(), session.loadCurrencies()]);
} catch (error) {
const text =
error instanceof ApiError && error.status === CONFLICT
? "Still in use or the last enabled currency"
: errorText(error);
notes = { ...notes, [item.id]: text };
}
}
async function toggle(item: Item<Currency>) {
const next = !item.attributes.enabled;
pending = { ...pending, [item.id]: next };
await act(item, next ? "/enable" : "/disable");
const { [item.id]: _, ...rest } = pending;
pending = rest;
}
const makePrimary = (item: Item<Currency>) => act(item, "/primary");
const remove = (item: Item<Currency>) => act(item, "", "DELETE");
const edit = (item: Item<Currency>) => goto(`/currencies/edit/${item.id}`);
const nav: ListNav<Item<Currency>> = new ListNav({
edit,
extra: () => [
{
group: "Currencies",
keys: "t",
label: "Enable or disable currency",
run: () => nav.current && toggle(nav.current),
},
{
group: "Currencies",
keys: "p",
label: "Make primary currency",
run: () => nav.current && makePrimary(nav.current),
},
{
group: "Currencies",
keys: "D",
label: "Delete currency (press twice)",
run: () => {
const item = nav.current;
if (item) {
arm.press(item.id, () => remove(item));
}
},
},
{
group: "Currencies",
keys: "c",
label: "Create a new currency",
run: () => goto("/currencies/create"),
},
],
id: "currencies",
items: () => currencies,
key: (item) => item.id,
open: edit,
});
nav.use();
</script>
<PageHeader crumbs={[{ label: "Currencies" }]} title="Currencies">
{#snippet actions()}
{#if owner}
<Button href="/currencies/create" size="sm">Create a new currency</Button>
{/if}
{/snippet}
</PageHeader>
<Box body="p-0" title="Currencies">
{#snippet tools()}
{#if me.data && !owner}
<span class={muted}
>Ask the site owner to add, remove or edit currencies</span
>
{/if}
{/snippet}
{#if query.isPending}
<p class="p-3 {muted}">Loading currencies</p>
{:else if query.error}
<p class="p-3 text-expense">{errorText(query.error)}</p>
{:else}
<ListTable key={(item) => item.id} {nav}>
{#snippet head()}
<th class="{th} w-20">Enabled</th>
<th class="{th} w-16">Code</th>
<th class={th}>Currency</th>
<th class="{th} hidden sm:table-cell w-20">Symbol</th>
<th class="{thNum} hidden sm:table-cell w-36">Number of decimals</th>
<th class="{th} w-40">Primary</th>
<th class={th}></th>
{/snippet}
{#snippet row(
item
)}
{@const c = item.attributes}
<td class={td}>
<input
aria-label="Enabled"
checked={pending[item.id] ?? c.enabled}
class="size-3.5 accent-brand align-middle"
disabled={c.primary}
onchange={() => toggle(item)}
type="checkbox"
>
</td>
<td class="{td} num {c.enabled ? "" : "text-cladd-fg-soft"}">
{c.code}
</td>
<td class="{td} {c.enabled ? "" : "text-cladd-fg-soft"}">
<a class="hover:underline" href="/currencies/edit/{item.id}"
>{c.name}</a
>
</td>
<td
class="{td} hidden sm:table-cell {c.enabled
? ""
: "text-cladd-fg-soft"}"
>
{c.symbol}
</td>
<td class="{tdNum} hidden sm:table-cell">{c.decimal_places}</td>
<td class={td}>
{#if c.primary}
<span class="font-semibold text-brand">Primary currency</span>
{:else if c.enabled}
<Button
onclick={() => makePrimary(item)}
size="xs"
variant="outline"
>Make primary</Button
>
{/if}
</td>
<td class="{td} text-[12px]">
{#if arm.key === item.id}
<span class="text-expense">Press D again to delete</span>
{:else if notes[item.id]}
<span class="text-expense">{notes[item.id]}</span>
{/if}
</td>
{/snippet}
{#snippet menu(
item
)}
<ContextMenuItem onSelect={() => edit(item)}>
Edit
<span class="ml-auto kbd">e</span>
</ContextMenuItem>
{#if !item.attributes.primary}
<ContextMenuItem onSelect={() => toggle(item)}>
{item.attributes.enabled ? "Disable" : "Enable"}
<span class="ml-auto kbd">t</span>
</ContextMenuItem>
<ContextMenuItem onSelect={() => makePrimary(item)}>
Make primary
<span class="ml-auto kbd">p</span>
</ContextMenuItem>
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
{/if}
{/snippet}
</ListTable>
{/if}
</Box>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Currency, Item } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { Action } from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import Status from "#lib/components/misc/Status.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import Input from "#lib/components/ui/input/input.svelte";
import { useKeys } from "#lib/keys.svelte.js";
import { session } from "#lib/session.svelte.js";
import { goto } from "$app/navigation";
let { item }: { item?: Item<Currency> } = $props();
const form = $state(
untrack(() => ({
code: item?.attributes.code ?? "",
decimal_places: item?.attributes.decimal_places ?? 2,
enabled: item?.attributes.enabled ?? true,
name: item?.attributes.name ?? "",
symbol: item?.attributes.symbol ?? "",
}))
);
const save = new Action();
const removal = new Action();
let element: HTMLFormElement | undefined = $state();
async function done() {
await Promise.all([invalidate(), session.loadCurrencies()]);
await goto("/currencies");
}
async function submit(event?: SubmitEvent) {
event?.preventDefault();
const body = { ...form, decimal_places: Number(form.decimal_places) };
const ok = await save.run(() =>
item
? api(`/v1/currencies/${item.attributes.code}`, { body, method: "PUT" })
: api("/v1/currencies", { body, method: "POST" })
);
if (ok) {
await done();
}
}
async function remove() {
if (
item &&
(await removal.run(
() =>
api(`/v1/currencies/${item.attributes.code}`, { method: "DELETE" }),
"Deleted"
))
) {
await done();
}
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: item ? "Update currency" : "Store new currency",
run: () => element?.requestSubmit(),
},
]);
</script>
<form class="max-w-3xl" onsubmit={submit} bind:this={element}>
<Box title="Mandatory fields">
<div class="flex flex-col gap-3">
<Field for="name" label="Name" problem={save.field("name")}>
<Input id="name" maxlength={48} required bind:value={form.name} />
</Field>
<Field for="symbol" label="Symbol" problem={save.field("symbol")}>
<Input id="symbol" maxlength={51} required bind:value={form.symbol} />
</Field>
<Field for="code" label="Code" problem={save.field("code")}>
<Input
class="num uppercase"
id="code"
maxlength={51}
required
bind:value={form.code}
/>
</Field>
<Field
for="decimal_places"
label="Number of decimals"
problem={save.field("decimal_places")}
>
<Input
class="num w-24"
id="decimal_places"
max={12}
min={0}
required
type="number"
bind:value={form.decimal_places}
/>
</Field>
{#if item}
<Field for="enabled" label="Enabled">
<input
class="mt-2 size-3.5 accent-brand"
id="enabled"
type="checkbox"
bind:checked={form.enabled}
>
</Field>
{/if}
</div>
{#snippet footer()}
<Button disabled={save.busy} size="sm" type="submit">
{item ? "Update currency" : "Store new currency"}
</Button>
<Status action={save} />
{#if item}
<span class="ml-auto flex items-center gap-2">
<Status action={removal} busy="Deleting" />
<ConfirmButton onconfirm={remove}>Delete currency</ConfirmButton>
</span>
{/if}
{/snippet}
</Box>
</form>
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import CurrencyForm from "../CurrencyForm.svelte";
</script>
<PageHeader
crumbs={[
{ href: "/currencies", label: "Currencies" },
{ label: "Create a new currency" },
]}
title="Create a new currency"
/>
<CurrencyForm />
@@ -0,0 +1,31 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { errorText } from "#lib/components/misc/action.svelte.js";
import { misc } from "#lib/components/misc/queries.js";
import { muted } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { page } from "$app/state";
import CurrencyForm from "../../CurrencyForm.svelte";
const query = createQuery(misc.currencies);
const item = $derived(query.data?.find((c) => c.id === page.params.id));
const title = $derived(
item ? `Edit currency "${item.attributes.name}"` : "Edit currency"
);
</script>
<PageHeader
crumbs={[{ href: "/currencies", label: "Currencies" }, { label: title }]}
{title}
/>
{#if item}
{#key item.id}
<CurrencyForm {item} />
{/key}
{:else if query.error}
<p class="text-expense">{errorText(query.error)}</p>
{:else if query.isPending}
<p class={muted}>Loading currency</p>
{:else}
<p class={muted}>There is no currency with this id.</p>
{/if}
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { all } from "#lib/api/lists.js";
import type { ExchangeRate, Item } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import OldUi from "#lib/components/misc/OldUi.svelte";
import { dateOnly, misc } from "#lib/components/misc/queries.js";
import {
link,
muted,
td,
tdNum,
th,
thNum,
} from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { formatDate, formatNumber, toNumber } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
interface Pair {
count: number;
from: string;
key: string;
latest?: ExchangeRate;
to: string;
}
const currencies = createQuery(misc.currencies);
const rates = createQuery(() => ({
queryFn: () => all<ExchangeRate>("/v1/exchange-rates"),
queryKey: ["exchange-rates"],
}));
const flag = createQuery(() =>
misc.config("configuration.enable_exchange_rates")
);
const names = $derived(
new Map(
(currencies.data ?? []).map((c) => [c.attributes.code, c.attributes.name])
)
);
function collect(list: Item<ExchangeRate>[]): Pair[] {
const pairs = new Map<string, Pair>();
const add = (from: string, to: string) => {
const key = [from, to].sort().join("-");
const found = pairs.get(key);
if (found) {
return found;
}
const pair: Pair = { count: 0, from, key, to };
pairs.set(key, pair);
return pair;
};
const enabled = (currencies.data ?? [])
.filter((c) => c.attributes.enabled)
.sort(
(a, b) =>
Number(Boolean(b.attributes.primary)) -
Number(Boolean(a.attributes.primary)) ||
a.attributes.code.localeCompare(b.attributes.code)
)
.map((c) => c.attributes.code);
for (const [i, from] of enabled.entries()) {
for (const to of enabled.slice(i + 1)) {
add(from, to);
}
}
for (const { attributes: rate } of list) {
const from = rate.from_currency_code ?? "";
const to = rate.to_currency_code ?? "";
if (from === to) {
continue;
}
const pair = add(from, to);
pair.count += 1;
if (!pair.latest || (rate.date ?? "") > (pair.latest.date ?? "")) {
pair.latest = rate;
}
}
return [...pairs.values()].sort(
(a, b) =>
Number(b.count > 0) - Number(a.count > 0) ||
a.from.localeCompare(b.from) ||
a.to.localeCompare(b.to)
);
}
const pairs = $derived(collect(rates.data ?? []));
function latestRate(pair: Pair): number | null {
if (!pair.latest) {
return null;
}
const rate = toNumber(pair.latest.rate);
return pair.latest.from_currency_code === pair.from ? rate : 1 / rate;
}
const open = (pair: Pair) => goto(`/exchange-rates/${pair.from}/${pair.to}`);
const nav: ListNav<Pair> = new ListNav({
id: "pairs",
items: () => pairs,
key: (pair) => pair.key,
open,
});
nav.use();
</script>
<PageHeader crumbs={[{ label: "Exchange rates" }]} title="Exchange rates" />
<div class="flex flex-col gap-3">
{#if flag.data === false}
<OldUi href="/settings/configuration">
Exchange rates are switched off in the Firefly III configuration, so
amounts are not converted.
</OldUi>
{/if}
<Box body="p-0" title="Exchange rates">
{#if rates.isPending || currencies.isPending}
<p class="p-3 {muted}">Loading exchange rates</p>
{:else if rates.error}
<p class="p-3 text-expense">{errorText(rates.error)}</p>
{:else}
<ListTable
empty="Not enough currencies. If you have just one currency enabled, there is no need to add exchange rates."
key={(pair) => pair.key}
{nav}
>
{#snippet head()}
<th class="{th} w-32">Pair</th>
<th class="{th} hidden sm:table-cell">Between</th>
<th class="{thNum} w-24">Rates</th>
<th class="{th} w-36">Latest date</th>
<th class="{thNum} w-40">Latest rate</th>
{/snippet}
{#snippet row(
pair
)}
{@const rate = latestRate(pair)}
<td class="{td} num">
<a class={link} href="/exchange-rates/{pair.from}/{pair.to}"
>{pair.from}
→ {pair.to}</a
>
</td>
<td class="{td} hidden sm:table-cell">
Between {names.get(pair.from) ?? pair.from} and
{names.get(pair.to) ?? pair.to}
(and the other way around)
</td>
<td class={tdNum}>{pair.count}</td>
<td class="{td} num">{formatDate(dateOnly(pair.latest?.date))}</td>
<td class={tdNum}>
{rate === null ? "" : formatNumber(rate, 6)}
</td>
{/snippet}
</ListTable>
{/if}
</Box>
</div>
@@ -0,0 +1,357 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { tick } from "svelte";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { ExchangeRate, Item } 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 {
Action,
Arm,
errorText,
} from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import ListTable from "#lib/components/misc/ListTable.svelte";
import MenuDelete from "#lib/components/misc/MenuDelete.svelte";
import Status from "#lib/components/misc/Status.svelte";
import { muted, td, tdNum, th, thNum } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import Input from "#lib/components/ui/input/input.svelte";
import { parse, today } from "#lib/dates.js";
import {
currentLocale,
formatDate,
formatNumber,
toNumber,
} from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { page } from "$app/state";
interface Row {
backward?: Item<ExchangeRate>;
date: string;
forward?: Item<ExchangeRate>;
}
const DIGITS = 6;
const monthLabel = new Intl.DateTimeFormat(currentLocale(), {
month: "short",
year: "numeric",
});
const from = $derived((page.params.from ?? "").toUpperCase());
const to = $derived((page.params.to ?? "").toUpperCase());
const query = createQuery(() => ({
queryFn: () => all<ExchangeRate>(`/v1/exchange-rates/${from}/${to}`),
queryKey: ["exchange-rates", from, to],
}));
const rows = $derived.by(() => {
const map = new Map<string, Row>();
for (const item of query.data ?? []) {
const date = (item.attributes.date ?? "").slice(0, 10);
const row = map.get(date) ?? { date };
if (item.attributes.from_currency_code === from) {
row.forward = item;
} else {
row.backward = item;
}
map.set(date, row);
}
return [...map.values()].sort((a, b) => b.date.localeCompare(a.date));
});
const value = (item?: Item<ExchangeRate>) =>
item ? toNumber(item.attributes.rate) : null;
const invert = (n: number | null) => (n ? 1 / n : null);
const forwardOf = (row: Row) =>
value(row.forward) ?? invert(value(row.backward));
const backwardOf = (row: Row) =>
value(row.backward) ?? invert(value(row.forward));
const show = (n: number | null) =>
n === null ? "" : formatNumber(n, DIGITS);
const series = $derived([
{
format: (n: number) => formatNumber(n, DIGITS),
key: "rate",
label: `${from} → ${to}`,
points: [...rows]
.reverse()
.map((row) => ({ x: row.date, y: forwardOf(row) ?? 0 })),
},
]);
let editing = $state<string | null>(null);
const draft = $state({ backward: "", forward: "" });
const rowAction = new Action();
const arm = new Arm();
const removal = new Action();
async function edit(row: Row) {
editing = row.date;
draft.forward = row.forward?.attributes.rate ?? "";
draft.backward = row.backward?.attributes.rate ?? "";
rowAction.reset();
await tick();
document.getElementById("rate-forward")?.focus();
}
const clean = (text: string) => text.trim().replace(",", ".");
function store(
item: Item<ExchangeRate> | undefined,
pair: string[],
date: string,
text: string
) {
const rate = clean(text);
if (item && !rate) {
return api(`/v1/exchange-rates/${item.id}`, { method: "DELETE" });
}
if (item && toNumber(rate) !== toNumber(item.attributes.rate)) {
return api(`/v1/exchange-rates/${item.id}`, {
body: { rate },
method: "PUT",
});
}
if (!item && rate) {
return api("/v1/exchange-rates", {
body: { date, from: pair[0], rate, to: pair[1] },
method: "POST",
});
}
}
async function save(event: SubmitEvent, row: Row) {
event.preventDefault();
const ok = await rowAction.run(async () => {
await store(row.forward, [from, to], row.date, draft.forward);
await store(row.backward, [to, from], row.date, draft.backward);
await invalidate();
});
if (ok) {
editing = null;
}
}
async function remove(row: Row) {
const ids = [row.forward?.id, row.backward?.id].filter(Boolean);
const ok = await removal.run(async () => {
await Promise.all(
ids.map((id) => api(`/v1/exchange-rates/${id}`, { method: "DELETE" }))
);
await invalidate();
}, "Deleted");
if (ok && editing === row.date) {
editing = null;
}
}
function cancel(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
editing = null;
}
}
const add = $state({ date: today(), rate: "" });
const adding = new Action();
async function create(event: SubmitEvent) {
event.preventDefault();
const ok = await adding.run(async () => {
await api("/v1/exchange-rates", {
body: { date: add.date, from, rate: clean(add.rate), to },
method: "POST",
});
await invalidate();
});
if (ok) {
add.rate = "";
}
}
const nav: ListNav<Row> = new ListNav({
edit,
extra: () => [
{
group: "Exchange rates",
keys: "D",
label: "Delete rates of this date (press twice)",
run: () => {
const row = nav.current;
if (row) {
arm.press(row.date, () => remove(row));
}
},
},
{
group: "Exchange rates",
keys: "c",
label: "Add a new exchange rate",
run: () => document.getElementById("new-rate-date")?.focus(),
},
],
id: "rates",
items: () => rows,
key: (row) => row.date,
open: edit,
});
nav.use();
</script>
<PageHeader
crumbs={[
{ href: "/exchange-rates", label: "Exchange rates" },
{ label: `${from} → ${to}` },
]}
title="Exchange rates"
/>
<div class="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(20rem,1fr)]">
<div class="flex min-w-0 flex-col gap-4">
{#if rows.length > 1}
<Box title="{from} → {to}">
<LineChart
axisFormat={(n) => formatNumber(n, 4)}
height={200}
label="Exchange rate {from} to {to} by date"
{series}
toggle={false}
xFormat={(day) => monthLabel.format(parse(day))}
/>
</Box>
{/if}
<Box body="p-0" title="Table with exchange rates">
{#snippet tools()}
<Status action={removal} busy="Deleting" />
{/snippet}
{#if query.isPending}
<p class="p-3 {muted}">Loading exchange rates</p>
{:else if query.error}
<p class="p-3 text-expense">{errorText(query.error)}</p>
{:else}
<ListTable
empty="There are no exchange rates between {from} and {to} yet."
key={(row) => row.date}
{nav}
>
{#snippet head()}
<th class="{th} w-36">Date</th>
<th class="{thNum} w-44">{from} → {to}</th>
<th class="{thNum} w-44">{to} → {from}</th>
<th class={th}></th>
{/snippet}
{#snippet row(
row
)}
{#if editing === row.date}
<td class="{td} num">{formatDate(row.date)}</td>
<td class={td} colspan="3">
<form
class="flex flex-wrap items-center gap-2"
onsubmit={(event) => save(event, row)}
>
<Input
aria-label="{from} → {to}"
class="num h-7 w-40 text-right"
id="rate-forward"
inputmode="decimal"
onkeydown={cancel}
placeholder={show(forwardOf(row))}
bind:value={draft.forward}
/>
<Input
aria-label="{to} → {from}"
class="num h-7 w-40 text-right"
inputmode="decimal"
onkeydown={cancel}
placeholder={show(backwardOf(row))}
bind:value={draft.backward}
/>
<Button disabled={rowAction.busy} size="xs" type="submit"
>Save</Button
>
<Button
onclick={() => (editing = null)}
size="xs"
variant="ghost"
>Cancel</Button
>
<ConfirmButton onconfirm={() => remove(row)}
>Delete</ConfirmButton
>
<Status action={rowAction} />
</form>
</td>
{:else}
<td class="{td} num">{formatDate(row.date)}</td>
<td class="{tdNum} {row.forward ? "" : "text-cladd-fg-soft"}">
{show(forwardOf(row))}
</td>
<td class="{tdNum} {row.backward ? "" : "text-cladd-fg-soft"}">
{show(backwardOf(row))}
</td>
<td class="{td} text-[12px]">
{#if arm.key === row.date}
<span class="text-expense">Press D again to delete</span>
{/if}
</td>
{/if}
{/snippet}
{#snippet menu(
row
)}
<ContextMenuItem onSelect={() => edit(row)}>
Edit
<span class="ml-auto kbd">e</span>
</ContextMenuItem>
<MenuDelete {arm} id={row.date} run={() => remove(row)} />
{/snippet}
</ListTable>
{/if}
</Box>
</div>
<form onsubmit={create}>
<Box title="Add a new exchange rate">
<div class="flex flex-col gap-3">
<Field for="new-rate-date" label="Date" problem={adding.field("date")}>
<Input
class="num"
id="new-rate-date"
required
type="date"
bind:value={add.date}
/>
</Field>
<Field
for="new-rate"
help="On this day, how many {to} will you get for one {from}?"
label="Rate"
problem={adding.field("rate")}
>
<Input
class="num"
id="new-rate"
inputmode="decimal"
required
bind:value={add.rate}
/>
</Field>
</div>
{#snippet footer()}
<Button disabled={adding.busy} size="sm" type="submit"
>Save new rate</Button
>
<Status action={adding} />
{/snippet}
</Box>
</form>
</div>
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
import Download from "@lucide/svelte/icons/download";
import { createQuery } from "@tanstack/svelte-query";
import { lists } from "#lib/api/lists.js";
import Box from "#lib/components/Box.svelte";
import { Action } from "#lib/components/misc/action.svelte.js";
import { download } from "#lib/components/misc/download.js";
import Field from "#lib/components/misc/Field.svelte";
import { sizeText } from "#lib/components/misc/queries.js";
import Status from "#lib/components/misc/Status.svelte";
import { muted } from "#lib/components/misc/styles.js";
import { setQuery } from "#lib/components/misc/url.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import Input from "#lib/components/ui/input/input.svelte";
import { today } from "#lib/dates.js";
import { useKeys } from "#lib/keys.svelte.js";
import { period } from "#lib/period.svelte.js";
import { page } from "$app/state";
const TYPES = [
["transactions", "Transactions"],
["accounts", "Accounts"],
["budgets", "Budgets"],
["categories", "Categories"],
["tags", "Tags"],
["bills", "Subscriptions"],
["piggy-banks", "Piggy banks"],
["recurring", "Recurring transactions"],
["rules", "Rules"],
];
const EARLIEST = "1970-01-02";
const params = $derived(page.url.searchParams);
const type = $derived(
TYPES.find(([key]) => key === params.get("type"))?.[0] ?? "transactions"
);
const from = $derived(params.get("from") ?? period.start);
const to = $derived(params.get("to") ?? period.end);
const picked = $derived(
(params.get("accounts") ?? "").split(",").filter(Boolean)
);
const assets = createQuery(() => lists.accounts("asset"));
const accounts = $derived(
(assets.data ?? []).filter((a) => a.attributes.active !== false)
);
function toggle(id: string) {
const next = picked.includes(id)
? picked.filter((p) => p !== id)
: [...picked, id];
setQuery({ accounts: next.join(",") });
}
const action = new Action();
let element: HTMLFormElement | undefined = $state();
async function submit(event: SubmitEvent) {
event.preventDefault();
let size = 0;
const ok = await action.run(async () => {
if (from >= to) {
throw new Error("The start date must be before the end date");
}
size = await download(
`/v1/data/export/${type}`,
{ accounts: picked.join(","), end: to, start: from, type: "csv" },
`${type}-export.csv`
);
});
if (ok) {
action.message = `Downloaded ${sizeText(size)}`;
}
}
useKeys(() => [
{
group: "Export",
input: true,
keys: "mod+Enter",
label: "Export",
run: () => element?.requestSubmit(),
},
]);
</script>
<PageHeader
crumbs={[{ label: "Export data from Firefly III" }]}
title="Export data from Firefly III"
/>
<form class="max-w-4xl" onsubmit={submit} bind:this={element}>
<Box title="Export data from Firefly III">
<div class="flex flex-col gap-4">
<Field label="Type of export">
<div class="grid gap-1.5 pt-1.5 sm:grid-cols-3">
{#each TYPES as [key, label] (key)}
<label class="flex items-center gap-2">
<input
checked={type === key}
class="size-3.5 accent-brand"
name="type"
onchange={() => setQuery({ type: key })}
type="radio"
value={key}
>
{label}
</label>
{/each}
</div>
</Field>
<Field label="Date range">
<div class="flex flex-wrap items-center gap-2">
<Input
aria-label="Start date"
class="num w-40"
onchange={(event) => setQuery({ from: event.currentTarget.value })}
type="date"
value={from}
/>
<span class="text-cladd-fg-soft">to</span>
<Input
aria-label="End date"
class="num w-40"
onchange={(event) => setQuery({ to: event.currentTarget.value })}
type="date"
value={to}
/>
<Button
onclick={() => setQuery({ from: null, to: null })}
size="sm"
variant="outline"
>This period</Button
>
<Button
onclick={() => setQuery({ from: EARLIEST, to: today() })}
size="sm"
variant="outline"
>Everything</Button
>
</div>
</Field>
{#if type === "transactions"}
<Field label="Accounts">
<div class="flex flex-col gap-1.5 pt-1.5">
<p class={muted}>
{picked.length === 0
? "Transactions from all asset accounts. Pick accounts to narrow it down."
: `Transactions from ${picked.length} of ${accounts.length} asset accounts.`}
{#if picked.length > 0}
<button
class="text-brand hover:underline"
onclick={() => setQuery({ accounts: null })}
type="button"
>
Use all
</button>
{/if}
</p>
<div class="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
{#each accounts as account (account.id)}
<label class="flex min-w-0 items-center gap-2">
<input
checked={picked.includes(account.id)}
class="size-3.5 accent-brand"
onchange={() => toggle(account.id)}
type="checkbox"
>
<span class="truncate">{account.attributes.name}</span>
</label>
{/each}
</div>
</div>
</Field>
{/if}
</div>
{#snippet footer()}
<Button disabled={action.busy} size="sm" type="submit">
<Download />
Export {TYPES.find(([key]) => key === type)?.[1].toLowerCase()} as CSV
</Button>
<Status {action} busy="Preparing the file" />
{/snippet}
</Box>
</form>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import OldUi from "#lib/components/misc/OldUi.svelte";
import { setQuery } from "#lib/components/misc/url.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { useKeys } from "#lib/keys.svelte.js";
import { page } from "$app/state";
import General from "./General.svelte";
import HomeScreen from "./HomeScreen.svelte";
import Layout from "./Layout.svelte";
const TABS = [
{ key: "general", label: "General" },
{ key: "frontpage", label: "Home screen" },
{ key: "layout", label: "Layout" },
{ key: "notifications", label: "Notifications" },
];
const tab = $derived(
TABS.find((t) => t.key === page.url.searchParams.get("tab"))?.key ??
"general"
);
const open = (key: string) =>
setQuery({ tab: key === "general" ? null : key });
useKeys(() =>
TABS.map((t, i) => ({
group: "Preferences",
keys: String(i + 1),
label: `${t.label} tab`,
run: () => open(t.key),
}))
);
</script>
<PageHeader crumbs={[{ label: "Preferences" }]} title="Preferences" />
<div
class="mb-4 flex flex-wrap gap-1 border-cladd-outline border-b"
role="tablist"
>
{#each TABS as t, i (t.key)}
<button
aria-selected={tab === t.key}
class="-mb-px flex items-center gap-2 border-b-2 px-3 py-2 font-medium text-[13px] {tab ===
t.key
? "border-brand text-cladd-fg"
: "border-transparent text-cladd-fg-soft hover:text-cladd-fg"}"
onclick={() => open(t.key)}
role="tab"
type="button"
>
{t.label}
<span class="kbd">{i + 1}</span>
</button>
{/each}
</div>
{#if tab === "frontpage"}
<HomeScreen />
{:else if tab === "layout"}
<Layout />
{:else if tab === "notifications"}
<OldUi href="/preferences?ffui=old">
Notification settings (email, Slack, Pushover) stay in the old interface.
</OldUi>
{:else}
<General />
{/if}
+259
View File
@@ -0,0 +1,259 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { invalidate } from "#lib/api/lists.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import { misc } from "#lib/components/misc/queries.js";
import { muted, select } from "#lib/components/misc/styles.js";
import Input from "#lib/components/ui/input/input.svelte";
import { today } from "#lib/dates.js";
import { session } from "#lib/session.svelte.js";
import Section from "./Section.svelte";
type Languages = Record<
string,
{ name_english: string; name_locale: string }
>;
const FIELD_GROUPS = [
{
fields: [
["interest_date", "Interest date"],
["book_date", "Book date"],
["process_date", "Processing date"],
["due_date", "Due date"],
["payment_date", "Payment date"],
["invoice_date", "Invoice date"],
],
label: "Date fields",
},
{
fields: [
["internal_reference", "Internal reference"],
["external_url", "External URL"],
["notes", "Notes"],
["location", "Location"],
["links", "Transaction links"],
],
label: "Other fields",
},
{ fields: [["attachments", "Attachments"]], label: "Attachment fields" },
];
const SAMPLE_AMOUNT = 1_234_567.89;
const languages = createQuery(() => misc.config("firefly.languages"));
const exchange = createQuery(() =>
misc.config("configuration.enable_exchange_rates")
);
const options = $derived(Object.entries((languages.data ?? {}) as Languages));
const on = (value: unknown) => value === true || value === 1 || value === "1";
const { prefs } = session;
const year = today().slice(0, 4);
let language = $state(String(prefs.language ?? "en_US"));
let locale = $state(String(prefs.locale ?? "equal"));
let fiscal = $state(on(prefs.customFiscalYear));
let fiscalStart = $state(
`${year}-${String(prefs.fiscalYearStart ?? "01-01")}`
);
let primary = $state(on(prefs.convert_to_primary));
let anonymous = $state(on(prefs.anonymous));
const optional = $state<Record<string, boolean>>({
...((prefs.transaction_journal_optional_fields ?? {}) as Record<
string,
boolean
>),
});
const names = new Intl.DisplayNames(["en"], { type: "language" });
const nameOf = (key: string) => {
try {
return names.of(key.replace("_", "-")) ?? key;
} catch {
return key;
}
};
const sample = $derived.by(() => {
const tag = (locale === "equal" ? language : locale).replace("_", "-");
try {
const number = new Intl.NumberFormat(tag, {
minimumFractionDigits: 2,
}).format(SAMPLE_AMOUNT);
const date = new Intl.DateTimeFormat(tag, { dateStyle: "medium" }).format(
new Date()
);
return `${number} · ${date}`;
} catch {
return "";
}
});
const languageAction = new Action();
const localeAction = new Action();
const fiscalAction = new Action();
const primaryAction = new Action();
const anonymousAction = new Action();
const fieldsAction = new Action();
const saveFiscal = () =>
fiscalAction.run(async () => {
await session.setPreference("customFiscalYear", fiscal);
await session.setPreference("fiscalYearStart", fiscalStart.slice(5));
});
const savePrimary = () =>
primaryAction.run(async () => {
await session.setPreference("convert_to_primary", primary);
await invalidate();
});
const saveAnonymous = () =>
anonymousAction.run(async () => {
await session.setPreference("anonymous", anonymous);
await invalidate();
});
</script>
<div class="grid items-start gap-4 lg:grid-cols-2">
<div class="flex flex-col gap-4">
<Section
action={languageAction}
help="Firefly III supports several languages. Which one do you prefer?"
onsave={() =>
languageAction.run(() => session.setPreference("language", language))}
title="Languages"
>
<select aria-label="Language" class={select} bind:value={language}>
{#if !options.some(([key]) => key === language)}
<option value={language}>{language}</option>
{/if}
{#each options as [key, lang] (key)}
<option value={key}>{lang.name_locale} ({lang.name_english})</option>
{/each}
</select>
</Section>
<Section
action={localeAction}
help="How numbers and dates are formatted."
onsave={() =>
localeAction.run(() => session.setPreference("locale", locale))}
title="Locale settings"
>
<select aria-label="Locale" class={select} bind:value={locale}>
<option value="equal">(equal to language)</option>
{#if locale !== "equal" && !options.some(([key]) => key === locale)}
<option value={locale}>{nameOf(locale)}</option>
{/if}
{#each options as [key] (key)}
<option value={key}>{nameOf(key)}</option>
{/each}
</select>
{#if sample}
<p class="mt-2 {muted}">Looks like <span class="num">{sample}</span></p>
{/if}
</Section>
<Section
action={fiscalAction}
help="In countries that use a financial year other than January 1 to December 31, you can switch this on and specify start / end days of the fiscal year."
onsave={saveFiscal}
title="Fiscal year settings"
>
<div class="flex flex-wrap items-center gap-x-6 gap-y-2">
<label class="flex items-center gap-2">
<input
class="size-3.5 accent-brand"
type="checkbox"
bind:checked={fiscal}
>
Enabled
</label>
<span class="flex items-center gap-2">
<label for="fiscal-start">Fiscal year start date</label>
<Input
class="num w-40"
disabled={!fiscal}
id="fiscal-start"
type="date"
bind:value={fiscalStart}
/>
</span>
</div>
</Section>
<Section
action={primaryAction}
help="Show amounts converted to your primary currency in as many places as possible. Conversion is not always exact."
onsave={savePrimary}
title="Display amounts in your primary currency"
>
<label class="flex items-center gap-2">
<input
class="size-3.5 accent-brand"
type="checkbox"
bind:checked={primary}
>
Display primary amounts
</label>
{#if exchange.data === false}
<p class="mt-2 {muted}">
Exchange rates are switched off in the Firefly III configuration, so
this has no effect yet.
</p>
{/if}
</Section>
<Section
action={anonymousAction}
help="Hide all amounts, except in edit boxes. The eye icon in the top bar does the same."
onsave={saveAnonymous}
title="Hidden amounts"
>
<label class="flex items-center gap-2">
<input
class="size-3.5 accent-brand"
type="checkbox"
bind:checked={anonymous}
>
Hide amounts
</label>
</Section>
</div>
<Section
action={fieldsAction}
help="Not all fields are shown when you create a transaction. Turn on the ones you use. A field that already has a value is always shown."
onsave={() =>
fieldsAction.run(() =>
session.setPreference("transaction_journal_optional_fields", {
...Object.fromEntries(
FIELD_GROUPS.flatMap((g) => g.fields).map(([key]) => [key, false])
),
...optional,
})
)}
title="Optional fields for transactions"
>
<div class="flex flex-col gap-4">
{#each FIELD_GROUPS as group (group.label)}
<fieldset class="flex flex-col gap-1.5">
<legend class="mb-1.5 font-semibold text-[13px]">
{group.label}
</legend>
{#each group.fields as [key, label] (key)}
<label class="flex items-center gap-2">
<input
class="size-3.5 accent-brand"
type="checkbox"
bind:checked={optional[key]}
>
{label}
</label>
{/each}
</fieldset>
{/each}
</div>
</Section>
</div>
+202
View File
@@ -0,0 +1,202 @@
<script lang="ts">
import ArrowDown from "@lucide/svelte/icons/arrow-down";
import ArrowUp from "@lucide/svelte/icons/arrow-up";
import { createQuery } from "@tanstack/svelte-query";
import { invalidate, lists } from "#lib/api/lists.js";
import type { Account, Item } from "#lib/api/types.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import { td, th } from "#lib/components/misc/styles.js";
import Button from "#lib/components/ui/button/button.svelte";
import { ListNav } from "#lib/list-nav.svelte.js";
import { session } from "#lib/session.svelte.js";
import Section from "./Section.svelte";
const ROLES: Record<string, string> = {
cashWalletAsset: "Cash wallet",
ccAsset: "Credit card",
defaultAsset: "Default asset account",
savingAsset: "Savings account",
sharedAsset: "Shared asset account",
};
const LIABILITIES: Record<string, string> = {
debt: "Debt",
loan: "Loan",
mortgage: "Mortgage",
};
const assets = createQuery(() => lists.accounts("asset"));
const liabilities = createQuery(() => lists.accounts("liabilities"));
const accounts = $derived(
[...(assets.data ?? []), ...(liabilities.data ?? [])].filter(
(a) => a.attributes.active !== false
)
);
const byId = $derived(new Map(accounts.map((a) => [a.id, a])));
let chosen = $derived.by(() => {
if (!(assets.data && liabilities.data)) {
return null;
}
const saved = session.frontpageAccounts.filter((id) => byId.has(id));
return saved.length > 0 ? saved : accounts.map((a) => a.id);
});
const rows = $derived.by(() => {
const picked = chosen ?? [];
const first = picked
.map((id) => byId.get(id))
.filter((a) => a !== undefined);
const rest = accounts
.filter((a) => !picked.includes(a.id))
.sort((a, b) => a.attributes.name.localeCompare(b.attributes.name));
return [...first, ...rest];
});
const kind = (account: Account) =>
account.type === "asset"
? (ROLES[account.account_role ?? ""] ?? "Asset account")
: (LIABILITIES[account.liability_type ?? ""] ?? "Liability");
function toggle(item: Item<Account> | undefined) {
if (!(item && chosen)) {
return;
}
chosen = chosen.includes(item.id)
? chosen.filter((id) => id !== item.id)
: [...chosen, item.id];
nav.cursor = rows.indexOf(item);
}
function move(item: Item<Account> | undefined, delta: number) {
if (!(item && chosen)) {
return;
}
const from = chosen.indexOf(item.id);
const to = from + delta;
if (from < 0 || to < 0 || to >= chosen.length) {
return;
}
const next = [...chosen];
[next[from], next[to]] = [next[to], next[from]];
chosen = next;
nav.move(to);
}
const action = new Action();
function save() {
return action.run(async () => {
if (!chosen?.length) {
throw new Error("Pick at least one account");
}
await session.setPreference("frontpageAccounts", chosen.map(Number));
await invalidate();
});
}
const nav: ListNav<Item<Account>> = new ListNav({
extra: () => [
{
group: "Home screen accounts",
keys: "x",
label: "Show or hide account on the home screen",
run: () => toggle(nav.current),
},
{
group: "Home screen accounts",
hidden: true,
keys: "Space",
label: "Show or hide account on the home screen",
run: () => toggle(nav.current),
},
{
group: "Home screen accounts",
keys: "K",
label: "Move account up",
run: () => move(nav.current, -1),
},
{
group: "Home screen accounts",
keys: "J",
label: "Move account down",
run: () => move(nav.current, 1),
},
{
group: "Home screen accounts",
input: true,
keys: "mod+s",
label: "Save home screen accounts",
run: save,
},
],
id: "frontpage",
items: () => rows,
key: (item) => item.id,
});
nav.use();
</script>
<div class="max-w-3xl">
<Section
{action}
help="Which accounts should be displayed on the home page, and in what order? Use x to pick, Shift+J and Shift+K to move."
onsave={save}
title="Home screen accounts"
>
<div class="-mx-3">
<ListTable key={(item) => item.id} {nav}>
{#snippet head()}
<th class="{th} w-10"></th>
<th class="{th} w-10 text-right">#</th>
<th class={th}>Account</th>
<th class="{th} hidden sm:table-cell">Type</th>
<th class="{th} w-20"></th>
{/snippet}
{#snippet row(
item
)}
{@const position = chosen?.indexOf(item.id) ?? -1}
<td class="{td} text-center">
<input
aria-label="Show {item.attributes.name} on the home screen"
checked={position >= 0}
class="size-3.5 accent-brand align-middle"
onchange={() => toggle(item)}
type="checkbox"
>
</td>
<td class="{td} num text-right text-cladd-fg-soft">
{position >= 0 ? position + 1 : ""}
</td>
<td class="{td} {position >= 0 ? "" : "text-cladd-fg-soft"}">
{item.attributes.name}
</td>
<td class="{td} hidden sm:table-cell text-cladd-fg-soft">
{kind(item.attributes)}
</td>
<td class="{td} whitespace-nowrap">
{#if position >= 0}
<Button
aria-label="Move up"
disabled={position === 0}
onclick={() => move(item, -1)}
size="icon-xs"
variant="ghost"
><ArrowUp /></Button
>
<Button
aria-label="Move down"
disabled={position === (chosen?.length ?? 0) - 1}
onclick={() => move(item, 1)}
size="icon-xs"
variant="ghost"
><ArrowDown /></Button
>
{/if}
</td>
{/snippet}
</ListTable>
</div>
</Section>
</div>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import { setMode } from "mode-watcher";
import { invalidate } from "#lib/api/lists.js";
import { Action } from "#lib/components/misc/action.svelte.js";
import Input from "#lib/components/ui/input/input.svelte";
import type { ViewRange } from "#lib/dates.js";
import { period } from "#lib/period.svelte.js";
import { session } from "#lib/session.svelte.js";
import Section from "./Section.svelte";
type Dark = "light" | "dark" | "browser";
const VIEW_RANGES: [ViewRange, string][] = [
["1D", "One day"],
["1W", "One week"],
["last7", "Last 7 days"],
["1M", "One month"],
["last30", "Last 30 days"],
["MTD", "Month to date"],
["3M", "Three months (quarter)"],
["last90", "Last 90 days"],
["QTD", "Quarter to date"],
["6M", "Six months"],
["1Y", "One year"],
["YTD", "Year to date"],
["last365", "Last year"],
];
const DARK: [Dark, string][] = [
["light", "Always light"],
["dark", "Always dark"],
["browser", "Let your browser decide"],
];
const MAX_PAGE_SIZE = 1336;
let view = $state<ViewRange>(session.viewRange);
let size = $state(session.pageSize);
let dark = $state<Dark>((session.prefs.darkMode as Dark) ?? "browser");
const viewAction = new Action();
const sizeAction = new Action();
const darkAction = new Action();
const saveView = () =>
viewAction.run(async () => {
await session.setPreference("viewRange", view);
period.view = view;
period.reset();
});
const saveSize = () =>
sizeAction.run(async () => {
const value = Math.round(Number(size));
if (!(value >= 1 && value <= MAX_PAGE_SIZE)) {
throw new Error(`Pick a number from 1 to ${MAX_PAGE_SIZE}`);
}
await session.setPreference("listPageSize", value);
await invalidate();
});
const saveDark = () =>
darkAction.run(async () => {
await session.setPreference("darkMode", dark);
setMode(dark === "browser" ? "system" : dark);
});
</script>
<div class="grid items-start gap-4 lg:grid-cols-2">
<Section
action={viewAction}
help="Some charts are automatically grouped in periods. Your budgets will also be grouped in periods. What period would you prefer?"
onsave={saveView}
title="View range"
>
<div class="grid gap-1.5 sm:grid-flow-col sm:grid-rows-7">
{#each VIEW_RANGES as [value, label] (value)}
<label class="flex items-center gap-2">
<input
class="size-3.5 accent-brand"
name="viewRange"
type="radio"
{value}
bind:group={view}
>
{label}
</label>
{/each}
</div>
</Section>
<div class="flex flex-col gap-4">
<Section
action={sizeAction}
help="Any list of things (accounts, transactions, etc) shows at most this many per page."
onsave={saveSize}
title="Page size"
>
<span class="flex items-center gap-2">
<label for="page-size">Page size</label>
<Input
class="num w-28"
id="page-size"
max={MAX_PAGE_SIZE}
min={1}
type="number"
bind:value={size}
/>
</span>
</Section>
<Section
action={darkAction}
help="Tell Firefly III when to use dark mode. This interface follows the same setting."
onsave={saveDark}
title="Dark mode"
>
<div class="flex flex-col gap-1.5">
{#each DARK as [value, label] (value)}
<label class="flex items-center gap-2">
<input
class="size-3.5 accent-brand"
name="darkMode"
type="radio"
{value}
bind:group={dark}
>
{label}
</label>
{/each}
</div>
</Section>
</div>
</div>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Box from "#lib/components/Box.svelte";
import type { Action } from "#lib/components/misc/action.svelte.js";
import Status from "#lib/components/misc/Status.svelte";
import { muted } from "#lib/components/misc/styles.js";
import Button from "#lib/components/ui/button/button.svelte";
let {
title,
help,
action,
onsave,
children,
}: {
title: string;
help?: string;
action: Action;
onsave: () => unknown;
children: Snippet;
} = $props();
async function submit(event: SubmitEvent) {
event.preventDefault();
await onsave();
}
</script>
<form class="flex min-w-0" onsubmit={submit}>
<Box class="w-full" {title}>
{#if help}
<p class="mb-3 {muted}">{help}</p>
{/if}
{@render children()}
{#snippet footer()}
<Button disabled={action.busy} size="sm" type="submit">Save</Button>
<Status {action} />
{/snippet}
</Box>
</form>
+211
View File
@@ -0,0 +1,211 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Item, Recurrence } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import MenuDelete from "#lib/components/misc/MenuDelete.svelte";
import { dateOnly } from "#lib/components/misc/queries.js";
import {
link,
muted,
td,
tdNum,
th,
thNum,
} from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import { formatDate } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
import {
describe,
endsText,
stopped,
TYPES,
toneOf,
upcoming,
} from "./recurrence.js";
const query = createQuery(() => ({
queryFn: () => all<Recurrence>("/v1/recurrences"),
queryKey: ["recurrences"],
}));
const list = $derived(
[...(query.data ?? [])].sort(
(a, b) =>
Number(b.attributes.active) - Number(a.attributes.active) ||
(a.attributes.title ?? "").localeCompare(b.attributes.title ?? "")
)
);
const arm = new Arm();
let notes = $state<Record<string, string>>({});
const open = (item: Item<Recurrence>) => goto(`/recurring/show/${item.id}`);
const edit = (item: Item<Recurrence>) =>
location.assign(`/recurring/edit/${item.id}`);
async function remove(item: Item<Recurrence>) {
try {
await api(`/v1/recurrences/${item.id}`, { method: "DELETE" });
await invalidate();
} catch (error) {
notes = { ...notes, [item.id]: errorText(error) };
}
}
const nav: ListNav<Item<Recurrence>> = new ListNav({
edit,
extra: () => [
{
group: "Recurring transactions",
keys: "D",
label: "Delete recurring transaction (press twice)",
run: () => {
const item = nav.current;
if (item) {
arm.press(item.id, () => remove(item));
}
},
},
{
group: "Recurring transactions",
keys: "c",
label: "Create a recurring transaction (old interface)",
run: () => location.assign("/recurring/create"),
},
],
id: "recurring",
items: () => list,
key: (item) => item.id,
open,
});
nav.use();
</script>
<PageHeader
crumbs={[{ label: "Recurring transactions" }]}
title="Recurring transactions"
>
{#snippet actions()}
<Button data-sveltekit-reload href="/recurring/create" size="sm"
>Create a recurring transaction</Button
>
{/snippet}
</PageHeader>
<Box body="p-0" title="Recurring transactions">
{#snippet tools()}
<span class="hidden sm:inline {muted}"
>Creating and editing happens in the old interface</span
>
{/snippet}
{#if query.isPending}
<p class="p-3 {muted}">Loading recurring transactions</p>
{:else if query.error}
<p class="p-3 text-expense">{errorText(query.error)}</p>
{:else}
<ListTable
empty="You have no recurring transactions yet. Use them to make Firefly III create transactions for you on a schedule."
key={(item) => item.id}
{nav}
>
{#snippet head()}
<th class={th}>Title</th>
<th class="{th} hidden sm:table-cell w-24">Type</th>
<th class="{thNum} w-32">Amount</th>
<th class={th}>From → to</th>
<th class={th}>Repetitions</th>
<th class="{th} w-32">Next</th>
<th class="{th} hidden sm:table-cell w-16">Active</th>
<th class={th}></th>
{/snippet}
{#snippet row(
item
)}
{@const r = item.attributes}
{@const tx = r.transactions?.[0]}
<td class="{td} {r.active ? "" : "text-cladd-fg-soft line-through"}">
<a class={link} href="/recurring/show/{item.id}">{r.title}</a>
{#if r.description}
<div class="text-[12px] text-cladd-fg-soft no-underline">
{r.description}
</div>
{/if}
</td>
<td class="{td} hidden sm:table-cell">
{TYPES[r.type ?? ""] ?? r.type}
</td>
<td class={tdNum}>
{#each r.transactions ?? [] as split, i (split.id ?? i)}
<div>
<Amount
amount={split.amount}
decimals={split.currency_decimal_places}
symbol={split.currency_symbol}
tone={toneOf(r.type)}
/>
</div>
{/each}
</td>
<td class={td}>
{#if tx}
<a class="hover:underline" href="/accounts/show/{tx.source_id}"
>{tx.source_name}</a
>
→
<a class="hover:underline" href="/accounts/show/{tx.destination_id}"
>{tx.destination_name}</a
>
{/if}
</td>
<td class={td}>
{#each r.repetitions ?? [] as rep, i (rep.id ?? i)}
<div>{describe(rep)}</div>
{/each}
<div
class="text-[12px] {stopped(r)
? "text-expense"
: "text-cladd-fg-soft"}"
>
{stopped(r)
? `Stopped repeating on ${formatDate(dateOnly(r.repeat_until))}`
: endsText(r)}
</div>
</td>
<td class="{td} num">
{#each upcoming(r, 2) as date (date)}
<div>{formatDate(date)}</div>
{/each}
</td>
<td class="{td} hidden sm:table-cell">{r.active ? "Yes" : "No"}</td>
<td class="{td} text-[12px]">
{#if arm.key === item.id}
<span class="text-expense">Press D again to delete</span>
{:else if notes[item.id]}
<span class="text-expense">{notes[item.id]}</span>
{/if}
</td>
{/snippet}
{#snippet menu(
item
)}
<ContextMenuItem onSelect={() => open(item)}>
Show
<span class="ml-auto kbd">↵</span>
</ContextMenuItem>
<ContextMenuItem onSelect={() => edit(item)}>
Edit in the old interface
<span class="ml-auto kbd">e</span>
</ContextMenuItem>
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
{/snippet}
</ListTable>
{/if}
</Box>
+101
View File
@@ -0,0 +1,101 @@
import type { Recurrence } from "#lib/api/types.js";
import { dateOnly } from "#lib/components/misc/queries.js";
import { parse, today } from "#lib/dates.js";
import { currentLocale, formatDate } from "#lib/format.js";
export const TYPES: Record<string, string> = {
deposit: "Deposit",
transfer: "Transfer",
withdrawal: "Withdrawal",
};
export const TONES = {
deposit: "income",
transfer: "transfer",
withdrawal: "expense",
} as const;
export const toneOf = (type?: string) =>
TONES[(type ?? "withdrawal") as keyof typeof TONES] ?? "neutral";
export function endsText(r: Recurrence): string {
if (r.repeat_until) {
return `Repeats until ${formatDate(dateOnly(r.repeat_until))}`;
}
if (r.nr_of_repetitions) {
return r.nr_of_repetitions === 1
? "Repeats 1 time"
: `Repeats ${r.nr_of_repetitions} times`;
}
return "Repeats forever";
}
export function stopped(r: Recurrence): boolean {
return Boolean(r.repeat_until && r.repeat_until.slice(0, 10) < today());
}
export function upcoming(r: Recurrence, count = 3): string[] {
const now = today();
const dates = (r.repetitions ?? [])
.flatMap((rep) => rep.occurrences ?? [])
.map((date) => date.slice(0, 10))
.filter((date) => date >= now);
return [...new Set(dates)].sort().slice(0, count);
}
type Repetition = NonNullable<Recurrence["repetitions"]>[number];
const DAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
const WEEKEND: Record<number, string> = {
2: ", skipped on weekends",
3: ", moved to Friday on weekends",
4: ", moved to Monday on weekends",
};
const SUFFIX = ["th", "st", "nd", "rd"];
export function ordinal(n: number): string {
const teen = n % 100 >= 11 && n % 100 <= 13;
return `${n}${teen ? "th" : (SUFFIX[n % 10] ?? "th")}`;
}
function every(unit: string, skip = 0): string {
if (skip === 0) {
return `Every ${unit}`;
}
return skip === 1
? `Every other ${unit}`
: `Every ${ordinal(skip + 1)} ${unit}`;
}
function base(rep: Repetition): string {
const day = (value: string | number) =>
DAYS[Number(value) - 1] ?? String(value);
switch (rep.type) {
case "daily":
return every("day", rep.skip);
case "weekly":
return `${every("week", rep.skip)} on ${day(rep.moment)}`;
case "monthly":
return `${every("month", rep.skip)} on the ${ordinal(Number(rep.moment))}`;
case "ndom": {
const [week, weekday] = rep.moment.split(",");
return `${every("month", rep.skip)} on the ${ordinal(Number(week))} ${day(weekday)}`;
}
case "yearly":
return `${every("year", rep.skip)} on ${new Intl.DateTimeFormat(currentLocale(), { day: "numeric", month: "long" }).format(parse(rep.moment))}`;
default:
return rep.description ?? rep.type;
}
}
export function describe(rep: Repetition): string {
return `${base(rep)}${WEEKEND[rep.weekend ?? 1] ?? ""}`;
}
@@ -0,0 +1,336 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { One, Recurrence, TransactionGroup } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { Action, errorText } from "#lib/components/misc/action.svelte.js";
import { dateOnly } from "#lib/components/misc/queries.js";
import Status from "#lib/components/misc/Status.svelte";
import {
link,
muted,
td,
tdNum,
th,
thNum,
} from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import { today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import {
describe,
endsText,
stopped,
TYPES,
toneOf,
} from "../../recurrence.js";
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
queryFn: () => api<One<Recurrence>>(`/v1/recurrences/${id}`),
queryKey: ["recurrence", id],
}));
const created = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/recurrences/${id}/transactions`),
queryKey: ["recurrence-transactions", id],
}));
const r = $derived(query.data?.data.attributes);
const type = $derived(TYPES[r?.type ?? ""] ?? "Transaction");
const plural = $derived(`${type.toLowerCase()}s`);
const count = $derived(created.data?.length ?? 0);
const remaining = $derived(
r?.nr_of_repetitions ? Math.max(0, r.nr_of_repetitions - count) : null
);
const createdDates = $derived(
new Set(
(created.data ?? []).map((g) =>
(g.attributes.transactions[0]?.date ?? "").slice(0, 10)
)
)
);
function dates(occurrences: string[] = []) {
const now = today();
const list = occurrences.map((d) => d.slice(0, 10)).filter((d) => d >= now);
return remaining === null ? list : list.slice(0, remaining);
}
let firing = $state<string | null>(null);
let fired = $state<Record<string, string>>({});
async function trigger(date: string) {
firing = date;
try {
const result = await api<{ data: unknown[] }>(
`/v1/recurrences/${id}/trigger`,
{ method: "POST", query: { date } }
);
fired = {
...fired,
[date]: result.data.length > 0 ? "Created" : "Nothing to create",
};
await invalidate();
} catch (error) {
fired = { ...fired, [date]: errorText(error) };
} finally {
firing = null;
}
}
const removal = new Action();
async function remove() {
const ok = await removal.run(
() => api(`/v1/recurrences/${id}`, { method: "DELETE" }),
"Deleted"
);
if (ok) {
await invalidate();
await goto("/recurring");
}
}
const title = $derived(
r
? `Overview for recurring transaction "${r.title}"`
: "Recurring transaction"
);
</script>
<PageHeader
crumbs={[
{ href: "/recurring", label: "Recurring transactions" },
{ label: r?.title ?? "Recurring transaction" },
]}
{title}
/>
{#if r}
<div class="flex flex-col gap-4">
<div class="grid items-start gap-4 lg:grid-cols-3">
<Box
class="lg:col-span-2"
title="{r.title} ({type.toLowerCase()}){r.active ? "" : " (inactive)"}"
>
<div class="flex flex-col gap-2">
{#if r.description}
<p>{r.description}</p>
{/if}
{#if !r.active}
<p class="text-warn">
This recurring transaction is not active and will not generate new
transactions.
</p>
{/if}
{#if r.nr_of_repetitions}
<p class={remaining === 0 ? "text-expense" : ""}>
This recurring transaction will be created at most
{r.nr_of_repetitions}
times, and has been created {count} times already.
</p>
{/if}
<dl class="grid grid-cols-[9rem_minmax(0,1fr)] gap-x-3 gap-y-1">
<dt class="text-cladd-fg-soft">Repeats</dt>
<dd>
{#each r.repetitions ?? [] as rep, i (rep.id ?? i)}
<div>{describe(rep)}</div>
{/each}
</dd>
<dt class="text-cladd-fg-soft">First date</dt>
<dd class="num">{formatDate(dateOnly(r.first_date))}</dd>
<dt class="text-cladd-fg-soft">Ends</dt>
<dd class={stopped(r) ? "text-expense" : ""}>
{stopped(r)
? `This recurring transaction stopped repeating on ${formatDate(dateOnly(r.repeat_until))}.`
: endsText(r)}
</dd>
<dt class="text-cladd-fg-soft">Last created</dt>
<dd class={r.latest_date ? "num" : ""}>
{r.latest_date ? formatDate(dateOnly(r.latest_date)) : "Never"}
</dd>
<dt class="text-cladd-fg-soft">Apply rules</dt>
<dd>{r.apply_rules ? "Yes" : "No"}</dd>
{#if r.notes}
<dt class="text-cladd-fg-soft">Notes</dt>
<dd class="whitespace-pre-line">{r.notes}</dd>
{/if}
</dl>
</div>
{#snippet footer()}
<Button
data-sveltekit-reload
href="/recurring/edit/{id}"
size="sm"
variant="outline"
>Edit in the old interface</Button
>
<span class="ml-auto flex items-center gap-2">
<Status action={removal} busy="Deleting" />
<ConfirmButton
confirmLabel={count > 0
? `Press again, the ${count} created stay`
: "Press again to delete"}
onconfirm={remove}
>Delete</ConfirmButton
>
</span>
{/snippet}
</Box>
<Box body="p-0" title="Expected {plural}">
{#each r.repetitions ?? [] as rep, i (rep.id ?? i)}
<h3 class="border-cladd-outline border-b px-3 py-1.5 font-semibold">
{describe(rep)}
</h3>
<table class="w-full text-[13px]">
<tbody>
{#each dates(rep.occurrences) as date (date)}
<tr
class="h-7 border-cladd-outline/60 border-b last:border-b-0"
>
<td class="{td} num pl-3">{formatDate(date)}</td>
<td class="{td} pr-3 text-right">
{#if fired[date]}
<span class="text-[12px] text-cladd-fg-soft"
>{fired[date]}</span
>
{:else if createdDates.has(date)}
<span class="text-[12px] text-cladd-fg-soft"
>Created</span
>
{:else}
<Button
disabled={firing !== null}
onclick={() => trigger(date)}
size="xs"
variant="outline"
>Create right now</Button
>
{/if}
</td>
</tr>
{:else}
<tr>
<td class="px-3 py-2 {muted}">No upcoming dates.</td>
</tr>
{/each}
</tbody>
</table>
{/each}
</Box>
</div>
<Box body="p-0" title="Transaction data">
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr>
<th class={th}>Description</th>
<th class={th}>Source account</th>
<th class={th}>Destination account</th>
<th class={thNum}>Amount</th>
<th class={th}>Category</th>
<th class={th}>Budget</th>
<th class={th}>Other</th>
</tr>
</thead>
<tbody>
{#each r.transactions ?? [] as tx, i (tx.id ?? i)}
<tr class="h-7 border-cladd-outline/60 border-b last:border-b-0">
<td class={td}>{tx.description}</td>
<td class={td}>
<a class={link} href="/accounts/show/{tx.source_id}"
>{tx.source_name}</a
>
</td>
<td class={td}>
<a class={link} href="/accounts/show/{tx.destination_id}"
>{tx.destination_name}</a
>
</td>
<td class={tdNum}>
<Amount
amount={tx.amount}
decimals={tx.currency_decimal_places}
symbol={tx.currency_symbol}
tone={toneOf(r.type)}
/>
{#if tx.foreign_amount}
<span class="text-cladd-fg-soft">
(<Amount
amount={tx.foreign_amount}
decimals={tx.foreign_currency_decimal_places}
symbol={tx.foreign_currency_symbol}
tone="neutral"
/>)
</span>
{/if}
</td>
<td class={td}>
{#if tx.category_id}
<a class={link} href="/categories/show/{tx.category_id}"
>{tx.category_name}</a
>
{/if}
</td>
<td class={td}>
{#if tx.budget_id}
<a class={link} href="/budgets/show/{tx.budget_id}"
>{tx.budget_name}</a
>
{/if}
</td>
<td class="{td} text-[12px]">
{#if tx.tags?.length}
<div>Tags: {tx.tags.join(", ")}</div>
{/if}
{#if tx.piggy_bank_id}
<div>
Piggy bank:
<a
class={link}
href="/piggy-banks/show/{tx.piggy_bank_id}"
>{tx.piggy_bank_name}</a
>
</div>
{/if}
{#if tx.subscription_id}
<div>
Subscription:
<a
class={link}
href="/subscriptions/show/{tx.subscription_id}"
>{tx.subscription_name}</a
>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Box>
<Box body="p-0" title="Created {plural}">
<TransactionTable
empty="Nothing created yet."
groups={created.data ?? []}
id="created"
loading={created.isPending}
selectable={false}
/>
</Box>
</div>
{:else if query.error}
<p class="text-expense">{errorText(query.error)}</p>
{:else}
<p class={muted}>Loading recurring transaction</p>
{/if}
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Item, LinkType } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import MenuDelete from "#lib/components/misc/MenuDelete.svelte";
import {
link,
muted,
td,
tdNum,
th,
thNum,
} from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
import { linkQueries } from "./queries.js";
const types = createQuery(linkQueries.types);
const links = createQuery(() => linkQueries.links());
const counts = $derived.by(() => {
const map = new Map<string, number>();
for (const item of links.data ?? []) {
const key = item.attributes.link_type_id;
map.set(key, (map.get(key) ?? 0) + 1);
}
return map;
});
const list = $derived(
[...(types.data ?? [])].sort((a, b) =>
a.attributes.name.localeCompare(b.attributes.name)
)
);
const arm = new Arm();
let notes = $state<Record<string, string>>({});
const open = (item: Item<LinkType>) =>
goto(`/settings/links/show/${item.id}`);
const edit = (item: Item<LinkType>) => {
if (item.attributes.editable) {
goto(`/settings/links/edit/${item.id}`);
}
};
async function remove(item: Item<LinkType>) {
try {
await api(`/v1/link-types/${item.id}`, { method: "DELETE" });
await invalidate();
} catch (error) {
notes = { ...notes, [item.id]: errorText(error) };
}
}
const nav: ListNav<Item<LinkType>> = new ListNav({
edit,
extra: () => [
{
group: "Link types",
keys: "D",
label: "Delete link type (press twice)",
run: () => {
const item = nav.current;
if (item?.attributes.editable) {
arm.press(item.id, () => remove(item));
}
},
},
{
group: "Link types",
keys: "c",
label: "Create new link type",
run: () => goto("/settings/links/create"),
},
],
id: "link-types",
items: () => list,
key: (item) => item.id,
open,
});
nav.use();
</script>
<PageHeader
crumbs={[
{ href: "/settings", label: "System settings" },
{ label: "Transaction links configuration" },
]}
subtitle="Transaction links configuration"
title="System settings"
>
{#snippet actions()}
<Button href="/settings/links/create" size="sm"
>Create new link type</Button
>
{/snippet}
</PageHeader>
<Box body="p-0" title="Transaction links configuration">
{#if types.isPending}
<p class="p-3 {muted}">Loading link types</p>
{:else if types.error}
<p class="p-3 text-expense">{errorText(types.error)}</p>
{:else}
<ListTable key={(item) => item.id} {nav}>
{#snippet head()}
<th class={th}>Name</th>
<th class={th}>Inward description</th>
<th class={th}>Outward description</th>
<th class="{thNum} w-48">Number of transactions</th>
<th class={th}></th>
{/snippet}
{#snippet row(
item
)}
{@const t = item.attributes}
<td class="{td} font-semibold">
<a class="hover:underline" href="/settings/links/show/{item.id}"
>{t.name}</a
>
</td>
<td class={td}>{t.inward}</td>
<td class={td}>{t.outward}</td>
<td class={tdNum}>
<a class={link} href="/settings/links/show/{item.id}"
>{links.data ? (counts.get(item.id) ?? 0) : ""}</a
>
</td>
<td class="{td} text-[12px]">
{#if arm.key === item.id}
<span class="text-expense">Press D again to delete</span>
{:else if notes[item.id]}
<span class="text-expense">{notes[item.id]}</span>
{:else if !t.editable}
<span class="text-cladd-fg-soft">Built in</span>
{/if}
</td>
{/snippet}
{#snippet menu(
item
)}
<ContextMenuItem onSelect={() => open(item)}>
Show linked transactions
<span class="ml-auto kbd">↵</span>
</ContextMenuItem>
{#if item.attributes.editable}
<ContextMenuItem onSelect={() => edit(item)}>
Edit
<span class="ml-auto kbd">e</span>
</ContextMenuItem>
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
{/if}
{/snippet}
</ListTable>
{/if}
</Box>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import type { One, TransactionGroup } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import { link } from "#lib/components/misc/styles.js";
let { id }: { id: string } = $props();
const query = createQuery(() => ({
queryFn: () => api<One<TransactionGroup>>(`/v1/transaction-journals/${id}`),
queryKey: ["transaction-journal", id],
}));
const group = $derived(query.data?.data);
const split = $derived(
group?.attributes.transactions.find((s) => s.transaction_journal_id === id)
);
const tone = $derived(
(
{
deposit: "income",
transfer: "transfer",
withdrawal: "expense",
} as const
)[split?.type as "deposit"] ?? "neutral"
);
</script>
{#if group && split}
<span class="flex items-baseline justify-between gap-3">
<a class="truncate {link}" href="/transactions/show/{group.id}"
>{split.description}</a
>
<Amount
amount={split.amount}
code={split.currency_code}
decimals={split.currency_decimal_places}
symbol={split.currency_symbol}
{tone}
/>
</span>
{:else if query.error}
<span class="text-cladd-fg-soft">Transaction #{id}</span>
{/if}
@@ -0,0 +1,139 @@
<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 { Item, LinkType } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { Action } from "#lib/components/misc/action.svelte.js";
import Field from "#lib/components/misc/Field.svelte";
import Status from "#lib/components/misc/Status.svelte";
import { muted } from "#lib/components/misc/styles.js";
import Button from "#lib/components/ui/button/button.svelte";
import Input from "#lib/components/ui/input/input.svelte";
import { useKeys } from "#lib/keys.svelte.js";
import { goto } from "$app/navigation";
import { linkQueries } from "./queries.js";
let { item }: { item?: Item<LinkType> } = $props();
const form = $state(
untrack(() => ({
inward: item?.attributes.inward ?? "",
name: item?.attributes.name ?? "",
outward: item?.attributes.outward ?? "",
}))
);
const locked = $derived(item ? !item.attributes.editable : false);
const links = createQuery(() => ({
...linkQueries.links(item?.attributes.name),
enabled: Boolean(item),
}));
const count = $derived(links.data?.length ?? 0);
const save = new Action();
const removal = new Action();
let element: HTMLFormElement | undefined = $state();
async function submit(event: SubmitEvent) {
event.preventDefault();
const ok = await save.run(() =>
item
? api(`/v1/link-types/${item.id}`, { body: form, method: "PUT" })
: api("/v1/link-types", { body: form, method: "POST" })
);
if (ok) {
await invalidate();
await goto("/settings/links");
}
}
async function remove() {
if (
item &&
(await removal.run(
() => api(`/v1/link-types/${item.id}`, { method: "DELETE" }),
"Deleted"
))
) {
await invalidate();
await goto("/settings/links");
}
}
useKeys(() => [
{
group: "Form",
input: true,
keys: "mod+Enter",
label: item ? "Update link type" : "Store new link type",
run: () => element?.requestSubmit(),
},
]);
</script>
<form class="max-w-3xl" onsubmit={submit} bind:this={element}>
<Box title="Mandatory fields">
<div class="flex flex-col gap-3">
{#if locked}
<p class={muted}>
This link type is built into Firefly III and cannot be changed.
</p>
{/if}
<Field
for="name"
help='For example "Duplicates"'
label="Name"
problem={save.field("name")}
>
<Input disabled={locked} id="name" required bind:value={form.name} />
</Field>
<Field
for="inward"
help='For example "duplicates"'
label="Inward description"
problem={save.field("inward")}
>
<Input
disabled={locked}
id="inward"
required
bind:value={form.inward}
/>
</Field>
<Field
for="outward"
help='For example "is duplicated by"'
label="Outward description"
problem={save.field("outward")}
>
<Input
disabled={locked}
id="outward"
required
bind:value={form.outward}
/>
</Field>
</div>
{#snippet footer()}
<Button disabled={save.busy || locked} size="sm" type="submit">
{item ? "Update link type" : "Store new link type"}
</Button>
<Status action={save} />
{#if item && !locked}
<span class="ml-auto flex items-center gap-2">
<Status action={removal} busy="Deleting" />
<ConfirmButton
confirmLabel={count > 0
? `Press again to delete it and ${count} links`
: "Press again to delete"}
onconfirm={remove}
>Delete link type</ConfirmButton
>
</span>
{/if}
{/snippet}
</Box>
</form>
@@ -0,0 +1,15 @@
<script lang="ts">
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import LinkTypeForm from "../LinkTypeForm.svelte";
</script>
<PageHeader
crumbs={[
{ href: "/settings", label: "System settings" },
{ href: "/settings/links", label: "Transaction links configuration" },
{ label: "Create new link type" },
]}
subtitle="Create new link type"
title="System settings"
/>
<LinkTypeForm />
@@ -0,0 +1,35 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { errorText } from "#lib/components/misc/action.svelte.js";
import { muted } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { page } from "$app/state";
import LinkTypeForm from "../../LinkTypeForm.svelte";
import { linkQueries } from "../../queries.js";
const id = $derived(page.params.id ?? "");
const query = createQuery(() => linkQueries.one(id));
const item = $derived(query.data?.data);
const subtitle = $derived(
item ? `Edit link type "${item.attributes.name}"` : "Edit link type"
);
</script>
<PageHeader
crumbs={[
{ href: "/settings", label: "System settings" },
{ href: "/settings/links", label: "Transaction links configuration" },
{ label: subtitle },
]}
{subtitle}
title="System settings"
/>
{#if item}
{#key item.id}
<LinkTypeForm {item} />
{/key}
{:else if query.error}
<p class="text-expense">{errorText(query.error)}</p>
{:else}
<p class={muted}>Loading link type</p>
{/if}
+19
View File
@@ -0,0 +1,19 @@
import { api } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import type { LinkType, One, TransactionLink } from "#lib/api/types.js";
export const linkQueries = {
links: (name?: string) => ({
queryFn: () =>
all<TransactionLink>("/v1/transaction-links", name ? { name } : {}),
queryKey: ["transaction-links", name ?? ""],
}),
one: (id: string) => ({
queryFn: () => api<One<LinkType>>(`/v1/link-types/${id}`),
queryKey: ["link-type", id],
}),
types: () => ({
queryFn: () => all<LinkType>("/v1/link-types"),
queryKey: ["link-types"],
}),
};
@@ -0,0 +1,156 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Item, TransactionLink } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import MenuDelete from "#lib/components/misc/MenuDelete.svelte";
import { muted, td, th } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import { ListNav } from "#lib/list-nav.svelte.js";
import { page } from "$app/state";
import Journal from "../../Journal.svelte";
import { linkQueries } from "../../queries.js";
const id = $derived(page.params.id ?? "");
const type = createQuery(() => linkQueries.one(id));
const name = $derived(type.data?.data.attributes.name);
const links = createQuery(() => ({
...linkQueries.links(name),
enabled: Boolean(name),
}));
const arm = new Arm();
let notes = $state<Record<string, string>>({});
async function run(
item: Item<TransactionLink>,
task: () => Promise<unknown>
) {
notes = { ...notes, [item.id]: "" };
try {
await task();
await invalidate();
} catch (error) {
notes = { ...notes, [item.id]: errorText(error) };
}
}
const swap = (item: Item<TransactionLink>) =>
run(item, () =>
api(`/v1/transaction-links/${item.id}`, {
body: {
inward_id: item.attributes.outward_id,
link_type_id: item.attributes.link_type_id,
outward_id: item.attributes.inward_id,
},
method: "PUT",
})
);
const remove = (item: Item<TransactionLink>) =>
run(item, () =>
api(`/v1/transaction-links/${item.id}`, { method: "DELETE" })
);
const nav: ListNav<Item<TransactionLink>> = new ListNav({
extra: () => [
{
group: "Links",
keys: "s",
label: "Switch direction of link",
run: () => nav.current && swap(nav.current),
},
{
group: "Links",
keys: "D",
label: "Delete link (press twice)",
run: () => {
const item = nav.current;
if (item) {
arm.press(item.id, () => remove(item));
}
},
},
],
id: "links",
items: () => links.data ?? [],
key: (item) => item.id,
});
nav.use();
const title = $derived(
name ? `Overview for link type "${name}"` : "Link type"
);
</script>
<PageHeader
crumbs={[
{ href: "/settings", label: "System settings" },
{ href: "/settings/links", label: "Transaction links configuration" },
{ label: name ?? "Link type" },
]}
subtitle="Transaction links configuration"
title="System settings"
>
{#snippet actions()}
{#if type.data?.data.attributes.editable}
<Button href="/settings/links/edit/{id}" size="sm" variant="outline"
>Edit link type</Button
>
{/if}
{/snippet}
</PageHeader>
<Box body="p-0" {title}>
{#if type.error}
<p class="p-3 text-expense">{errorText(type.error)}</p>
{:else if links.isPending}
<p class="p-3 {muted}">Loading links</p>
{:else if links.error}
<p class="p-3 text-expense">{errorText(links.error)}</p>
{:else}
<ListTable
empty="No transactions are linked this way yet."
key={(item) => item.id}
{nav}
>
{#snippet head()}
<th class="{th} w-[40%]">Inward transaction</th>
<th class={th}></th>
<th class="{th} w-[40%]">Outward transaction</th>
<th class={th}></th>
{/snippet}
{#snippet row(
item
)}
<td class={td}><Journal id={item.attributes.inward_id} /></td>
<td class="{td} text-cladd-fg-soft">
{type.data?.data.attributes.outward}
</td>
<td class={td}><Journal id={item.attributes.outward_id} /></td>
<td class="{td} text-[12px]">
{#if arm.key === item.id}
<span class="text-expense">Press D again to delete</span>
{:else if notes[item.id]}
<span class="text-expense">{notes[item.id]}</span>
{:else if item.attributes.notes}
<span class="text-cladd-fg-soft">{item.attributes.notes}</span>
{/if}
</td>
{/snippet}
{#snippet menu(
item
)}
<ContextMenuItem onSelect={() => swap(item)}>
Switch direction
<span class="ml-auto kbd">s</span>
</ContextMenuItem>
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
{/snippet}
</ListTable>
{/if}
</Box>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import type { Schemas } from "#lib/api/client.js";
import { all } from "#lib/api/lists.js";
import { errorText } from "#lib/components/misc/action.svelte.js";
import { muted } from "#lib/components/misc/styles.js";
import { formatDateTime } from "#lib/format.js";
let { webhook, message }: { webhook: string; message: string } = $props();
const query = createQuery(() => ({
queryFn: () =>
all<Schemas["WebhookAttempt"]>(
`/v1/webhooks/${webhook}/messages/${message}/attempts`
),
queryKey: ["webhook-attempts", webhook, message],
}));
</script>
{#if query.isPending}
<p class={muted}>Loading attempts</p>
{:else if query.error}
<p class="text-expense">{errorText(query.error)}</p>
{:else}
{#each query.data ?? [] as attempt (attempt.id)}
{@const a = attempt.attributes}
<section class="flex flex-col gap-1 border-cladd-outline border-t py-2">
<p class="flex gap-3">
<span class="num">Attempt at {formatDateTime(a.created_at)}</span>
<span
class="num {a.status_code && a.status_code < 300
? "text-income"
: "text-expense"}"
>{a.status_code ?? "no answer"}</span
>
</p>
{#if a.logs}
<pre
class="max-h-40 overflow-auto whitespace-pre-wrap text-[12px] text-cladd-fg-soft"
>{a.logs}</pre>
{/if}
{#if a.response}
<pre
class="max-h-40 overflow-auto whitespace-pre-wrap text-[12px]"
>{a.response}</pre>
{/if}
</section>
{:else}
<p class={muted}>No attempts yet.</p>
{/each}
{/if}
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import OldUi from "#lib/components/misc/OldUi.svelte";
let { href = "/webhooks/index?ffui=old" }: { href?: string } = $props();
</script>
<OldUi {href}>
Webhooks are switched off in this Firefly III (ALLOW_WEBHOOKS=false).
</OldUi>
+152
View File
@@ -0,0 +1,152 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Item, Webhook } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import MenuDelete from "#lib/components/misc/MenuDelete.svelte";
import { link, muted, td, th } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
import Disabled from "../Disabled.svelte";
import {
DELIVERIES,
labels,
RESPONSES,
switchedOff,
TRIGGERS,
} from "../webhooks.js";
const query = createQuery(() => ({
queryFn: () => all<Webhook>("/v1/webhooks"),
queryKey: ["webhooks"],
retry: false,
}));
const off = $derived(switchedOff(query.error));
const arm = new Arm();
let notes = $state<Record<string, string>>({});
const open = (item: Item<Webhook>) => goto(`/webhooks/show/${item.id}`);
const edit = (item: Item<Webhook>) =>
location.assign(`/webhooks/edit/${item.id}`);
async function remove(item: Item<Webhook>) {
try {
await api(`/v1/webhooks/${item.id}`, { method: "DELETE" });
await invalidate();
} catch (error) {
notes = { ...notes, [item.id]: errorText(error) };
}
}
const nav: ListNav<Item<Webhook>> = new ListNav({
edit,
extra: () => [
{
group: "Webhooks",
keys: "D",
label: "Delete webhook (press twice)",
run: () => {
const item = nav.current;
if (item) {
arm.press(item.id, () => remove(item));
}
},
},
{
group: "Webhooks",
keys: "c",
label: "Create new webhook (old interface)",
run: () => location.assign("/webhooks/create"),
},
],
id: "webhooks",
items: () => query.data ?? [],
key: (item) => item.id,
open,
});
nav.use();
</script>
<PageHeader crumbs={[{ label: "Webhooks" }]} title="Webhooks">
{#snippet actions()}
{#if !off}
<Button data-sveltekit-reload href="/webhooks/create" size="sm"
>Create new webhook</Button
>
{/if}
{/snippet}
</PageHeader>
{#if off}
<Disabled />
{:else}
<Box body="p-0" title="Webhooks">
{#snippet tools()}
<span class="hidden sm:inline {muted}"
>Creating and editing happens in the old interface</span
>
{/snippet}
{#if query.isPending}
<p class="p-3 {muted}">Loading webhooks</p>
{:else if query.error}
<p class="p-3 text-expense">{errorText(query.error)}</p>
{:else}
<ListTable
empty="There are no webhooks yet."
key={(item) => item.id}
{nav}
>
{#snippet head()}
<th class={th}>Title</th>
<th class={th}>Responds when</th>
<th class={th}>Responds with (delivery)</th>
<th class="{th} w-16">Active</th>
<th class={th}>URL</th>
<th class={th}></th>
{/snippet}
{#snippet row(
item
)}
{@const w = item.attributes}
<td class="{td} {w.active ? "" : "line-through"}">
<a class={link} href="/webhooks/show/{item.id}">{w.title}</a>
</td>
<td class={td}>{labels(w.triggers, TRIGGERS)}</td>
<td class={td}>
{labels(w.responses, RESPONSES)}
({labels(w.deliveries, DELIVERIES)})
</td>
<td class={td}>{w.active ? "Yes" : "No"}</td>
<td class="{td} break-all font-mono text-[12px]">{w.url}</td>
<td class="{td} text-[12px]">
{#if arm.key === item.id}
<span class="text-expense">Press D again to delete</span>
{:else if notes[item.id]}
<span class="text-expense">{notes[item.id]}</span>
{/if}
</td>
{/snippet}
{#snippet menu(
item
)}
<ContextMenuItem onSelect={() => open(item)}>
Inspect
<span class="ml-auto kbd">↵</span>
</ContextMenuItem>
<ContextMenuItem onSelect={() => edit(item)}>
Edit in the old interface
<span class="ml-auto kbd">e</span>
</ContextMenuItem>
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
{/snippet}
</ListTable>
{/if}
</Box>
{/if}
@@ -0,0 +1,234 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api, type Schemas } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Item, One, Webhook } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { Action, errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import Status from "#lib/components/misc/Status.svelte";
import { link, muted, td, th } from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import Input from "#lib/components/ui/input/input.svelte";
import { formatDateTime } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Attempts from "../../Attempts.svelte";
import Disabled from "../../Disabled.svelte";
import {
DELIVERIES,
labels,
RESPONSES,
switchedOff,
TRIGGERS,
} from "../../webhooks.js";
type Message = Schemas["WebhookMessage"];
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
queryFn: () => api<One<Webhook>>(`/v1/webhooks/${id}`),
queryKey: ["webhook", id],
retry: false,
}));
const messages = createQuery(() => ({
enabled: Boolean(query.data),
queryFn: () => all<Message>(`/v1/webhooks/${id}/messages`),
queryKey: ["webhook-messages", id],
}));
const w = $derived(query.data?.data.attributes);
const list = $derived(
[...(messages.data ?? [])].sort((a, b) =>
(b.attributes.created_at ?? "").localeCompare(
a.attributes.created_at ?? ""
)
)
);
let secret = $state(false);
let transaction = $state("");
const triggering = new Action();
const removal = new Action();
async function trigger(event: SubmitEvent) {
event.preventDefault();
const ok = await triggering.run(
() =>
api(`/v1/webhooks/${id}/trigger-transaction/${transaction.trim()}`, {
method: "POST",
}),
"Triggered. Results appear below in a moment."
);
if (ok) {
await invalidate();
}
}
async function remove() {
const ok = await removal.run(
() => api(`/v1/webhooks/${id}`, { method: "DELETE" }),
"Deleted"
);
if (ok) {
await invalidate();
await goto("/webhooks/index");
}
}
const pretty = (text?: string | null) => {
try {
return JSON.stringify(JSON.parse(text ?? ""), null, 2);
} catch {
return text ?? "";
}
};
const nav: ListNav<Item<Message>> = new ListNav({
id: "messages",
items: () => list,
key: (item) => item.id,
});
nav.use();
const current = $derived(nav.current ?? list[0]);
</script>
<PageHeader
crumbs={[
{ href: "/webhooks/index", label: "Webhooks" },
{ label: w?.title ?? "Webhook" },
]}
title={w?.title ?? "Webhook"}
/>
{#if switchedOff(query.error)}
<Disabled href="/webhooks/show/{id}?ffui=old" />
{:else if query.error}
<p class="text-expense">{errorText(query.error)}</p>
{:else if w}
<div class="flex flex-col gap-4">
<div class="grid items-start gap-4 lg:grid-cols-2">
<Box title={w.title}>
<dl class="grid grid-cols-[10rem_minmax(0,1fr)] gap-x-3 gap-y-1">
<dt class="text-cladd-fg-soft">Title</dt>
<dd>{w.title}</dd>
<dt class="text-cladd-fg-soft">Active</dt>
<dd>{w.active ? "Yes" : "No"}</dd>
<dt class="text-cladd-fg-soft">Responds when</dt>
<dd>{labels(w.triggers, TRIGGERS)}</dd>
<dt class="text-cladd-fg-soft">Responds with</dt>
<dd>{labels(w.responses, RESPONSES)}</dd>
<dt class="text-cladd-fg-soft">Delivery</dt>
<dd>{labels(w.deliveries, DELIVERIES)}</dd>
</dl>
{#snippet footer()}
<Button
data-sveltekit-reload
href="/webhooks/edit/{id}"
size="sm"
variant="outline"
>Edit in the old interface</Button
>
<span class="ml-auto flex items-center gap-2">
<Status action={removal} busy="Deleting" />
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
</span>
{/snippet}
</Box>
<Box title="Meta data">
<dl class="grid grid-cols-[10rem_minmax(0,1fr)] gap-x-3 gap-y-1">
<dt class="text-cladd-fg-soft">URL</dt>
<dd class="break-all font-mono text-[12px]">
<a class={link} href={w.url} rel="noopener" target="_blank"
>{w.url}</a
>
</dd>
<dt class="text-cladd-fg-soft">Secret</dt>
<dd class="flex items-center gap-2">
<span class="break-all font-mono"
>{secret ? w.secret : "••••••••••••"}</span
>
<Button
onclick={() => (secret = !secret)}
size="xs"
variant="outline"
>{secret ? "Hide" : "Show"}</Button
>
</dd>
</dl>
{#snippet footer()}
<form class="flex flex-wrap items-center gap-2" onsubmit={trigger}>
<label class="text-cladd-fg-soft" for="trigger-id"
>Send it for transaction</label
>
<Input
class="num h-7 w-28"
id="trigger-id"
inputmode="numeric"
placeholder="ID"
required
bind:value={transaction}
/>
<Button disabled={triggering.busy} size="xs" type="submit"
>Trigger</Button
>
<Status action={triggering} busy="Sending" />
</form>
{/snippet}
</Box>
</div>
<div class="grid items-start gap-4 {current ? "lg:grid-cols-2" : ""}">
<Box body="p-0" title="Webhook messages">
{#if messages.isPending}
<p class="p-3 {muted}">Loading messages</p>
{:else if messages.error}
<p class="p-3 text-expense">{errorText(messages.error)}</p>
{:else}
<ListTable
empty="This webhook has not sent anything yet."
key={(item) => item.id}
{nav}
>
{#snippet head()}
<th class="{th} w-44">Date and time</th>
<th class={th}>UUID</th>
<th class="{th} w-16">Sent</th>
<th class="{th} w-20">Errored</th>
{/snippet}
{#snippet row(
item
)}
{@const m = item.attributes}
<td class="{td} num">{formatDateTime(m.created_at)}</td>
<td class="{td} num truncate text-[12px]">{m.uuid}</td>
<td class={td}>{m.sent ? "Yes" : "No"}</td>
<td class="{td} {m.errored ? "text-expense" : ""}">
{m.errored ? "Yes" : "No"}
</td>
{/snippet}
</ListTable>
{/if}
</Box>
{#if current}
<Box title="Message {current.attributes.uuid ?? ""}">
<pre
class="max-h-80 overflow-auto whitespace-pre-wrap text-[12px]"
>{pretty(current.attributes.message)}</pre>
<h3 class="mt-3 mb-1 font-semibold">Attempts</h3>
{#key current.id}
<Attempts message={current.id} webhook={id} />
{/key}
</Box>
{/if}
</div>
</div>
{:else}
<p class={muted}>Loading webhook</p>
{/if}
+32
View File
@@ -0,0 +1,32 @@
import { ApiError } from "#lib/api/client.js";
const NOT_FOUND = 404;
export const TRIGGERS: Record<string, string> = {
ANY: "After any event",
DESTROY_BUDGET: "After budget delete",
DESTROY_TRANSACTION: "After transaction delete",
STORE_BUDGET: "After budget creation",
STORE_TRANSACTION: "After transaction creation",
STORE_UPDATE_BUDGET_LIMIT: "After budgeted amount change",
UPDATE_BUDGET: "After budget update",
UPDATE_TRANSACTION: "After transaction update",
};
export const RESPONSES: Record<string, string> = {
ACCOUNTS: "Account details",
BUDGET: "Budget details",
NONE: "No details",
RELEVANT: "Relevant details",
TRANSACTIONS: "Transaction details",
};
export const DELIVERIES: Record<string, string> = { JSON: "JSON" };
export const labels = (
values: string[] | undefined,
map: Record<string, string>
) => (values ?? []).map((value) => map[value] ?? value).join(", ");
export const switchedOff = (error: unknown) =>
error instanceof ApiError && error.status === NOT_FOUND;