fix(transactions,editor,reports): v1 period column, one amount edge, unclipped report numbers, solid save, mobile headers

This commit is contained in:
hh committed 2026-10-16 04:26:07 +00:00
1 parent fa322d22e9
commit e279d3948a
8 files changed
+223 -78

No files matched your search

+15 -12
View File
@@ -10,6 +10,7 @@
import Field from "#lib/components/inputs/Field.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Button from "#lib/components/ui/button/button.svelte";
import { formatMoney } from "#lib/format.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { ui } from "#lib/ui.svelte.js";
@@ -626,20 +627,29 @@
</Box>
{/if}
<div class="grid gap-4 lg:grid-cols-2">
<Attachments groupId={saved?.id ?? null} {journals} bind:queued />
{#if editing && journals.length}
<Links {journals} />
{/if}
</div>
<div
class="cladd-surface cladd-outline sticky bottom-0 z-20 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-cladd-lg px-3 py-2 sm:gap-x-4 sm:py-2.5 shadow-[0_-6px_16px_-12px_rgb(0_0_0/0.35)]"
>
<button
class="cladd-surface cladd-gradient cladd-outline cladd-hoverable cladd-color-brand inline-flex h-9 items-center gap-2 rounded-cladd-md px-3.5 font-semibold text-[13px] text-cladd-primary disabled:opacity-60 sm:h-cladd-md"
<Button
class="h-9 px-3.5 text-[13px] sm:h-cladd-md"
color="brand"
disabled={saving}
onclick={save}
type="button"
variant="fill"
>
{editing ? "Update transaction" : "Save transaction"}
<span class="kbd hidden sm:inline-flex"
<span
class="kbd hidden bg-white/20! text-current! shadow-none! sm:inline-flex"
>{prettyKeys("mod+Enter")[0]}</span
>
</button>
</Button>
<label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft">
<span class="hidden sm:inline">After saving</span>
<select
@@ -685,11 +695,4 @@
</p>
{/if}
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Attachments groupId={saved?.id ?? null} {journals} bind:queued />
{#if editing && journals.length}
<Links {journals} />
{/if}
</div>
</div>
+37 -11
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import Calendar from "@lucide/svelte/icons/calendar";
import { untrack } from "svelte";
import { tick, untrack } from "svelte";
import type { HTMLInputAttributes } from "svelte/elements";
import { addDays, today } from "#lib/dates.js";
import { formatDate } from "#lib/format.js";
@@ -11,16 +11,21 @@
value = $bindable(""),
ref = $bindable(null),
invalid = false,
compact = false,
oncommit,
class: className = "",
...rest
}: Omit<HTMLInputAttributes, "value"> & {
value?: string;
ref?: HTMLInputElement | null;
invalid?: boolean;
compact?: boolean;
oncommit?: (value: string) => void;
} = $props();
let text = $state(value);
let picker = $state<HTMLInputElement | null>(null);
let focused = $state(false);
const parsed = $derived(parseDate(text, today()));
const shown = $derived(parsed ?? value);
@@ -29,6 +34,9 @@
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}`
: ""
);
const display = $derived(
compact && !focused && shown ? formatDate(shown) : text
);
$effect(() => {
const next = value;
@@ -47,14 +55,33 @@
}
}
function commit(next: string) {
text = next;
value = next;
oncommit?.(next);
}
function handleFocus(
event: FocusEvent & { currentTarget: HTMLInputElement }
) {
focused = true;
const input = event.currentTarget;
if (compact) {
tick().then(() => input.select());
}
}
function handleBlur() {
focused = false;
if (parsed) {
text = parsed;
value = parsed;
commit(parsed);
}
}
function handleKey(event: KeyboardEvent) {
if (event.key === "Enter" && parsed) {
commit(parsed);
}
if (
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
!(event.altKey || event.ctrlKey || event.metaKey)
@@ -62,8 +89,7 @@
event.preventDefault();
const step =
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
value = addDays(parsed ?? value ?? today(), step);
text = value;
commit(addDays(parsed ?? value ?? today(), step));
}
}
@@ -78,17 +104,20 @@
{...rest}
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
autocomplete="off"
class="{INPUT} num pr-28"
class="{INPUT} num {compact ? "pr-8" : "pr-28"}"
onblur={handleBlur}
onfocus={handleFocus}
oninput={handleInput}
onkeydown={handleKey}
spellcheck="false"
value={text}
value={display}
>
{#if !compact}
<span
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
>{label}</span
>
{/if}
<button
aria-label="Pick a date"
class="absolute inset-y-0 right-0 grid w-8 place-items-center text-cladd-fg-soft hover:text-cladd-fg"
@@ -101,10 +130,7 @@
<input
aria-hidden="true"
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
onchange={({ currentTarget }) => {
({ value } = currentTarget);
text = value;
}}
onchange={({ currentTarget }) => commit(currentTarget.value)}
tabindex="-1"
type="date"
{value}
@@ -73,17 +73,17 @@
title="Account balances"
>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-[13px]">
<table class="w-full min-w-[34rem] border-collapse text-[13px]">
<thead>
<tr class="text-[12px] text-cladd-fg-soft">
<th class="h-7 px-3 text-left font-medium">Name</th>
<th class="h-7 px-3 text-right font-medium">
<th class="h-7 w-full px-3 text-left font-medium">Name</th>
<th class="h-7 w-32 px-3 text-right font-medium">
Balance at start of period
</th>
<th class="h-7 px-3 text-right font-medium">
<th class="h-7 w-32 px-3 text-right font-medium">
Balance at end of period
</th>
<th class="h-7 px-3 text-right font-medium">Difference</th>
<th class="h-7 w-32 px-3 text-right font-medium">Difference</th>
</tr>
</thead>
<tbody>
@@ -95,7 +95,7 @@
? "bg-row-selected"
: ""}"
>
<td class="max-w-[16rem] px-3">
<td class="max-w-0 px-3">
<button
aria-pressed={active}
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
+35 -29
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import DateInput from "#lib/components/inputs/DateInput.svelte";
import { useKeys } from "#lib/keys.svelte.js";
import { recall, remember } from "#lib/reports/memory.js";
import { listOptions, listReady } from "#lib/reports/options.js";
@@ -25,7 +26,7 @@
let accountsOpen = $state(false);
let itemsOpen = $state(false);
let typeSelect = $state<HTMLSelectElement>();
let startInput = $state<HTMLInputElement>();
let startInput = $state<HTMLInputElement | null>(null);
const p = $derived(report.params);
const presets = rangePresets();
@@ -82,8 +83,9 @@
go({ items: defaultItems(type), type });
}
function setRange(start: string, end: string) {
if (start && end && start <= end) {
function setRange(a: string, b: string) {
const [start, end] = a <= b ? [a, b] : [b, a];
if (start && end && (start !== p.start || end !== p.end)) {
go({ end, start });
}
}
@@ -159,11 +161,13 @@
<div
class="cladd-surface cladd-outline z-20 mb-4 lg:sticky lg:top-14 flex flex-wrap items-end justify-between gap-x-4 gap-y-2 rounded-cladd-lg px-3 py-2"
>
<div class="flex min-w-0 flex-1 flex-wrap items-end gap-x-3 gap-y-2">
<label class="flex flex-col gap-0.5">
<div
class="flex min-w-0 flex-1 flex-wrap items-end gap-x-3 gap-y-2 max-sm:basis-full"
>
<label class="flex flex-col gap-0.5 max-sm:w-full">
<span class="text-[11px] text-cladd-fg-soft">Report type</span>
<select
class="cladd-cut cladd-focusable-input h-8 max-w-[14rem] rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
class="cladd-cut cladd-focusable-input h-8 max-w-[14rem] rounded-cladd-sm px-2 text-[13px] text-cladd-fg max-sm:max-w-none"
onchange={(event) => setType(event.currentTarget.value as ReportType)}
value={p.type}
bind:this={typeSelect}
@@ -173,12 +177,12 @@
{/each}
</select>
</label>
<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex min-w-0 flex-col gap-0.5 max-sm:w-full">
<label class="text-[11px] text-cladd-fg-soft" for="report-accounts"
>Accounts</label
>
<MultiSelect
class="w-48"
class="w-44 max-sm:w-full"
id="report-accounts"
label="Accounts"
loading={!report.accountsReady}
@@ -189,12 +193,12 @@
/>
</div>
{#if list}
<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex min-w-0 flex-col gap-0.5 max-sm:w-full">
<label class="text-[11px] text-cladd-fg-soft" for="report-items"
>{LIST_LABELS[list].many}</label
>
<MultiSelect
class="w-48"
class="w-48 max-sm:w-full"
id="report-items"
label={LIST_LABELS[list].many}
loading={!listReady(list, sources)}
@@ -205,48 +209,50 @@
/>
</div>
{/if}
<div class="flex flex-col gap-0.5">
<div class="flex flex-col gap-0.5 max-sm:w-full">
<span class="text-[11px] text-cladd-fg-soft">Date range</span>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<div class="flex items-center gap-1">
<div class="flex flex-wrap items-center gap-x-1 gap-y-2 sm:gap-x-2">
<button
aria-label="Previous period"
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg max-sm:order-2"
onclick={() => shift(-1)}
title="Previous period ["
type="button"
>
<ChevronLeft class="size-4" />
</button>
<input
<div
class="flex items-center gap-2 max-sm:w-full max-sm:flex-col max-sm:items-stretch"
>
<DateInput
aria-label="Start date"
class="cladd-cut cladd-focusable-input num h-8 w-[7.4rem] rounded-cladd-sm px-1.5 text-[13px] sm:w-[8.2rem] sm:px-2 text-cladd-fg"
onchange={(event) => setRange(event.currentTarget.value, p.end)}
type="date"
class="w-[8.75rem] max-sm:w-full [&>input]:h-8"
compact
oncommit={(value) => setRange(value, p.end)}
value={p.start}
bind:this={startInput}
>
<span class="text-cladd-fg-soft">to</span>
<input
bind:ref={startInput}
/>
<span class="text-cladd-fg-soft max-sm:hidden">to</span>
<DateInput
aria-label="End date"
class="cladd-cut cladd-focusable-input num h-8 w-[7.4rem] rounded-cladd-sm px-1.5 text-[13px] sm:w-[8.2rem] sm:px-2 text-cladd-fg"
onchange={(event) => setRange(p.start, event.currentTarget.value)}
type="date"
class="w-[8.75rem] max-sm:w-full [&>input]:h-8"
compact
oncommit={(value) => setRange(p.start, value)}
value={p.end}
>
/>
</div>
<button
aria-label="Next period"
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg"
class="grid size-8 place-items-center rounded-cladd-sm text-cladd-fg-soft hover:bg-row-hover hover:text-cladd-fg max-sm:order-4"
onclick={() => shift(1)}
title="Next period ]"
type="button"
>
<ChevronRight class="size-4" />
</button>
</div>
<select
aria-label="Preset range"
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg"
class="cladd-cut cladd-focusable-input h-8 rounded-cladd-sm px-2 text-[13px] text-cladd-fg max-sm:order-3 max-sm:flex-1"
onchange={(event) => {
const found = presets.find(
(x) => x.key === event.currentTarget.value
+3 -3
View File
@@ -24,14 +24,14 @@
</svelte:head>
<header class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 pb-4">
<div class="flex min-w-0 items-baseline gap-3">
<div class="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-0.5">
<h1
class="truncate font-semibold text-[22px] text-cladd-fg tracking-[-0.02em]"
class="break-words font-semibold text-[22px] text-cladd-fg tracking-[-0.02em]"
>
{title}
</h1>
{#if subtitle}
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
<span class="text-[13px] text-cladd-fg-soft">{subtitle}</span>
{/if}
</div>
<div class="flex flex-wrap items-center gap-3">
@@ -0,0 +1,107 @@
<script lang="ts">
import { createQuery, keepPreviousData } from "@tanstack/svelte-query";
import { all } from "#lib/api/lists.js";
import type { TransactionGroup } from "#lib/api/types.js";
import MoneyList from "#lib/components/categories/MoneyList.svelte";
import {
byPeriod,
list,
periodRanges,
rows,
SIDEBAR_PERIODS,
} from "#lib/components/categories/sums.js";
import type { Range } from "#lib/dates.js";
import { period, periodLabel } from "#lib/period.svelte.js";
import { listPath } from "#lib/transactions/split.js";
const FIELDS = [
["spent", "Spent", "withdrawal"],
["earned", "Earned", "deposit"],
["transferred", "Transferred", "transfer"],
] as const;
let { type, class: className = "" }: { type: string; class?: string } =
$props();
const ranges = $derived(
periodRanges(period.end, period.view, SIDEBAR_PERIODS)
);
const first = $derived(ranges.at(-1)?.start ?? period.start);
const last = $derived(ranges[0]?.end ?? period.end);
const query = createQuery(() => ({
placeholderData: keepPreviousData,
queryFn: async () => ({
first,
groups: await all<TransactionGroup>("/v1/transactions", {
end: last,
start: first,
type,
}),
last,
}),
queryKey: ["transactions-periods", type, first, last],
}));
const periods = $derived(
byPeriod(
rows(query.data?.groups ?? []),
ranges.filter((range) => !period.first || range.end >= period.first)
)
);
const fields = $derived(
FIELDS.filter(([, , kind]) => type === "all" || type === kind)
);
function covered(range: Range) {
const { data } = query;
return Boolean(data && data.first <= range.start && range.end <= data.last);
}
const href = (range: Range) =>
`${listPath(type)}?start=${range.start}&end=${range.end}`;
</script>
<nav
aria-label="Periods"
class="flex min-w-0 gap-2 overflow-x-auto min-[1600px]:flex-col min-[1600px]:overflow-visible {className}"
>
{#each periods as item (item.range.start)}
{@const current =
item.range.start === period.start && item.range.end === period.end}
<a
aria-current={current ? "page" : undefined}
class="cladd-surface cladd-outline flex w-40 shrink-0 flex-col gap-0.5 rounded-cladd-md px-3 py-2 text-[12px] min-[1600px]:w-auto {current
? "bg-row-cursor outline-2 outline-brand -outline-offset-2"
: "hover:bg-row-hover"}"
href={href(item.range)}
>
<span class="font-semibold text-brand"
>{periodLabel(item.range, period.view)}</span
>
{#if !covered(item.range)}
<span class="text-cladd-fg-softer">Loading...</span>
{:else if item.totals.count === 0}
<span class="text-cladd-fg-softer">No transactions</span>
{:else}
<span class="flex justify-between gap-2">
<span class="text-cladd-fg-soft">Transactions</span>
<span class="num text-cladd-fg">{item.totals.count}</span>
</span>
{#each fields as [field, label] (field)}
{@const sums = list(item.totals[field])}
{#if sums.length > 0}
<span class="flex justify-between gap-2">
<span class="text-cladd-fg-soft">{label}</span>
<MoneyList
class="flex-col items-end"
{sums}
tone={field === "transferred" ? "transfer" : "auto"}
/>
</span>
{/if}
{/each}
{/if}
</a>
{/each}
</nav>
@@ -330,14 +330,12 @@
{/if}
</td>
<td class="text-right">
<div class="flex flex-wrap justify-end gap-x-1">
{@render money(amount)}
{#if foreign}
<span class="num text-[12px] text-cladd-fg-soft"
>({formatMoney(foreign)})</span
>
{/if}
<div class="num text-[12px] text-cladd-fg-soft">
{formatMoney(foreign)}
</div>
{/if}
</td>
{#if accountId}
<td class="text-right">{@render plain(balanceAfter(split, accountId))}</td>
@@ -411,15 +409,10 @@
>
</div>
</td>
<td class="text-right">
<div class="flex flex-wrap justify-end gap-x-1 font-semibold">
{#each sums as sum, i (sum.code)}
{@render money(sum)}
{#if i < sums.length - 1}
,
{/if}
<td class="text-right font-semibold">
{#each sums as sum (sum.code)}
<div>{@render money(sum)}</div>
{/each}
</div>
</td>
{#if accountId}
<td></td>
@@ -484,7 +477,7 @@
{/if}
<th class="w-6 max-sm:hidden"><span class="sr-only">Type</span></th>
<th>Description</th>
<th class="w-28 text-right md:w-44">Amount</th>
<th class="w-28 text-right md:w-32">Amount</th>
{#if accountId}
<th class="w-32 text-right">Balance</th>
{/if}
@@ -9,6 +9,7 @@
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import Pager from "#lib/components/transactions/Pager.svelte";
import PeriodSummary from "#lib/components/transactions/PeriodSummary.svelte";
import Periods from "#lib/components/transactions/Periods.svelte";
import QueryInput from "#lib/components/transactions/QueryInput.svelte";
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
import Button from "#lib/components/ui/button/button.svelte";
@@ -191,7 +192,10 @@
/>
{/if}
<Box body="p-0" title={LIST_TITLE[type] ?? "Transactions"}>
<div
class="grid items-start gap-4 min-[1600px]:grid-cols-[minmax(0,1fr)_13rem]"
>
<Box body="p-0" title={LIST_TITLE[type] ?? "Transactions"}>
{#snippet tools()}
<span class="max-sm:hidden">
<Pager
@@ -203,7 +207,9 @@
</span>
{/snippet}
<TransactionTable
empty={everything ? "No transactions." : "No transactions in this period."}
empty={everything
? "No transactions."
: "No transactions in this period."}
groups={list.data?.data ?? []}
id="tx-list"
loading={list.isFetching}
@@ -226,4 +232,8 @@
/>
</span>
{/snippet}
</Box>
</Box>
{#if !everything}
<Periods class="order-first min-[1600px]:order-none" {type} />
{/if}
</div>