feat(editor,quick-entry): page editor with splits, links, attachments and one-line quick entry
This commit is contained in:
1 parent
135a2d9328
commit
7e19684cff
22 files changed
+4067
-10
No files matched your search
@@ -196,13 +196,13 @@ export async function api<T>(
|
|||||||
...init
|
...init
|
||||||
}: Omit<RequestInit, "body"> & { query?: Query; body?: unknown } = {}
|
}: Omit<RequestInit, "body"> & { query?: Query; body?: unknown } = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const payload =
|
const json = !(body === undefined || body instanceof Blob);
|
||||||
body === undefined || body instanceof Blob ? body : JSON.stringify(body);
|
|
||||||
const request = withHeaders(
|
const request = withHeaders(
|
||||||
new Request(apiUrl(path, query), {
|
new Request(apiUrl(path, query), {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
...init,
|
...init,
|
||||||
body: payload as BodyInit | undefined,
|
body: json ? JSON.stringify(body) : (body as BodyInit | undefined),
|
||||||
|
headers: json ? { "Content-Type": "application/json" } : init.headers,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const response = await recover(request.clone(), await fetch(request));
|
const response = await recover(request.clone(), await fetch(request));
|
||||||
|
|||||||
@@ -1,9 +1,409 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import X from "@lucide/svelte/icons/x";
|
||||||
|
import { onMount, tick } from "svelte";
|
||||||
|
import { ApiError, api } from "#lib/api/client.js";
|
||||||
|
import { invalidate } from "#lib/api/lists.js";
|
||||||
|
import type { One, Split, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import { Catalog } from "#lib/components/editor/catalog.svelte.js";
|
||||||
|
import { setDraft } from "#lib/components/editor/draft.js";
|
||||||
|
import {
|
||||||
|
type Form,
|
||||||
|
KINDS,
|
||||||
|
TYPE_LABEL,
|
||||||
|
toPayload,
|
||||||
|
validate,
|
||||||
|
} from "#lib/components/editor/model.js";
|
||||||
|
import {
|
||||||
|
complete,
|
||||||
|
counterpartyKinds,
|
||||||
|
type Marker,
|
||||||
|
markerValue,
|
||||||
|
parseQuick,
|
||||||
|
resolveQuick,
|
||||||
|
type Segment,
|
||||||
|
segmentAt,
|
||||||
|
sidesOf,
|
||||||
|
tagsOf,
|
||||||
|
} from "#lib/components/editor/quick.js";
|
||||||
|
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
|
import { LIST } from "#lib/components/inputs/styles.js";
|
||||||
|
import { today } from "#lib/dates.js";
|
||||||
|
import { formatDate, formatMoney } from "#lib/format.js";
|
||||||
|
import { prettyKeys } from "#lib/keys.svelte.js";
|
||||||
import { ui } from "#lib/ui.svelte.js";
|
import { ui } from "#lib/ui.svelte.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
const SUGGESTIONS = 6;
|
||||||
|
const HINT =
|
||||||
|
"12.50 lunch @payee ^account !category $budget #tag ~yesterday · + in front for a deposit · >account for a transfer";
|
||||||
|
|
||||||
|
const catalog = new Catalog();
|
||||||
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
|
let text = $state("");
|
||||||
|
let caret = $state(0);
|
||||||
|
let active = $state(-1);
|
||||||
|
let saving = $state(false);
|
||||||
|
let message = $state<{ tone: "ok" | "error"; text: string } | null>(null);
|
||||||
|
let saved = $state<{ id: string; text: string } | null>(null);
|
||||||
|
let recent = $state<Record<string, Split | null>>({});
|
||||||
|
|
||||||
|
const quick = $derived(parseQuick(text));
|
||||||
|
const own = $derived(sidesOf(quick.type).own);
|
||||||
|
const other = $derived(sidesOf(quick.type).other);
|
||||||
|
const segment = $derived(segmentAt(quick, text, caret));
|
||||||
|
|
||||||
|
function optionsFor(at: Segment | null): Option[] {
|
||||||
|
if (!at) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const query = at.value.trim();
|
||||||
|
const lists: Record<Marker, () => Option[]> = {
|
||||||
|
"!": () => catalog.categoryOptions(query),
|
||||||
|
"@": () => catalog.accountOptions(KINDS[quick.type][other], query),
|
||||||
|
"#": () => catalog.tagOptions(query, tagsOf(quick)),
|
||||||
|
"^": () => catalog.accountOptions(KINDS[quick.type][own], query),
|
||||||
|
">": () => catalog.accountOptions(KINDS.transfer.destination, query),
|
||||||
|
"~": () => [],
|
||||||
|
$: () => catalog.budgetOptions(query),
|
||||||
|
};
|
||||||
|
const found = lists[at.marker]().slice(0, SUGGESTIONS);
|
||||||
|
const done =
|
||||||
|
found.length === 1 &&
|
||||||
|
found[0].label.toLowerCase() === query.toLowerCase() &&
|
||||||
|
text.slice(0, caret).endsWith(" ");
|
||||||
|
return done ? [] : found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = $derived(optionsFor(segment));
|
||||||
|
|
||||||
|
const resolved = $derived(resolveQuick(quick, catalog, recent));
|
||||||
|
|
||||||
|
const errors = $derived(validate(resolved.form));
|
||||||
|
const allProblems = $derived([
|
||||||
|
...(quick.amount === null ? ["Start with an amount, like 12.50"] : []),
|
||||||
|
...resolved.problems,
|
||||||
|
...Object.entries(errors)
|
||||||
|
.filter(([key]) => !key.endsWith(".amount"))
|
||||||
|
.map(([, value]) => value[0]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const name = markerValue(quick, "@");
|
||||||
|
const found = name
|
||||||
|
? catalog.findAccount(counterpartyKinds(quick.type), name)
|
||||||
|
: undefined;
|
||||||
|
if (!found || found.id in recent || quick.type === "transfer") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const side = other;
|
||||||
|
catalog.lastFor(found.id, side, quick.type).then((past) => {
|
||||||
|
recent = { ...recent, [found.id]: past?.split ?? null };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
input?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function money(form: Form) {
|
||||||
|
const [split] = form.splits;
|
||||||
|
return formatMoney({
|
||||||
|
amount: quick.amount ?? 0,
|
||||||
|
code: split.currency?.code,
|
||||||
|
decimals: split.currency?.decimals,
|
||||||
|
symbol: split.currency?.symbol,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCaret() {
|
||||||
|
caret = input?.selectionStart ?? text.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function accept(option: Option) {
|
||||||
|
if (!segment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = complete(text, segment, option.label);
|
||||||
|
({ text } = next);
|
||||||
|
active = -1;
|
||||||
|
await tick();
|
||||||
|
input?.setSelectionRange(next.caret, next.caret);
|
||||||
|
({ caret } = next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allProblems.length) {
|
||||||
|
message = { text: allProblems[0], tone: "error" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
message = null;
|
||||||
|
const { form } = resolved;
|
||||||
|
const [split] = form.splits;
|
||||||
|
try {
|
||||||
|
const result = await api<One<TransactionGroup>>("/v1/transactions", {
|
||||||
|
body: toPayload(form, false, { applyRules: true, fireWebhooks: true }),
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const target = split[other].name || "(cash)";
|
||||||
|
saved = {
|
||||||
|
id: result.data.id,
|
||||||
|
text: `${split.description} ${money(form)} ${own === "source" ? "→" : "←"} ${target}`,
|
||||||
|
};
|
||||||
|
text = "";
|
||||||
|
caret = 0;
|
||||||
|
await invalidate();
|
||||||
|
} catch (error) {
|
||||||
|
message = {
|
||||||
|
text:
|
||||||
|
error instanceof ApiError
|
||||||
|
? (Object.values(error.errors)[0]?.[0] ?? error.message)
|
||||||
|
: String(error),
|
||||||
|
tone: "error",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undo() {
|
||||||
|
if (!saved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id, text: label } = saved;
|
||||||
|
saved = null;
|
||||||
|
await api(`/v1/transactions/${id}`, { method: "DELETE" });
|
||||||
|
message = { text: `Deleted ${label}`, tone: "ok" };
|
||||||
|
await invalidate();
|
||||||
|
input?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor() {
|
||||||
|
setDraft($state.snapshot(resolved.form) as Form);
|
||||||
|
ui.quickEntry = false;
|
||||||
|
goto(`/transactions/create/${quick.type}?draft=${Date.now()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(event: KeyboardEvent) {
|
||||||
|
const mod = event.ctrlKey || event.metaKey;
|
||||||
|
const count = suggestions.length;
|
||||||
|
if (event.key === "Enter" && mod) {
|
||||||
|
event.preventDefault();
|
||||||
|
openEditor();
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (active >= 0 && suggestions[active]) {
|
||||||
|
accept(suggestions[active]);
|
||||||
|
} else {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
} else if (event.key === "Tab" && count && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
accept(suggestions[Math.max(active, 0)]);
|
||||||
|
} else if (
|
||||||
|
count &&
|
||||||
|
(event.key === "ArrowDown" || (event.ctrlKey && event.key === "n"))
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
active = (active + 1) % count;
|
||||||
|
} else if (
|
||||||
|
count &&
|
||||||
|
(event.key === "ArrowUp" || (event.ctrlKey && event.key === "p"))
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
active = (active - 1 + count) % count;
|
||||||
|
} else if (event.key === "Escape" && count) {
|
||||||
|
event.preventDefault();
|
||||||
|
caret = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountLabel = (value: Form["splits"][number]["source"]) => {
|
||||||
|
if (!value.name) {
|
||||||
|
return "(cash)";
|
||||||
|
}
|
||||||
|
return value.id ? value.name : `${value.name} (new)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = $derived.by(() => {
|
||||||
|
const { form, fromPast } = resolved;
|
||||||
|
const [split] = form.splits;
|
||||||
|
const last = (yes: boolean) => (yes ? " (last time)" : "");
|
||||||
|
const parts: { text: string; tone?: string }[] = [
|
||||||
|
{
|
||||||
|
text: TYPE_LABEL[quick.type],
|
||||||
|
tone: `font-semibold ${tone(quick.type)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: quick.amount === null ? "no amount yet" : money(form),
|
||||||
|
tone: "num font-semibold text-cladd-fg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `${accountLabel(split.source)} → ${accountLabel(split.destination)}`,
|
||||||
|
},
|
||||||
|
{ text: `"${split.description}"`, tone: "text-cladd-fg" },
|
||||||
|
];
|
||||||
|
if (split.category) {
|
||||||
|
const fresh = catalog.findCategory(split.category) ? "" : " (new)";
|
||||||
|
parts.push({
|
||||||
|
text: `category ${split.category}${fresh}${last(fromPast.category)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (split.budget.id) {
|
||||||
|
parts.push({
|
||||||
|
text: `budget ${split.budget.name}${last(fromPast.budget)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (split.tags.length) {
|
||||||
|
parts.push({ text: split.tags.map((tag) => `#${tag}`).join(" ") });
|
||||||
|
}
|
||||||
|
if (form.date !== today()) {
|
||||||
|
parts.push({ text: formatDate(form.date, "day") });
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tone = (kind: string) =>
|
||||||
|
({
|
||||||
|
deposit: "text-income",
|
||||||
|
transfer: "text-transfer",
|
||||||
|
withdrawal: "text-expense",
|
||||||
|
})[kind] ?? "";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="border-cladd-outline border-b bg-cladd-surface px-6 py-3 text-[13px]"
|
class="border-cladd-outline border-b bg-cladd-surface px-4 py-2.5 text-[13px] lg:px-6"
|
||||||
>
|
>
|
||||||
<button onclick={() => (ui.quickEntry = false)} type="button">Close</button>
|
<div class="mx-auto flex w-full max-w-[1920px] flex-col gap-1.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="hidden shrink-0 font-semibold text-cladd-fg-soft sm:block"
|
||||||
|
for="quick-entry"
|
||||||
|
>Quick entry</label
|
||||||
|
>
|
||||||
|
<div class="relative min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="quick-entry-list"
|
||||||
|
aria-expanded={suggestions.length > 0}
|
||||||
|
aria-label="Quick entry"
|
||||||
|
autocomplete="off"
|
||||||
|
class="cladd-cut cladd-focusable-input h-10 w-full rounded-cladd-md px-3 font-mono text-[16px] text-cladd-fg placeholder:font-sans placeholder:text-cladd-fg-softer sm:h-9 sm:text-[14px]"
|
||||||
|
enterkeyhint="send"
|
||||||
|
id="quick-entry"
|
||||||
|
onclick={syncCaret}
|
||||||
|
oninput={() => {
|
||||||
|
syncCaret();
|
||||||
|
active = -1;
|
||||||
|
saved = null;
|
||||||
|
message = null;
|
||||||
|
}}
|
||||||
|
onkeydown={handleKey}
|
||||||
|
onkeyup={(event) => {
|
||||||
|
if (event.key.startsWith("Arrow") && !suggestions.length) {
|
||||||
|
syncCaret();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="12.50 lunch @Costa Coffee !Eating out #work"
|
||||||
|
role="combobox"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:this={input}
|
||||||
|
bind:value={text}
|
||||||
|
>
|
||||||
|
{#if suggestions.length}
|
||||||
|
<div class={LIST} id="quick-entry-list" role="listbox">
|
||||||
|
{#each suggestions as option, index (option.id)}
|
||||||
|
<div
|
||||||
|
aria-selected={index === active}
|
||||||
|
class="flex cursor-pointer items-baseline gap-2 px-2.5 py-1.5 sm:py-1 {index ===
|
||||||
|
active
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
onmousedown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
accept(option);
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||||
|
{#if option.hint}
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft"
|
||||||
|
>{option.hint}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if option.meta}
|
||||||
|
<span class="text-[11px] text-cladd-fg-softer"
|
||||||
|
>{option.meta}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand inline-flex h-10 items-center gap-1.5 rounded-cladd-md px-3 font-semibold text-[12px] text-cladd-primary sm:h-9"
|
||||||
|
disabled={saving}
|
||||||
|
onclick={save}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Save <span class="kbd hidden sm:inline-flex">↵</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable hidden h-9 items-center gap-1.5 rounded-cladd-md px-3 font-semibold text-[12px] text-cladd-fg sm:inline-flex"
|
||||||
|
onclick={openEditor}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Editor <span class="kbd">{prettyKeys("mod+Enter")[0]}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Close quick entry"
|
||||||
|
class="grid size-9 shrink-0 place-items-center rounded-cladd-md text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-cladd-fg"
|
||||||
|
onclick={() => (ui.quickEntry = false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex min-h-5 flex-wrap items-baseline gap-x-2 gap-y-0.5 sm:pl-[5.6rem]"
|
||||||
|
>
|
||||||
|
{#if saved}
|
||||||
|
<span class="text-income" role="status">Saved: {saved.text}</span>
|
||||||
|
<button class="text-brand hover:underline" onclick={undo} type="button">
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
<span class="text-cladd-fg-softer">·</span>
|
||||||
|
<a
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
href="/transactions/show/{saved.id}"
|
||||||
|
>Open</a
|
||||||
|
>
|
||||||
|
{:else if message}
|
||||||
|
<span
|
||||||
|
class={message.tone === "error"
|
||||||
|
? "text-expense"
|
||||||
|
: "text-cladd-fg-soft"}
|
||||||
|
role="status"
|
||||||
|
>{message.text}</span
|
||||||
|
>
|
||||||
|
{:else if text.trim()}
|
||||||
|
{#each preview as part, index (index)}
|
||||||
|
{#if index}
|
||||||
|
<span class="text-cladd-fg-softer">·</span>
|
||||||
|
{/if}
|
||||||
|
<span class={part.tone ?? "text-cladd-fg-soft"}>{part.text}</span>
|
||||||
|
{/each}
|
||||||
|
{#if allProblems.length}
|
||||||
|
<span class="text-expense">· {allProblems[0]}</span>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span class="text-cladd-fg-soft">{HINT}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Account, Item } from "#lib/api/types.js";
|
||||||
|
import AutocompleteInput, {
|
||||||
|
type Option,
|
||||||
|
} from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
|
import type { Catalog } from "./catalog.svelte.js";
|
||||||
|
import { type AccountRef, emptyAccount, toAccountRef } from "./model.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(),
|
||||||
|
catalog,
|
||||||
|
kinds,
|
||||||
|
free = false,
|
||||||
|
id,
|
||||||
|
invalid = false,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = "",
|
||||||
|
onpick,
|
||||||
|
}: {
|
||||||
|
value: AccountRef;
|
||||||
|
catalog: Catalog;
|
||||||
|
kinds: string[];
|
||||||
|
free?: boolean;
|
||||||
|
id: string;
|
||||||
|
invalid?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
onpick?: (value: AccountRef) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function setText(text: string) {
|
||||||
|
if (text !== value.name) {
|
||||||
|
value = { ...emptyAccount(), name: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelected(option: Option | null) {
|
||||||
|
if (option?.data) {
|
||||||
|
value = toAccountRef(option.data as Item<Account>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AutocompleteInput
|
||||||
|
{disabled}
|
||||||
|
{free}
|
||||||
|
{id}
|
||||||
|
{invalid}
|
||||||
|
onpick={() => onpick?.(value)}
|
||||||
|
openOnFocus={!free}
|
||||||
|
{placeholder}
|
||||||
|
search={(query) => catalog.accountOptions(kinds, query)}
|
||||||
|
bind:selected={
|
||||||
|
() => (value.id ? { id: value.id, label: value.name } : null),
|
||||||
|
setSelected
|
||||||
|
}
|
||||||
|
bind:value={() => value.name, setText}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import type { Attachment, One } from "#lib/api/types.js";
|
||||||
|
|
||||||
|
export async function upload(journalId: string, file: File) {
|
||||||
|
const created = await api<One<Attachment>>("/v1/attachments", {
|
||||||
|
body: {
|
||||||
|
attachable_id: journalId,
|
||||||
|
attachable_type: "TransactionJournal",
|
||||||
|
filename: file.name,
|
||||||
|
title: file.name,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await api(`/v1/attachments/${created.data.id}/upload`, {
|
||||||
|
body: new Blob([file], { type: "application/octet-stream" }),
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await api(`/v1/attachments/${created.data.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import Paperclip from "@lucide/svelte/icons/paperclip";
|
||||||
|
import X from "@lucide/svelte/icons/x";
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { all, invalidate } from "#lib/api/lists.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import { formatNumber } from "#lib/format.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
groupId = null,
|
||||||
|
journals = [],
|
||||||
|
queued = $bindable([]),
|
||||||
|
}: {
|
||||||
|
groupId?: string | null;
|
||||||
|
journals?: { id: string; label: string }[];
|
||||||
|
queued?: File[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let input = $state<HTMLInputElement | null>(null);
|
||||||
|
let target = $state("");
|
||||||
|
let status = $state("");
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const existing = createQuery(() => ({
|
||||||
|
enabled: Boolean(groupId),
|
||||||
|
queryFn: () => all<Attachment>(`/v1/transactions/${groupId}/attachments`),
|
||||||
|
queryKey: ["transaction-attachments", groupId],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const journalLabel = (id: string | undefined) =>
|
||||||
|
journals.length > 1
|
||||||
|
? (journals.find((journal) => journal.id === id)?.label ?? "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const size = (bytes: number | undefined) =>
|
||||||
|
bytes && bytes > 1_048_576
|
||||||
|
? `${formatNumber(bytes / 1_048_576, 1)} MB`
|
||||||
|
: `${formatNumber((bytes ?? 0) / 1024, 0)} KB`;
|
||||||
|
|
||||||
|
async function chosen(files: FileList | null) {
|
||||||
|
const list = [...(files ?? [])];
|
||||||
|
if (input) {
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
if (!list.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!groupId) {
|
||||||
|
queued = [...queued, ...list];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
status = `Uploading ${list.length} file${list.length > 1 ? "s" : ""}`;
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
list.map((file) => upload(target || journals[0].id, file))
|
||||||
|
);
|
||||||
|
status = `Uploaded ${list.map((file) => file.name).join(", ")}`;
|
||||||
|
await invalidate();
|
||||||
|
} catch (error) {
|
||||||
|
status = `Upload failed: ${error instanceof Error ? error.message : error}`;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
await api(`/v1/attachments/${id}`, { method: "DELETE" });
|
||||||
|
status = "Attachment deleted";
|
||||||
|
await invalidate();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box title="Attachments">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each existing.data ?? [] as item (item.id)}
|
||||||
|
<div class="flex min-h-7 items-center gap-2 text-[13px]">
|
||||||
|
<Paperclip class="size-3.5 shrink-0 text-cladd-fg-soft" />
|
||||||
|
<a
|
||||||
|
class="min-w-0 flex-1 truncate text-brand hover:underline"
|
||||||
|
data-sveltekit-reload
|
||||||
|
href="/attachments/view/{item.id}"
|
||||||
|
rel="noopener"
|
||||||
|
target="_blank"
|
||||||
|
>{item.attributes.title || item.attributes.filename}</a
|
||||||
|
>
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft"
|
||||||
|
>{journalLabel(item.attributes.attachable_id)}</span
|
||||||
|
>
|
||||||
|
<span class="num text-[12px] text-cladd-fg-soft"
|
||||||
|
>{size(item.attributes.size)}</span
|
||||||
|
>
|
||||||
|
<ConfirmButton onconfirm={() => remove(item.id)}>Delete</ConfirmButton>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#each queued as file, index (`${file.name}:${index}`)}
|
||||||
|
<div class="flex min-h-7 items-center gap-2 text-[13px]">
|
||||||
|
<Paperclip class="size-3.5 shrink-0 text-cladd-fg-soft" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">{file.name}</span>
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft">uploads after saving</span>
|
||||||
|
<button
|
||||||
|
aria-label="Remove {file.name}"
|
||||||
|
class="grid size-6 place-items-center rounded text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-cladd-fg"
|
||||||
|
onclick={() => (queued = queued.filter((_, i) => i !== index))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{#if journals.length > 1}
|
||||||
|
<select
|
||||||
|
aria-label="Attach to split"
|
||||||
|
class="cladd-cut h-cladd-md rounded-cladd-md px-2 text-[13px]"
|
||||||
|
bind:value={
|
||||||
|
() => target || journals[0]?.id,
|
||||||
|
(value) => (target = value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{#each journals as journal (journal.id)}
|
||||||
|
<option value={journal.id}>{journal.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
<label
|
||||||
|
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-focusable-within inline-flex h-cladd-md cursor-pointer items-center gap-1.5 rounded-cladd-md px-2.5 font-semibold text-[12px] text-cladd-fg"
|
||||||
|
>
|
||||||
|
<Paperclip class="size-3.5" />
|
||||||
|
{busy ? "Uploading" : "Add files"}
|
||||||
|
<input
|
||||||
|
class="sr-only"
|
||||||
|
disabled={busy}
|
||||||
|
multiple
|
||||||
|
onchange={(event) => chosen(event.currentTarget.files)}
|
||||||
|
type="file"
|
||||||
|
bind:this={input}
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
{#if status}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft" role="status"
|
||||||
|
>{status}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
<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 { 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][] = [
|
||||||
|
["alt+d", "description", "description"],
|
||||||
|
["alt+f", "source", "source account"],
|
||||||
|
["alt+t", "destination", "destination account"],
|
||||||
|
["alt+w", "date", "date"],
|
||||||
|
["alt+m", "amount", "amount"],
|
||||||
|
["alt+k", "category", "category"],
|
||||||
|
["alt+b", "budget", "budget"],
|
||||||
|
["alt+g", "tags", "tags"],
|
||||||
|
["alt+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 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 currentKey(): number {
|
||||||
|
const holder = document.activeElement?.closest("[data-split]");
|
||||||
|
return Number(holder?.getAttribute("data-split") ?? 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",
|
||||||
|
input: true,
|
||||||
|
keys: "mod+alt+Backspace",
|
||||||
|
label: "Remove the current split",
|
||||||
|
run: () => removeSplit(),
|
||||||
|
},
|
||||||
|
...TX_TYPES.map((type, index) => ({
|
||||||
|
group: "Editor",
|
||||||
|
input: true,
|
||||||
|
keys: `alt+${index + 1}`,
|
||||||
|
label: `Make it a ${type}`,
|
||||||
|
run: () => setType(type),
|
||||||
|
})),
|
||||||
|
...FIELDS.map(([keys, field, label]) => ({
|
||||||
|
group: "Editor",
|
||||||
|
input: true,
|
||||||
|
keys,
|
||||||
|
label: `Go to ${label}`,
|
||||||
|
run: () => focusField(field),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
group: "Editor",
|
||||||
|
input: true,
|
||||||
|
keys: "alt+i",
|
||||||
|
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" 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"
|
||||||
|
>{prettyKeys(`alt+${index + 1}`)[0]}</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="alt+i"
|
||||||
|
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="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="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand inline-flex h-9 items-center gap-2 rounded-cladd-md px-3.5 font-semibold text-[13px] text-cladd-primary disabled:opacity-60 sm:h-cladd-md"
|
||||||
|
disabled={saving}
|
||||||
|
onclick={save}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{editing ? "Update transaction" : "Save transaction"}
|
||||||
|
<span class="kbd hidden 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 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>
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { ApiError, api } from "#lib/api/client.js";
|
||||||
|
import { all, invalidate } from "#lib/api/lists.js";
|
||||||
|
import type {
|
||||||
|
LinkType,
|
||||||
|
One,
|
||||||
|
Page,
|
||||||
|
TransactionGroup,
|
||||||
|
TransactionLink,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import ConfirmButton from "#lib/components/ConfirmButton.svelte";
|
||||||
|
import AutocompleteInput, {
|
||||||
|
type Option,
|
||||||
|
} from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
|
import Field from "#lib/components/inputs/Field.svelte";
|
||||||
|
import { INPUT } from "#lib/components/inputs/styles.js";
|
||||||
|
import { formatDate, formatMoney } from "#lib/format.js";
|
||||||
|
|
||||||
|
let { journals }: { journals: { id: string; label: string }[] } = $props();
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
journal: string;
|
||||||
|
notes: string;
|
||||||
|
other: { description: string; group: string; money: string } | null;
|
||||||
|
phrase: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let from = $state("");
|
||||||
|
let kind = $state("");
|
||||||
|
let notes = $state("");
|
||||||
|
let search = $state("");
|
||||||
|
let picked = $state<Option | null>(null);
|
||||||
|
let status = $state("");
|
||||||
|
|
||||||
|
const ids = $derived(journals.map((journal) => journal.id));
|
||||||
|
|
||||||
|
const types = createQuery(() => ({
|
||||||
|
queryFn: () => all<LinkType>("/v1/link-types"),
|
||||||
|
queryKey: ["link-types"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const phrases = $derived(
|
||||||
|
[...(types.data ?? [])]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.attributes.name === "Related") -
|
||||||
|
Number(a.attributes.name === "Related")
|
||||||
|
)
|
||||||
|
.flatMap((item) => [
|
||||||
|
{ label: item.attributes.outward, value: `${item.id}:outward` },
|
||||||
|
...(item.attributes.inward === item.attributes.outward
|
||||||
|
? []
|
||||||
|
: [{ label: item.attributes.inward, value: `${item.id}:inward` }]),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
async function describe(journalId: string) {
|
||||||
|
const group = await api<One<TransactionGroup>>(
|
||||||
|
`/v1/transaction-journals/${journalId}`
|
||||||
|
).catch(() => null);
|
||||||
|
const split = group?.data.attributes.transactions.find(
|
||||||
|
(item) => item.transaction_journal_id === journalId
|
||||||
|
);
|
||||||
|
if (!(group && split)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
description: split.description,
|
||||||
|
group: group.data.id,
|
||||||
|
money: formatMoney({
|
||||||
|
amount: split.amount,
|
||||||
|
decimals: split.currency_decimal_places,
|
||||||
|
symbol: split.currency_symbol,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = createQuery(() => ({
|
||||||
|
enabled: ids.length > 0 && types.isSuccess,
|
||||||
|
queryFn: async (): Promise<Row[]> => {
|
||||||
|
const pages = await Promise.all(
|
||||||
|
ids.map((id) =>
|
||||||
|
api<Page<TransactionLink>>(`/v1/transaction-journals/${id}/links`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const byType = new Map(
|
||||||
|
(types.data ?? []).map((item) => [item.id, item.attributes])
|
||||||
|
);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const found: {
|
||||||
|
link: Page<TransactionLink>["data"][number];
|
||||||
|
own: string;
|
||||||
|
}[] = [];
|
||||||
|
for (const [index, page] of pages.entries()) {
|
||||||
|
for (const link of page.data) {
|
||||||
|
if (!seen.has(link.id)) {
|
||||||
|
seen.add(link.id);
|
||||||
|
found.push({ link, own: ids[index] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rows: Row[] = await Promise.all(
|
||||||
|
found.map(async ({ link, own }) => {
|
||||||
|
const outgoing = link.attributes.inward_id === own;
|
||||||
|
const type = byType.get(link.attributes.link_type_id);
|
||||||
|
return {
|
||||||
|
id: link.id,
|
||||||
|
journal: own,
|
||||||
|
notes: link.attributes.notes ?? "",
|
||||||
|
other: await describe(
|
||||||
|
outgoing ? link.attributes.outward_id : link.attributes.inward_id
|
||||||
|
),
|
||||||
|
phrase: (outgoing ? type?.outward : type?.inward) ?? "",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
queryKey: ["journal-links", ids],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const searchTransactions = async (query: string): Promise<Option[]> => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const items = await api<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
date?: string;
|
||||||
|
amount?: string;
|
||||||
|
currency_code?: string;
|
||||||
|
}[]
|
||||||
|
>("/v1/autocomplete/transactions-with-meta", {
|
||||||
|
query: { limit: 10, query },
|
||||||
|
});
|
||||||
|
return items
|
||||||
|
.filter((item) => !ids.includes(String(item.id)))
|
||||||
|
.map((item) => ({
|
||||||
|
hint: item.amount
|
||||||
|
? `${formatMoney({ amount: item.amount, code: item.currency_code })}`
|
||||||
|
: undefined,
|
||||||
|
id: String(item.id),
|
||||||
|
label: item.description,
|
||||||
|
meta: item.date ? formatDate(item.date, "medium") : undefined,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
const own = from || ids[0];
|
||||||
|
const [typeId, direction] = (kind || phrases[0]?.value || "").split(":");
|
||||||
|
if (!(picked && typeId)) {
|
||||||
|
status = "Pick a transaction to link";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const outward = direction === "outward";
|
||||||
|
try {
|
||||||
|
await api("/v1/transaction-links", {
|
||||||
|
body: {
|
||||||
|
inward_id: outward ? own : picked.id,
|
||||||
|
link_type_id: typeId,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
outward_id: outward ? picked.id : own,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
status = `Linked to "${picked.label}"`;
|
||||||
|
picked = null;
|
||||||
|
search = "";
|
||||||
|
notes = "";
|
||||||
|
await invalidate();
|
||||||
|
} catch (error) {
|
||||||
|
status =
|
||||||
|
error instanceof ApiError
|
||||||
|
? (Object.values(error.errors)[0]?.[0] ?? error.message)
|
||||||
|
: String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
await api(`/v1/transaction-links/${id}`, { method: "DELETE" });
|
||||||
|
status = "Link deleted";
|
||||||
|
await invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalLabel = (id: string) =>
|
||||||
|
journals.length > 1
|
||||||
|
? (journals.find((journal) => journal.id === id)?.label ?? "")
|
||||||
|
: "This transaction";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Box title="Transaction links">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#if links.data?.length}
|
||||||
|
<ul class="flex flex-col gap-1.5">
|
||||||
|
{#each links.data as row (row.id)}
|
||||||
|
<li class="flex min-h-7 flex-wrap items-center gap-x-2 text-[13px]">
|
||||||
|
<span class="text-cladd-fg-soft">{journalLabel(row.journal)}</span>
|
||||||
|
<span>{row.phrase}</span>
|
||||||
|
{#if row.other}
|
||||||
|
<a
|
||||||
|
class="min-w-0 truncate text-brand hover:underline"
|
||||||
|
href="/transactions/show/{row.other.group}"
|
||||||
|
>"{row.other.description}"</a
|
||||||
|
>
|
||||||
|
<span class="num text-cladd-fg-soft">({row.other.money})</span>
|
||||||
|
{/if}
|
||||||
|
{#if row.notes}
|
||||||
|
<span class="text-cladd-fg-soft">{row.notes}</span>
|
||||||
|
{/if}
|
||||||
|
<ConfirmButton class="ml-auto" onconfirm={() => remove(row.id)}
|
||||||
|
>Delete</ConfirmButton
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if links.isSuccess}
|
||||||
|
<p class="text-[13px] text-cladd-fg-soft">No links yet.</p>
|
||||||
|
{/if}
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
{#if journals.length > 1}
|
||||||
|
<Field for="link-from" label="Split">
|
||||||
|
<select
|
||||||
|
class={INPUT}
|
||||||
|
id="link-from"
|
||||||
|
bind:value={() => from || ids[0], (value) => (from = value)}
|
||||||
|
>
|
||||||
|
{#each journals as journal (journal.id)}
|
||||||
|
<option value={journal.id}>{journal.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
<Field for="link-type" label="This transaction">
|
||||||
|
<select
|
||||||
|
class={INPUT}
|
||||||
|
id="link-type"
|
||||||
|
bind:value={
|
||||||
|
() => kind || phrases[0]?.value,
|
||||||
|
(value) => (kind = value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{#each phrases as phrase (phrase.value)}
|
||||||
|
<option value={phrase.value}>{phrase.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
class={journals.length > 1 ? "sm:col-span-2" : ""}
|
||||||
|
for="link-search"
|
||||||
|
label="Transaction"
|
||||||
|
>
|
||||||
|
<AutocompleteInput
|
||||||
|
async
|
||||||
|
id="link-search"
|
||||||
|
placeholder="Search by description or #id"
|
||||||
|
search={searchTransactions}
|
||||||
|
bind:selected={picked}
|
||||||
|
bind:value={search}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field class="sm:col-span-2" for="link-notes" label="Notes">
|
||||||
|
<input class={INPUT} id="link-notes" bind:value={notes}>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable inline-flex h-cladd-md items-center rounded-cladd-md px-2.5 font-semibold text-[12px] text-cladd-fg"
|
||||||
|
onclick={add}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Link transaction
|
||||||
|
</button>
|
||||||
|
{#if status}
|
||||||
|
<span class="text-[12px] text-cladd-fg-soft" role="status"
|
||||||
|
>{status}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AutocompleteInput, {
|
||||||
|
type Option,
|
||||||
|
} from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
|
import type { Ref } from "./model.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(),
|
||||||
|
search,
|
||||||
|
id,
|
||||||
|
invalid = false,
|
||||||
|
placeholder = "",
|
||||||
|
}: {
|
||||||
|
value: Ref;
|
||||||
|
search: (query: string) => Option[];
|
||||||
|
id: string;
|
||||||
|
invalid?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AutocompleteInput
|
||||||
|
{id}
|
||||||
|
{invalid}
|
||||||
|
openOnFocus
|
||||||
|
{placeholder}
|
||||||
|
{search}
|
||||||
|
bind:selected={
|
||||||
|
() => (value.id ? { id: value.id, label: value.name } : null),
|
||||||
|
(option) => {
|
||||||
|
if (option) {
|
||||||
|
value = { id: option.id, name: option.label };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bind:value={
|
||||||
|
() => value.name,
|
||||||
|
(text) => {
|
||||||
|
if (text !== value.name) {
|
||||||
|
value = { id: null, name: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Trash from "@lucide/svelte/icons/trash-2";
|
||||||
|
import type { Split } from "#lib/api/types.js";
|
||||||
|
import Box from "#lib/components/Box.svelte";
|
||||||
|
import AmountInput from "#lib/components/inputs/AmountInput.svelte";
|
||||||
|
import AutocompleteInput from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
|
import DateInput from "#lib/components/inputs/DateInput.svelte";
|
||||||
|
import Field from "#lib/components/inputs/Field.svelte";
|
||||||
|
import { INPUT } from "#lib/components/inputs/styles.js";
|
||||||
|
import TagsInput from "#lib/components/inputs/TagsInput.svelte";
|
||||||
|
import { prettyKeys } from "#lib/keys.svelte.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import AccountField from "./AccountField.svelte";
|
||||||
|
import type { Catalog } from "./catalog.svelte.js";
|
||||||
|
import {
|
||||||
|
type AccountRef,
|
||||||
|
CASH,
|
||||||
|
type Cur,
|
||||||
|
type Errors,
|
||||||
|
emptyAccount,
|
||||||
|
emptyRef,
|
||||||
|
type Form,
|
||||||
|
FREE_KIND,
|
||||||
|
fieldError,
|
||||||
|
hasForeign,
|
||||||
|
KINDS,
|
||||||
|
META_DATE_LABEL,
|
||||||
|
META_DATES,
|
||||||
|
SHARED,
|
||||||
|
type Side,
|
||||||
|
toAccountRef,
|
||||||
|
} from "./model.js";
|
||||||
|
import RefField from "./RefField.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
form = $bindable(),
|
||||||
|
index,
|
||||||
|
catalog,
|
||||||
|
errors,
|
||||||
|
create,
|
||||||
|
onremove,
|
||||||
|
}: {
|
||||||
|
form: Form;
|
||||||
|
index: number;
|
||||||
|
catalog: Catalog;
|
||||||
|
errors: Errors;
|
||||||
|
create: boolean;
|
||||||
|
onremove: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const split = $derived(form.splits[index]);
|
||||||
|
const count = $derived(form.splits.length);
|
||||||
|
const first = $derived(index === 0);
|
||||||
|
const id = (field: string) => `f-${split.key}-${field}`;
|
||||||
|
const err = (field: string) => fieldError(errors, index, field);
|
||||||
|
const keyHint = (keys: string) => (first ? keys : "");
|
||||||
|
const withdrawal = $derived(form.type === "withdrawal");
|
||||||
|
const counterparty = $derived<Side | null>(
|
||||||
|
{ deposit: "source", transfer: null, withdrawal: "destination" }[
|
||||||
|
form.type
|
||||||
|
] as Side | null
|
||||||
|
);
|
||||||
|
|
||||||
|
const optionalPref = $derived(
|
||||||
|
(session.prefs.transaction_journal_optional_fields ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
boolean
|
||||||
|
>
|
||||||
|
);
|
||||||
|
let more = $state(false);
|
||||||
|
const showExtra = (key: string, value: string) =>
|
||||||
|
more || Boolean(value) || optionalPref[key] === true;
|
||||||
|
|
||||||
|
let filled = $state<{ text: string; undo: () => void } | null>(null);
|
||||||
|
|
||||||
|
function fill(past: Split, withCounterparty: boolean) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const undo: (() => void)[] = [];
|
||||||
|
const side = counterparty;
|
||||||
|
if (withCounterparty && side && past.type === form.type) {
|
||||||
|
const pastId = past[`${side}_id`];
|
||||||
|
const current = split[side];
|
||||||
|
const account = pastId ? catalog.accountById(pastId) : undefined;
|
||||||
|
if (
|
||||||
|
account &&
|
||||||
|
past[`${side}_type`] !== CASH &&
|
||||||
|
!(current.id || current.name.trim())
|
||||||
|
) {
|
||||||
|
const next = toAccountRef(account);
|
||||||
|
split[side] = next;
|
||||||
|
parts.push(next.name);
|
||||||
|
undo.push(() => {
|
||||||
|
if (split[side].id === next.id) {
|
||||||
|
split[side] = emptyAccount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const category = past.category_name ?? "";
|
||||||
|
if (category && !split.category.trim()) {
|
||||||
|
split.category = category;
|
||||||
|
parts.push(`category ${category}`);
|
||||||
|
undo.push(() => {
|
||||||
|
if (split.category === category) {
|
||||||
|
split.category = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const budget = past.budget_id;
|
||||||
|
if (withdrawal && budget && !(split.budget.id || split.budget.name)) {
|
||||||
|
split.budget = { id: budget, name: past.budget_name ?? "" };
|
||||||
|
parts.push(`budget ${past.budget_name ?? ""}`);
|
||||||
|
undo.push(() => {
|
||||||
|
if (split.budget.id === budget) {
|
||||||
|
split.budget = emptyRef();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parts.length) {
|
||||||
|
filled = {
|
||||||
|
text: parts.join(", "),
|
||||||
|
undo: () => {
|
||||||
|
for (const step of undo) {
|
||||||
|
step();
|
||||||
|
}
|
||||||
|
filled = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickedAccount(side: Side, value: AccountRef) {
|
||||||
|
if (side !== counterparty || !value.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const past = await catalog.lastFor(value.id, side, form.type);
|
||||||
|
if (past) {
|
||||||
|
fill(past.split, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickedDescription(journalId: unknown) {
|
||||||
|
if (typeof journalId !== "string" || !journalId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const past = await catalog.fromJournal(journalId);
|
||||||
|
if (past) {
|
||||||
|
fill(past.split, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = (side: Side) =>
|
||||||
|
FREE_KIND[form.type][side] ? "(cash) or a new name" : "";
|
||||||
|
|
||||||
|
const sharedName = (side: Side) =>
|
||||||
|
form.splits[0][side].name || (FREE_KIND[form.type][side] ? "(cash)" : "");
|
||||||
|
|
||||||
|
const transferForeign = $derived(
|
||||||
|
form.type === "transfer" && hasForeign(split)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet account(
|
||||||
|
side: Side,
|
||||||
|
label: string,
|
||||||
|
keys: string
|
||||||
|
)}
|
||||||
|
<Field error={err(side)} for={id(side)} keys={keyHint(keys)} {label}>
|
||||||
|
{#if !first && SHARED[form.type][side]}
|
||||||
|
<p
|
||||||
|
class="flex h-9 items-center truncate text-[13px] text-cladd-fg-soft sm:h-cladd-md"
|
||||||
|
id={id(side)}
|
||||||
|
>
|
||||||
|
{sharedName(side) || "Same as split 1"}
|
||||||
|
<span class="ml-1 text-cladd-fg-softer">(same for all splits)</span>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<AccountField
|
||||||
|
{catalog}
|
||||||
|
disabled={split.reconciled}
|
||||||
|
free={Boolean(FREE_KIND[form.type][side])}
|
||||||
|
id={id(side)}
|
||||||
|
invalid={Boolean(err(side))}
|
||||||
|
kinds={KINDS[form.type][side]}
|
||||||
|
onpick={(value) => pickedAccount(side, value)}
|
||||||
|
placeholder={placeholder(side)}
|
||||||
|
bind:value={split[side]}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</Field>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
body="p-3 sm:p-4"
|
||||||
|
title={count > 1
|
||||||
|
? `Split ${index + 1} / ${count}`
|
||||||
|
: "Transaction information"}
|
||||||
|
>
|
||||||
|
{#snippet tools()}
|
||||||
|
{#if count > 1}
|
||||||
|
<button
|
||||||
|
class="inline-flex h-7 items-center gap-1.5 rounded-cladd-sm px-2 text-[12px] text-cladd-fg-soft hover:bg-cladd-surface-next hover:text-expense"
|
||||||
|
onclick={onremove}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash class="size-3.5" />
|
||||||
|
Remove split
|
||||||
|
<span class="kbd hidden lg:inline-flex"
|
||||||
|
>{prettyKeys("mod+alt+Backspace")[0]}</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<div class="grid gap-x-6 gap-y-3 lg:grid-cols-2">
|
||||||
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
|
<Field
|
||||||
|
error={err("description")}
|
||||||
|
for={id("description")}
|
||||||
|
keys={keyHint("alt+d")}
|
||||||
|
label="Description"
|
||||||
|
>
|
||||||
|
<AutocompleteInput
|
||||||
|
async
|
||||||
|
free
|
||||||
|
id={id("description")}
|
||||||
|
invalid={Boolean(err("description"))}
|
||||||
|
onpick={(option) => pickedDescription(option.data)}
|
||||||
|
search={catalog.descriptionOptions}
|
||||||
|
bind:value={split.description}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{@render account("source", "Source account", "alt+f")}
|
||||||
|
{@render account("destination", "Destination account", "alt+t")}
|
||||||
|
{#if first}
|
||||||
|
<Field error={err("date")} for={id("date")} keys="alt+w" label="Date">
|
||||||
|
<DateInput
|
||||||
|
id={id("date")}
|
||||||
|
invalid={Boolean(err("date"))}
|
||||||
|
placeholder="today, -1, 14.10"
|
||||||
|
bind:value={form.date}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
error={err("amount")}
|
||||||
|
for={id("amount")}
|
||||||
|
keys={keyHint("alt+m")}
|
||||||
|
label="Amount"
|
||||||
|
>
|
||||||
|
<AmountInput
|
||||||
|
currency={split.currency?.code ?? ""}
|
||||||
|
decimals={split.currency?.decimals ?? 2}
|
||||||
|
disabled={split.reconciled}
|
||||||
|
id={id("amount")}
|
||||||
|
invalid={Boolean(err("amount"))}
|
||||||
|
placeholder="0.00 or 12.5+3"
|
||||||
|
bind:value={split.amount}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{#if transferForeign}
|
||||||
|
<Field
|
||||||
|
error={err("foreign_amount")}
|
||||||
|
for={id("foreign-amount")}
|
||||||
|
label="Amount in {form.splits[0].destination.name ||
|
||||||
|
split.foreignCurrency?.code}"
|
||||||
|
>
|
||||||
|
<AmountInput
|
||||||
|
currency={split.foreignCurrency?.code ?? ""}
|
||||||
|
decimals={split.foreignCurrency?.decimals ?? 2}
|
||||||
|
disabled={split.reconciled}
|
||||||
|
id={id("foreign-amount")}
|
||||||
|
invalid={Boolean(err("foreign_amount"))}
|
||||||
|
bind:value={split.foreignAmount}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{:else if form.type !== "transfer"}
|
||||||
|
<Field
|
||||||
|
error={err("foreign_amount") || err("foreign_currency_code")}
|
||||||
|
for={id("foreign-currency")}
|
||||||
|
label="Foreign amount"
|
||||||
|
>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<AutocompleteInput
|
||||||
|
class="w-20 shrink-0"
|
||||||
|
disabled={split.reconciled}
|
||||||
|
id={id("foreign-currency")}
|
||||||
|
invalid={Boolean(err("foreign_currency_code"))}
|
||||||
|
onpick={(option) =>
|
||||||
|
(split.foreignCurrency = option.data as Cur)}
|
||||||
|
placeholder="Cur."
|
||||||
|
search={catalog.currencyOptions}
|
||||||
|
bind:value={
|
||||||
|
() => split.foreignCurrency?.code ?? "",
|
||||||
|
(text) => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
split.foreignCurrency = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{#if hasForeign(split)}
|
||||||
|
<AmountInput
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
currency={split.foreignCurrency?.code ?? ""}
|
||||||
|
decimals={split.foreignCurrency?.decimals ?? 2}
|
||||||
|
disabled={split.reconciled}
|
||||||
|
id={id("foreign-amount")}
|
||||||
|
invalid={Boolean(err("foreign_amount"))}
|
||||||
|
bind:value={split.foreignAmount}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if split.reconciled}
|
||||||
|
<p class="text-[12px] text-warn">
|
||||||
|
This split is reconciled, so its amount and accounts are locked.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex min-w-0 flex-col gap-3 border-cladd-outline lg:border-l lg:pl-6"
|
||||||
|
>
|
||||||
|
{#if withdrawal}
|
||||||
|
<Field
|
||||||
|
error={err("budget_id")}
|
||||||
|
for={id("budget")}
|
||||||
|
keys={keyHint("alt+b")}
|
||||||
|
label="Budget"
|
||||||
|
>
|
||||||
|
<RefField
|
||||||
|
id={id("budget")}
|
||||||
|
invalid={Boolean(err("budget_id"))}
|
||||||
|
placeholder="(no budget)"
|
||||||
|
search={catalog.budgetOptions}
|
||||||
|
bind:value={split.budget}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
<Field
|
||||||
|
error={err("category_name")}
|
||||||
|
for={id("category")}
|
||||||
|
keys={keyHint("alt+k")}
|
||||||
|
label="Category"
|
||||||
|
>
|
||||||
|
<AutocompleteInput
|
||||||
|
free
|
||||||
|
id={id("category")}
|
||||||
|
invalid={Boolean(err("category_name"))}
|
||||||
|
openOnFocus
|
||||||
|
search={catalog.categoryOptions}
|
||||||
|
bind:value={split.category}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{#if filled}
|
||||||
|
<p class="-mt-1.5 text-[12px] text-cladd-fg-soft">
|
||||||
|
Filled from last time: {filled.text}.
|
||||||
|
<button
|
||||||
|
class="text-brand underline-offset-2 hover:underline"
|
||||||
|
onclick={filled.undo}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if create}
|
||||||
|
<Field
|
||||||
|
error={err("piggy_bank_id")}
|
||||||
|
for={id("piggy")}
|
||||||
|
label="Piggy bank"
|
||||||
|
>
|
||||||
|
<RefField
|
||||||
|
id={id("piggy")}
|
||||||
|
invalid={Boolean(err("piggy_bank_id"))}
|
||||||
|
placeholder="(no piggy bank)"
|
||||||
|
search={catalog.piggyOptions}
|
||||||
|
bind:value={split.piggy}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
<Field
|
||||||
|
error={err("tags")}
|
||||||
|
for={id("tags")}
|
||||||
|
keys={keyHint("alt+g")}
|
||||||
|
label="Tags"
|
||||||
|
>
|
||||||
|
<TagsInput
|
||||||
|
id={id("tags")}
|
||||||
|
placeholder="Comma or Enter between tags"
|
||||||
|
search={(query, taken) => catalog.tagOptions(query, taken)}
|
||||||
|
bind:tags={split.tags}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{#if withdrawal}
|
||||||
|
<Field error={err("bill_id")} for={id("bill")} label="Subscription">
|
||||||
|
<RefField
|
||||||
|
id={id("bill")}
|
||||||
|
invalid={Boolean(err("bill_id"))}
|
||||||
|
placeholder="(no subscription)"
|
||||||
|
search={catalog.billOptions}
|
||||||
|
bind:value={split.bill}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
<Field
|
||||||
|
error={err("notes")}
|
||||||
|
for={id("notes")}
|
||||||
|
keys={keyHint("alt+r")}
|
||||||
|
label="Notes"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
class="{INPUT} h-auto max-h-80 min-h-16 py-1.5 leading-snug [field-sizing:content]"
|
||||||
|
id={id("notes")}
|
||||||
|
bind:value={split.notes}
|
||||||
|
></textarea>
|
||||||
|
</Field>
|
||||||
|
{#each META_DATES as key (key)}
|
||||||
|
{#if showExtra(key, split.dates[key])}
|
||||||
|
<Field error={err(key)} for={id(key)} label={META_DATE_LABEL[key]}>
|
||||||
|
<DateInput id={id(key)} bind:value={split.dates[key]} />
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if showExtra("internal_reference", split.internalReference)}
|
||||||
|
<Field
|
||||||
|
error={err("internal_reference")}
|
||||||
|
for={id("internal-reference")}
|
||||||
|
label="Internal reference"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class={INPUT}
|
||||||
|
id={id("internal-reference")}
|
||||||
|
bind:value={split.internalReference}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
{#if showExtra("external_url", split.externalUrl)}
|
||||||
|
<Field
|
||||||
|
error={err("external_url")}
|
||||||
|
for={id("external-url")}
|
||||||
|
label="External URL"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class={INPUT}
|
||||||
|
id={id("external-url")}
|
||||||
|
type="url"
|
||||||
|
bind:value={split.externalUrl}
|
||||||
|
>
|
||||||
|
</Field>
|
||||||
|
{/if}
|
||||||
|
{#if !more}
|
||||||
|
<button
|
||||||
|
class="self-start text-[12px] text-brand underline-offset-2 hover:underline"
|
||||||
|
onclick={() => (more = true)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
More fields: dates, internal reference, external URL
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { createQuery } from "@tanstack/svelte-query";
|
||||||
|
import { api } from "#lib/api/client.js";
|
||||||
|
import { lists } from "#lib/api/lists.js";
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
Item,
|
||||||
|
One,
|
||||||
|
Page,
|
||||||
|
TransactionGroup,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import type { Option } from "#lib/components/inputs/AutocompleteInput.svelte";
|
||||||
|
import { formatMoney } from "#lib/format.js";
|
||||||
|
import { fuzzyFilter } from "#lib/fuzzy.js";
|
||||||
|
import { session } from "#lib/session.svelte.js";
|
||||||
|
import { accountKind, type Cur, type Side, type TxType } from "./model.js";
|
||||||
|
|
||||||
|
const LIMIT = 40;
|
||||||
|
const OWN_KINDS = new Set(["Asset account", "Debt", "Loan", "Mortgage"]);
|
||||||
|
|
||||||
|
const lower = (value: string) => value.trim().toLowerCase();
|
||||||
|
|
||||||
|
function rank<T>(
|
||||||
|
items: T[],
|
||||||
|
query: string,
|
||||||
|
text: (item: T) => string,
|
||||||
|
recent: (item: T) => string
|
||||||
|
) {
|
||||||
|
if (query.trim()) {
|
||||||
|
return fuzzyFilter(items, query, text, LIMIT);
|
||||||
|
}
|
||||||
|
return [...items]
|
||||||
|
.sort((a, b) => recent(b).localeCompare(recent(a)))
|
||||||
|
.slice(0, LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recent {
|
||||||
|
split: TransactionGroup["transactions"][number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Catalog {
|
||||||
|
accounts = createQuery(() => lists.accounts("all"));
|
||||||
|
categories = createQuery(() => lists.categories());
|
||||||
|
budgets = createQuery(() => lists.budgets());
|
||||||
|
tags = createQuery(() => lists.tags());
|
||||||
|
bills = createQuery(() => lists.bills());
|
||||||
|
piggies = createQuery(() => lists.piggyBanks());
|
||||||
|
|
||||||
|
private readonly history = new Map<string, Promise<Recent | null>>();
|
||||||
|
|
||||||
|
accountsOf(kinds: string[]): Item<Account>[] {
|
||||||
|
return (this.accounts.data ?? []).filter(
|
||||||
|
(item) =>
|
||||||
|
item.attributes.active && kinds.includes(accountKind(item.attributes))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findAccount(kinds: string[], name: string): Item<Account> | undefined {
|
||||||
|
const wanted = lower(name);
|
||||||
|
return this.accountsOf(kinds).find(
|
||||||
|
(item) => lower(item.attributes.name) === wanted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
accountById(id: string): Item<Account> | undefined {
|
||||||
|
return (this.accounts.data ?? []).find((item) => item.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
accountOptions(kinds: string[], query: string): Option[] {
|
||||||
|
const items = rank(
|
||||||
|
this.accountsOf(kinds),
|
||||||
|
query,
|
||||||
|
(item) => item.attributes.name,
|
||||||
|
(item) => item.attributes.last_activity ?? ""
|
||||||
|
);
|
||||||
|
return items.map((item) => {
|
||||||
|
const kind = accountKind(item.attributes);
|
||||||
|
const own = OWN_KINDS.has(kind);
|
||||||
|
return {
|
||||||
|
data: item,
|
||||||
|
hint: own
|
||||||
|
? formatMoney({
|
||||||
|
amount: item.attributes.current_balance,
|
||||||
|
decimals: item.attributes.currency_decimal_places,
|
||||||
|
symbol: item.attributes.currency_symbol,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
id: item.id,
|
||||||
|
label: item.attributes.name,
|
||||||
|
meta: own
|
||||||
|
? `${kind.replace(" account", "")} · ${item.attributes.currency_code}`
|
||||||
|
: kind.replace(" account", ""),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryNames(): string[] {
|
||||||
|
return (this.categories.data ?? []).map((item) => item.attributes.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryOptions = (query: string): Option[] =>
|
||||||
|
rank(
|
||||||
|
this.categories.data ?? [],
|
||||||
|
query,
|
||||||
|
(item) => item.attributes.name,
|
||||||
|
(item) => item.attributes.last_activity ?? ""
|
||||||
|
).map((item) => ({ id: item.id, label: item.attributes.name }));
|
||||||
|
|
||||||
|
budgetOptions = (query: string): Option[] =>
|
||||||
|
fuzzyFilter(
|
||||||
|
(this.budgets.data ?? []).filter((item) => item.attributes.active),
|
||||||
|
query,
|
||||||
|
(item) => item.attributes.name,
|
||||||
|
LIMIT
|
||||||
|
).map((item) => ({ id: item.id, label: item.attributes.name }));
|
||||||
|
|
||||||
|
billOptions = (query: string): Option[] =>
|
||||||
|
fuzzyFilter(
|
||||||
|
(this.bills.data ?? []).filter((item) => item.attributes.active),
|
||||||
|
query,
|
||||||
|
(item) => item.attributes.name ?? "",
|
||||||
|
LIMIT
|
||||||
|
).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.attributes.name ?? "",
|
||||||
|
meta: item.attributes.object_group_title ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
piggyOptions = (query: string): Option[] =>
|
||||||
|
fuzzyFilter(
|
||||||
|
(this.piggies.data ?? []).filter(
|
||||||
|
(item) => item.attributes.active !== false
|
||||||
|
),
|
||||||
|
query,
|
||||||
|
(item) => item.attributes.name,
|
||||||
|
LIMIT
|
||||||
|
).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
label: item.attributes.name,
|
||||||
|
meta: item.attributes.object_group_title ?? undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
tagOptions(query: string, taken: string[] = []): Option[] {
|
||||||
|
const skip = new Set(taken.map(lower));
|
||||||
|
return fuzzyFilter(
|
||||||
|
(this.tags.data ?? []).filter(
|
||||||
|
(item) => !skip.has(lower(item.attributes.tag))
|
||||||
|
),
|
||||||
|
query,
|
||||||
|
(item) => item.attributes.tag,
|
||||||
|
LIMIT
|
||||||
|
).map((item) => ({ id: item.id, label: item.attributes.tag }));
|
||||||
|
}
|
||||||
|
|
||||||
|
findBudget(name: string) {
|
||||||
|
const wanted = lower(name);
|
||||||
|
return (this.budgets.data ?? []).find(
|
||||||
|
(item) => lower(item.attributes.name) === wanted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
findCategory(name: string) {
|
||||||
|
const wanted = lower(name);
|
||||||
|
return (this.categories.data ?? []).find(
|
||||||
|
(item) => lower(item.attributes.name) === wanted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
currencyOptions = (query: string): Option[] =>
|
||||||
|
fuzzyFilter(
|
||||||
|
session.currencies.filter((item) => item.attributes.enabled),
|
||||||
|
query,
|
||||||
|
(item) => `${item.attributes.code} ${item.attributes.name}`,
|
||||||
|
LIMIT
|
||||||
|
).map((item) => ({
|
||||||
|
data: {
|
||||||
|
code: item.attributes.code,
|
||||||
|
decimals: item.attributes.decimal_places ?? 2,
|
||||||
|
symbol: item.attributes.symbol,
|
||||||
|
} satisfies Cur,
|
||||||
|
id: item.id,
|
||||||
|
label: item.attributes.code,
|
||||||
|
meta: item.attributes.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
descriptionOptions = async (query: string): Promise<Option[]> => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const items = await api<
|
||||||
|
{ id: string; name: string; transaction_group_id?: string }[]
|
||||||
|
>("/v1/autocomplete/transactions", { query: { limit: 10, query } });
|
||||||
|
return items.map((item) => ({
|
||||||
|
data: item.id,
|
||||||
|
id: item.id,
|
||||||
|
label: item.name,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
defaultAccount(): Item<Account> | undefined {
|
||||||
|
const own = this.accountsOf(["Asset account"]);
|
||||||
|
for (const id of session.frontpageAccounts) {
|
||||||
|
const found = own.find((item) => item.id === id);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
own.find((item) => item.attributes.account_role === "defaultAsset") ??
|
||||||
|
own[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFor(accountId: string, side: Side, type: TxType): Promise<Recent | null> {
|
||||||
|
const key = `${accountId}:${side}`;
|
||||||
|
let found = this.history.get(key);
|
||||||
|
if (!found) {
|
||||||
|
found = api<Page<TransactionGroup>>(
|
||||||
|
`/v1/accounts/${accountId}/transactions`,
|
||||||
|
{
|
||||||
|
query: { limit: 5, types: type },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((page) => {
|
||||||
|
for (const group of page.data) {
|
||||||
|
const split = group.attributes.transactions.find(
|
||||||
|
(item) => item[`${side}_id`] === accountId
|
||||||
|
);
|
||||||
|
if (split) {
|
||||||
|
return { split };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
this.history.set(key, found);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fromJournal(journalId: string): Promise<Recent | null> {
|
||||||
|
const group = await api<One<TransactionGroup>>(
|
||||||
|
`/v1/transaction-journals/${journalId}`
|
||||||
|
).catch(() => null);
|
||||||
|
const split = group?.data.attributes.transactions.find(
|
||||||
|
(item) => item.transaction_journal_id === journalId
|
||||||
|
);
|
||||||
|
return split ? { split } : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Form } from "./model.js";
|
||||||
|
|
||||||
|
let pending: Form | null = null;
|
||||||
|
|
||||||
|
export function setDraft(form: Form) {
|
||||||
|
pending = form;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeDraft(): Form | null {
|
||||||
|
const draft = pending;
|
||||||
|
pending = null;
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
import type { Account, Item, Split, TransactionGroup } from "#lib/api/types.js";
|
||||||
|
import { amountString, evalAmount } from "#lib/components/inputs/parse.js";
|
||||||
|
import { today } from "#lib/dates.js";
|
||||||
|
|
||||||
|
export type TxType = "withdrawal" | "deposit" | "transfer";
|
||||||
|
export const TX_TYPES: TxType[] = ["withdrawal", "deposit", "transfer"];
|
||||||
|
export const TYPE_LABEL: Record<TxType, string> = {
|
||||||
|
deposit: "Deposit",
|
||||||
|
transfer: "Transfer",
|
||||||
|
withdrawal: "Withdrawal",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Cur {
|
||||||
|
code: string;
|
||||||
|
decimals: number;
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ref {
|
||||||
|
id: string | null;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountRef extends Ref {
|
||||||
|
currency: Cur | null;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const META_DATES = [
|
||||||
|
"interest_date",
|
||||||
|
"book_date",
|
||||||
|
"process_date",
|
||||||
|
"due_date",
|
||||||
|
"payment_date",
|
||||||
|
"invoice_date",
|
||||||
|
] as const;
|
||||||
|
export type MetaDate = (typeof META_DATES)[number];
|
||||||
|
export const META_DATE_LABEL: Record<MetaDate, string> = {
|
||||||
|
book_date: "Book date",
|
||||||
|
due_date: "Due date",
|
||||||
|
interest_date: "Interest date",
|
||||||
|
invoice_date: "Invoice date",
|
||||||
|
payment_date: "Payment date",
|
||||||
|
process_date: "Processing date",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SplitForm {
|
||||||
|
amount: string;
|
||||||
|
bill: Ref;
|
||||||
|
budget: Ref;
|
||||||
|
category: string;
|
||||||
|
currency: Cur | null;
|
||||||
|
dates: Record<MetaDate, string>;
|
||||||
|
description: string;
|
||||||
|
destination: AccountRef;
|
||||||
|
externalUrl: string;
|
||||||
|
foreignAmount: string;
|
||||||
|
foreignCurrency: Cur | null;
|
||||||
|
internalReference: string;
|
||||||
|
journalId: string | null;
|
||||||
|
key: number;
|
||||||
|
notes: string;
|
||||||
|
piggy: Ref;
|
||||||
|
reconciled: boolean;
|
||||||
|
source: AccountRef;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Form {
|
||||||
|
date: string;
|
||||||
|
splits: SplitForm[];
|
||||||
|
time: string;
|
||||||
|
title: string;
|
||||||
|
type: TxType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Side = "source" | "destination";
|
||||||
|
|
||||||
|
const ASSET = "Asset account";
|
||||||
|
const EXPENSE = "Expense account";
|
||||||
|
const REVENUE = "Revenue account";
|
||||||
|
export const CASH = "Cash account";
|
||||||
|
const LIABILITIES = ["Debt", "Loan", "Mortgage"];
|
||||||
|
|
||||||
|
export const KINDS: Record<TxType, Record<Side, string[]>> = {
|
||||||
|
deposit: {
|
||||||
|
destination: [ASSET, ...LIABILITIES],
|
||||||
|
source: [REVENUE, ...LIABILITIES],
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
destination: [ASSET, ...LIABILITIES],
|
||||||
|
source: [ASSET, ...LIABILITIES],
|
||||||
|
},
|
||||||
|
withdrawal: {
|
||||||
|
destination: [EXPENSE, ...LIABILITIES],
|
||||||
|
source: [ASSET, ...LIABILITIES],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FREE_KIND: Record<TxType, Partial<Record<Side, string>>> = {
|
||||||
|
deposit: { source: REVENUE },
|
||||||
|
transfer: {},
|
||||||
|
withdrawal: { destination: EXPENSE },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SHARED: Record<TxType, Record<Side, boolean>> = {
|
||||||
|
deposit: { destination: true, source: false },
|
||||||
|
transfer: { destination: true, source: true },
|
||||||
|
withdrawal: { destination: false, source: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OWN_SIDE: Record<TxType, Side> = {
|
||||||
|
deposit: "destination",
|
||||||
|
transfer: "source",
|
||||||
|
withdrawal: "source",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROUTES: Record<TxType, Record<string, string[]>> = {
|
||||||
|
deposit: {
|
||||||
|
[CASH]: [ASSET, ...LIABILITIES],
|
||||||
|
[REVENUE]: [ASSET, ...LIABILITIES],
|
||||||
|
...Object.fromEntries(LIABILITIES.map((kind) => [kind, [ASSET]])),
|
||||||
|
},
|
||||||
|
transfer: {
|
||||||
|
[ASSET]: [ASSET],
|
||||||
|
...Object.fromEntries(LIABILITIES.map((kind) => [kind, LIABILITIES])),
|
||||||
|
},
|
||||||
|
withdrawal: {
|
||||||
|
[ASSET]: [EXPENSE, ...LIABILITIES, CASH],
|
||||||
|
...Object.fromEntries(LIABILITIES.map((kind) => [kind, [EXPENSE, CASH]])),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fits = (type: TxType, source: string, destination: string) =>
|
||||||
|
ROUTES[type][source]?.includes(destination) ?? false;
|
||||||
|
|
||||||
|
export const typeFor = (source: string, destination: string) =>
|
||||||
|
TX_TYPES.find((type) => fits(type, source, destination)) ?? null;
|
||||||
|
|
||||||
|
const SHORT_KIND: Record<string, string> = {
|
||||||
|
asset: ASSET,
|
||||||
|
cash: CASH,
|
||||||
|
expense: EXPENSE,
|
||||||
|
revenue: REVENUE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function accountKind(account: Account): string {
|
||||||
|
if (account.type === "liabilities" || account.type === "liability") {
|
||||||
|
const type = account.liability_type ?? "debt";
|
||||||
|
return type[0].toUpperCase() + type.slice(1);
|
||||||
|
}
|
||||||
|
return SHORT_KIND[account.type] ?? account.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountCurrency = (account: Account): Cur | null =>
|
||||||
|
account.currency_code
|
||||||
|
? {
|
||||||
|
code: account.currency_code,
|
||||||
|
decimals: account.currency_decimal_places ?? 2,
|
||||||
|
symbol: account.currency_symbol ?? account.currency_code,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
export const toAccountRef = (item: Item<Account>): AccountRef => ({
|
||||||
|
currency: accountCurrency(item.attributes),
|
||||||
|
id: item.id,
|
||||||
|
kind: accountKind(item.attributes),
|
||||||
|
name: item.attributes.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function kindOf(type: TxType, side: Side, ref: AccountRef): string {
|
||||||
|
if (ref.id) {
|
||||||
|
return ref.kind;
|
||||||
|
}
|
||||||
|
const free = FREE_KIND[type][side];
|
||||||
|
if (!free) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return ref.name.trim() ? free : CASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastKey = 0;
|
||||||
|
const nextKey = () => {
|
||||||
|
lastKey += 1;
|
||||||
|
return lastKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyRef = (): Ref => ({ id: null, name: "" });
|
||||||
|
export const emptyAccount = (): AccountRef => ({
|
||||||
|
currency: null,
|
||||||
|
id: null,
|
||||||
|
kind: "",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function emptySplit(): SplitForm {
|
||||||
|
return {
|
||||||
|
amount: "",
|
||||||
|
bill: emptyRef(),
|
||||||
|
budget: emptyRef(),
|
||||||
|
category: "",
|
||||||
|
currency: null,
|
||||||
|
dates: Object.fromEntries(META_DATES.map((key) => [key, ""])) as Record<
|
||||||
|
MetaDate,
|
||||||
|
string
|
||||||
|
>,
|
||||||
|
description: "",
|
||||||
|
destination: emptyAccount(),
|
||||||
|
externalUrl: "",
|
||||||
|
foreignAmount: "",
|
||||||
|
foreignCurrency: null,
|
||||||
|
internalReference: "",
|
||||||
|
journalId: null,
|
||||||
|
key: nextKey(),
|
||||||
|
notes: "",
|
||||||
|
piggy: emptyRef(),
|
||||||
|
reconciled: false,
|
||||||
|
source: emptyAccount(),
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyForm = (type: TxType): Form => ({
|
||||||
|
date: today(),
|
||||||
|
splits: [emptySplit()],
|
||||||
|
time: "",
|
||||||
|
title: "",
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cur = (
|
||||||
|
code: string | null | undefined,
|
||||||
|
symbol: string | null | undefined,
|
||||||
|
decimals: number | null | undefined
|
||||||
|
): Cur | null =>
|
||||||
|
code ? { code, decimals: decimals ?? 2, symbol: symbol ?? code } : null;
|
||||||
|
|
||||||
|
const toRef = (
|
||||||
|
id: string | null | undefined,
|
||||||
|
name: string | null | undefined
|
||||||
|
) => (id ? { id, name: name ?? "" } : emptyRef());
|
||||||
|
|
||||||
|
function accountFrom(
|
||||||
|
id: string | null,
|
||||||
|
name: string | null | undefined,
|
||||||
|
kind: string | undefined,
|
||||||
|
currency: Cur | null
|
||||||
|
): AccountRef {
|
||||||
|
if (kind === CASH || !id) {
|
||||||
|
return { ...emptyAccount(), kind: CASH };
|
||||||
|
}
|
||||||
|
return { currency, id, kind: kind ?? "", name: name ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixed = (value: string | null | undefined, decimals: number) =>
|
||||||
|
value ? Number(value).toFixed(decimals) : "";
|
||||||
|
|
||||||
|
function fromSplit(split: Split, clone: boolean): SplitForm {
|
||||||
|
const currency = cur(
|
||||||
|
split.currency_code,
|
||||||
|
split.currency_symbol,
|
||||||
|
split.currency_decimal_places
|
||||||
|
);
|
||||||
|
const foreign = split.foreign_amount
|
||||||
|
? cur(
|
||||||
|
split.foreign_currency_code,
|
||||||
|
split.foreign_currency_symbol,
|
||||||
|
split.foreign_currency_decimal_places
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const own = split.type === "deposit" ? "destination" : "source";
|
||||||
|
const otherCurrency =
|
||||||
|
split.type === "transfer" ? (foreign ?? currency) : null;
|
||||||
|
return {
|
||||||
|
...emptySplit(),
|
||||||
|
amount: fixed(split.amount, currency?.decimals ?? 2),
|
||||||
|
bill: toRef(split.bill_id, split.bill_name),
|
||||||
|
budget: toRef(split.budget_id, split.budget_name),
|
||||||
|
category: split.category_name ?? "",
|
||||||
|
currency,
|
||||||
|
dates: Object.fromEntries(
|
||||||
|
META_DATES.map((key) => [key, split[key]?.slice(0, 10) ?? ""])
|
||||||
|
) as Record<MetaDate, string>,
|
||||||
|
description: split.description,
|
||||||
|
destination: accountFrom(
|
||||||
|
split.destination_id,
|
||||||
|
split.destination_name,
|
||||||
|
split.destination_type,
|
||||||
|
own === "destination" ? currency : otherCurrency
|
||||||
|
),
|
||||||
|
externalUrl: split.external_url ?? "",
|
||||||
|
foreignAmount: foreign ? fixed(split.foreign_amount, foreign.decimals) : "",
|
||||||
|
foreignCurrency: foreign,
|
||||||
|
internalReference: split.internal_reference ?? "",
|
||||||
|
journalId: clone ? null : (split.transaction_journal_id ?? null),
|
||||||
|
notes: split.notes ?? "",
|
||||||
|
reconciled: clone ? false : Boolean(split.reconciled),
|
||||||
|
source: accountFrom(
|
||||||
|
split.source_id,
|
||||||
|
split.source_name,
|
||||||
|
split.source_type,
|
||||||
|
own === "source" ? currency : null
|
||||||
|
),
|
||||||
|
tags: split.tags ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isEditableType = (type: string): type is TxType =>
|
||||||
|
(TX_TYPES as string[]).includes(type);
|
||||||
|
|
||||||
|
export function fromGroup(group: Item<TransactionGroup>, clone = false): Form {
|
||||||
|
const splits = group.attributes.transactions;
|
||||||
|
const [first] = splits;
|
||||||
|
return {
|
||||||
|
date: clone ? today() : first.date.slice(0, 10),
|
||||||
|
splits: splits.map((split) => fromSplit(split, clone)),
|
||||||
|
time: clone ? "" : first.date.slice(11),
|
||||||
|
title: group.attributes.group_title ?? "",
|
||||||
|
type: isEditableType(first.type) ? first.type : "withdrawal",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountFields(side: Side, value: AccountRef) {
|
||||||
|
if (value.id) {
|
||||||
|
return { [`${side}_id`]: value.id };
|
||||||
|
}
|
||||||
|
const name = value.name.trim();
|
||||||
|
return name ? { [`${side}_name`]: name } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sideOf = (form: Form, split: SplitForm, side: Side) =>
|
||||||
|
SHARED[form.type][side] ? form.splits[0][side] : split[side];
|
||||||
|
|
||||||
|
export function hasForeign(split: SplitForm): boolean {
|
||||||
|
return Boolean(
|
||||||
|
split.foreignCurrency && split.foreignCurrency.code !== split.currency?.code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPayload(form: Form, split: SplitForm, update: boolean) {
|
||||||
|
const withdrawal = form.type === "withdrawal";
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
bill_id: withdrawal ? split.bill.id : null,
|
||||||
|
budget_id: withdrawal ? split.budget.id : null,
|
||||||
|
category_name: split.category.trim() || null,
|
||||||
|
date: form.time ? `${form.date}T${form.time}` : form.date,
|
||||||
|
description: split.description.trim(),
|
||||||
|
external_url: split.externalUrl.trim() || null,
|
||||||
|
internal_reference: split.internalReference.trim() || null,
|
||||||
|
notes: split.notes.trim() || null,
|
||||||
|
tags: split.tags,
|
||||||
|
type: form.type,
|
||||||
|
};
|
||||||
|
for (const key of META_DATES) {
|
||||||
|
body[key] = split.dates[key] || null;
|
||||||
|
}
|
||||||
|
if (update && split.journalId) {
|
||||||
|
body.transaction_journal_id = split.journalId;
|
||||||
|
}
|
||||||
|
if (!(update || split.piggy.id === null)) {
|
||||||
|
body.piggy_bank_id = split.piggy.id;
|
||||||
|
}
|
||||||
|
if (split.reconciled) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
const foreign = hasForeign(split) && split.foreignAmount.trim() !== "";
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
...accountFields("source", sideOf(form, split, "source")),
|
||||||
|
...accountFields("destination", sideOf(form, split, "destination")),
|
||||||
|
amount: amountString(split.amount, split.currency?.decimals ?? 2),
|
||||||
|
currency_code: split.currency?.code,
|
||||||
|
foreign_amount: foreign
|
||||||
|
? amountString(split.foreignAmount, split.foreignCurrency?.decimals ?? 2)
|
||||||
|
: null,
|
||||||
|
foreign_currency_code: foreign ? split.foreignCurrency?.code : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropEmpty = (body: Record<string, unknown>) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(body).filter(
|
||||||
|
([, value]) => value !== null && value !== undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface SubmitOptions {
|
||||||
|
applyRules: boolean;
|
||||||
|
fireWebhooks: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTitle(form: Form): string | null {
|
||||||
|
if (form.splits.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return form.title.trim() || form.splits[0].description.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPayload(form: Form, update: boolean, options: SubmitOptions) {
|
||||||
|
const splits = form.splits.map((split) => splitPayload(form, split, update));
|
||||||
|
return {
|
||||||
|
apply_rules: options.applyRules,
|
||||||
|
fire_webhooks: options.fireWebhooks,
|
||||||
|
group_title: groupTitle(form),
|
||||||
|
...(update ? {} : { error_if_duplicate_hash: false }),
|
||||||
|
transactions: update ? splits : splits.map(dropEmpty),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Errors = Record<string, string[]>;
|
||||||
|
|
||||||
|
const ACCOUNT_FIELDS: Record<Side, string[]> = {
|
||||||
|
destination: ["destination_id", "destination_name"],
|
||||||
|
source: ["source_id", "source_name"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fieldError(
|
||||||
|
errors: Errors,
|
||||||
|
index: number,
|
||||||
|
field: string
|
||||||
|
): string {
|
||||||
|
const keys = ACCOUNT_FIELDS[field as Side] ?? [field];
|
||||||
|
for (const key of keys) {
|
||||||
|
const found = errors[`transactions.${index}.${key}`];
|
||||||
|
if (found?.length) {
|
||||||
|
return found[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountProblem(
|
||||||
|
form: Form,
|
||||||
|
split: SplitForm,
|
||||||
|
side: Side
|
||||||
|
): string | null {
|
||||||
|
const value = sideOf(form, split, side);
|
||||||
|
if (value.id) {
|
||||||
|
return KINDS[form.type][side].includes(value.kind)
|
||||||
|
? null
|
||||||
|
: `A ${value.kind.toLowerCase()} can't be the ${side} of a ${form.type}`;
|
||||||
|
}
|
||||||
|
if (FREE_KIND[form.type][side]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.name.trim()
|
||||||
|
? `No ${KINDS[form.type][side][0].toLowerCase()} or liability called "${value.name.trim()}"`
|
||||||
|
: `Choose the ${side} account`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mismatch(
|
||||||
|
form: Form
|
||||||
|
): { from: string; to: string; suggest: TxType | null } | null {
|
||||||
|
for (const split of form.splits) {
|
||||||
|
const source = kindOf(form.type, "source", sideOf(form, split, "source"));
|
||||||
|
const destination = kindOf(
|
||||||
|
form.type,
|
||||||
|
"destination",
|
||||||
|
sideOf(form, split, "destination")
|
||||||
|
);
|
||||||
|
if (source && destination && !fits(form.type, source, destination)) {
|
||||||
|
return {
|
||||||
|
from: source,
|
||||||
|
suggest: typeFor(source, destination),
|
||||||
|
to: destination,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Problems = [string, string][];
|
||||||
|
|
||||||
|
function foreignProblems(form: Form, split: SplitForm): Problems {
|
||||||
|
const foreign = split.foreignAmount.trim();
|
||||||
|
const problems: Problems = [];
|
||||||
|
if (hasForeign(split) && !foreign) {
|
||||||
|
problems.push([
|
||||||
|
"foreign_amount",
|
||||||
|
form.type === "transfer"
|
||||||
|
? `Enter the amount in ${split.foreignCurrency?.code}`
|
||||||
|
: "Enter the foreign amount or clear the currency",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (foreign && !hasForeign(split)) {
|
||||||
|
problems.push(["foreign_currency_code", "Choose the foreign currency"]);
|
||||||
|
}
|
||||||
|
if (foreign && (evalAmount(foreign) ?? 0) <= 0) {
|
||||||
|
problems.push(["foreign_amount", "The amount must be a positive number"]);
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moneyProblems(form: Form, split: SplitForm, index: number): Problems {
|
||||||
|
const problems: Problems = [];
|
||||||
|
const amount = evalAmount(split.amount);
|
||||||
|
if (amount === null || amount <= 0) {
|
||||||
|
problems.push([
|
||||||
|
"amount",
|
||||||
|
split.amount.trim()
|
||||||
|
? "The amount must be a positive number"
|
||||||
|
: "Enter an amount",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for (const side of ["source", "destination"] as Side[]) {
|
||||||
|
const problem =
|
||||||
|
index === 0 || !SHARED[form.type][side]
|
||||||
|
? accountProblem(form, split, side)
|
||||||
|
: null;
|
||||||
|
if (problem) {
|
||||||
|
problems.push([`${side}_id`, problem]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const source = sideOf(form, split, "source");
|
||||||
|
if (source.id && source.id === sideOf(form, split, "destination").id) {
|
||||||
|
problems.push([
|
||||||
|
"destination_id",
|
||||||
|
"Source and destination are the same account",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
problems.push(...foreignProblems(form, split));
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkProblems(form: Form, split: SplitForm): Problems {
|
||||||
|
const withdrawal = form.type === "withdrawal";
|
||||||
|
const loose = (value: Ref) => !value.id && value.name.trim() !== "";
|
||||||
|
const checks: [boolean, string, string][] = [
|
||||||
|
[withdrawal && loose(split.budget), "budget_id", "Pick an existing budget"],
|
||||||
|
[
|
||||||
|
withdrawal && loose(split.bill),
|
||||||
|
"bill_id",
|
||||||
|
"Pick an existing subscription",
|
||||||
|
],
|
||||||
|
[loose(split.piggy), "piggy_bank_id", "Pick an existing piggy bank"],
|
||||||
|
];
|
||||||
|
return checks
|
||||||
|
.filter(([failed]) => failed)
|
||||||
|
.map(([, field, message]) => [field, message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate(form: Form): Errors {
|
||||||
|
const errors: Errors = {};
|
||||||
|
if (!form.date) {
|
||||||
|
errors["transactions.0.date"] = [
|
||||||
|
"Enter a date like today, -1, 14.10 or 2026-10-14",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
form.splits.forEach((split, index) => {
|
||||||
|
const problems: Problems = [
|
||||||
|
...(index === 0 && !split.description.trim()
|
||||||
|
? ([["description", "Describe the transaction"]] as Problems)
|
||||||
|
: []),
|
||||||
|
...(split.reconciled ? [] : moneyProblems(form, split, index)),
|
||||||
|
...linkProblems(form, split),
|
||||||
|
];
|
||||||
|
for (const [field, message] of problems) {
|
||||||
|
errors[`transactions.${index}.${field}`] = [message];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function switchType(form: Form, to: TxType) {
|
||||||
|
const from = form.type;
|
||||||
|
if (from === to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const split of form.splits) {
|
||||||
|
const own = split[OWN_SIDE[from]];
|
||||||
|
const other = split[OWN_SIDE[from] === "source" ? "destination" : "source"];
|
||||||
|
const ownSide = OWN_SIDE[to];
|
||||||
|
const otherSide: Side = ownSide === "source" ? "destination" : "source";
|
||||||
|
split[ownSide] = own;
|
||||||
|
split[otherSide] = adapt(other, to, otherSide);
|
||||||
|
}
|
||||||
|
form.type = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapt(value: AccountRef, type: TxType, side: Side): AccountRef {
|
||||||
|
if (value.id && KINDS[type][side].includes(value.kind)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (FREE_KIND[type][side] && value.name.trim()) {
|
||||||
|
return { ...emptyAccount(), name: value.name };
|
||||||
|
}
|
||||||
|
return emptyAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function total(form: Form): { amount: number; currency: Cur | null }[] {
|
||||||
|
const sums = new Map<string, { amount: number; currency: Cur | null }>();
|
||||||
|
for (const split of form.splits) {
|
||||||
|
const code = split.currency?.code ?? "";
|
||||||
|
const entry = sums.get(code) ?? { amount: 0, currency: split.currency };
|
||||||
|
entry.amount += evalAmount(split.amount) ?? 0;
|
||||||
|
sums.set(code, entry);
|
||||||
|
}
|
||||||
|
return [...sums.values()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import type { Split } from "#lib/api/types.js";
|
||||||
|
import { evalAmount, parseDate } from "#lib/components/inputs/parse.js";
|
||||||
|
import { today } from "#lib/dates.js";
|
||||||
|
import type { Catalog } from "./catalog.svelte.js";
|
||||||
|
import {
|
||||||
|
emptyForm,
|
||||||
|
KINDS,
|
||||||
|
OWN_SIDE,
|
||||||
|
type Side,
|
||||||
|
type SplitForm,
|
||||||
|
type TxType,
|
||||||
|
TYPE_LABEL,
|
||||||
|
toAccountRef,
|
||||||
|
} from "./model.js";
|
||||||
|
|
||||||
|
export type Marker = "@" | "^" | ">" | "!" | "$" | "#" | "~";
|
||||||
|
|
||||||
|
const MULTI = new Set<string>(["@", "^", ">", "!", "$"]);
|
||||||
|
const MARKERS = new Set<string>([...MULTI, "#", "~"]);
|
||||||
|
const WORD = /\S+/g;
|
||||||
|
const NUMERIC = /^[\d(]/;
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
end: number;
|
||||||
|
marker: Marker;
|
||||||
|
start: number;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Quick {
|
||||||
|
amount: number | null;
|
||||||
|
amountText: string;
|
||||||
|
description: string;
|
||||||
|
segments: Segment[];
|
||||||
|
type: TxType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseQuick(input: string): Quick {
|
||||||
|
const segments: Segment[] = [];
|
||||||
|
const words: string[] = [];
|
||||||
|
let amount: number | null = null;
|
||||||
|
let amountText = "";
|
||||||
|
let deposit = false;
|
||||||
|
let current: Segment | null = null;
|
||||||
|
let first = true;
|
||||||
|
for (const match of input.matchAll(WORD)) {
|
||||||
|
let [word] = match;
|
||||||
|
let start = match.index;
|
||||||
|
if (first && word.startsWith("+")) {
|
||||||
|
deposit = true;
|
||||||
|
word = word.slice(1);
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
if (!word) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (MARKERS.has(word[0])) {
|
||||||
|
const segment = {
|
||||||
|
end: start + word.length,
|
||||||
|
marker: word[0] as Marker,
|
||||||
|
start,
|
||||||
|
value: word.slice(1),
|
||||||
|
};
|
||||||
|
segments.push(segment);
|
||||||
|
current = MULTI.has(segment.marker) ? segment : null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current) {
|
||||||
|
current.value = `${current.value} ${word}`;
|
||||||
|
current.end = start + word.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value: number | null =
|
||||||
|
amount === null && NUMERIC.test(word) ? evalAmount(word) : null;
|
||||||
|
if (value !== null) {
|
||||||
|
amount = value;
|
||||||
|
amountText = word;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
words.push(word);
|
||||||
|
}
|
||||||
|
const hasTarget = segments.some((segment) => segment.marker === ">");
|
||||||
|
let type: TxType = "withdrawal";
|
||||||
|
if (hasTarget) {
|
||||||
|
type = "transfer";
|
||||||
|
} else if (deposit) {
|
||||||
|
type = "deposit";
|
||||||
|
}
|
||||||
|
return { amount, amountText, description: words.join(" "), segments, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markerValue = (quick: Quick, marker: Marker) =>
|
||||||
|
quick.segments
|
||||||
|
.filter((segment) => segment.marker === marker)
|
||||||
|
.at(-1)
|
||||||
|
?.value.trim() ?? "";
|
||||||
|
|
||||||
|
export const tagsOf = (quick: Quick) =>
|
||||||
|
quick.segments
|
||||||
|
.filter((segment) => segment.marker === "#" && segment.value)
|
||||||
|
.map((segment) => segment.value);
|
||||||
|
|
||||||
|
export function segmentAt(
|
||||||
|
quick: Quick,
|
||||||
|
input: string,
|
||||||
|
caret: number
|
||||||
|
): Segment | null {
|
||||||
|
const last = quick.segments.at(-1);
|
||||||
|
for (const segment of quick.segments) {
|
||||||
|
const open =
|
||||||
|
segment === last && MULTI.has(segment.marker) && caret === input.length;
|
||||||
|
if (caret > segment.start && (caret <= segment.end || open)) {
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function complete(
|
||||||
|
input: string,
|
||||||
|
segment: Segment,
|
||||||
|
label: string
|
||||||
|
): { caret: number; text: string } {
|
||||||
|
const before = input.slice(0, segment.start + 1) + label;
|
||||||
|
const rest = input.slice(segment.end).trimStart();
|
||||||
|
return { caret: before.length + 1, text: `${before} ${rest}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sidesOf = (type: TxType): { other: Side; own: Side } => {
|
||||||
|
const own = OWN_SIDE[type];
|
||||||
|
return { other: own === "source" ? "destination" : "source", own };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const counterpartyKinds = (type: TxType) =>
|
||||||
|
type === "transfer"
|
||||||
|
? KINDS.transfer.destination
|
||||||
|
: KINDS[type][sidesOf(type).other];
|
||||||
|
|
||||||
|
function resolveAccounts(quick: Quick, catalog: Catalog, split: SplitForm) {
|
||||||
|
const { own, other } = sidesOf(quick.type);
|
||||||
|
const ownName = markerValue(quick, "^");
|
||||||
|
const ownAccount = ownName
|
||||||
|
? catalog.findAccount(KINDS[quick.type][own], ownName)
|
||||||
|
: catalog.defaultAccount();
|
||||||
|
if (ownAccount) {
|
||||||
|
split[own] = toAccountRef(ownAccount);
|
||||||
|
}
|
||||||
|
const otherName = markerValue(quick, quick.type === "transfer" ? ">" : "@");
|
||||||
|
const otherAccount = otherName
|
||||||
|
? catalog.findAccount(counterpartyKinds(quick.type), otherName)
|
||||||
|
: undefined;
|
||||||
|
split[other] = otherAccount
|
||||||
|
? toAccountRef(otherAccount)
|
||||||
|
: { ...split[other], name: otherName };
|
||||||
|
return ownAccount || !ownName
|
||||||
|
? []
|
||||||
|
: [`No asset account or liability called "${ownName}"`];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLinks(
|
||||||
|
quick: Quick,
|
||||||
|
catalog: Catalog,
|
||||||
|
split: SplitForm,
|
||||||
|
past: Split | null | undefined
|
||||||
|
) {
|
||||||
|
const categoryName = markerValue(quick, "!");
|
||||||
|
const budgetName = markerValue(quick, "$");
|
||||||
|
const learn = quick.type !== "transfer" && past;
|
||||||
|
split.category = categoryName || (learn ? (past.category_name ?? "") : "");
|
||||||
|
const budget = budgetName ? catalog.findBudget(budgetName) : undefined;
|
||||||
|
if (budget) {
|
||||||
|
split.budget = { id: budget.id, name: budget.attributes.name };
|
||||||
|
} else if (!budgetName && learn && past.budget_id) {
|
||||||
|
split.budget = { id: past.budget_id, name: past.budget_name ?? "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
fromPast: {
|
||||||
|
budget: !budgetName && Boolean(split.budget.id),
|
||||||
|
category: !categoryName && Boolean(split.category),
|
||||||
|
},
|
||||||
|
problems: budgetName && !budget ? [`No budget called "${budgetName}"`] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveQuick(
|
||||||
|
quick: Quick,
|
||||||
|
catalog: Catalog,
|
||||||
|
recent: Record<string, Split | null>
|
||||||
|
) {
|
||||||
|
const form = emptyForm(quick.type);
|
||||||
|
const [split] = form.splits;
|
||||||
|
const { own, other } = sidesOf(quick.type);
|
||||||
|
const problems = resolveAccounts(quick, catalog, split);
|
||||||
|
const counterparty = split[other].id;
|
||||||
|
const links = resolveLinks(
|
||||||
|
quick,
|
||||||
|
catalog,
|
||||||
|
split,
|
||||||
|
counterparty ? recent[counterparty] : null
|
||||||
|
);
|
||||||
|
problems.push(...links.problems);
|
||||||
|
split.tags = tagsOf(quick);
|
||||||
|
split.amount = quick.amountText;
|
||||||
|
split.currency = split[own].currency;
|
||||||
|
split.description =
|
||||||
|
quick.description ||
|
||||||
|
split[other].name ||
|
||||||
|
split.category ||
|
||||||
|
TYPE_LABEL[quick.type];
|
||||||
|
const dateText = markerValue(quick, "~");
|
||||||
|
const date = dateText ? parseDate(dateText) : today();
|
||||||
|
if (date) {
|
||||||
|
form.date = date;
|
||||||
|
} else {
|
||||||
|
problems.push(`"${dateText}" is not a date`);
|
||||||
|
}
|
||||||
|
return { form, fromPast: links.fromPast, problems };
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
|
import { formatNumber } from "#lib/format.js";
|
||||||
|
import { evalAmount, isExpression } from "./parse.js";
|
||||||
|
import { INPUT } from "./styles.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(""),
|
||||||
|
ref = $bindable(null),
|
||||||
|
currency = "",
|
||||||
|
decimals = 2,
|
||||||
|
invalid = false,
|
||||||
|
class: className = "",
|
||||||
|
...rest
|
||||||
|
}: Omit<HTMLInputAttributes, "value"> & {
|
||||||
|
value?: string;
|
||||||
|
ref?: HTMLInputElement | null;
|
||||||
|
currency?: string;
|
||||||
|
decimals?: number;
|
||||||
|
invalid?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const result = $derived(evalAmount(value));
|
||||||
|
const preview = $derived(
|
||||||
|
isExpression(value) && result !== null
|
||||||
|
? `= ${formatNumber(result, decimals)}`
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalise() {
|
||||||
|
if (result !== null && result > 0) {
|
||||||
|
value = result.toFixed(decimals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative {className}">
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
{...rest}
|
||||||
|
aria-invalid={invalid ||
|
||||||
|
(value.trim() !== "" && result === null) ||
|
||||||
|
undefined}
|
||||||
|
autocomplete="off"
|
||||||
|
class="{INPUT} num text-right {currency ? "pr-12" : ""}"
|
||||||
|
inputmode="decimal"
|
||||||
|
onblur={normalise}
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value
|
||||||
|
>
|
||||||
|
{#if currency}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-[12px] text-cladd-fg-soft"
|
||||||
|
>{currency}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if preview}
|
||||||
|
<span
|
||||||
|
class="num pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-[12px] text-cladd-fg-soft"
|
||||||
|
>{preview}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export interface Option {
|
||||||
|
data?: unknown;
|
||||||
|
hint?: string;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
meta?: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
|
import { INPUT, LIST } from "./styles.js";
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 150;
|
||||||
|
const STEPS: Record<string, number> = { ArrowDown: 1, ArrowUp: -1 };
|
||||||
|
const CTRL_STEPS: Record<string, number> = { n: 1, p: -1 };
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(""),
|
||||||
|
selected = $bindable(null),
|
||||||
|
ref = $bindable(null),
|
||||||
|
search,
|
||||||
|
onpick,
|
||||||
|
free = false,
|
||||||
|
async = false,
|
||||||
|
openOnFocus = false,
|
||||||
|
invalid = false,
|
||||||
|
bare = false,
|
||||||
|
class: className = "",
|
||||||
|
inputClass = "",
|
||||||
|
onkeydown: onkey,
|
||||||
|
onblur: onleave,
|
||||||
|
onfocus: onenter,
|
||||||
|
onenter: onsubmit,
|
||||||
|
...rest
|
||||||
|
}: Omit<HTMLInputAttributes, "value" | "onkeydown" | "onblur" | "onfocus"> & {
|
||||||
|
value?: string;
|
||||||
|
selected?: Option | null;
|
||||||
|
ref?: HTMLInputElement | null;
|
||||||
|
search: (query: string) => Option[] | Promise<Option[]>;
|
||||||
|
onpick?: (option: Option) => void;
|
||||||
|
free?: boolean;
|
||||||
|
async?: boolean;
|
||||||
|
openOnFocus?: boolean;
|
||||||
|
invalid?: boolean;
|
||||||
|
bare?: boolean;
|
||||||
|
inputClass?: string;
|
||||||
|
onkeydown?: (event: KeyboardEvent) => void;
|
||||||
|
onblur?: (event: FocusEvent) => void;
|
||||||
|
onfocus?: (event: FocusEvent) => void;
|
||||||
|
onenter?: (event: KeyboardEvent) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const uid = $props.id();
|
||||||
|
let open = $state(false);
|
||||||
|
let items = $state<Option[]>([]);
|
||||||
|
let active = $state(-1);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let ticket = 0;
|
||||||
|
|
||||||
|
const query = $derived(value.trim());
|
||||||
|
const exact = $derived(
|
||||||
|
items.find((item) => item.label.toLowerCase() === query.toLowerCase())
|
||||||
|
);
|
||||||
|
const shown = $derived(
|
||||||
|
free && query && !exact
|
||||||
|
? [...items, { hint: "new", id: "", label: query }]
|
||||||
|
: items
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load(text: string, highlight: boolean) {
|
||||||
|
ticket += 1;
|
||||||
|
const mine = ticket;
|
||||||
|
const result = await search(text);
|
||||||
|
if (mine !== ticket || document.activeElement !== ref) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items = result;
|
||||||
|
open = true;
|
||||||
|
active = highlight && text.trim() ? 0 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(highlight: boolean) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (async) {
|
||||||
|
timer = setTimeout(() => load(value, highlight), DEBOUNCE_MS);
|
||||||
|
} else {
|
||||||
|
load(value, highlight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ticket += 1;
|
||||||
|
open = false;
|
||||||
|
active = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(option: Option) {
|
||||||
|
value = option.label;
|
||||||
|
selected = option.id ? option : null;
|
||||||
|
close();
|
||||||
|
onpick?.(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(step: number) {
|
||||||
|
if (!open) {
|
||||||
|
refresh(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = shown.length;
|
||||||
|
if (count) {
|
||||||
|
active =
|
||||||
|
(active + step + count + (active < 0 && step < 0 ? 1 : 0)) % count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepOf(event: KeyboardEvent): number {
|
||||||
|
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey;
|
||||||
|
return STEPS[event.key] ?? (ctrl ? CTRL_STEPS[event.key] : 0) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(event: KeyboardEvent) {
|
||||||
|
onkey?.(event);
|
||||||
|
if (event.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const step = stepOf(event);
|
||||||
|
if (step) {
|
||||||
|
event.preventDefault();
|
||||||
|
move(step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (open && event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plain = !(
|
||||||
|
event.shiftKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.altKey
|
||||||
|
);
|
||||||
|
const choice = open && plain ? shown[active] : undefined;
|
||||||
|
if (choice && (event.key === "Enter" || event.key === "Tab")) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
pick(choice);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && plain) {
|
||||||
|
onsubmit?.(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput({
|
||||||
|
currentTarget,
|
||||||
|
}: Event & { currentTarget: HTMLInputElement }) {
|
||||||
|
({ value } = currentTarget);
|
||||||
|
if (selected && selected.label !== value) {
|
||||||
|
selected = null;
|
||||||
|
}
|
||||||
|
refresh(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur(event: FocusEvent) {
|
||||||
|
if (!selected && exact) {
|
||||||
|
value = exact.label;
|
||||||
|
selected = exact;
|
||||||
|
onpick?.(exact);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
onleave?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus(event: FocusEvent) {
|
||||||
|
if (openOnFocus) {
|
||||||
|
refresh(false);
|
||||||
|
}
|
||||||
|
onenter?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open && active >= 0) {
|
||||||
|
document
|
||||||
|
.getElementById(`${uid}-${active}`)
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="{bare ? "" : "relative"} {className}">
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
{...rest}
|
||||||
|
aria-activedescendant={open && active >= 0 ? `${uid}-${active}` : undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="{uid}-list"
|
||||||
|
aria-expanded={open && shown.length > 0}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
autocomplete="off"
|
||||||
|
class={inputClass || (bare ? "" : INPUT)}
|
||||||
|
onblur={handleBlur}
|
||||||
|
onfocus={handleFocus}
|
||||||
|
oninput={handleInput}
|
||||||
|
onkeydown={handleKey}
|
||||||
|
role="combobox"
|
||||||
|
spellcheck="false"
|
||||||
|
{value}
|
||||||
|
>
|
||||||
|
{#if open && shown.length > 0}
|
||||||
|
<div class={LIST} id="{uid}-list" role="listbox">
|
||||||
|
{#each shown as option, index (`${option.id}:${option.label}`)}
|
||||||
|
<div
|
||||||
|
aria-selected={index === active}
|
||||||
|
class="flex cursor-pointer items-baseline gap-2 px-2.5 py-1.5 text-[13px] sm:py-1 {index ===
|
||||||
|
active
|
||||||
|
? "bg-row-cursor"
|
||||||
|
: "hover:bg-row-hover"}"
|
||||||
|
id="{uid}-{index}"
|
||||||
|
onmousedown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
pick(option);
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
{#if option.hint === "new"}
|
||||||
|
<span class="text-cladd-fg-soft">Create</span>
|
||||||
|
"{option.label}"
|
||||||
|
{:else}
|
||||||
|
{option.label}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if option.hint && option.hint !== "new"}
|
||||||
|
<span class="num shrink-0 text-[12px] text-cladd-fg-soft"
|
||||||
|
>{option.hint}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if option.meta}
|
||||||
|
<span class="shrink-0 text-[11px] text-cladd-fg-softer"
|
||||||
|
>{option.meta}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Calendar from "@lucide/svelte/icons/calendar";
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
|
import { addDays, today } from "#lib/dates.js";
|
||||||
|
import { formatDate } from "#lib/format.js";
|
||||||
|
import { parseDate } from "./parse.js";
|
||||||
|
import { INPUT } from "./styles.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(""),
|
||||||
|
ref = $bindable(null),
|
||||||
|
invalid = false,
|
||||||
|
class: className = "",
|
||||||
|
...rest
|
||||||
|
}: Omit<HTMLInputAttributes, "value"> & {
|
||||||
|
value?: string;
|
||||||
|
ref?: HTMLInputElement | null;
|
||||||
|
invalid?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let text = $state(value);
|
||||||
|
let picker = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const parsed = $derived(parseDate(text, today()));
|
||||||
|
const shown = $derived(parsed ?? value);
|
||||||
|
const label = $derived(
|
||||||
|
shown
|
||||||
|
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}`
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const next = value;
|
||||||
|
untrack(() => {
|
||||||
|
if (next && next !== parseDate(text)) {
|
||||||
|
text = next;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
|
||||||
|
text = event.currentTarget.value;
|
||||||
|
const next = parseDate(text);
|
||||||
|
if (next) {
|
||||||
|
value = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
if (parsed) {
|
||||||
|
text = parsed;
|
||||||
|
value = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
|
||||||
|
!(event.altKey || event.ctrlKey || event.metaKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
const step =
|
||||||
|
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
|
||||||
|
value = addDays(parsed ?? value ?? today(), step);
|
||||||
|
text = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPicker() {
|
||||||
|
picker?.showPicker?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative {className}">
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
{...rest}
|
||||||
|
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
|
||||||
|
autocomplete="off"
|
||||||
|
class="{INPUT} num pr-28"
|
||||||
|
onblur={handleBlur}
|
||||||
|
oninput={handleInput}
|
||||||
|
onkeydown={handleKey}
|
||||||
|
spellcheck="false"
|
||||||
|
value={text}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
|
||||||
|
>{label}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Pick a date"
|
||||||
|
class="absolute inset-y-0 right-0 grid w-8 place-items-center text-cladd-fg-soft hover:text-cladd-fg"
|
||||||
|
onclick={openPicker}
|
||||||
|
tabindex="-1"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Calendar class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
|
||||||
|
onchange={({ currentTarget }) => {
|
||||||
|
({ value } = currentTarget);
|
||||||
|
text = value;
|
||||||
|
}}
|
||||||
|
tabindex="-1"
|
||||||
|
type="date"
|
||||||
|
{value}
|
||||||
|
bind:this={picker}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { prettyKeys } from "#lib/keys.svelte.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
for: target,
|
||||||
|
error: message = "",
|
||||||
|
keys = "",
|
||||||
|
note,
|
||||||
|
children,
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
for?: string;
|
||||||
|
error?: string;
|
||||||
|
keys?: string;
|
||||||
|
note?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col gap-1 {className}">
|
||||||
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
|
<label class="text-[12px] text-cladd-fg-soft" for={target}>{label}</label>
|
||||||
|
{#if keys}
|
||||||
|
<span class="kbd hidden lg:inline-flex"
|
||||||
|
>{prettyKeys(keys).join(" ")}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{@render children()}
|
||||||
|
{#if message}
|
||||||
|
<p class="text-[12px] text-expense" role="alert">{message}</p>
|
||||||
|
{/if}
|
||||||
|
{@render note?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import X from "@lucide/svelte/icons/x";
|
||||||
|
import AutocompleteInput, { type Option } from "./AutocompleteInput.svelte";
|
||||||
|
import { WELL } from "./styles.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
tags = $bindable([]),
|
||||||
|
ref = $bindable(null),
|
||||||
|
search,
|
||||||
|
id,
|
||||||
|
placeholder = "",
|
||||||
|
invalid = false,
|
||||||
|
}: {
|
||||||
|
tags?: string[];
|
||||||
|
ref?: HTMLInputElement | null;
|
||||||
|
search: (query: string, taken: string[]) => Option[];
|
||||||
|
id?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
invalid?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let text = $state("");
|
||||||
|
|
||||||
|
function add(raw: string) {
|
||||||
|
const names = raw
|
||||||
|
.split(",")
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const known = new Set(tags.map((tag) => tag.toLowerCase()));
|
||||||
|
const fresh = names.filter((name) => !known.has(name.toLowerCase()));
|
||||||
|
if (fresh.length) {
|
||||||
|
tags = [...tags, ...fresh];
|
||||||
|
}
|
||||||
|
text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
tags = tags.filter((_, i) => i !== index);
|
||||||
|
ref?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(event: KeyboardEvent) {
|
||||||
|
const plain = !(event.ctrlKey || event.metaKey || event.altKey);
|
||||||
|
if (event.key === "," && plain) {
|
||||||
|
event.preventDefault();
|
||||||
|
add(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Backspace" && text === "" && tags.length && plain) {
|
||||||
|
event.preventDefault();
|
||||||
|
tags = tags.slice(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnter(event: KeyboardEvent) {
|
||||||
|
if (text.trim()) {
|
||||||
|
event.preventDefault();
|
||||||
|
add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="{WELL} relative flex min-h-9 w-full flex-wrap items-center gap-1 px-1.5 py-1 sm:min-h-cladd-md sm:py-0.5"
|
||||||
|
>
|
||||||
|
{#each tags as tag, index (tag)}
|
||||||
|
<span
|
||||||
|
class="inline-flex h-6 items-center gap-1 rounded-[5px] bg-cladd-surface-next pr-0.5 pl-1.5 text-[12px] text-cladd-fg sm:h-5"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
aria-label="Remove tag {tag}"
|
||||||
|
class="grid size-4 place-items-center rounded text-cladd-fg-soft hover:bg-black/10 hover:text-cladd-fg"
|
||||||
|
onclick={() => remove(index)}
|
||||||
|
tabindex="-1"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X class="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
<AutocompleteInput
|
||||||
|
bare
|
||||||
|
class="min-w-24 flex-1"
|
||||||
|
free
|
||||||
|
{id}
|
||||||
|
inputClass="h-7 w-full min-w-0 bg-transparent px-1 text-[16px] outline-none placeholder:text-cladd-fg-softer sm:h-6 sm:text-[13px]"
|
||||||
|
{invalid}
|
||||||
|
onblur={() => add(text)}
|
||||||
|
onenter={handleEnter}
|
||||||
|
onkeydown={handleKey}
|
||||||
|
onpick={(option) => add(option.label)}
|
||||||
|
placeholder={tags.length ? "" : placeholder}
|
||||||
|
search={(query) => search(query, tags)}
|
||||||
|
bind:ref
|
||||||
|
bind:value={text}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { addDays, iso, today } from "#lib/dates.js";
|
||||||
|
|
||||||
|
const SPACES = /\s+/g;
|
||||||
|
const NUMBER = /^\d[\d.,]*/;
|
||||||
|
const OPERATOR = /[+\-*/()]/;
|
||||||
|
const ISO_DAY = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
|
||||||
|
const DOTTED = /^(\d{1,2})[./](\d{1,2})(?:[./](\d{2,4}))?$/;
|
||||||
|
const RELATIVE = /^[+-]\d{1,4}$/;
|
||||||
|
const EXPRESSION = /\d\s*[+\-*/]\s*[\d(]/;
|
||||||
|
|
||||||
|
function toDecimal(raw: string): number | null {
|
||||||
|
const comma = raw.lastIndexOf(",");
|
||||||
|
const dot = raw.lastIndexOf(".");
|
||||||
|
let text = raw;
|
||||||
|
if (comma !== -1 && dot !== -1) {
|
||||||
|
const decimal = comma > dot ? "," : ".";
|
||||||
|
const thousands = decimal === "," ? "." : ",";
|
||||||
|
text = raw.split(thousands).join("").replace(decimal, ".");
|
||||||
|
} else if (comma !== -1) {
|
||||||
|
const parts = raw.split(",");
|
||||||
|
const fraction = parts.at(-1) ?? "";
|
||||||
|
text =
|
||||||
|
parts.length === 2 && fraction.length <= 2
|
||||||
|
? parts.join(".")
|
||||||
|
: parts.join("");
|
||||||
|
}
|
||||||
|
const value = Number(text);
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(input: string): (number | string)[] | null {
|
||||||
|
const tokens: (number | string)[] = [];
|
||||||
|
let rest = input;
|
||||||
|
while (rest) {
|
||||||
|
const number = rest.match(NUMBER);
|
||||||
|
if (number) {
|
||||||
|
const value = toDecimal(number[0]);
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
tokens.push(value);
|
||||||
|
rest = rest.slice(number[0].length);
|
||||||
|
} else if (OPERATOR.test(rest[0])) {
|
||||||
|
tokens.push(rest[0]);
|
||||||
|
rest = rest.slice(1);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluate(tokens: (number | string)[]): number | null {
|
||||||
|
let index = 0;
|
||||||
|
const peek = () => tokens[index];
|
||||||
|
const next = () => {
|
||||||
|
index += 1;
|
||||||
|
return tokens[index - 1];
|
||||||
|
};
|
||||||
|
const factor = (): number | null => {
|
||||||
|
const token = next();
|
||||||
|
if (token === "-") {
|
||||||
|
const value = factor();
|
||||||
|
return value === null ? null : -value;
|
||||||
|
}
|
||||||
|
if (token === "(") {
|
||||||
|
const value = sum();
|
||||||
|
return next() === ")" ? value : null;
|
||||||
|
}
|
||||||
|
return typeof token === "number" ? token : null;
|
||||||
|
};
|
||||||
|
const product = (): number | null => {
|
||||||
|
let left = factor();
|
||||||
|
while (left !== null && (peek() === "*" || peek() === "/")) {
|
||||||
|
const op = next();
|
||||||
|
const right = factor();
|
||||||
|
if (right === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
left = op === "*" ? left * right : left / right;
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
};
|
||||||
|
const sum = (): number | null => {
|
||||||
|
let left = product();
|
||||||
|
while (left !== null && (peek() === "+" || peek() === "-")) {
|
||||||
|
const op = next();
|
||||||
|
const right = product();
|
||||||
|
if (right === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
left = op === "+" ? left + right : left - right;
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
};
|
||||||
|
const value = sum();
|
||||||
|
return index === tokens.length && value !== null && Number.isFinite(value)
|
||||||
|
? value
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evalAmount(input: string): number | null {
|
||||||
|
const text = input.replace(SPACES, "");
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tokens = tokenize(text);
|
||||||
|
return tokens ? evaluate(tokens) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isExpression = (input: string) =>
|
||||||
|
EXPRESSION.test(input) || input.includes("(");
|
||||||
|
|
||||||
|
export function amountString(input: string, decimals = 2): string {
|
||||||
|
const value = evalAmount(input);
|
||||||
|
return value === null ? "" : value.toFixed(decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valid(year: number, month: number, day: number): string | null {
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
return date.getMonth() === month - 1 && date.getDate() === day
|
||||||
|
? iso(date)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORDS: Record<string, number> = {
|
||||||
|
tod: 0,
|
||||||
|
today: 0,
|
||||||
|
tom: 1,
|
||||||
|
tomorrow: 1,
|
||||||
|
yd: -1,
|
||||||
|
yest: -1,
|
||||||
|
yesterday: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseDate(input: string, base = today()): string | null {
|
||||||
|
const text = input.trim().toLowerCase();
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text in WORDS) {
|
||||||
|
return addDays(base, WORDS[text]);
|
||||||
|
}
|
||||||
|
if (RELATIVE.test(text)) {
|
||||||
|
return addDays(base, Number(text));
|
||||||
|
}
|
||||||
|
const full = text.match(ISO_DAY);
|
||||||
|
if (full) {
|
||||||
|
return valid(Number(full[1]), Number(full[2]), Number(full[3]));
|
||||||
|
}
|
||||||
|
const dotted = text.match(DOTTED);
|
||||||
|
if (dotted) {
|
||||||
|
const year = Number(base.slice(0, 4));
|
||||||
|
const raw = dotted[3] ? Number(dotted[3]) : year;
|
||||||
|
return valid(
|
||||||
|
raw < 100 ? 2000 + raw : raw,
|
||||||
|
Number(dotted[2]),
|
||||||
|
Number(dotted[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const WELL =
|
||||||
|
"cladd-cut cladd-hoverable cladd-focusable-input rounded-cladd-md text-cladd-fg";
|
||||||
|
|
||||||
|
export const INPUT = `${WELL} flex h-9 w-full min-w-0 px-2.5 text-[16px] placeholder:text-cladd-fg-softer disabled:cursor-not-allowed disabled:opacity-60 sm:h-cladd-md sm:text-[13px]`;
|
||||||
|
|
||||||
|
export const LIST =
|
||||||
|
"cladd-surface cladd-outline absolute top-full right-0 left-0 z-50 mt-1 max-h-72 overflow-y-auto rounded-cladd-md py-1 shadow-lg";
|
||||||
+32
-5
@@ -1,3 +1,5 @@
|
|||||||
|
import { getContext, setContext, untrack } from "svelte";
|
||||||
|
|
||||||
export interface Binding {
|
export interface Binding {
|
||||||
group?: string;
|
group?: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
@@ -11,6 +13,16 @@ const SEQUENCE_MS = 1200;
|
|||||||
const EDITABLE =
|
const EDITABLE =
|
||||||
"input, textarea, select, [contenteditable=''], [contenteditable='true']";
|
"input, textarea, select, [contenteditable=''], [contenteditable='true']";
|
||||||
|
|
||||||
|
function baseKey(event: KeyboardEvent): string {
|
||||||
|
if (event.altKey && event.code.startsWith("Key")) {
|
||||||
|
return event.code.slice(3).toLowerCase();
|
||||||
|
}
|
||||||
|
if (event.altKey && event.code.startsWith("Digit")) {
|
||||||
|
return event.code.slice(5);
|
||||||
|
}
|
||||||
|
return event.key === " " ? "Space" : event.key;
|
||||||
|
}
|
||||||
|
|
||||||
function token(event: KeyboardEvent): string {
|
function token(event: KeyboardEvent): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
@@ -19,7 +31,7 @@ function token(event: KeyboardEvent): string {
|
|||||||
if (event.altKey) {
|
if (event.altKey) {
|
||||||
parts.push("alt");
|
parts.push("alt");
|
||||||
}
|
}
|
||||||
const key = event.key === " " ? "Space" : event.key;
|
const key = baseKey(event);
|
||||||
if (event.shiftKey && key.length > 1) {
|
if (event.shiftKey && key.length > 1) {
|
||||||
parts.push("shift");
|
parts.push("shift");
|
||||||
}
|
}
|
||||||
@@ -32,16 +44,24 @@ export function isEditable(target: EventTarget | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Keys {
|
class Keys {
|
||||||
layers = $state<Binding[][]>([]);
|
layers = $state.raw<Binding[][]>([]);
|
||||||
pending = $state("");
|
pending = $state("");
|
||||||
sheetOpen = $state(false);
|
sheetOpen = $state(false);
|
||||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
all = $derived(this.layers.flat());
|
all = $derived(this.layers.flat());
|
||||||
|
|
||||||
register(bindings: Binding[]): () => void {
|
private readonly depths = new WeakMap<Binding[], number>();
|
||||||
|
|
||||||
|
register(bindings: Binding[], depth = 0): () => void {
|
||||||
const layer = bindings;
|
const layer = bindings;
|
||||||
this.layers = [...this.layers, layer];
|
this.depths.set(layer, depth);
|
||||||
|
const above = this.layers.findIndex(
|
||||||
|
(l) => (this.depths.get(l) ?? 0) > depth
|
||||||
|
);
|
||||||
|
const next = [...this.layers];
|
||||||
|
next.splice(above < 0 ? next.length : above, 0, layer);
|
||||||
|
this.layers = next;
|
||||||
return () => {
|
return () => {
|
||||||
this.layers = this.layers.filter((l) => l !== layer);
|
this.layers = this.layers.filter((l) => l !== layer);
|
||||||
};
|
};
|
||||||
@@ -99,8 +119,15 @@ class Keys {
|
|||||||
|
|
||||||
export const keys = new Keys();
|
export const keys = new Keys();
|
||||||
|
|
||||||
|
const DEPTH = Symbol("keys-depth");
|
||||||
|
|
||||||
export function useKeys(bindings: () => Binding[]) {
|
export function useKeys(bindings: () => Binding[]) {
|
||||||
$effect(() => keys.register(bindings()));
|
const depth = getContext<number | undefined>(DEPTH) ?? 0;
|
||||||
|
setContext(DEPTH, depth + 1);
|
||||||
|
$effect(() => {
|
||||||
|
const list = bindings();
|
||||||
|
return untrack(() => keys.register(list, depth));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const APPLE = /Mac|iPhone|iPad/;
|
const APPLE = /Mac|iPhone|iPad/;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<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 { takeDraft } from "#lib/components/editor/draft.js";
|
||||||
|
import Editor from "#lib/components/editor/Editor.svelte";
|
||||||
|
import {
|
||||||
|
emptyForm,
|
||||||
|
fromGroup,
|
||||||
|
isEditableType,
|
||||||
|
} from "#lib/components/editor/model.js";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const raw = $derived(
|
||||||
|
page.params.type === "transfers" ? "transfer" : (page.params.type ?? "")
|
||||||
|
);
|
||||||
|
const type = $derived(isEditableType(raw) ? raw : "withdrawal");
|
||||||
|
const cloneId = $derived(page.url.searchParams.get("clone"));
|
||||||
|
const prefill = $derived({
|
||||||
|
destination: page.url.searchParams.get("destination"),
|
||||||
|
source: page.url.searchParams.get("source"),
|
||||||
|
});
|
||||||
|
const draftKey = $derived(page.url.searchParams.get("draft"));
|
||||||
|
|
||||||
|
const clone = createQuery(() => ({
|
||||||
|
enabled: Boolean(cloneId),
|
||||||
|
queryFn: () => api<One<TransactionGroup>>(`/v1/transactions/${cloneId}`),
|
||||||
|
queryKey: ["transaction", cloneId],
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key `${type}:${cloneId}:${draftKey}`}
|
||||||
|
{#if cloneId && clone.isPending}
|
||||||
|
<p class="py-8 text-cladd-fg-soft">Loading the transaction to clone</p>
|
||||||
|
{:else if cloneId && clone.data}
|
||||||
|
<Editor initial={fromGroup(clone.data.data, true)} />
|
||||||
|
{:else}
|
||||||
|
{@const draft = takeDraft()}
|
||||||
|
{#if cloneId && clone.isError}
|
||||||
|
<p class="pb-3 text-[13px] text-expense">
|
||||||
|
Transaction #{cloneId}
|
||||||
|
could not be loaded, so this is an empty form.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<Editor initial={draft ?? emptyForm(type)} {prefill} />
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<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 Editor from "#lib/components/editor/Editor.svelte";
|
||||||
|
import { fromGroup, isEditableType } from "#lib/components/editor/model.js";
|
||||||
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
const id = $derived(page.params.id ?? "");
|
||||||
|
|
||||||
|
const group = createQuery(() => ({
|
||||||
|
queryFn: () => api<One<TransactionGroup>>(`/v1/transactions/${id}`),
|
||||||
|
queryKey: ["transaction", id],
|
||||||
|
staleTime: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const type = $derived(
|
||||||
|
group.data?.data.attributes.transactions[0]?.type ?? ""
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#key id}
|
||||||
|
{#if group.isPending}
|
||||||
|
<p class="py-8 text-cladd-fg-soft">Loading transaction #{id}</p>
|
||||||
|
{:else if group.isError}
|
||||||
|
<PageHeader subtitle="Edit transaction" title="Transactions" />
|
||||||
|
<p class="text-expense">
|
||||||
|
Transaction #{id}
|
||||||
|
could not be loaded: {group.error.message}
|
||||||
|
</p>
|
||||||
|
{:else if group.data && !isEditableType(type)}
|
||||||
|
<PageHeader subtitle="Edit transaction" title="Transactions" />
|
||||||
|
<p class="text-[13px]">
|
||||||
|
This is a {type}, which this editor does not change.
|
||||||
|
<a
|
||||||
|
class="text-brand underline"
|
||||||
|
data-sveltekit-reload
|
||||||
|
href="/transactions/edit/{id}?ffui=old"
|
||||||
|
>Edit it in the old interface</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{:else if group.data}
|
||||||
|
<Editor group={group.data.data} initial={fromGroup(group.data.data)} />
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
Reference in new issue
Block a user