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

469 lines
14 KiB
Svelte

<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 { 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">X</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("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", "f")}
{@render account("destination", "Destination account", "t")}
{#if first}
<Field error={err("date")} for={id("date")} keys="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("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("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("c")}
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("#")}
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")}
keys={keyHint("s")}
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("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>