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

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

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { Item, Recurrence } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { Arm, errorText } from "#lib/components/misc/action.svelte.js";
import ListTable from "#lib/components/misc/ListTable.svelte";
import MenuDelete from "#lib/components/misc/MenuDelete.svelte";
import { dateOnly } from "#lib/components/misc/queries.js";
import {
link,
muted,
td,
tdNum,
th,
thNum,
} from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import ContextMenuItem from "#lib/components/ui/context-menu/context-menu-item.svelte";
import { formatDate } from "#lib/format.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import { goto } from "$app/navigation";
import {
describe,
endsText,
stopped,
TYPES,
toneOf,
upcoming,
} from "./recurrence.js";
const query = createQuery(() => ({
queryFn: () => all<Recurrence>("/v1/recurrences"),
queryKey: ["recurrences"],
}));
const list = $derived(
[...(query.data ?? [])].sort(
(a, b) =>
Number(b.attributes.active) - Number(a.attributes.active) ||
(a.attributes.title ?? "").localeCompare(b.attributes.title ?? "")
)
);
const arm = new Arm();
let notes = $state<Record<string, string>>({});
const open = (item: Item<Recurrence>) => goto(`/recurring/show/${item.id}`);
const edit = (item: Item<Recurrence>) =>
location.assign(`/recurring/edit/${item.id}`);
async function remove(item: Item<Recurrence>) {
try {
await api(`/v1/recurrences/${item.id}`, { method: "DELETE" });
await invalidate();
} catch (error) {
notes = { ...notes, [item.id]: errorText(error) };
}
}
const nav: ListNav<Item<Recurrence>> = new ListNav({
edit,
extra: () => [
{
group: "Recurring transactions",
keys: "D",
label: "Delete recurring transaction (press twice)",
run: () => {
const item = nav.current;
if (item) {
arm.press(item.id, () => remove(item));
}
},
},
{
group: "Recurring transactions",
keys: "c",
label: "Create a recurring transaction (old interface)",
run: () => location.assign("/recurring/create"),
},
],
id: "recurring",
items: () => list,
key: (item) => item.id,
open,
});
nav.use();
</script>
<PageHeader
crumbs={[{ label: "Recurring transactions" }]}
title="Recurring transactions"
>
{#snippet actions()}
<Button data-sveltekit-reload href="/recurring/create" size="sm"
>Create a recurring transaction</Button
>
{/snippet}
</PageHeader>
<Box body="p-0" title="Recurring transactions">
{#snippet tools()}
<span class="hidden sm:inline {muted}"
>Creating and editing happens in the old interface</span
>
{/snippet}
{#if query.isPending}
<p class="p-3 {muted}">Loading recurring transactions</p>
{:else if query.error}
<p class="p-3 text-expense">{errorText(query.error)}</p>
{:else}
<ListTable
empty="You have no recurring transactions yet. Use them to make Firefly III create transactions for you on a schedule."
key={(item) => item.id}
{nav}
>
{#snippet head()}
<th class={th}>Title</th>
<th class="{th} hidden sm:table-cell w-24">Type</th>
<th class="{thNum} w-32">Amount</th>
<th class={th}>From → to</th>
<th class={th}>Repetitions</th>
<th class="{th} w-32">Next</th>
<th class="{th} hidden sm:table-cell w-16">Active</th>
<th class={th}></th>
{/snippet}
{#snippet row(
item
)}
{@const r = item.attributes}
{@const tx = r.transactions?.[0]}
<td class="{td} {r.active ? "" : "text-cladd-fg-soft line-through"}">
<a class={link} href="/recurring/show/{item.id}">{r.title}</a>
{#if r.description}
<div class="text-[12px] text-cladd-fg-soft no-underline">
{r.description}
</div>
{/if}
</td>
<td class="{td} hidden sm:table-cell">
{TYPES[r.type ?? ""] ?? r.type}
</td>
<td class={tdNum}>
{#each r.transactions ?? [] as split, i (split.id ?? i)}
<div>
<Amount
amount={split.amount}
decimals={split.currency_decimal_places}
symbol={split.currency_symbol}
tone={toneOf(r.type)}
/>
</div>
{/each}
</td>
<td class={td}>
{#if tx}
<a class="hover:underline" href="/accounts/show/{tx.source_id}"
>{tx.source_name}</a
>
→
<a class="hover:underline" href="/accounts/show/{tx.destination_id}"
>{tx.destination_name}</a
>
{/if}
</td>
<td class={td}>
{#each r.repetitions ?? [] as rep, i (rep.id ?? i)}
<div>{describe(rep)}</div>
{/each}
<div
class="text-[12px] {stopped(r)
? "text-expense"
: "text-cladd-fg-soft"}"
>
{stopped(r)
? `Stopped repeating on ${formatDate(dateOnly(r.repeat_until))}`
: endsText(r)}
</div>
</td>
<td class="{td} num">
{#each upcoming(r, 2) as date (date)}
<div>{formatDate(date)}</div>
{/each}
</td>
<td class="{td} hidden sm:table-cell">{r.active ? "Yes" : "No"}</td>
<td class="{td} text-[12px]">
{#if arm.key === item.id}
<span class="text-expense">Press D again to delete</span>
{:else if notes[item.id]}
<span class="text-expense">{notes[item.id]}</span>
{/if}
</td>
{/snippet}
{#snippet menu(
item
)}
<ContextMenuItem onSelect={() => open(item)}>
Show
<span class="ml-auto kbd">↵</span>
</ContextMenuItem>
<ContextMenuItem onSelect={() => edit(item)}>
Edit in the old interface
<span class="ml-auto kbd">e</span>
</ContextMenuItem>
<MenuDelete {arm} id={item.id} run={() => remove(item)} />
{/snippet}
</ListTable>
{/if}
</Box>
+101
View File
@@ -0,0 +1,101 @@
import type { Recurrence } from "#lib/api/types.js";
import { dateOnly } from "#lib/components/misc/queries.js";
import { parse, today } from "#lib/dates.js";
import { currentLocale, formatDate } from "#lib/format.js";
export const TYPES: Record<string, string> = {
deposit: "Deposit",
transfer: "Transfer",
withdrawal: "Withdrawal",
};
export const TONES = {
deposit: "income",
transfer: "transfer",
withdrawal: "expense",
} as const;
export const toneOf = (type?: string) =>
TONES[(type ?? "withdrawal") as keyof typeof TONES] ?? "neutral";
export function endsText(r: Recurrence): string {
if (r.repeat_until) {
return `Repeats until ${formatDate(dateOnly(r.repeat_until))}`;
}
if (r.nr_of_repetitions) {
return r.nr_of_repetitions === 1
? "Repeats 1 time"
: `Repeats ${r.nr_of_repetitions} times`;
}
return "Repeats forever";
}
export function stopped(r: Recurrence): boolean {
return Boolean(r.repeat_until && r.repeat_until.slice(0, 10) < today());
}
export function upcoming(r: Recurrence, count = 3): string[] {
const now = today();
const dates = (r.repetitions ?? [])
.flatMap((rep) => rep.occurrences ?? [])
.map((date) => date.slice(0, 10))
.filter((date) => date >= now);
return [...new Set(dates)].sort().slice(0, count);
}
type Repetition = NonNullable<Recurrence["repetitions"]>[number];
const DAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
const WEEKEND: Record<number, string> = {
2: ", skipped on weekends",
3: ", moved to Friday on weekends",
4: ", moved to Monday on weekends",
};
const SUFFIX = ["th", "st", "nd", "rd"];
export function ordinal(n: number): string {
const teen = n % 100 >= 11 && n % 100 <= 13;
return `${n}${teen ? "th" : (SUFFIX[n % 10] ?? "th")}`;
}
function every(unit: string, skip = 0): string {
if (skip === 0) {
return `Every ${unit}`;
}
return skip === 1
? `Every other ${unit}`
: `Every ${ordinal(skip + 1)} ${unit}`;
}
function base(rep: Repetition): string {
const day = (value: string | number) =>
DAYS[Number(value) - 1] ?? String(value);
switch (rep.type) {
case "daily":
return every("day", rep.skip);
case "weekly":
return `${every("week", rep.skip)} on ${day(rep.moment)}`;
case "monthly":
return `${every("month", rep.skip)} on the ${ordinal(Number(rep.moment))}`;
case "ndom": {
const [week, weekday] = rep.moment.split(",");
return `${every("month", rep.skip)} on the ${ordinal(Number(week))} ${day(weekday)}`;
}
case "yearly":
return `${every("year", rep.skip)} on ${new Intl.DateTimeFormat(currentLocale(), { day: "numeric", month: "long" }).format(parse(rep.moment))}`;
default:
return rep.description ?? rep.type;
}
}
export function describe(rep: Repetition): string {
return `${base(rep)}${WEEKEND[rep.weekend ?? 1] ?? ""}`;
}
@@ -0,0 +1,336 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type { One, Recurrence, TransactionGroup } from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import { Action, errorText } from "#lib/components/misc/action.svelte.js";
import { dateOnly } from "#lib/components/misc/queries.js";
import Status from "#lib/components/misc/Status.svelte";
import {
link,
muted,
td,
tdNum,
th,
thNum,
} from "#lib/components/misc/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import { today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import {
describe,
endsText,
stopped,
TYPES,
toneOf,
} from "../../recurrence.js";
const id = $derived(page.params.id ?? "");
const query = createQuery(() => ({
queryFn: () => api<One<Recurrence>>(`/v1/recurrences/${id}`),
queryKey: ["recurrence", id],
}));
const created = createQuery(() => ({
queryFn: () => all<TransactionGroup>(`/v1/recurrences/${id}/transactions`),
queryKey: ["recurrence-transactions", id],
}));
const r = $derived(query.data?.data.attributes);
const type = $derived(TYPES[r?.type ?? ""] ?? "Transaction");
const plural = $derived(`${type.toLowerCase()}s`);
const count = $derived(created.data?.length ?? 0);
const remaining = $derived(
r?.nr_of_repetitions ? Math.max(0, r.nr_of_repetitions - count) : null
);
const createdDates = $derived(
new Set(
(created.data ?? []).map((g) =>
(g.attributes.transactions[0]?.date ?? "").slice(0, 10)
)
)
);
function dates(occurrences: string[] = []) {
const now = today();
const list = occurrences.map((d) => d.slice(0, 10)).filter((d) => d >= now);
return remaining === null ? list : list.slice(0, remaining);
}
let firing = $state<string | null>(null);
let fired = $state<Record<string, string>>({});
async function trigger(date: string) {
firing = date;
try {
const result = await api<{ data: unknown[] }>(
`/v1/recurrences/${id}/trigger`,
{ method: "POST", query: { date } }
);
fired = {
...fired,
[date]: result.data.length > 0 ? "Created" : "Nothing to create",
};
await invalidate();
} catch (error) {
fired = { ...fired, [date]: errorText(error) };
} finally {
firing = null;
}
}
const removal = new Action();
async function remove() {
const ok = await removal.run(
() => api(`/v1/recurrences/${id}`, { method: "DELETE" }),
"Deleted"
);
if (ok) {
await invalidate();
await goto("/recurring");
}
}
const title = $derived(
r
? `Overview for recurring transaction "${r.title}"`
: "Recurring transaction"
);
</script>
<PageHeader
crumbs={[
{ href: "/recurring", label: "Recurring transactions" },
{ label: r?.title ?? "Recurring transaction" },
]}
{title}
/>
{#if r}
<div class="flex flex-col gap-4">
<div class="grid items-start gap-4 lg:grid-cols-3">
<Box
class="lg:col-span-2"
title="{r.title} ({type.toLowerCase()}){r.active ? "" : " (inactive)"}"
>
<div class="flex flex-col gap-2">
{#if r.description}
<p>{r.description}</p>
{/if}
{#if !r.active}
<p class="text-warn">
This recurring transaction is not active and will not generate new
transactions.
</p>
{/if}
{#if r.nr_of_repetitions}
<p class={remaining === 0 ? "text-expense" : ""}>
This recurring transaction will be created at most
{r.nr_of_repetitions}
times, and has been created {count} times already.
</p>
{/if}
<dl class="grid grid-cols-[9rem_minmax(0,1fr)] gap-x-3 gap-y-1">
<dt class="text-cladd-fg-soft">Repeats</dt>
<dd>
{#each r.repetitions ?? [] as rep, i (rep.id ?? i)}
<div>{describe(rep)}</div>
{/each}
</dd>
<dt class="text-cladd-fg-soft">First date</dt>
<dd class="num">{formatDate(dateOnly(r.first_date))}</dd>
<dt class="text-cladd-fg-soft">Ends</dt>
<dd class={stopped(r) ? "text-expense" : ""}>
{stopped(r)
? `This recurring transaction stopped repeating on ${formatDate(dateOnly(r.repeat_until))}.`
: endsText(r)}
</dd>
<dt class="text-cladd-fg-soft">Last created</dt>
<dd class={r.latest_date ? "num" : ""}>
{r.latest_date ? formatDate(dateOnly(r.latest_date)) : "Never"}
</dd>
<dt class="text-cladd-fg-soft">Apply rules</dt>
<dd>{r.apply_rules ? "Yes" : "No"}</dd>
{#if r.notes}
<dt class="text-cladd-fg-soft">Notes</dt>
<dd class="whitespace-pre-line">{r.notes}</dd>
{/if}
</dl>
</div>
{#snippet footer()}
<Button
data-sveltekit-reload
href="/recurring/edit/{id}"
size="sm"
variant="outline"
>Edit in the old interface</Button
>
<span class="ml-auto flex items-center gap-2">
<Status action={removal} busy="Deleting" />
<ConfirmButton
confirmLabel={count > 0
? `Press again, the ${count} created stay`
: "Press again to delete"}
onconfirm={remove}
>Delete</ConfirmButton
>
</span>
{/snippet}
</Box>
<Box body="p-0" title="Expected {plural}">
{#each r.repetitions ?? [] as rep, i (rep.id ?? i)}
<h3 class="border-cladd-outline border-b px-3 py-1.5 font-semibold">
{describe(rep)}
</h3>
<table class="w-full text-[13px]">
<tbody>
{#each dates(rep.occurrences) as date (date)}
<tr
class="h-7 border-cladd-outline/60 border-b last:border-b-0"
>
<td class="{td} num pl-3">{formatDate(date)}</td>
<td class="{td} pr-3 text-right">
{#if fired[date]}
<span class="text-[12px] text-cladd-fg-soft"
>{fired[date]}</span
>
{:else if createdDates.has(date)}
<span class="text-[12px] text-cladd-fg-soft"
>Created</span
>
{:else}
<Button
disabled={firing !== null}
onclick={() => trigger(date)}
size="xs"
variant="outline"
>Create right now</Button
>
{/if}
</td>
</tr>
{:else}
<tr>
<td class="px-3 py-2 {muted}">No upcoming dates.</td>
</tr>
{/each}
</tbody>
</table>
{/each}
</Box>
</div>
<Box body="p-0" title="Transaction data">
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<thead>
<tr>
<th class={th}>Description</th>
<th class={th}>Source account</th>
<th class={th}>Destination account</th>
<th class={thNum}>Amount</th>
<th class={th}>Category</th>
<th class={th}>Budget</th>
<th class={th}>Other</th>
</tr>
</thead>
<tbody>
{#each r.transactions ?? [] as tx, i (tx.id ?? i)}
<tr class="h-7 border-cladd-outline/60 border-b last:border-b-0">
<td class={td}>{tx.description}</td>
<td class={td}>
<a class={link} href="/accounts/show/{tx.source_id}"
>{tx.source_name}</a
>
</td>
<td class={td}>
<a class={link} href="/accounts/show/{tx.destination_id}"
>{tx.destination_name}</a
>
</td>
<td class={tdNum}>
<Amount
amount={tx.amount}
decimals={tx.currency_decimal_places}
symbol={tx.currency_symbol}
tone={toneOf(r.type)}
/>
{#if tx.foreign_amount}
<span class="text-cladd-fg-soft">
(<Amount
amount={tx.foreign_amount}
decimals={tx.foreign_currency_decimal_places}
symbol={tx.foreign_currency_symbol}
tone="neutral"
/>)
</span>
{/if}
</td>
<td class={td}>
{#if tx.category_id}
<a class={link} href="/categories/show/{tx.category_id}"
>{tx.category_name}</a
>
{/if}
</td>
<td class={td}>
{#if tx.budget_id}
<a class={link} href="/budgets/show/{tx.budget_id}"
>{tx.budget_name}</a
>
{/if}
</td>
<td class="{td} text-[12px]">
{#if tx.tags?.length}
<div>Tags: {tx.tags.join(", ")}</div>
{/if}
{#if tx.piggy_bank_id}
<div>
Piggy bank:
<a
class={link}
href="/piggy-banks/show/{tx.piggy_bank_id}"
>{tx.piggy_bank_name}</a
>
</div>
{/if}
{#if tx.subscription_id}
<div>
Subscription:
<a
class={link}
href="/subscriptions/show/{tx.subscription_id}"
>{tx.subscription_name}</a
>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Box>
<Box body="p-0" title="Created {plural}">
<TransactionTable
empty="Nothing created yet."
groups={created.data ?? []}
id="created"
loading={created.isPending}
selectable={false}
/>
</Box>
</div>
{:else if query.error}
<p class="text-expense">{errorText(query.error)}</p>
{:else}
<p class={muted}>Loading recurring transaction</p>
{/if}