feat(currencies,preferences,export,misc): currencies, rates, attachments, link types, preferences, export, administrations, recurring and webhooks
This commit is contained in:
1 parent
89bbf56062
commit
f8a5fd6c6d
47 files changed
+4496
-8
No files matched your search
@@ -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}
|
||||
@@ -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],
|
||||
}),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user