All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Lists contracts/pension docs with written summaries, an inline viewer and download at /trackers/money/documents. Files and summaries live on the host (MONEY_DOCUMENTS_ROOT, bind-mounted read-only by deploy-remote.sh), never in git or static/; the file endpoint is admin-only (401/403) and private, no-store.
1238 lines
42 KiB
Svelte
1238 lines
42 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
let hydrated = $state(false);
|
||
onMount(() => {
|
||
hydrated = true;
|
||
});
|
||
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
||
import { Checkbox } from '#lib/components/ui/checkbox/index.js';
|
||
import * as Dialog from '#lib/components/ui/dialog/index.js';
|
||
import './money-tracker.css';
|
||
import { enhance } from '$app/forms';
|
||
import Plus from 'lucide-svelte/icons/plus';
|
||
import Pencil from 'lucide-svelte/icons/pencil';
|
||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||
import Upload from 'lucide-svelte/icons/upload';
|
||
import Download from 'lucide-svelte/icons/download';
|
||
import FileText from 'lucide-svelte/icons/file-text';
|
||
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
|
||
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
|
||
import { compactAmount, sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js';
|
||
import { Badge } from '#lib/components/ui/badge/index.js';
|
||
import { resolve } from '$app/paths';
|
||
import { page } from '$app/state';
|
||
import { replaceState } from '$app/navigation';
|
||
import { Button } from '#lib/components/ui/button/index.js';
|
||
import { Input } from '#lib/components/ui/input/index.js';
|
||
import * as Tabs from '#lib/components/ui/tabs/index.js';
|
||
import {
|
||
accountBalances,
|
||
accountColor,
|
||
clipSeries,
|
||
type HistoryRange,
|
||
historyStart,
|
||
parseHistoryRange,
|
||
monthlyCents,
|
||
type TimelineView,
|
||
utilityEstimate,
|
||
wealthProjection,
|
||
wealthTimeline
|
||
} from '#lib/money-tracker/charts.js';
|
||
import type { PageProps } from './$types';
|
||
|
||
let { data, form }: PageProps = $props();
|
||
type Mode =
|
||
| 'account'
|
||
| 'transaction'
|
||
| 'recurring'
|
||
| 'forecast'
|
||
| 'settings'
|
||
| 'projectionSettings'
|
||
| 'reading'
|
||
| 'wish'
|
||
| 'insurance'
|
||
| 'import'
|
||
| 'delete';
|
||
const kinds = [
|
||
{ id: 'electricity', label: 'Electricity', unit: 'kWh' },
|
||
{ id: 'gas', label: 'Gas', unit: 'm³' },
|
||
{ id: 'water', label: 'Water', unit: 'm³' }
|
||
] as const;
|
||
const presetColors = [
|
||
'#000000',
|
||
'#ffd100',
|
||
'#009286',
|
||
'#0070f3',
|
||
'#e5484d',
|
||
'#8e4ec6',
|
||
'#e56910'
|
||
];
|
||
const euro = new Intl.NumberFormat('en-NL', { style: 'currency', currency: 'EUR' });
|
||
let mode = $state<Mode>('transaction');
|
||
let active = $state<Record<string, string | number | null>>({});
|
||
let deleteAction = $state('');
|
||
let showActionError = $state(false);
|
||
let search = $state('');
|
||
let accountFilter = $state<number[]>([]);
|
||
let sortOrder = $state<'newest' | 'oldest'>('newest');
|
||
let pageNumber = $state(1);
|
||
let selectedUtility = $state<(typeof kinds)[number]['id']>('electricity');
|
||
let dialogOpen = $state(false);
|
||
let importAccountId = $state('');
|
||
let importNotice = $state('');
|
||
let actualTransactions = $derived(data.transactions.filter((item) => item.date <= data.today));
|
||
let futureTransactions = $derived(data.transactions.filter((item) => item.date > data.today));
|
||
let balances = $derived(accountBalances(data.accounts, actualTransactions));
|
||
let totalAccounts = $derived(data.accounts.filter((account) => account.includeInTotal !== false));
|
||
let totalIds = $derived(new Set(totalAccounts.map((account) => account.id)));
|
||
let total = $derived(
|
||
totalAccounts.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
|
||
);
|
||
let invested = $derived(
|
||
totalAccounts
|
||
.filter((account) => account.kind === 'investment')
|
||
.reduce((sum, account) => sum + (balances.get(account.id) ?? 0), 0)
|
||
);
|
||
let colors = $derived(
|
||
new Map(data.accounts.map((account, index) => [account.id, accountColor(account, index)]))
|
||
);
|
||
let monthNet = $derived(
|
||
actualTransactions
|
||
.filter(
|
||
(item) => item.date.slice(0, 7) === data.today.slice(0, 7) && totalIds.has(item.accountId)
|
||
)
|
||
.reduce((sum, item) => sum + item.amountCents, 0)
|
||
);
|
||
let monthlyInsurance = $derived(
|
||
data.insurances.reduce(
|
||
(sum, item) => sum + monthlyCents(item.premiumCents ?? 0, item.cadence),
|
||
0
|
||
)
|
||
);
|
||
let filtered = $derived(
|
||
(sortOrder === 'newest' ? [...data.transactions].reverse() : data.transactions).filter(
|
||
(row) =>
|
||
(!accountFilter.length || accountFilter.includes(row.accountId)) &&
|
||
(!search ||
|
||
row.description.toLowerCase().includes(search.toLowerCase()) ||
|
||
accountName(row.accountId).toLowerCase().includes(search.toLowerCase()))
|
||
)
|
||
);
|
||
let shown = $derived(filtered.slice((pageNumber - 1) * 25, pageNumber * 25));
|
||
let monthlyRecurring = $derived(
|
||
data.recurring
|
||
.filter(
|
||
(item) =>
|
||
(!item.startsOn || item.startsOn <= data.today) &&
|
||
(!item.endsOn || item.endsOn >= data.today)
|
||
)
|
||
.reduce((sum, item) => sum + monthlyCents(item.amountCents, item.cadence), 0)
|
||
);
|
||
let projection = $derived(
|
||
wealthProjection(
|
||
data.accounts,
|
||
balances,
|
||
futureTransactions,
|
||
data.forecasts,
|
||
data.recurring,
|
||
data.projectionSettings,
|
||
data.today
|
||
)
|
||
);
|
||
const timelineViews: { id: TimelineView; label: string }[] = [
|
||
{ id: 'total', label: 'Total' },
|
||
{ id: 'split', label: 'Spendable vs invested' },
|
||
{ id: 'accounts', label: 'By account' }
|
||
];
|
||
let timelineView = $state<TimelineView>('total');
|
||
const historyRanges: { id: HistoryRange; label: string }[] = [
|
||
{ id: '1y', label: '1 year' },
|
||
{ id: '3y', label: '3 years' },
|
||
{ id: 'all', label: 'All' }
|
||
];
|
||
// The range lives in ?range= so it survives reloads; 1y is the default.
|
||
let historyRange = $state<HistoryRange>(parseHistoryRange(page.url.searchParams.get('range')));
|
||
function setHistoryRange(value: string) {
|
||
historyRange = parseHistoryRange(value);
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set('range', historyRange);
|
||
// Shallow routing: rewrites the current history entry without a load, reload or scroll.
|
||
replaceState(url, page.state);
|
||
}
|
||
let timeline = $derived(
|
||
clipSeries(
|
||
wealthTimeline(data.accounts, actualTransactions, projection, data.today, timelineView),
|
||
historyStart(historyRange, data.today)
|
||
)
|
||
);
|
||
let projectionEnd = $derived(projection.map((line) => line.points.at(-1)?.value ?? 0));
|
||
let wishTiers = $derived(
|
||
[5, 4, 3, 2, 1].map((tier) => ({
|
||
tier,
|
||
value: data.wishlist
|
||
.filter((item) => item.priority >= tier)
|
||
.reduce((sum, item) => sum + (item.estimatedCents ?? 0), 0),
|
||
count: data.wishlist.filter((item) => item.priority >= tier).length
|
||
}))
|
||
);
|
||
let sortedWishlist = $derived(sortWishlist(data.wishlist));
|
||
let unpricedWishes = $derived(
|
||
data.wishlist.filter((item) => item.estimatedCents === null).length
|
||
);
|
||
let utility = $derived(kinds.find((item) => item.id === selectedUtility)!);
|
||
let settings = $derived(data.utilitySettings.find((item) => item.kind === selectedUtility));
|
||
let readings = $derived(data.utilityReadings.filter((item) => item.kind === selectedUtility));
|
||
let estimate = $derived(
|
||
utilityEstimate(
|
||
data.utilityReadings,
|
||
selectedUtility,
|
||
settings?.ratePerUnit ?? '0',
|
||
settings?.monthlyAdvanceCents ?? 0,
|
||
settings?.billingStart
|
||
)
|
||
);
|
||
let utilitySeries = $derived([
|
||
{
|
||
label: utility.label,
|
||
color: '#0070f3',
|
||
points: readings.map((item) => ({ date: item.date, value: Number(item.reading) }))
|
||
}
|
||
]);
|
||
|
||
function amount(value: number) {
|
||
return euro.format(value / 100);
|
||
}
|
||
function inputAmount(value: unknown) {
|
||
return typeof value === 'number' ? (value / 100).toFixed(2) : '';
|
||
}
|
||
function accountName(id: number | null) {
|
||
return data.accounts.find((account) => account.id === id)?.name ?? 'Unassigned';
|
||
}
|
||
function dateLabel(value: string) {
|
||
return new Date(value + 'T12:00:00Z').toLocaleDateString('en-NL', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
timeZone: 'UTC'
|
||
});
|
||
}
|
||
function open(next: Mode, record: object = {}, action = '') {
|
||
mode = next;
|
||
active = record as Record<string, string | number | null>;
|
||
deleteAction = action;
|
||
showActionError = false;
|
||
dialogOpen = true;
|
||
}
|
||
function toggleAccount(id: number) {
|
||
accountFilter = accountFilter.includes(id)
|
||
? accountFilter.filter((item) => item !== id)
|
||
: [...accountFilter, id];
|
||
pageNumber = 1;
|
||
}
|
||
function deletion(name: string, id: number, action: string) {
|
||
open('delete', { id, name }, action);
|
||
}
|
||
function exportCsv() {
|
||
const escape = (value: string) => '"' + value.replaceAll('"', '""') + '"';
|
||
const lines = [
|
||
'date,account,description,amount',
|
||
...data.transactions.map((item) =>
|
||
[
|
||
item.date,
|
||
accountName(item.accountId),
|
||
item.description,
|
||
(item.amountCents / 100).toFixed(2)
|
||
]
|
||
.map(escape)
|
||
.join(',')
|
||
)
|
||
];
|
||
const url = URL.createObjectURL(
|
||
new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
||
);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = 'money-transactions.csv';
|
||
link.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}
|
||
</script>
|
||
|
||
{#snippet signed(value: number)}
|
||
<span class:positive={value > 0} class:negative={value < 0}>{amount(value)}</span>
|
||
{/snippet}
|
||
{#snippet swatch(id: number | null)}
|
||
<span class="swatch" style:background={id === null ? 'transparent' : colors.get(id)}></span>
|
||
{/snippet}
|
||
{#snippet rowActions(label: string, edit: () => void, remove: () => void)}
|
||
<span class="actions"
|
||
><Button variant="ghost" aria-label={'Edit ' + label} onclick={edit}
|
||
><Pencil size={14} /></Button
|
||
><Button variant="ghost" aria-label={'Delete ' + label} onclick={remove}
|
||
><Trash2 size={14} /></Button
|
||
></span
|
||
>
|
||
{/snippet}
|
||
|
||
<div class="money-app mx-auto max-w-screen-2xl px-4 py-8 sm:px-6 sm:py-10">
|
||
<header class="app-header">
|
||
<div>
|
||
<p class="muted">Personal finance</p>
|
||
<h1>Money</h1>
|
||
</div>
|
||
<div class="header-actions">
|
||
<Button variant="outline" href={resolve('/trackers/money/documents')}
|
||
><FileText size={16} /> Documents</Button
|
||
><Button variant="outline" onclick={() => open('import')}
|
||
><Upload size={16} /> Import CSV</Button
|
||
><Button onclick={() => open('transaction')}><Plus size={16} /> Add transaction</Button>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="stat-strip" aria-label="Summary">
|
||
<div class="stat net">
|
||
<p class="eyebrow">Net worth · {totalAccounts.length} accounts</p>
|
||
<p class="stat-value">{@render signed(total)}</p>
|
||
</div>
|
||
<div class="stat">
|
||
<p class="eyebrow">Spendable</p>
|
||
<p class="stat-value">{@render signed(total - invested)}</p>
|
||
</div>
|
||
<div class="stat">
|
||
<p class="eyebrow">Invested</p>
|
||
<p class="stat-value">{@render signed(invested)}</p>
|
||
</div>
|
||
<div class="stat">
|
||
<p class="eyebrow">Net this month</p>
|
||
<p class="stat-value">{@render signed(monthNet)}</p>
|
||
</div>
|
||
<div class="stat">
|
||
<p class="eyebrow">Recurring / month</p>
|
||
<p class="stat-value">{@render signed(Math.round(monthlyRecurring))}</p>
|
||
</div>
|
||
<div class="stat">
|
||
<p class="eyebrow">
|
||
Projected {dateLabel(projection[0].points.at(-1)?.date ?? data.today)}
|
||
</p>
|
||
<p class="stat-value">{@render signed(projectionEnd[0])}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<QuickAdd accounts={data.accounts} transactions={data.transactions} today={data.today} />
|
||
{#if importNotice}<p class="mb-4 text-sm" role="status">{importNotice}</p>{/if}
|
||
|
||
<div class="dashboard">
|
||
<section class="panel span-8">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<h2>Wealth over time</h2>
|
||
<p>Actual balances through today, then the projection.</p>
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
onclick={() => open('projectionSettings', data.projectionSettings ?? {})}
|
||
>Edit projection</Button
|
||
>
|
||
</div>
|
||
<div class="mb-3 flex flex-wrap gap-2">
|
||
<Tabs.Root bind:value={timelineView}
|
||
><Tabs.List
|
||
>{#each timelineViews as item (item.id)}<Tabs.Trigger value={item.id}
|
||
>{item.label}</Tabs.Trigger
|
||
>{/each}</Tabs.List
|
||
></Tabs.Root
|
||
>
|
||
<Tabs.Root value={historyRange} onValueChange={setHistoryRange} aria-label="History range"
|
||
><Tabs.List
|
||
>{#each historyRanges as item (item.id)}<Tabs.Trigger value={item.id}
|
||
>{item.label}</Tabs.Trigger
|
||
>{/each}</Tabs.List
|
||
></Tabs.Root
|
||
>
|
||
</div>
|
||
<MoneyChart
|
||
series={timeline}
|
||
title="Actual and projected wealth over time"
|
||
marker={{ date: data.today, label: 'Today' }}
|
||
grid={historyRange === 'all' ? 'years' : 'months'}
|
||
/>
|
||
<div class="projection-totals" aria-label="Projected balances at end of period">
|
||
<div>
|
||
<span class="eyebrow">Total at end</span><strong
|
||
>{@render signed(projectionEnd[0])}</strong
|
||
>
|
||
</div>
|
||
<div>
|
||
<span class="eyebrow">Spendable at end</span><strong
|
||
>{@render signed(projectionEnd[1])}</strong
|
||
>
|
||
</div>
|
||
<div>
|
||
<span class="eyebrow">Invested at end</span><strong
|
||
>{@render signed(projectionEnd[2])}</strong
|
||
>
|
||
</div>
|
||
</div>
|
||
<p class="muted projection-note">
|
||
Includes future dated transactions and estimates{data.projectionSettings
|
||
?.includeRecurring !== false
|
||
? ', plus recurring monthly equivalents from next month'
|
||
: ''}. {data.projectionSettings?.investmentAccountId
|
||
? `The selected investment account and new contributions use a hypothetical ${data.projectionSettings.annualReturnPercent}% annual return.`
|
||
: `Excess above ${amount(data.projectionSettings?.reserveCents ?? 100000)} moves into a projected investment balance with a hypothetical ${data.projectionSettings?.annualReturnPercent ?? '5.00'}% annual return.`}
|
||
Contributions move money between spendable and invested balances. Enter an item only once across
|
||
transactions, estimates, and recurring items to avoid counting it twice.
|
||
</p>
|
||
</section>
|
||
|
||
<section class="panel span-4" id="accounts">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<h2>Accounts</h2>
|
||
<p>Select accounts to filter transactions.</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={!hydrated}
|
||
class="button secondary"
|
||
onclick={() => open('account')}><Plus size={16} /> Add</Button
|
||
>
|
||
</div>
|
||
{#if data.accounts.length}<ul class="item-list">
|
||
{#each data.accounts as account (account.id)}<li
|
||
class:selected={accountFilter.includes(account.id)}
|
||
>
|
||
<Button
|
||
variant="ghost"
|
||
type="button"
|
||
class="item-main"
|
||
aria-pressed={accountFilter.includes(account.id)}
|
||
onclick={() => toggleAccount(account.id)}
|
||
>{@render swatch(account.id)}<span
|
||
><strong>{account.name}</strong><small class="capitalize"
|
||
>{account.kind}{#if account.includeInTotal === false} · not in total{/if} · {#if account.bankBalanceAt}bank
|
||
{amount(account.bankBalanceCents ?? 0)}, {new Date(
|
||
account.bankBalanceAt
|
||
).toLocaleDateString('en-GB', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})}{#if account.bankBalanceCents !== (balances.get(account.id) ?? 0)}<span
|
||
class="text-destructive"
|
||
>
|
||
· differs from tracker</span
|
||
>{/if}{:else}opening {amount(account.openingBalanceCents)}{/if}</small
|
||
></span
|
||
></Button
|
||
><strong class="item-amount">{@render signed(balances.get(account.id) ?? 0)}</strong
|
||
>{@render rowActions(
|
||
account.name,
|
||
() => open('account', account),
|
||
() => deletion(account.name, account.id, 'deleteAccount')
|
||
)}
|
||
</li>{/each}
|
||
</ul>{:else}<p class="empty">
|
||
Add an account or import your transactions to get started.
|
||
</p>{/if}
|
||
</section>
|
||
|
||
<section class="panel span-8" id="transactions">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<h2>Transactions</h2>
|
||
<p>{filtered.length} of {data.transactions.length} entries</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<Button variant="outline" href={resolve('/trackers/money/transactions')}
|
||
>All transactions</Button
|
||
><Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
|
||
><Download size={16} /> Export CSV</Button
|
||
>
|
||
</div>
|
||
</div>
|
||
<div class="filters">
|
||
<label class="sr-only" for="transaction-search">Search transactions</label><Input
|
||
id="transaction-search"
|
||
type="search"
|
||
placeholder="Search descriptions"
|
||
bind:value={search}
|
||
oninput={() => (pageNumber = 1)}
|
||
/>
|
||
<label class="sr-only" for="transaction-order">Transaction order</label><NativeSelect
|
||
id="transaction-order"
|
||
bind:value={sortOrder}
|
||
onchange={() => (pageNumber = 1)}
|
||
><option value="newest">Newest first</option><option value="oldest">Oldest first</option
|
||
></NativeSelect
|
||
>
|
||
</div>
|
||
{#if data.accounts.length}<div class="chips" role="group" aria-label="Filter by account">
|
||
<Button
|
||
variant="ghost"
|
||
type="button"
|
||
class={!accountFilter.length ? 'current' : ''}
|
||
aria-pressed={!accountFilter.length}
|
||
onclick={() => {
|
||
accountFilter = [];
|
||
pageNumber = 1;
|
||
}}>All accounts</Button
|
||
>{#each data.accounts as account (account.id)}<Button
|
||
variant="ghost"
|
||
type="button"
|
||
class={accountFilter.includes(account.id) ? 'current' : ''}
|
||
aria-pressed={accountFilter.includes(account.id)}
|
||
onclick={() => toggleAccount(account.id)}
|
||
>{@render swatch(account.id)}{account.name}</Button
|
||
>{/each}
|
||
</div>{/if}
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead
|
||
><tr
|
||
><th>Date</th><th>Description</th><th>Account</th><th class="numeric">Amount</th><th
|
||
class="actions-heading">Actions</th
|
||
></tr
|
||
></thead
|
||
><tbody
|
||
>{#each shown as row (row.id)}<tr class:future={row.date > data.today}
|
||
><td class="nowrap">{dateLabel(row.date)}</td><td class="strong"
|
||
>{row.description}{#if row.source !== 'manual'}
|
||
<Badge variant="secondary" class="ml-2"
|
||
>{row.source === 'enablebanking' ? 'bank' : row.source}</Badge
|
||
>{/if}</td
|
||
><td class="nowrap"
|
||
><span class="account-cell"
|
||
>{@render swatch(row.accountId)}{accountName(row.accountId)}</span
|
||
></td
|
||
><td class="numeric">{@render signed(row.amountCents)}</td><td class="actions"
|
||
><Button
|
||
variant="ghost"
|
||
aria-label={'Edit ' + row.description}
|
||
onclick={() => open('transaction', row)}><Pencil size={14} /></Button
|
||
><Button
|
||
variant="ghost"
|
||
aria-label={'Delete ' + row.description}
|
||
onclick={() => deletion(row.description, row.id, 'deleteTransaction')}
|
||
><Trash2 size={14} /></Button
|
||
></td
|
||
></tr
|
||
>{/each}</tbody
|
||
>
|
||
</table>
|
||
</div>
|
||
{#if !shown.length}<p class="empty">
|
||
No matching transactions.
|
||
</p>{/if}{#if filtered.length > 25}<div class="pagination">
|
||
<span>Page {pageNumber} of {Math.ceil(filtered.length / 25)}</span>
|
||
<div>
|
||
<Button
|
||
variant="ghost"
|
||
class="button secondary"
|
||
disabled={pageNumber === 1}
|
||
onclick={() => pageNumber--}>Previous</Button
|
||
><Button
|
||
variant="ghost"
|
||
class="button secondary"
|
||
disabled={pageNumber * 25 >= filtered.length}
|
||
onclick={() => pageNumber++}>Next</Button
|
||
>
|
||
</div>
|
||
</div>{/if}
|
||
</section>
|
||
|
||
<div class="span-4 stack">
|
||
<section class="panel" id="recurring">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<h2>Recurring</h2>
|
||
<p>
|
||
{@render signed(Math.round(monthlyRecurring))} per month now. Feeds the projection when
|
||
enabled.
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={!hydrated}
|
||
class="button secondary"
|
||
onclick={() => open('recurring')}><Plus size={16} /> Add</Button
|
||
>
|
||
</div>
|
||
{#if data.recurring.length}<ul class="item-list scroll">
|
||
{#each data.recurring as row (row.id)}<li>
|
||
<span class="item-main"
|
||
>{@render swatch(row.accountId)}<span
|
||
><strong>{row.name}</strong><small
|
||
><span class="capitalize">{row.cadence}</span> · {row.accountId
|
||
? accountName(row.accountId)
|
||
: 'Any account'} · {row.startsOn ? dateLabel(row.startsOn) : 'Now'} – {row.endsOn
|
||
? dateLabel(row.endsOn)
|
||
: 'ongoing'}</small
|
||
></span
|
||
></span
|
||
><strong class="item-amount">{@render signed(row.amountCents)}</strong
|
||
>{@render rowActions(
|
||
row.name,
|
||
() => open('recurring', row),
|
||
() => deletion(row.name, row.id, 'deleteRecurring')
|
||
)}
|
||
</li>{/each}
|
||
</ul>{:else}<p class="empty">Rent, subscriptions, salary and other known repeats.</p>{/if}
|
||
</section>
|
||
<section class="panel" id="estimates">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<h2>Future estimates</h2>
|
||
<p>Dated income and costs you expect.</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={!hydrated}
|
||
class="button secondary"
|
||
onclick={() => open('forecast')}><Plus size={16} /> Add</Button
|
||
>
|
||
</div>
|
||
{#if data.forecasts.length}<ul class="item-list scroll">
|
||
{#each data.forecasts as row (row.id)}<li>
|
||
<span class="item-main"
|
||
>{@render swatch(row.accountId)}<span
|
||
><strong>{row.description}</strong><small
|
||
>{dateLabel(row.date)} · {row.accountId
|
||
? accountName(row.accountId)
|
||
: 'Unassigned'}</small
|
||
></span
|
||
></span
|
||
><strong class="item-amount">{@render signed(row.amountCents)}</strong
|
||
>{@render rowActions(
|
||
row.description,
|
||
() => open('forecast', row),
|
||
() => deletion(row.description, row.id, 'deleteForecast')
|
||
)}
|
||
</li>{/each}
|
||
</ul>{:else}<p class="empty">Add expected costs or income to look ahead.</p>{/if}
|
||
</section>
|
||
</div>
|
||
|
||
<section class="panel span-4" id="utilities">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<h2>Utilities</h2>
|
||
<p>Meter readings and estimated variable cost.</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={!hydrated}
|
||
class="button secondary"
|
||
onclick={() => open('reading', { kind: selectedUtility })}
|
||
><Plus size={16} /> Reading</Button
|
||
>
|
||
</div>
|
||
<div class="subtabs">
|
||
{#each kinds as kind (kind.id)}<Button
|
||
variant="ghost"
|
||
class={selectedUtility === kind.id ? 'current' : ''}
|
||
onclick={() => (selectedUtility = kind.id)}>{kind.label}</Button
|
||
>{/each}
|
||
</div>
|
||
<div class="mini-stats">
|
||
<div>
|
||
<span class="eyebrow">Usage</span><strong
|
||
>{estimate ? estimate.usage.toLocaleString('en-NL', { maximumFractionDigits: 3 }) : '—'}
|
||
<small>{utility.unit}</small></strong
|
||
>
|
||
</div>
|
||
<div>
|
||
<span class="eyebrow">Est. cost</span><strong
|
||
>{estimate ? amount(estimate.estimatedCents) : '—'}</strong
|
||
>
|
||
</div>
|
||
<div>
|
||
<span class="eyebrow">Advances − cost</span><strong
|
||
>{#if estimate}{@render signed(estimate.differenceCents)}{:else}—{/if}</strong
|
||
>
|
||
</div>
|
||
</div>
|
||
<MoneyChart
|
||
series={utilitySeries}
|
||
title={utility.label + ' meter readings over time'}
|
||
unit=""
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
class="text-button utility-settings"
|
||
onclick={() =>
|
||
open('settings', {
|
||
kind: selectedUtility,
|
||
ratePerUnit: settings?.ratePerUnit ?? '0',
|
||
monthlyAdvanceCents: settings?.monthlyAdvanceCents ?? 0,
|
||
billingStart: settings?.billingStart ?? ''
|
||
})}>Edit rate and advance</Button
|
||
>
|
||
{#if readings.length}<ul class="item-list scroll">
|
||
{#each [...readings].reverse() as row (row.id)}<li>
|
||
<span class="item-main"><span><strong>{dateLabel(row.date)}</strong></span></span
|
||
><span class="item-amount"
|
||
>{Number(row.reading).toLocaleString('en-NL', { maximumFractionDigits: 3 })}
|
||
{utility.unit}</span
|
||
>{@render rowActions(
|
||
'reading from ' + row.date,
|
||
() => open('reading', row),
|
||
() =>
|
||
deletion(
|
||
utility.label + ' reading on ' + dateLabel(row.date),
|
||
row.id,
|
||
'deleteReading'
|
||
)
|
||
)}
|
||
</li>{/each}
|
||
</ul>{:else}<p class="empty">Add at least two readings to calculate usage.</p>{/if}
|
||
<p class="muted utility-note">
|
||
Uses the first and last readings in the billing period, the current unit rate and prorated
|
||
monthly advances. Fixed charges, taxes and settlements are not included.
|
||
</p>
|
||
</section>
|
||
|
||
<section class="panel span-4" id="wishlist">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<h2>Wish list</h2>
|
||
<p>Cumulative cost by priority tier.</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={!hydrated}
|
||
class="button secondary"
|
||
onclick={() => open('wish')}><Plus size={16} /> Add</Button
|
||
>
|
||
</div>
|
||
<div class="wish-tier-grid">
|
||
{#each wishTiers as row (row.tier)}<div class={tierClass(row.tier)}>
|
||
<span class="eyebrow">{row.tier === 5 ? 'Tier 5' : `5–${row.tier}`}</span><strong
|
||
title={amount(row.value)}
|
||
><span class="currency">€ </span>{compactAmount(row.value)}</strong
|
||
><small>{row.count} {row.count === 1 ? 'item' : 'items'}</small>
|
||
</div>{/each}
|
||
</div>
|
||
{#if unpricedWishes}<p class="muted projection-note">
|
||
{unpricedWishes} unpriced {unpricedWishes === 1 ? 'item is' : 'items are'} excluded from these
|
||
totals.
|
||
</p>{/if}
|
||
{#if data.wishlist.length}<ul class="item-list scroll">
|
||
{#each sortedWishlist as row (row.id)}<li class="wish {tierClass(row.priority)}">
|
||
<span class="item-main"
|
||
><span class="tier" title="Tier {row.priority}">{row.priority}</span><span
|
||
><strong>{row.name}</strong>{#if row.notes}<small>{row.notes}</small>{/if}</span
|
||
></span
|
||
><strong class="item-amount"
|
||
>{row.estimatedCents == null ? '—' : amount(row.estimatedCents)}</strong
|
||
>{@render rowActions(
|
||
row.name,
|
||
() => open('wish', row),
|
||
() => deletion(row.name, row.id, 'deleteWishlist')
|
||
)}
|
||
</li>{/each}
|
||
</ul>{:else}<p class="empty">Nothing on your wish list yet.</p>{/if}
|
||
</section>
|
||
|
||
<section class="panel span-4" id="insurance">
|
||
<div class="panel-heading">
|
||
<div>
|
||
<h2>Insurance</h2>
|
||
<p>{amount(Math.round(monthlyInsurance))} per month in premiums.</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={!hydrated}
|
||
class="button secondary"
|
||
onclick={() => open('insurance')}><Plus size={16} /> Add</Button
|
||
>
|
||
</div>
|
||
{#if data.insurances.length}<ul class="item-list scroll">
|
||
{#each data.insurances as row (row.id)}<li>
|
||
<span class="item-main"
|
||
><span
|
||
><strong>{row.name}</strong><small
|
||
>{[row.provider, row.coverage].filter(Boolean).join(' · ') || '—'}</small
|
||
></span
|
||
></span
|
||
><strong class="item-amount"
|
||
>{row.premiumCents == null
|
||
? '—'
|
||
: amount(row.premiumCents) + ' / ' + row.cadence}</strong
|
||
>{@render rowActions(
|
||
row.name,
|
||
() => open('insurance', row),
|
||
() => deletion(row.name, row.id, 'deleteInsurance')
|
||
)}
|
||
</li>{/each}
|
||
</ul>{:else}<p class="empty">Add policies to keep track of what is covered.</p>{/if}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<Dialog.Root bind:open={dialogOpen}>
|
||
<Dialog.Content class="money-dialog block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
||
<Dialog.Description class="sr-only">Manage your money records.</Dialog.Description>
|
||
{#if showActionError && form?.message}<p class="form-error" role="alert">{form.message}</p>{/if}
|
||
<Dialog.Header class="mb-6 pr-8"
|
||
><Dialog.Title>
|
||
{mode === 'delete'
|
||
? 'Delete item'
|
||
: mode === 'import'
|
||
? 'Import transactions'
|
||
: mode === 'settings'
|
||
? 'Utility settings'
|
||
: mode === 'projectionSettings'
|
||
? 'Wealth projection'
|
||
: (active.id ? 'Edit ' : 'Add ') +
|
||
(mode === 'wish'
|
||
? 'wish list item'
|
||
: mode === 'reading'
|
||
? 'meter reading'
|
||
: mode)}
|
||
</Dialog.Title></Dialog.Header
|
||
>
|
||
{#if mode === 'delete'}
|
||
<p class="dialog-copy">Delete <strong>{active.name}</strong>? This cannot be undone.</p>
|
||
<form
|
||
method="POST"
|
||
action={'?/' + deleteAction}
|
||
use:enhance={() =>
|
||
async ({ result, update }) => {
|
||
await update();
|
||
showActionError = result.type === 'failure';
|
||
if (result.type === 'success') dialogOpen = false;
|
||
}}
|
||
>
|
||
<input type="hidden" name="id" value={active.id ?? ''} />
|
||
<div class="dialog-footer">
|
||
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
|
||
>Cancel</Button
|
||
><Button type="submit" class="button danger">Delete</Button>
|
||
</div>
|
||
</form>
|
||
{:else if mode === 'import'}
|
||
<p class="dialog-copy">
|
||
Upload your CSV or tab-separated export with <code>date, account, description, amount</code>
|
||
columns, a Revolut statement export (only completed EUR rows are imported, fees included), or
|
||
Knab exports. Knab files go to the account with that IBAN; pick an account to merge several Knab
|
||
accounts (current + savings) into one, and transfers between them are left out. Dates like
|
||
<code>Fri 1 Aug 2025</code>
|
||
and amounts like <code>-€1,234.50</code> are supported. Empty rows are skipped, and entries appear
|
||
in date order. “Start balance” rows import as transactions, so leave those accounts’ opening balances
|
||
at zero. New account names are created automatically. Rows already imported, or already entered
|
||
with the same amount within a day, are skipped.
|
||
</p>
|
||
<form
|
||
method="POST"
|
||
action="?/importTransactions"
|
||
enctype="multipart/form-data"
|
||
use:enhance={() =>
|
||
async ({ result, update }) => {
|
||
await update();
|
||
showActionError = result.type === 'failure';
|
||
if (result.type === 'success') {
|
||
dialogOpen = false;
|
||
const done = result.data as
|
||
| { imported?: number; duplicates?: number; internal?: number; replaced?: number }
|
||
| undefined;
|
||
importNotice =
|
||
`Imported ${done?.imported ?? 0} transactions; skipped ${done?.duplicates ?? 0} duplicates` +
|
||
(done?.internal ? `, ${done.internal} transfers between your own accounts` : '') +
|
||
(done?.replaced
|
||
? `; replaced ${done.replaced} hand entries that were sums of payments`
|
||
: '') +
|
||
'.';
|
||
}
|
||
}}
|
||
>
|
||
<label class="field"
|
||
>CSV or TSV files<Input
|
||
type="file"
|
||
name="file"
|
||
multiple
|
||
accept=".csv,.tsv,text/csv,text/tab-separated-values"
|
||
required
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Account (for files without an account column, like Revolut statements, or to merge Knab
|
||
accounts)<NativeSelect name="accountId" bind:value={importAccountId}
|
||
><option value="">From the file</option
|
||
>{#each data.accounts as account (account.id)}<option value={String(account.id)}
|
||
>{account.name}</option
|
||
>{/each}</NativeSelect
|
||
></label
|
||
>
|
||
<div class="dialog-footer">
|
||
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
|
||
>Cancel</Button
|
||
><Button type="submit" class="button primary">Import</Button>
|
||
</div>
|
||
</form>
|
||
{:else}
|
||
<form
|
||
method="POST"
|
||
action={'?/' +
|
||
(mode === 'account'
|
||
? 'saveAccount'
|
||
: mode === 'transaction'
|
||
? 'saveTransaction'
|
||
: mode === 'recurring'
|
||
? 'saveRecurring'
|
||
: mode === 'forecast'
|
||
? 'saveForecast'
|
||
: mode === 'settings'
|
||
? 'saveUtilitySettings'
|
||
: mode === 'projectionSettings'
|
||
? 'saveProjectionSettings'
|
||
: mode === 'reading'
|
||
? 'saveReading'
|
||
: mode === 'wish'
|
||
? 'saveWishlist'
|
||
: 'saveInsurance')}
|
||
use:enhance={() =>
|
||
async ({ result, update }) => {
|
||
await update({ reset: false });
|
||
showActionError = result.type === 'failure';
|
||
if (result.type === 'success') dialogOpen = false;
|
||
}}
|
||
>
|
||
{#if active.id}<input type="hidden" name="id" value={active.id} />{/if}
|
||
{#if mode === 'account'}
|
||
<label class="field"
|
||
>Account name<Input
|
||
name="name"
|
||
required
|
||
maxlength={80}
|
||
value={active.name ?? ''}
|
||
placeholder="Daily"
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Opening balance (€)<Input
|
||
name="openingBalance"
|
||
type="number"
|
||
step="0.01"
|
||
required
|
||
value={inputAmount(active.openingBalanceCents)}
|
||
/><small>Balance before the first transaction you record.</small></label
|
||
>
|
||
<label class="field"
|
||
>Account type<NativeSelect name="kind"
|
||
><option value="spendable" selected={active.kind !== 'investment'}>Spendable</option
|
||
><option value="investment" selected={active.kind === 'investment'}>Investment</option
|
||
></NativeSelect
|
||
><small
|
||
>Investment accounts are included in total wealth but excluded from the cash reserve.</small
|
||
></label
|
||
>
|
||
<label class="field check-field"
|
||
><Checkbox name="includeInTotal" value="on" checked={active.includeInTotal !== false} /> Count
|
||
toward total wealth</label
|
||
>
|
||
<label class="field"
|
||
>Color<Input
|
||
name="color"
|
||
type="color"
|
||
list="account-colors"
|
||
value={active.color ??
|
||
(typeof active.id === 'number'
|
||
? colors.get(active.id)
|
||
: accountColor({}, data.accounts.length))}
|
||
/><datalist id="account-colors"
|
||
>{#each presetColors as color (color)}<option value={color}></option>{/each}</datalist
|
||
><small>Used for the account's chart line, filter chip and transaction rows.</small
|
||
></label
|
||
>
|
||
{:else if mode === 'transaction' || mode === 'forecast'}
|
||
<label class="field"
|
||
>Date<Input
|
||
name="date"
|
||
type="date"
|
||
required
|
||
value={active.date ?? new Date().toISOString().slice(0, 10)}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Description<Input
|
||
name="description"
|
||
required
|
||
maxlength={500}
|
||
value={active.description ?? ''}
|
||
placeholder="What was this for?"
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Account<NativeSelect name="accountId" required={mode === 'transaction'}
|
||
><option value="">{mode === 'forecast' ? 'Unassigned' : 'Select an account'}</option
|
||
>{#each data.accounts as account (account.id)}<option
|
||
value={account.id}
|
||
selected={active.accountId === account.id}>{account.name}</option
|
||
>{/each}</NativeSelect
|
||
></label
|
||
>
|
||
<label class="field"
|
||
>Amount (€)<Input
|
||
name="amount"
|
||
type="number"
|
||
step="0.01"
|
||
required
|
||
value={inputAmount(active.amountCents)}
|
||
placeholder="-12.50"
|
||
/><small>Positive for money in, negative for money out.</small></label
|
||
>
|
||
{:else if mode === 'recurring'}
|
||
<label class="field"
|
||
>Name<Input
|
||
name="name"
|
||
required
|
||
maxlength={200}
|
||
value={active.name ?? ''}
|
||
placeholder="Rent"
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Amount (€)<Input
|
||
name="amount"
|
||
type="number"
|
||
step="0.01"
|
||
required
|
||
value={inputAmount(active.amountCents)}
|
||
placeholder="-850.00"
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Frequency<NativeSelect name="cadence"
|
||
><option value="weekly" selected={active.cadence === 'weekly'}>Weekly</option><option
|
||
value="monthly"
|
||
selected={!active.cadence || active.cadence === 'monthly'}>Monthly</option
|
||
><option value="yearly" selected={active.cadence === 'yearly'}>Yearly</option
|
||
></NativeSelect
|
||
></label
|
||
>
|
||
<label class="field"
|
||
>Account<NativeSelect name="accountId"
|
||
><option value="">Any account</option
|
||
>{#each data.accounts as account (account.id)}<option
|
||
value={account.id}
|
||
selected={active.accountId === account.id}>{account.name}</option
|
||
>{/each}</NativeSelect
|
||
></label
|
||
>
|
||
<label class="field"
|
||
>Starts on<Input name="startsOn" type="date" value={active.startsOn ?? ''} /><small
|
||
>Optional. Leave blank for an ongoing item.</small
|
||
></label
|
||
>
|
||
<label class="field"
|
||
>Ends on<Input name="endsOn" type="date" value={active.endsOn ?? ''} /></label
|
||
>
|
||
{:else if mode === 'projectionSettings'}
|
||
<p class="dialog-copy">
|
||
A planning scenario. Returns are hypothetical and calculated monthly. Contributions
|
||
transfer money from spendable accounts into a projected investment balance.
|
||
</p>
|
||
<label class="field"
|
||
>Existing investment account (optional)<NativeSelect name="investmentAccountId"
|
||
><option value="">Start projected investments at €0</option
|
||
>{#each data.accounts.filter((account) => account.kind === 'investment') as account (account.id)}<option
|
||
value={account.id}
|
||
selected={active.investmentAccountId === account.id}>{account.name}</option
|
||
>{/each}</NativeSelect
|
||
><small
|
||
>Select an existing investment account to include its current balance in the growth
|
||
model.</small
|
||
></label
|
||
>
|
||
<label class="field"
|
||
>Spendable reserve (€)<Input
|
||
name="reserve"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
required
|
||
value={inputAmount(active.reserveCents ?? 100000)}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Hypothetical annual return (%)<Input
|
||
name="annualReturnPercent"
|
||
type="number"
|
||
min="0"
|
||
max="100"
|
||
step="0.01"
|
||
required
|
||
value={active.annualReturnPercent ?? '5.00'}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Contribution rule<NativeSelect name="contributionMode"
|
||
><option value="excess" selected={active.contributionMode !== 'fixed'}
|
||
>Invest all above reserve</option
|
||
><option value="fixed" selected={active.contributionMode === 'fixed'}
|
||
>Fixed monthly amount, up to excess</option
|
||
></NativeSelect
|
||
></label
|
||
>
|
||
<label class="field"
|
||
>Fixed monthly amount (€)<Input
|
||
name="fixedMonthly"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
required
|
||
value={inputAmount(active.fixedMonthlyCents ?? 0)}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Project through<Input name="endDate" type="date" value={active.endDate ?? ''} /><small
|
||
>Optional, up to 20 years ahead. By default, ends at the latest future entry, or one
|
||
year from today.</small
|
||
></label
|
||
>
|
||
<label class="field check-field"
|
||
><Checkbox
|
||
name="includeRecurring"
|
||
value="on"
|
||
checked={active.includeRecurring !== false}
|
||
/> Include recurring items from next month</label
|
||
>
|
||
{:else if mode === 'settings'}
|
||
<input type="hidden" name="kind" value={active.kind ?? selectedUtility} />
|
||
<label class="field"
|
||
>Rate per {utility.unit} (€)<Input
|
||
name="ratePerUnit"
|
||
type="number"
|
||
min="0"
|
||
step="0.0001"
|
||
required
|
||
value={active.ratePerUnit ?? '0'}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Monthly advance (€)<Input
|
||
name="monthlyAdvance"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
required
|
||
value={inputAmount(active.monthlyAdvanceCents)}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Billing period start<Input
|
||
name="billingStart"
|
||
type="date"
|
||
value={active.billingStart ?? ''}
|
||
/><small
|
||
>Use the date of a meter reading at the start of your annual settlement period.</small
|
||
></label
|
||
>
|
||
{:else if mode === 'reading'}
|
||
<label class="field"
|
||
>Utility<NativeSelect name="kind"
|
||
>{#each kinds as kind (kind.id)}<option
|
||
value={kind.id}
|
||
selected={(active.kind ?? selectedUtility) === kind.id}>{kind.label}</option
|
||
>{/each}</NativeSelect
|
||
></label
|
||
>
|
||
<label class="field"
|
||
>Date<Input
|
||
name="date"
|
||
type="date"
|
||
required
|
||
value={active.date ?? new Date().toISOString().slice(0, 10)}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Meter reading<Input
|
||
name="reading"
|
||
type="number"
|
||
min="0"
|
||
step="0.001"
|
||
required
|
||
value={active.reading ?? ''}
|
||
/></label
|
||
>
|
||
{:else if mode === 'wish'}
|
||
<label class="field"
|
||
>Item<Input name="name" required maxlength={200} value={active.name ?? ''} /></label
|
||
>
|
||
<label class="field"
|
||
>Estimated cost (€)<Input
|
||
name="estimated"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={inputAmount(active.estimatedCents)}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Priority tier<NativeSelect name="priority"
|
||
>{#each [5, 4, 3, 2, 1] as tier (tier)}<option
|
||
value={tier}
|
||
selected={(active.priority ?? 3) === tier}
|
||
>Tier {tier}{tier === 5 ? ' · highest' : tier === 1 ? ' · lowest' : ''}</option
|
||
>{/each}</NativeSelect
|
||
></label
|
||
>
|
||
<label class="field"
|
||
>Notes<Textarea
|
||
name="notes"
|
||
maxlength={2000}
|
||
rows={3}
|
||
value={active.notes ?? ''}
|
||
/></label
|
||
>
|
||
{:else if mode === 'insurance'}
|
||
<label class="field"
|
||
>Policy name<Input
|
||
name="name"
|
||
required
|
||
maxlength={200}
|
||
value={active.name ?? ''}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Provider<Input name="provider" maxlength={200} value={active.provider ?? ''} /></label
|
||
>
|
||
<label class="field"
|
||
>What it covers<Textarea
|
||
name="coverage"
|
||
maxlength={2000}
|
||
rows={3}
|
||
value={active.coverage ?? ''}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Premium (€)<Input
|
||
name="premium"
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={inputAmount(active.premiumCents)}
|
||
/></label
|
||
>
|
||
<label class="field"
|
||
>Frequency<NativeSelect name="cadence"
|
||
><option value="weekly" selected={active.cadence === 'weekly'}>Weekly</option><option
|
||
value="monthly"
|
||
selected={!active.cadence || active.cadence === 'monthly'}>Monthly</option
|
||
><option value="yearly" selected={active.cadence === 'yearly'}>Yearly</option
|
||
></NativeSelect
|
||
></label
|
||
>
|
||
{/if}
|
||
<div class="dialog-footer">
|
||
<Button type="button" class="button secondary" onclick={() => (dialogOpen = false)}
|
||
>Cancel</Button
|
||
><Button type="submit" class="button primary">Save</Button>
|
||
</div>
|
||
</form>
|
||
{/if}
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|