diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 16686c2..d245057 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -196,13 +196,13 @@ export async function api( ...init }: Omit & { query?: Query; body?: unknown } = {} ): Promise { - const payload = - body === undefined || body instanceof Blob ? body : JSON.stringify(body); + const json = !(body === undefined || body instanceof Blob); const request = withHeaders( new Request(apiUrl(path, query), { credentials: "same-origin", ...init, - body: payload as BodyInit | undefined, + body: json ? JSON.stringify(body) : (body as BodyInit | undefined), + headers: json ? { "Content-Type": "application/json" } : init.headers, }) ); const response = await recover(request.clone(), await fetch(request)); diff --git a/src/lib/components/QuickEntry.svelte b/src/lib/components/QuickEntry.svelte index b18fc40..8c8025b 100644 --- a/src/lib/components/QuickEntry.svelte +++ b/src/lib/components/QuickEntry.svelte @@ -1,9 +1,409 @@
- +
+
+ +
+ 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} +
+ {#each suggestions as option, index (option.id)} +
{ + event.preventDefault(); + accept(option); + }} + role="option" + tabindex="-1" + > + {option.label} + {#if option.hint} + {option.hint} + {/if} + {#if option.meta} + {option.meta} + {/if} +
+ {/each} +
+ {/if} +
+ + + +
+
+ {#if saved} + Saved: {saved.text} + + · + Open + {:else if message} + {message.text} + {:else if text.trim()} + {#each preview as part, index (index)} + {#if index} + · + {/if} + {part.text} + {/each} + {#if allProblems.length} + · {allProblems[0]} + {/if} + {:else} + {HINT} + {/if} +
+
diff --git a/src/lib/components/editor/AccountField.svelte b/src/lib/components/editor/AccountField.svelte new file mode 100644 index 0000000..8c2f27d --- /dev/null +++ b/src/lib/components/editor/AccountField.svelte @@ -0,0 +1,58 @@ + + + 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} +/> diff --git a/src/lib/components/editor/Attachments.svelte b/src/lib/components/editor/Attachments.svelte new file mode 100644 index 0000000..c863db2 --- /dev/null +++ b/src/lib/components/editor/Attachments.svelte @@ -0,0 +1,176 @@ + + + + + +
+ {#each existing.data ?? [] as item (item.id)} +
+ + {item.attributes.title || item.attributes.filename} + {journalLabel(item.attributes.attachable_id)} + {size(item.attributes.size)} + remove(item.id)}>Delete +
+ {/each} + {#each queued as file, index (`${file.name}:${index}`)} +
+ + {file.name} + uploads after saving + +
+ {/each} +
+ {#if journals.length > 1} + + {/if} + + {#if status} + {status} + {/if} +
+
+
diff --git a/src/lib/components/editor/Editor.svelte b/src/lib/components/editor/Editor.svelte new file mode 100644 index 0000000..06531e1 --- /dev/null +++ b/src/lib/components/editor/Editor.svelte @@ -0,0 +1,695 @@ + + + + {#snippet actions()} + {#if editing && saved} + + + Clone + + Delete + {/if} + {/snippet} + + +
+
+
+ {#each TX_TYPES as type, index (type)} + + {/each} +
+ {#if editing && form.type !== originalType} +

+ Saving converts this {originalType} into a {form.type}. +

+ {/if} + {#if problem} + + {/if} +
+ + {#each form.splits as split, index (split.key)} +
+ removeSplit(split.key)} + bind:form + /> +
+ {/each} + +
+ + {#if form.splits.length > 1} + + Total of {form.splits.length} splits: + {#each totals as entry, index (entry.currency?.code ?? index)} + {money(entry.amount, entry.currency)} + {/each} + + {/if} +
+ + {#if form.splits.length > 1} + + + + + + {/if} + +
+ + + + + {#if status} +

+ {status.text} + {#each status.extra ?? [] as line (line)} + {line} + {/each} + {#if status.href} + Open + {/if} +

+ {/if} +
+ +
+ + {#if editing && journals.length} + + {/if} +
+
diff --git a/src/lib/components/editor/Links.svelte b/src/lib/components/editor/Links.svelte new file mode 100644 index 0000000..9c6ec57 --- /dev/null +++ b/src/lib/components/editor/Links.svelte @@ -0,0 +1,284 @@ + + + +
+ {#if links.data?.length} +
    + {#each links.data as row (row.id)} +
  • + {journalLabel(row.journal)} + {row.phrase} + {#if row.other} + "{row.other.description}" + ({row.other.money}) + {/if} + {#if row.notes} + {row.notes} + {/if} + remove(row.id)} + >Delete +
  • + {/each} +
+ {:else if links.isSuccess} +

No links yet.

+ {/if} +
+ {#if journals.length > 1} + + + + {/if} + + + + 1 ? "sm:col-span-2" : ""} + for="link-search" + label="Transaction" + > + + + + + +
+
+ + {#if status} + {status} + {/if} +
+
+
diff --git a/src/lib/components/editor/RefField.svelte b/src/lib/components/editor/RefField.svelte new file mode 100644 index 0000000..a0b0acb --- /dev/null +++ b/src/lib/components/editor/RefField.svelte @@ -0,0 +1,44 @@ + + + (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 }; + } + } + } +/> diff --git a/src/lib/components/editor/SplitBox.svelte b/src/lib/components/editor/SplitBox.svelte new file mode 100644 index 0000000..93d8cdd --- /dev/null +++ b/src/lib/components/editor/SplitBox.svelte @@ -0,0 +1,466 @@ + + +{#snippet account( + side: Side, + label: string, + keys: string +)} + + {#if !first && SHARED[form.type][side]} +

+ {sharedName(side) || "Same as split 1"} + (same for all splits) +

+ {:else} + pickedAccount(side, value)} + placeholder={placeholder(side)} + bind:value={split[side]} + /> + {/if} +
+{/snippet} + + 1 + ? `Split ${index + 1} / ${count}` + : "Transaction information"} +> + {#snippet tools()} + {#if count > 1} + + {/if} + {/snippet} +
+
+ + pickedDescription(option.data)} + search={catalog.descriptionOptions} + bind:value={split.description} + /> + + {@render account("source", "Source account", "alt+f")} + {@render account("destination", "Destination account", "alt+t")} + {#if first} + + + + {/if} +
+ + + + {#if transferForeign} + + + + {:else if form.type !== "transfer"} + +
+ + (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)} + + {/if} +
+
+ {/if} +
+ {#if split.reconciled} +

+ This split is reconciled, so its amount and accounts are locked. +

+ {/if} +
+ +
+ {#if withdrawal} + + + + {/if} + + + + {#if filled} +

+ Filled from last time: {filled.text}. + +

+ {/if} + {#if create} + + + + {/if} + + catalog.tagOptions(query, taken)} + bind:tags={split.tags} + /> + + {#if withdrawal} + + + + {/if} + + + + {#each META_DATES as key (key)} + {#if showExtra(key, split.dates[key])} + + + + {/if} + {/each} + {#if showExtra("internal_reference", split.internalReference)} + + + + {/if} + {#if showExtra("external_url", split.externalUrl)} + + + + {/if} + {#if !more} + + {/if} +
+
+
diff --git a/src/lib/components/editor/catalog.svelte.ts b/src/lib/components/editor/catalog.svelte.ts new file mode 100644 index 0000000..9cbfcd8 --- /dev/null +++ b/src/lib/components/editor/catalog.svelte.ts @@ -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( + 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>(); + + accountsOf(kinds: string[]): Item[] { + return (this.accounts.data ?? []).filter( + (item) => + item.attributes.active && kinds.includes(accountKind(item.attributes)) + ); + } + + findAccount(kinds: string[], name: string): Item | undefined { + const wanted = lower(name); + return this.accountsOf(kinds).find( + (item) => lower(item.attributes.name) === wanted + ); + } + + accountById(id: string): Item | 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 => { + 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 | 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 { + const key = `${accountId}:${side}`; + let found = this.history.get(key); + if (!found) { + found = api>( + `/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 { + const group = await api>( + `/v1/transaction-journals/${journalId}` + ).catch(() => null); + const split = group?.data.attributes.transactions.find( + (item) => item.transaction_journal_id === journalId + ); + return split ? { split } : null; + } +} diff --git a/src/lib/components/editor/draft.ts b/src/lib/components/editor/draft.ts new file mode 100644 index 0000000..f7dc5e6 --- /dev/null +++ b/src/lib/components/editor/draft.ts @@ -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; +} diff --git a/src/lib/components/editor/model.ts b/src/lib/components/editor/model.ts new file mode 100644 index 0000000..098666d --- /dev/null +++ b/src/lib/components/editor/model.ts @@ -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 = { + 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 = { + 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; + 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> = { + 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>> = { + deposit: { source: REVENUE }, + transfer: {}, + withdrawal: { destination: EXPENSE }, +}; + +export const SHARED: Record> = { + deposit: { destination: true, source: false }, + transfer: { destination: true, source: true }, + withdrawal: { destination: false, source: true }, +}; + +export const OWN_SIDE: Record = { + deposit: "destination", + transfer: "source", + withdrawal: "source", +}; + +const ROUTES: Record> = { + 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 = { + 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): 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, + 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, 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 = { + 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) => + 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; + +const ACCOUNT_FIELDS: Record = { + 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(); + 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()]; +} diff --git a/src/lib/components/editor/quick.ts b/src/lib/components/editor/quick.ts new file mode 100644 index 0000000..e4ebb57 --- /dev/null +++ b/src/lib/components/editor/quick.ts @@ -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(["@", "^", ">", "!", "$"]); +const MARKERS = new Set([...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 +) { + 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 }; +} diff --git a/src/lib/components/inputs/AmountInput.svelte b/src/lib/components/inputs/AmountInput.svelte new file mode 100644 index 0000000..304c5eb --- /dev/null +++ b/src/lib/components/inputs/AmountInput.svelte @@ -0,0 +1,63 @@ + + +
+ + {#if currency} + {currency} + {/if} + {#if preview} + {preview} + {/if} +
diff --git a/src/lib/components/inputs/AutocompleteInput.svelte b/src/lib/components/inputs/AutocompleteInput.svelte new file mode 100644 index 0000000..193b62b --- /dev/null +++ b/src/lib/components/inputs/AutocompleteInput.svelte @@ -0,0 +1,253 @@ + + + + +
+ = 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} +
+ {#each shown as option, index (`${option.id}:${option.label}`)} +
{ + event.preventDefault(); + pick(option); + }} + role="option" + tabindex="-1" + > + + {#if option.hint === "new"} + Create + "{option.label}" + {:else} + {option.label} + {/if} + + {#if option.hint && option.hint !== "new"} + {option.hint} + {/if} + {#if option.meta} + {option.meta} + {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/inputs/DateInput.svelte b/src/lib/components/inputs/DateInput.svelte new file mode 100644 index 0000000..c805587 --- /dev/null +++ b/src/lib/components/inputs/DateInput.svelte @@ -0,0 +1,113 @@ + + +
+ + {label} + + { + ({ value } = currentTarget); + text = value; + }} + tabindex="-1" + type="date" + {value} + bind:this={picker} + > +
diff --git a/src/lib/components/inputs/Field.svelte b/src/lib/components/inputs/Field.svelte new file mode 100644 index 0000000..8db5139 --- /dev/null +++ b/src/lib/components/inputs/Field.svelte @@ -0,0 +1,38 @@ + + +
+
+ + {#if keys} + + {/if} +
+ {@render children()} + {#if message} + + {/if} + {@render note?.()} +
diff --git a/src/lib/components/inputs/TagsInput.svelte b/src/lib/components/inputs/TagsInput.svelte new file mode 100644 index 0000000..61e240d --- /dev/null +++ b/src/lib/components/inputs/TagsInput.svelte @@ -0,0 +1,98 @@ + + +
+ {#each tags as tag, index (tag)} + + {tag} + + + {/each} + 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} + /> +
diff --git a/src/lib/components/inputs/parse.ts b/src/lib/components/inputs/parse.ts new file mode 100644 index 0000000..a12252f --- /dev/null +++ b/src/lib/components/inputs/parse.ts @@ -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 = { + 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; +} diff --git a/src/lib/components/inputs/styles.ts b/src/lib/components/inputs/styles.ts new file mode 100644 index 0000000..852aed5 --- /dev/null +++ b/src/lib/components/inputs/styles.ts @@ -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"; diff --git a/src/lib/keys.svelte.ts b/src/lib/keys.svelte.ts index 47fc4b9..12dfe4d 100644 --- a/src/lib/keys.svelte.ts +++ b/src/lib/keys.svelte.ts @@ -1,3 +1,5 @@ +import { getContext, setContext, untrack } from "svelte"; + export interface Binding { group?: string; hidden?: boolean; @@ -11,6 +13,16 @@ const SEQUENCE_MS = 1200; const EDITABLE = "input, textarea, select, [contenteditable=''], [contenteditable='true']"; +function baseKey(event: KeyboardEvent): string { + if (event.altKey && event.code.startsWith("Key")) { + return event.code.slice(3).toLowerCase(); + } + if (event.altKey && event.code.startsWith("Digit")) { + return event.code.slice(5); + } + return event.key === " " ? "Space" : event.key; +} + function token(event: KeyboardEvent): string { const parts: string[] = []; if (event.ctrlKey || event.metaKey) { @@ -19,7 +31,7 @@ function token(event: KeyboardEvent): string { if (event.altKey) { parts.push("alt"); } - const key = event.key === " " ? "Space" : event.key; + const key = baseKey(event); if (event.shiftKey && key.length > 1) { parts.push("shift"); } @@ -32,16 +44,24 @@ export function isEditable(target: EventTarget | null): boolean { } class Keys { - layers = $state([]); + layers = $state.raw([]); pending = $state(""); sheetOpen = $state(false); private timer: ReturnType | undefined; all = $derived(this.layers.flat()); - register(bindings: Binding[]): () => void { + private readonly depths = new WeakMap(); + + register(bindings: Binding[], depth = 0): () => void { const layer = bindings; - this.layers = [...this.layers, layer]; + this.depths.set(layer, depth); + const above = this.layers.findIndex( + (l) => (this.depths.get(l) ?? 0) > depth + ); + const next = [...this.layers]; + next.splice(above < 0 ? next.length : above, 0, layer); + this.layers = next; return () => { this.layers = this.layers.filter((l) => l !== layer); }; @@ -99,8 +119,15 @@ class Keys { export const keys = new Keys(); +const DEPTH = Symbol("keys-depth"); + export function useKeys(bindings: () => Binding[]) { - $effect(() => keys.register(bindings())); + const depth = getContext(DEPTH) ?? 0; + setContext(DEPTH, depth + 1); + $effect(() => { + const list = bindings(); + return untrack(() => keys.register(list, depth)); + }); } const APPLE = /Mac|iPhone|iPad/; diff --git a/src/routes/transactions/create/[type=txtype]/+page.svelte b/src/routes/transactions/create/[type=txtype]/+page.svelte new file mode 100644 index 0000000..29b45f6 --- /dev/null +++ b/src/routes/transactions/create/[type=txtype]/+page.svelte @@ -0,0 +1,47 @@ + + +{#key `${type}:${cloneId}:${draftKey}`} + {#if cloneId && clone.isPending} +

Loading the transaction to clone

+ {:else if cloneId && clone.data} + + {:else} + {@const draft = takeDraft()} + {#if cloneId && clone.isError} +

+ Transaction #{cloneId} + could not be loaded, so this is an empty form. +

+ {/if} + + {/if} +{/key} diff --git a/src/routes/transactions/edit/[id=id]/+page.svelte b/src/routes/transactions/edit/[id=id]/+page.svelte new file mode 100644 index 0000000..8c6d587 --- /dev/null +++ b/src/routes/transactions/edit/[id=id]/+page.svelte @@ -0,0 +1,46 @@ + + +{#key id} + {#if group.isPending} +

Loading transaction #{id}

+ {:else if group.isError} + +

+ Transaction #{id} + could not be loaded: {group.error.message} +

+ {:else if group.data && !isEditableType(type)} + +

+ This is a {type}, which this editor does not change. + Edit it in the old interface +

+ {:else if group.data} + + {/if} +{/key}