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,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
for: target,
|
||||||
|
problem,
|
||||||
|
help,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
for?: string;
|
||||||
|
problem?: string;
|
||||||
|
help?: string;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="@container">
|
||||||
|
<div class="grid gap-1 @lg:grid-cols-[11rem_minmax(0,1fr)] @lg:gap-3">
|
||||||
|
<label
|
||||||
|
class="font-medium text-[13px] text-cladd-fg @lg:pt-1.5 @lg:text-right"
|
||||||
|
for={target}
|
||||||
|
>{label}</label
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
|
{@render children()}
|
||||||
|
{#if problem}
|
||||||
|
<p class="text-[12px] text-expense">{problem}</p>
|
||||||
|
{:else if help}
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">{help}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script generics="T" lang="ts">
|
||||||
|
import { ContextMenu } from "bits-ui";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import ContextMenuContent from "#lib/components/ui/context-menu/context-menu-content.svelte";
|
||||||
|
import type { ListNav } from "#lib/list-nav.svelte.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
nav,
|
||||||
|
key,
|
||||||
|
head,
|
||||||
|
row,
|
||||||
|
menu,
|
||||||
|
empty = "Nothing here yet",
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
nav: ListNav<T>;
|
||||||
|
key: (item: T) => string;
|
||||||
|
head: Snippet;
|
||||||
|
row: Snippet<[T, number]>;
|
||||||
|
menu?: Snippet<[T]>;
|
||||||
|
empty?: string;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet rows()}
|
||||||
|
{#each nav.items as item, i (key(item))}
|
||||||
|
<tr
|
||||||
|
class="h-7 border-cladd-outline/60 border-b last:border-b-0 {nav.cursor ===
|
||||||
|
i
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
id={nav.rowId(i)}
|
||||||
|
onclick={(event) => nav.click(i, event)}
|
||||||
|
oncontextmenu={() => (nav.cursor = i)}
|
||||||
|
>
|
||||||
|
{@render row(item, i)}
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 py-3 text-cladd-fg-soft" colspan="99">{empty}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full border-collapse text-[13px] {className}">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{@render head()}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{#if menu}
|
||||||
|
<ContextMenu.Root>
|
||||||
|
<ContextMenu.Trigger>
|
||||||
|
{#snippet child({
|
||||||
|
props,
|
||||||
|
})}
|
||||||
|
<tbody {...props}>
|
||||||
|
{@render rows()}
|
||||||
|
</tbody>
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu.Trigger>
|
||||||
|
<ContextMenuContent class="w-56">
|
||||||
|
{#if nav.current}
|
||||||
|
{@render menu(nav.current)}
|
||||||
|
{/if}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu.Root>
|
||||||
|
{:else}
|
||||||
|
<tbody>
|
||||||
|
{@render rows()}
|
||||||
|
</tbody>
|
||||||
|
{/if}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
|
||||||
|
import type { Arm } from "./action.svelte.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
arm,
|
||||||
|
id,
|
||||||
|
run,
|
||||||
|
label = "Delete",
|
||||||
|
}: { arm: Arm; id: string; run: () => unknown; label?: string } = $props();
|
||||||
|
|
||||||
|
const armed = $derived(arm.key === id);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
if (!armed) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
arm.press(id, run);
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{armed ? "Press again to delete" : label}
|
||||||
|
<span class="ml-auto kbd">D</span>
|
||||||
|
</ContextMenuItem>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
href,
|
||||||
|
label = "Open the old interface",
|
||||||
|
children,
|
||||||
|
}: { href: string; label?: string; children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class="text-[12px] text-cladd-fg-soft">
|
||||||
|
{@render children()}
|
||||||
|
<a class="text-brand hover:underline" data-sveltekit-reload {href}>{label}</a>
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Action } from "./action.svelte.js";
|
||||||
|
|
||||||
|
let { action, busy = "Saving" }: { action: Action; busy?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if action.busy}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">{busy}</span>
|
||||||
|
{:else if action.message}
|
||||||
|
<span
|
||||||
|
class="text-[12px] {action.failed ? "text-expense" : "text-income"}"
|
||||||
|
role="status"
|
||||||
|
>{action.message}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { ApiError } from "#lib/api/client.js";
|
||||||
|
|
||||||
|
const UNAUTHORIZED = 401;
|
||||||
|
const ARM_MS = 3000;
|
||||||
|
|
||||||
|
export function errorText(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.status === UNAUTHORIZED) {
|
||||||
|
return "Only the owner can do this";
|
||||||
|
}
|
||||||
|
return Object.values(error.errors)[0]?.[0] ?? error.message;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Action {
|
||||||
|
busy = $state(false);
|
||||||
|
message = $state("");
|
||||||
|
failed = $state(false);
|
||||||
|
errors = $state<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
async run(task: () => Promise<unknown>, done = "Saved"): Promise<boolean> {
|
||||||
|
this.busy = true;
|
||||||
|
this.message = "";
|
||||||
|
this.failed = false;
|
||||||
|
this.errors = {};
|
||||||
|
try {
|
||||||
|
await task();
|
||||||
|
this.message = done;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.failed = true;
|
||||||
|
this.message = errorText(error);
|
||||||
|
this.errors = error instanceof ApiError ? error.errors : {};
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
field(name: string): string | undefined {
|
||||||
|
return this.errors[name]?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.message = "";
|
||||||
|
this.failed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Arm {
|
||||||
|
key = $state<string | null>(null);
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
press(key: string, run: () => unknown) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
if (this.key === key) {
|
||||||
|
this.key = null;
|
||||||
|
return run();
|
||||||
|
}
|
||||||
|
this.key = key;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.key = null;
|
||||||
|
}, ARM_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ApiError, apiHeaders, apiUrl, type Query } from "#lib/api/client.js";
|
||||||
|
|
||||||
|
const FILENAME = /filename="?([^";]+)"?/;
|
||||||
|
const REVOKE_MS = 1000;
|
||||||
|
|
||||||
|
export async function download(
|
||||||
|
path: string,
|
||||||
|
query: Query,
|
||||||
|
fallback: string
|
||||||
|
): Promise<number> {
|
||||||
|
const response = await fetch(apiUrl(path, query), {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: apiHeaders(false),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as {
|
||||||
|
message?: string;
|
||||||
|
} | null;
|
||||||
|
throw new ApiError(response.status, body?.message ?? response.statusText);
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const name =
|
||||||
|
response.headers.get("Content-Disposition")?.match(FILENAME)?.[1] ??
|
||||||
|
fallback;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = name;
|
||||||
|
anchor.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), REVOKE_MS);
|
||||||
|
return blob.size;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { all } from "#lib/api/lists.js";
|
||||||
|
import type { Currency, One } from "#lib/api/types.js";
|
||||||
|
|
||||||
|
interface Me {
|
||||||
|
email: string;
|
||||||
|
role?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FOREVER = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
export const misc = {
|
||||||
|
config: (key: string) => ({
|
||||||
|
queryFn: () =>
|
||||||
|
api<{ data: { value: unknown } }>(`/v1/configuration/${key}`).then(
|
||||||
|
(body) => body.data.value
|
||||||
|
),
|
||||||
|
queryKey: ["configuration", key],
|
||||||
|
staleTime: FOREVER,
|
||||||
|
}),
|
||||||
|
currencies: () => ({
|
||||||
|
queryFn: () => all<Currency>("/v1/currencies"),
|
||||||
|
queryKey: ["currencies"],
|
||||||
|
}),
|
||||||
|
me: () => ({
|
||||||
|
queryFn: () =>
|
||||||
|
api<One<Me>>("/v1/about/user").then((body) => body.data.attributes),
|
||||||
|
queryKey: ["about-user"],
|
||||||
|
staleTime: FOREVER,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dateOnly = (value?: string | null) => value?.slice(0, 10);
|
||||||
|
|
||||||
|
export function sizeText(bytes: number | null | undefined): string {
|
||||||
|
const value = bytes ?? 0;
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
let size = value;
|
||||||
|
let unit = 0;
|
||||||
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const th =
|
||||||
|
"h-8 whitespace-nowrap border-cladd-outline border-b px-2 text-left font-semibold text-[12px] text-cladd-fg-soft";
|
||||||
|
export const thNum = `${th} text-right`;
|
||||||
|
export const td = "px-2 py-1 align-middle";
|
||||||
|
export const tdNum = `${td} num text-right`;
|
||||||
|
export const link = "text-brand hover:underline";
|
||||||
|
export const select =
|
||||||
|
"cladd-cut cladd-hoverable cladd-focusable-input h-cladd-md w-full min-w-0 rounded-cladd-md px-2 font-medium text-cladd-fg text-cladd-xs";
|
||||||
|
export const textarea =
|
||||||
|
"cladd-cut cladd-hoverable cladd-focusable-input min-h-24 w-full min-w-0 rounded-cladd-md px-2.5 py-1.5 font-medium text-cladd-fg text-cladd-xs";
|
||||||
|
export const muted = "text-[12px] text-cladd-fg-soft";
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
export function setQuery(values: Record<string, string | null | undefined>) {
|
||||||
|
const url = new URL(page.url.href);
|
||||||
|
for (const [key, value] of Object.entries(values)) {
|
||||||
|
if (value) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return goto(url, { replace: true, state: page.state });
|
||||||
|
}
|
||||||
@@ -36,10 +36,12 @@
|
|||||||
await invalidate();
|
await invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycleTheme() {
|
async function cycleTheme() {
|
||||||
const order = ["system", "light", "dark"] as const;
|
const order = ["system", "light", "dark"] as const;
|
||||||
const current = userPrefersMode.current ?? "system";
|
const current = userPrefersMode.current ?? "system";
|
||||||
setMode(order[(order.indexOf(current) + 1) % order.length]);
|
const next = order[(order.indexOf(current) + 1) % order.length];
|
||||||
|
setMode(next);
|
||||||
|
await session.setPreference("darkMode", next === "system" ? "browser" : next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusSearch() {
|
function focusSearch() {
|
||||||
|
|||||||
+2
-5
@@ -95,11 +95,8 @@ function toDate(value: string | Date): Date {
|
|||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (value.length === 10) {
|
const [y, m, d] = value.slice(0, 10).split("-").map(Number);
|
||||||
const [y, m, d] = value.split("-").map(Number);
|
return new Date(y, m - 1, d);
|
||||||
return new Date(y, m - 1, d);
|
|
||||||
}
|
|
||||||
return new Date(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(
|
export function formatDate(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { setMode } from "mode-watcher";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
keepAlive,
|
keepAlive,
|
||||||
@@ -98,6 +99,15 @@ class Session {
|
|||||||
configureFormat(this.locale, this.anonymous);
|
configureFormat(this.locale, this.anonymous);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyTheme() {
|
||||||
|
const theme = this.prefs.darkMode;
|
||||||
|
if (theme === "light" || theme === "dark") {
|
||||||
|
setMode(theme);
|
||||||
|
} else if (theme === "browser") {
|
||||||
|
setMode("system");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async boot(): Promise<string | null> {
|
async boot(): Promise<string | null> {
|
||||||
const state = await ping();
|
const state = await ping();
|
||||||
if (state !== "ok") {
|
if (state !== "ok") {
|
||||||
@@ -113,6 +123,7 @@ class Session {
|
|||||||
this.version = about.data?.version ?? "";
|
this.version = about.data?.version ?? "";
|
||||||
this.apiVersion = about.data?.api_version ?? "";
|
this.apiVersion = about.data?.api_version ?? "";
|
||||||
this.email = user.data.attributes.email;
|
this.email = user.data.attributes.email;
|
||||||
|
this.applyTheme();
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
keepAlive();
|
keepAlive();
|
||||||
return takeReturnPath();
|
return takeReturnPath();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const SPA_EXACT = [
|
|||||||
"/recurring",
|
"/recurring",
|
||||||
"/webhooks/index",
|
"/webhooks/index",
|
||||||
"/settings/links",
|
"/settings/links",
|
||||||
|
"/settings/links/create",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SPA_PREFIXES = [
|
export const SPA_PREFIXES = [
|
||||||
@@ -41,10 +42,10 @@ export const SPA_PREFIXES = [
|
|||||||
"/recurring/show/",
|
"/recurring/show/",
|
||||||
"/webhooks/show/",
|
"/webhooks/show/",
|
||||||
"/settings/links/show/",
|
"/settings/links/show/",
|
||||||
|
"/settings/links/edit/",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FIREFLY_PREFIXES = [
|
export const FIREFLY_PREFIXES = [
|
||||||
"/accounts/reconcile/",
|
|
||||||
"/attachments/download/",
|
"/attachments/download/",
|
||||||
"/attachments/view/",
|
"/attachments/view/",
|
||||||
"/transactions/convert/",
|
"/transactions/convert/",
|
||||||
|
|||||||
@@ -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