Files
firefly-ui/src/lib/components/editor/Editor.svelte
T

707 lines
20 KiB
Svelte

<script lang="ts">
import Copy from "@lucide/svelte/icons/copy";
import Plus from "@lucide/svelte/icons/plus";
import { onMount, tick, untrack } from "svelte";
import { ApiError, api } from "#lib/api/client.js";
import { invalidate } from "#lib/api/lists.js";
import type { Item, One, TransactionGroup } from "#lib/api/types.js";
import Box from "#lib/components/Box.svelte";
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import { formatMoney } from "#lib/format.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { ui } from "#lib/ui.svelte.js";
import { goto } from "$app/navigation";
import Attachments, { upload } from "./Attachments.svelte";
import { Catalog } from "./catalog.svelte.js";
import Links from "./Links.svelte";
import {
type Errors,
emptyForm,
emptySplit,
type Form,
fromGroup,
groupTitle,
KINDS,
mismatch,
OWN_SIDE,
SHARED,
sideOf,
switchType,
TX_TYPES,
type TxType,
TYPE_LABEL,
toAccountRef,
toPayload,
total,
validate,
} from "./model.js";
import SplitBox from "./SplitBox.svelte";
type After = "show" | "another" | "stay";
const OWN_KEY = "ffui.editor.own";
const AFTER_KEY = "ffui.editor.after";
const LEAVE_MS = 3000;
const LIST: Record<TxType, string> = {
deposit: "deposit",
transfer: "transfers",
withdrawal: "withdrawal",
};
const FIELDS: [string, string, string][] = [
["d", "description", "description"],
["f", "source", "source account"],
["t", "destination", "destination account"],
["w", "date", "date"],
["m", "amount", "amount"],
["c", "category", "category"],
["b", "budget", "budget"],
["s", "bill", "subscription"],
["#", "tags", "tags"],
["r", "notes", "notes"],
];
const INLINE = new Set([
"description",
"source_id",
"source_name",
"destination_id",
"destination_name",
"date",
"amount",
"foreign_amount",
"foreign_currency_code",
"category_name",
"budget_id",
"bill_id",
"piggy_bank_id",
"tags",
"notes",
"internal_reference",
"external_url",
"interest_date",
"book_date",
"process_date",
"due_date",
"payment_date",
"invoice_date",
]);
let {
initial,
group = null,
prefill = {},
}: {
initial: Form;
group?: Item<TransactionGroup> | null;
prefill?: { source?: string | null; destination?: string | null };
} = $props();
const catalog = new Catalog();
const start = untrack(() => $state.snapshot(initial) as Form);
const editing = untrack(() => group !== null);
const originalType = start.type;
let form = $state<Form>(start);
let lastSplit = start.splits[0].key;
let saved = $state(untrack(() => group));
let snapshot = JSON.stringify(start);
let errors = $state<Errors>({});
let status = $state<{
tone: "ok" | "error" | "info";
text: string;
href?: string;
extra?: string[];
} | null>(null);
let saving = $state(false);
let leaving = $state(false);
let queued = $state<File[]>([]);
let applyRules = $state(true);
let fireWebhooks = $state(true);
let after = $state<After>(
(localStorage.getItem(
`${AFTER_KEY}.${editing ? "edit" : "create"}`
) as After) ?? "show"
);
let root = $state<HTMLElement | null>(null);
const problem = $derived(mismatch(form));
const totals = $derived(total(form));
const journals = $derived(
(saved?.attributes.transactions ?? []).map((split, index, list) => ({
id: split.transaction_journal_id ?? "",
label:
list.length > 1
? `Split ${index + 1}: ${split.description}`
: split.description,
}))
);
const title = $derived(
editing
? `Edit transaction "${saved?.attributes.group_title || saved?.attributes.transactions[0]?.description}"`
: `Create new ${form.type}`
);
$effect(() => {
localStorage.setItem(`${AFTER_KEY}.${editing ? "edit" : "create"}`, after);
});
$effect(() => {
for (const split of form.splits) {
if (split.reconciled) {
continue;
}
const own = sideOf(form, split, OWN_SIDE[form.type]);
if (own.currency && own.currency.code !== split.currency?.code) {
split.currency = own.currency;
}
if (form.type === "transfer") {
const target = sideOf(form, split, "destination").currency;
const want =
target && target.code !== split.currency?.code ? target : null;
if ((want?.code ?? null) !== (split.foreignCurrency?.code ?? null)) {
split.foreignCurrency = want;
}
}
}
});
let prefilled = false;
$effect(() => {
if (!catalog.accounts.data || prefilled) {
return;
}
prefilled = true;
untrack(applyPrefill);
});
function applyPrefill() {
const [first] = form.splits;
for (const side of ["source", "destination"] as const) {
const id = prefill[side];
const found = id ? catalog.accountById(id) : undefined;
if (found) {
first[side] = toAccountRef(found);
}
}
const side = OWN_SIDE[form.type];
if (editing || first[side].id) {
return;
}
const remembered = catalog.accountById(localStorage.getItem(OWN_KEY) ?? "");
const fallback =
remembered &&
KINDS[form.type][side].includes(toAccountRef(remembered).kind)
? remembered
: catalog.defaultAccount();
if (fallback) {
first[side] = toAccountRef(fallback);
}
snapshot = JSON.stringify($state.snapshot(form));
}
onMount(() => {
tick().then(() => focusField("description", false));
});
function splitOf(target: EventTarget | null): number | null {
const holder =
target instanceof Element ? target.closest("[data-split]") : null;
return holder ? Number(holder.getAttribute("data-split")) : null;
}
function currentKey(): number {
const key = splitOf(document.activeElement) ?? lastSplit;
return form.splits.some((split) => split.key === key)
? key
: form.splits[0].key;
}
function focusField(field: string, select = true) {
const keys = [currentKey(), form.splits[0].key];
for (const key of keys) {
const element = document.getElementById(`f-${key}-${field}`);
if (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement
) {
element.focus();
if (select) {
element.select();
}
return;
}
}
}
function setType(type: TxType) {
switchType(form, type);
if (!editing) {
goto(`/transactions/create/${type}${location.search}`, {
replace: true,
shallow: true,
});
}
}
async function addSplit() {
const last = form.splits.at(-1);
const next = emptySplit();
if (last) {
next.currency = last.currency;
next.foreignCurrency =
form.type === "transfer" ? last.foreignCurrency : null;
const counterparty =
OWN_SIDE[form.type] === "source" ? "destination" : "source";
if (!SHARED[form.type][counterparty]) {
next[counterparty] = { ...last[counterparty] };
}
}
form.splits.push(next);
await tick();
document.getElementById(`f-${next.key}-description`)?.focus();
}
async function removeSplit(key = currentKey()) {
if (form.splits.length < 2) {
return;
}
const index = form.splits.findIndex((split) => split.key === key);
if (index === 0) {
for (const side of ["source", "destination"] as const) {
if (SHARED[form.type][side]) {
form.splits[1][side] = form.splits[0][side];
}
}
}
form.splits.splice(index, 1);
await tick();
const focus = form.splits[Math.max(0, index - 1)];
document.getElementById(`f-${focus.key}-description`)?.focus();
}
function money(amount: number, currency: Form["splits"][number]["currency"]) {
return formatMoney({
amount,
code: currency?.code,
decimals: currency?.decimals,
symbol: currency?.symbol,
});
}
async function report() {
const keys = Object.keys(errors);
const extra = keys
.filter((key) => {
const field = key.split(".").at(-1) ?? "";
return (
!(key.startsWith("transactions.") && INLINE.has(field)) &&
key !== "group_title"
);
})
.map((key) => errors[key][0]);
status = {
extra,
text:
keys.length === 1
? "One field needs attention"
: `${keys.length} fields need attention`,
tone: "error",
};
await tick();
root?.querySelector<HTMLElement>("[aria-invalid='true']")?.focus();
}
async function save() {
if (saving) {
return;
}
errors = validate(form);
if (Object.keys(errors).length) {
await report();
return;
}
saving = true;
status = { text: editing ? "Updating" : "Saving", tone: "info" };
try {
const body = toPayload(form, editing, { applyRules, fireWebhooks });
const result = await api<One<TransactionGroup>>(
editing ? `/v1/transactions/${saved?.id}` : "/v1/transactions",
{ body, method: editing ? "PUT" : "POST" }
);
await finish(result.data);
} catch (error) {
if (
error instanceof ApiError &&
error.status === 422 &&
Object.keys(error.errors).length
) {
({ errors } = error);
await report();
} else {
status = {
text: `Firefly did not save it: ${error instanceof Error ? error.message : error}`,
tone: "error",
};
}
} finally {
saving = false;
}
}
async function finish(result: Item<TransactionGroup>) {
const splits = result.attributes.transactions;
const failed: string[] = [];
const results = await Promise.allSettled(
queued.map((file) => upload(splits[0].transaction_journal_id ?? "", file))
);
for (const [index, outcome] of results.entries()) {
if (outcome.status === "rejected") {
failed.push(queued[index].name);
}
}
queued = [];
const own = form.splits[0][OWN_SIDE[form.type]];
if (own.id) {
localStorage.setItem(OWN_KEY, own.id);
}
await invalidate();
const label = `"${groupTitle(form) ?? form.splits[0].description}" ${totals.map((entry) => money(entry.amount, entry.currency)).join(" + ")}`;
const href = `/transactions/show/${result.id}`;
const uploadNote = failed.length
? ` Upload failed for ${failed.join(", ")}.`
: "";
if (splits[0].type !== form.type) {
saved = result;
form = fromGroup(result);
snapshot = JSON.stringify($state.snapshot(form));
status = {
href,
text: `Saved, but Firefly kept it as a ${splits[0].type}: these accounts don't fit a ${form.type}.`,
tone: "error",
};
return;
}
if (after === "show" && !failed.length) {
await goto(href);
return;
}
if (after === "another" && !editing) {
reset();
status = { href, text: `Saved ${label}.${uploadNote}`, tone: "ok" };
return;
}
if (!editing) {
await goto(`/transactions/edit/${result.id}`);
return;
}
saved = result;
form = fromGroup(result);
snapshot = JSON.stringify($state.snapshot(form));
errors = {};
status = { href, text: `Saved ${label}.${uploadNote}`, tone: "ok" };
}
function reset() {
const next = emptyForm(form.type);
const side = OWN_SIDE[form.type];
next.date = form.date;
next.splits[0][side] = form.splits[0][side];
next.splits[0].currency = form.splits[0].currency;
form = next;
errors = {};
snapshot = JSON.stringify($state.snapshot(form));
tick().then(() => focusField("description"));
}
async function remove() {
await api(`/v1/transactions/${saved?.id}`, { method: "DELETE" });
await goto(`/transactions/${LIST[originalType]}`);
await invalidate();
}
function cancel() {
if (ui.quickEntry || ui.mobileNav) {
ui.quickEntry = false;
ui.mobileNav = false;
return;
}
const dirty = JSON.stringify($state.snapshot(form)) !== snapshot;
if (dirty && !leaving) {
leaving = true;
status = {
text: "Press Esc again to discard your changes",
tone: "info",
};
setTimeout(() => (leaving = false), LEAVE_MS);
return;
}
if (history.length > 1) {
history.back();
} else {
goto(`/transactions/${LIST[form.type]}`);
}
}
useKeys(() => [
{
group: "Editor",
input: true,
keys: "mod+Enter",
label: "Save the transaction",
run: save,
},
{
group: "Editor",
input: true,
keys: "mod+shift+Enter",
label: "Add another split",
run: addSplit,
},
{
group: "Editor",
keys: "X",
label: "Remove the current split",
run: () => removeSplit(),
},
...TX_TYPES.map((type, index) => ({
group: "Editor",
keys: `${index + 1}`,
label: `Make it a ${type}`,
run: () => setType(type),
})),
...FIELDS.map(([keys, field, label]) => ({
group: "Editor",
keys,
label: `Go to ${label}`,
run: () => focusField(field),
})),
{
group: "Editor",
keys: "T",
label: "Go to the split transaction title",
run: () => document.getElementById("group-title")?.focus(),
},
{
group: "Editor",
keys: "Escape",
label: "Cancel and go back",
run: cancel,
},
]);
const TONE = {
error: "text-expense",
info: "text-cladd-fg-soft",
ok: "text-income",
};
const TYPE_TONE: Record<TxType, string> = {
deposit: "text-income",
transfer: "text-transfer",
withdrawal: "text-expense",
};
</script>
<PageHeader
crumbs={[
{ href: `/transactions/${LIST[originalType]}`, label: "Transactions" },
{ label: editing ? `Edit #${saved?.id}` : `Create new ${form.type}` },
]}
subtitle={title}
title="Transactions"
>
{#snippet actions()}
{#if editing && saved}
<a
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-sm items-center gap-1.5 rounded-cladd-sm px-2.5 font-semibold text-[12px] text-cladd-fg"
href="/transactions/create/{form.type}?clone={saved.id}"
>
<Copy class="size-3.5" />
Clone
</a>
<ConfirmButton onconfirm={remove}>Delete</ConfirmButton>
{/if}
{/snippet}
</PageHeader>
<div
class="flex flex-col gap-4"
onfocusin={(event) => (lastSplit = splitOf(event.target) ?? lastSplit)}
bind:this={root}
>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<div
aria-label="Transaction type"
class="cladd-cut inline-flex h-9 items-center gap-0.5 rounded-full p-0.5 sm:h-8"
role="tablist"
>
{#each TX_TYPES as type, index (type)}
<button
aria-selected={form.type === type}
class="cladd-segment cladd-focusable inline-flex h-full items-center gap-2 rounded-full px-3 font-semibold text-[13px] {form.type ===
type
? TYPE_TONE[type]
: "text-cladd-fg-soft"}"
onclick={() => setType(type)}
role="tab"
tabindex={form.type === type ? 0 : -1}
type="button"
>
{TYPE_LABEL[type]}
<span class="kbd hidden lg:inline-flex">{index + 1}</span>
</button>
{/each}
</div>
{#if editing && form.type !== originalType}
<p class="text-[13px] text-warn">
Saving converts this {originalType} into a {form.type}.
</p>
{/if}
{#if problem}
<p class="text-[13px] text-warn" role="alert">
{problem.from}
to {problem.to.toLowerCase()}
{problem.suggest
? `is a ${problem.suggest}, not a ${form.type}.`
: `does not work as a ${form.type}.`}
{#if problem.suggest}
{@const suggest = problem.suggest}
<button
class="ml-1 font-semibold text-brand underline-offset-2 hover:underline"
onclick={() => setType(suggest)}
type="button"
>
Make it a {suggest}
</button>
{/if}
</p>
{/if}
</div>
{#each form.splits as split, index (split.key)}
<div data-split={split.key}>
<SplitBox
{catalog}
create={!editing}
{errors}
{index}
onremove={() => removeSplit(split.key)}
bind:form
/>
</div>
{/each}
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-md items-center gap-1.5 rounded-cladd-md px-2.5 font-semibold text-[12px] text-cladd-fg"
onclick={addSplit}
type="button"
>
<Plus class="size-3.5" />
Add another split
<span class="kbd hidden lg:inline-flex"
>{prettyKeys("mod+shift+Enter")[0]}</span
>
</button>
{#if form.splits.length > 1}
<span class="text-[13px] text-cladd-fg-soft">
Total of {form.splits.length} splits:
{#each totals as entry, index (entry.currency?.code ?? index)}
<span class="num ml-1 font-semibold text-cladd-fg"
>{money(entry.amount, entry.currency)}</span
>
{/each}
</span>
{/if}
</div>
{#if form.splits.length > 1}
<Box title="Split transaction">
<Field
error={errors.group_title?.[0]}
for="group-title"
keys="T"
label="Description of the split transaction"
>
<input
class={INPUT}
id="group-title"
placeholder={form.splits[0].description}
bind:value={form.title}
>
</Field>
</Box>
{/if}
<div class="grid gap-4 lg:grid-cols-2">
<Attachments groupId={saved?.id ?? null} {journals} bind:queued />
{#if editing && journals.length}
<Links {journals} />
{/if}
</div>
<div
class="cladd-surface cladd-outline sticky bottom-0 z-20 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-cladd-lg px-3 py-2 sm:gap-x-4 sm:py-2.5 shadow-[0_-6px_16px_-12px_rgb(0_0_0/0.35)]"
>
<Button
class="h-9 px-3.5 text-[13px] sm:h-cladd-md"
color="brand"
disabled={saving}
onclick={save}
variant="fill"
>
{editing ? "Update transaction" : "Save transaction"}
<span
class="kbd hidden bg-white/20! text-current! shadow-none! sm:inline-flex"
>{prettyKeys("mod+Enter")[0]}</span
>
</Button>
<label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft">
<span class="hidden sm:inline">After saving</span>
<select
aria-label="After saving"
class="cladd-cut h-8 rounded-cladd-md px-2 text-[13px] text-cladd-fg sm:h-cladd-md"
bind:value={after}
>
<option value="show">show the transaction</option>
{#if !editing}
<option value="another">create another one</option>
{/if}
<option value="stay">stay here to edit</option>
</select>
</label>
<label
class="hidden items-center gap-1.5 text-[13px] text-cladd-fg-soft sm:flex"
>
<input class="accent-brand" type="checkbox" bind:checked={applyRules}>
Apply rules
</label>
<label
class="hidden items-center gap-1.5 text-[13px] text-cladd-fg-soft sm:flex"
>
<input class="accent-brand" type="checkbox" bind:checked={fireWebhooks}>
Fire webhooks
</label>
{#if status}
<p
class="min-w-0 basis-full text-[13px] sm:basis-auto {TONE[status.tone]}"
role="status"
>
{status.text}
{#each status.extra ?? [] as line (line)}
<span class="block">{line}</span>
{/each}
{#if status.href}
<a
class="ml-1 text-brand underline-offset-2 hover:underline"
href={status.href}
>Open</a
>
{/if}
</p>
{/if}
</div>
</div>