fix(transactions,editor,reports): v1 period column, one amount edge, unclipped report numbers, solid save, mobile headers
This commit is contained in:
1 parent
fa322d22e9
commit
e279d3948a
8 files changed
+223
-78
No files matched your search
@@ -10,6 +10,7 @@
|
|||||||
import Field from "#lib/components/inputs/Field.svelte";
|
import Field from "#lib/components/inputs/Field.svelte";
|
||||||
import { INPUT } from "#lib/components/inputs/styles.js";
|
import { INPUT } from "#lib/components/inputs/styles.js";
|
||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
|
import Button from "#lib/components/ui/button/button.svelte";
|
||||||
import { formatMoney } from "#lib/format.js";
|
import { formatMoney } from "#lib/format.js";
|
||||||
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
|
||||||
import { ui } from "#lib/ui.svelte.js";
|
import { ui } from "#lib/ui.svelte.js";
|
||||||
@@ -626,20 +627,29 @@
|
|||||||
</Box>
|
</Box>
|
||||||
{/if}
|
{/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
|
<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)]"
|
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
|
<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"
|
class="h-9 px-3.5 text-[13px] sm:h-cladd-md"
|
||||||
|
color="brand"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
type="button"
|
variant="fill"
|
||||||
>
|
>
|
||||||
{editing ? "Update transaction" : "Save transaction"}
|
{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
|
>{prettyKeys("mod+Enter")[0]}</span
|
||||||
>
|
>
|
||||||
</button>
|
</Button>
|
||||||
<label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft">
|
<label class="flex items-center gap-2 text-[13px] text-cladd-fg-soft">
|
||||||
<span class="hidden sm:inline">After saving</span>
|
<span class="hidden sm:inline">After saving</span>
|
||||||
<select
|
<select
|
||||||
@@ -685,11 +695,4 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Calendar from "@lucide/svelte/icons/calendar";
|
import Calendar from "@lucide/svelte/icons/calendar";
|
||||||
import { untrack } from "svelte";
|
import { tick, untrack } from "svelte";
|
||||||
import type { HTMLInputAttributes } from "svelte/elements";
|
import type { HTMLInputAttributes } from "svelte/elements";
|
||||||
import { addDays, today } from "#lib/dates.js";
|
import { addDays, today } from "#lib/dates.js";
|
||||||
import { formatDate } from "#lib/format.js";
|
import { formatDate } from "#lib/format.js";
|
||||||
@@ -11,16 +11,21 @@
|
|||||||
value = $bindable(""),
|
value = $bindable(""),
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
invalid = false,
|
invalid = false,
|
||||||
|
compact = false,
|
||||||
|
oncommit,
|
||||||
class: className = "",
|
class: className = "",
|
||||||
...rest
|
...rest
|
||||||
}: Omit<HTMLInputAttributes, "value"> & {
|
}: Omit<HTMLInputAttributes, "value"> & {
|
||||||
value?: string;
|
value?: string;
|
||||||
ref?: HTMLInputElement | null;
|
ref?: HTMLInputElement | null;
|
||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
oncommit?: (value: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let text = $state(value);
|
let text = $state(value);
|
||||||
let picker = $state<HTMLInputElement | null>(null);
|
let picker = $state<HTMLInputElement | null>(null);
|
||||||
|
let focused = $state(false);
|
||||||
|
|
||||||
const parsed = $derived(parseDate(text, today()));
|
const parsed = $derived(parseDate(text, today()));
|
||||||
const shown = $derived(parsed ?? value);
|
const shown = $derived(parsed ?? value);
|
||||||
@@ -29,6 +34,9 @@
|
|||||||
? `${formatDate(shown, "day")}${shown.slice(0, 4) === today().slice(0, 4) ? "" : ` ${shown.slice(0, 4)}`}`
|
? `${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(() => {
|
$effect(() => {
|
||||||
const next = value;
|
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() {
|
function handleBlur() {
|
||||||
|
focused = false;
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
text = parsed;
|
commit(parsed);
|
||||||
value = parsed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKey(event: KeyboardEvent) {
|
function handleKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter" && parsed) {
|
||||||
|
commit(parsed);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
|
(event.key === "ArrowUp" || event.key === "ArrowDown") &&
|
||||||
!(event.altKey || event.ctrlKey || event.metaKey)
|
!(event.altKey || event.ctrlKey || event.metaKey)
|
||||||
@@ -62,8 +89,7 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const step =
|
const step =
|
||||||
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
|
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 7 : 1);
|
||||||
value = addDays(parsed ?? value ?? today(), step);
|
commit(addDays(parsed ?? value ?? today(), step));
|
||||||
text = value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,17 +104,20 @@
|
|||||||
{...rest}
|
{...rest}
|
||||||
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
|
aria-invalid={invalid || (text.trim() !== "" && !parsed) || undefined}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="{INPUT} num pr-28"
|
class="{INPUT} num {compact ? "pr-8" : "pr-28"}"
|
||||||
onblur={handleBlur}
|
onblur={handleBlur}
|
||||||
|
onfocus={handleFocus}
|
||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
onkeydown={handleKey}
|
onkeydown={handleKey}
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
value={text}
|
value={display}
|
||||||
>
|
>
|
||||||
|
{#if !compact}
|
||||||
<span
|
<span
|
||||||
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
|
class="pointer-events-none absolute inset-y-0 right-9 flex items-center text-[12px] text-cladd-fg-soft"
|
||||||
>{label}</span
|
>{label}</span
|
||||||
>
|
>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
aria-label="Pick a date"
|
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"
|
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
|
<input
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
|
class="pointer-events-none absolute right-0 bottom-0 h-0 w-0 opacity-0"
|
||||||
onchange={({ currentTarget }) => {
|
onchange={({ currentTarget }) => commit(currentTarget.value)}
|
||||||
({ value } = currentTarget);
|
|
||||||
text = value;
|
|
||||||
}}
|
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
type="date"
|
type="date"
|
||||||
{value}
|
{value}
|
||||||
|
|||||||
@@ -73,17 +73,17 @@
|
|||||||
title="Account balances"
|
title="Account balances"
|
||||||
>
|
>
|
||||||
<div class="overflow-x-auto">
|
<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>
|
<thead>
|
||||||
<tr class="text-[12px] text-cladd-fg-soft">
|
<tr class="text-[12px] text-cladd-fg-soft">
|
||||||
<th class="h-7 px-3 text-left font-medium">Name</th>
|
<th class="h-7 w-full px-3 text-left font-medium">Name</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 start of period
|
Balance at start of period
|
||||||
</th>
|
</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
|
Balance at end of period
|
||||||
</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
? "bg-row-selected"
|
? "bg-row-selected"
|
||||||
: ""}"
|
: ""}"
|
||||||
>
|
>
|
||||||
<td class="max-w-[16rem] px-3">
|
<td class="max-w-0 px-3">
|
||||||
<button
|
<button
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
class="block max-w-full truncate text-left hover:text-brand dark:hover:text-[var(--ff-series-1)] hover:underline"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
import ChevronLeft from "@lucide/svelte/icons/chevron-left";
|
||||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import DateInput from "#lib/components/inputs/DateInput.svelte";
|
||||||
import { useKeys } from "#lib/keys.svelte.js";
|
import { useKeys } from "#lib/keys.svelte.js";
|
||||||
import { recall, remember } from "#lib/reports/memory.js";
|
import { recall, remember } from "#lib/reports/memory.js";
|
||||||
import { listOptions, listReady } from "#lib/reports/options.js";
|
import { listOptions, listReady } from "#lib/reports/options.js";
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
let accountsOpen = $state(false);
|
let accountsOpen = $state(false);
|
||||||
let itemsOpen = $state(false);
|
let itemsOpen = $state(false);
|
||||||
let typeSelect = $state<HTMLSelectElement>();
|
let typeSelect = $state<HTMLSelectElement>();
|
||||||
let startInput = $state<HTMLInputElement>();
|
let startInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const p = $derived(report.params);
|
const p = $derived(report.params);
|
||||||
const presets = rangePresets();
|
const presets = rangePresets();
|
||||||
@@ -82,8 +83,9 @@
|
|||||||
go({ items: defaultItems(type), type });
|
go({ items: defaultItems(type), type });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRange(start: string, end: string) {
|
function setRange(a: string, b: string) {
|
||||||
if (start && end && start <= end) {
|
const [start, end] = a <= b ? [a, b] : [b, a];
|
||||||
|
if (start && end && (start !== p.start || end !== p.end)) {
|
||||||
go({ end, start });
|
go({ end, start });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,11 +161,13 @@
|
|||||||
<div
|
<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"
|
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">
|
<div
|
||||||
<label class="flex flex-col gap-0.5">
|
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>
|
<span class="text-[11px] text-cladd-fg-soft">Report type</span>
|
||||||
<select
|
<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)}
|
onchange={(event) => setType(event.currentTarget.value as ReportType)}
|
||||||
value={p.type}
|
value={p.type}
|
||||||
bind:this={typeSelect}
|
bind:this={typeSelect}
|
||||||
@@ -173,12 +177,12 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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"
|
<label class="text-[11px] text-cladd-fg-soft" for="report-accounts"
|
||||||
>Accounts</label
|
>Accounts</label
|
||||||
>
|
>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
class="w-48"
|
class="w-44 max-sm:w-full"
|
||||||
id="report-accounts"
|
id="report-accounts"
|
||||||
label="Accounts"
|
label="Accounts"
|
||||||
loading={!report.accountsReady}
|
loading={!report.accountsReady}
|
||||||
@@ -189,12 +193,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if list}
|
{#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"
|
<label class="text-[11px] text-cladd-fg-soft" for="report-items"
|
||||||
>{LIST_LABELS[list].many}</label
|
>{LIST_LABELS[list].many}</label
|
||||||
>
|
>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
class="w-48"
|
class="w-48 max-sm:w-full"
|
||||||
id="report-items"
|
id="report-items"
|
||||||
label={LIST_LABELS[list].many}
|
label={LIST_LABELS[list].many}
|
||||||
loading={!listReady(list, sources)}
|
loading={!listReady(list, sources)}
|
||||||
@@ -205,48 +209,50 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
<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 flex-wrap items-center gap-x-1 gap-y-2 sm:gap-x-2">
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
aria-label="Previous period"
|
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)}
|
onclick={() => shift(-1)}
|
||||||
title="Previous period ["
|
title="Previous period ["
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ChevronLeft class="size-4" />
|
<ChevronLeft class="size-4" />
|
||||||
</button>
|
</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"
|
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"
|
class="w-[8.75rem] max-sm:w-full [&>input]:h-8"
|
||||||
onchange={(event) => setRange(event.currentTarget.value, p.end)}
|
compact
|
||||||
type="date"
|
oncommit={(value) => setRange(value, p.end)}
|
||||||
value={p.start}
|
value={p.start}
|
||||||
bind:this={startInput}
|
bind:ref={startInput}
|
||||||
>
|
/>
|
||||||
<span class="text-cladd-fg-soft">to</span>
|
<span class="text-cladd-fg-soft max-sm:hidden">to</span>
|
||||||
<input
|
<DateInput
|
||||||
aria-label="End date"
|
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"
|
class="w-[8.75rem] max-sm:w-full [&>input]:h-8"
|
||||||
onchange={(event) => setRange(p.start, event.currentTarget.value)}
|
compact
|
||||||
type="date"
|
oncommit={(value) => setRange(p.start, value)}
|
||||||
value={p.end}
|
value={p.end}
|
||||||
>
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-label="Next period"
|
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)}
|
onclick={() => shift(1)}
|
||||||
title="Next period ]"
|
title="Next period ]"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ChevronRight class="size-4" />
|
<ChevronRight class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
<select
|
<select
|
||||||
aria-label="Preset range"
|
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) => {
|
onchange={(event) => {
|
||||||
const found = presets.find(
|
const found = presets.find(
|
||||||
(x) => x.key === event.currentTarget.value
|
(x) => x.key === event.currentTarget.value
|
||||||
|
|||||||
@@ -24,14 +24,14 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<header class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 pb-4">
|
<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
|
<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}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
<span class="truncate text-[13px] text-cladd-fg-soft">{subtitle}</span>
|
<span class="text-[13px] text-cladd-fg-soft">{subtitle}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<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}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex flex-wrap justify-end gap-x-1">
|
|
||||||
{@render money(amount)}
|
{@render money(amount)}
|
||||||
{#if foreign}
|
{#if foreign}
|
||||||
<span class="num text-[12px] text-cladd-fg-soft"
|
<div class="num text-[12px] text-cladd-fg-soft">
|
||||||
>({formatMoney(foreign)})</span
|
{formatMoney(foreign)}
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
{#if accountId}
|
{#if accountId}
|
||||||
<td class="text-right">{@render plain(balanceAfter(split, accountId))}</td>
|
<td class="text-right">{@render plain(balanceAfter(split, accountId))}</td>
|
||||||
@@ -411,15 +409,10 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right font-semibold">
|
||||||
<div class="flex flex-wrap justify-end gap-x-1 font-semibold">
|
{#each sums as sum (sum.code)}
|
||||||
{#each sums as sum, i (sum.code)}
|
<div>{@render money(sum)}</div>
|
||||||
{@render money(sum)}
|
|
||||||
{#if i < sums.length - 1}
|
|
||||||
,
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
{#if accountId}
|
{#if accountId}
|
||||||
<td></td>
|
<td></td>
|
||||||
@@ -484,7 +477,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<th class="w-6 max-sm:hidden"><span class="sr-only">Type</span></th>
|
<th class="w-6 max-sm:hidden"><span class="sr-only">Type</span></th>
|
||||||
<th>Description</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}
|
{#if accountId}
|
||||||
<th class="w-32 text-right">Balance</th>
|
<th class="w-32 text-right">Balance</th>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
import PageHeader from "#lib/components/shell/PageHeader.svelte";
|
||||||
import Pager from "#lib/components/transactions/Pager.svelte";
|
import Pager from "#lib/components/transactions/Pager.svelte";
|
||||||
import PeriodSummary from "#lib/components/transactions/PeriodSummary.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 QueryInput from "#lib/components/transactions/QueryInput.svelte";
|
||||||
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
import TransactionTable from "#lib/components/transactions/TransactionTable.svelte";
|
||||||
import Button from "#lib/components/ui/button/button.svelte";
|
import Button from "#lib/components/ui/button/button.svelte";
|
||||||
@@ -191,7 +192,10 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/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()}
|
{#snippet tools()}
|
||||||
<span class="max-sm:hidden">
|
<span class="max-sm:hidden">
|
||||||
<Pager
|
<Pager
|
||||||
@@ -203,7 +207,9 @@
|
|||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<TransactionTable
|
<TransactionTable
|
||||||
empty={everything ? "No transactions." : "No transactions in this period."}
|
empty={everything
|
||||||
|
? "No transactions."
|
||||||
|
: "No transactions in this period."}
|
||||||
groups={list.data?.data ?? []}
|
groups={list.data?.data ?? []}
|
||||||
id="tx-list"
|
id="tx-list"
|
||||||
loading={list.isFetching}
|
loading={list.isFetching}
|
||||||
@@ -226,4 +232,8 @@
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Box>
|
</Box>
|
||||||
|
{#if !everything}
|
||||||
|
<Periods class="order-first min-[1600px]:order-none" {type} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in new issue
Block a user