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
@@ -1,9 +1,409 @@
|
||||
<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 { 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>
|
||||
|
||||
<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>
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user