@hearthlog/module-finance 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/README.md +11 -2
- package/hearthlog.module.json +90 -0
- package/package.json +44 -3
- package/src/attention.ts +41 -0
- package/src/components/finance/AddAssetForm.svelte +58 -0
- package/src/components/finance/AddTransactionForm.svelte +127 -0
- package/src/components/finance/AssetDrawer.svelte +133 -0
- package/src/components/finance/AssetItem.svelte +96 -0
- package/src/components/finance/AssetView.svelte +165 -0
- package/src/components/finance/AssetsSheet.svelte +122 -0
- package/src/components/finance/BudgetItemActionsCell.svelte +18 -0
- package/src/components/finance/BudgetItemAmountCell.svelte +5 -0
- package/src/components/finance/BudgetItemCategoryCell.svelte +7 -0
- package/src/components/finance/BudgetItemDrawer.svelte +116 -0
- package/src/components/finance/BudgetItemNameCell.svelte +5 -0
- package/src/components/finance/BudgetItemSelectCell.svelte +25 -0
- package/src/components/finance/BudgetItemsTable.svelte +206 -0
- package/src/components/finance/BudgetPlanDrawer.svelte +89 -0
- package/src/components/finance/BudgetPlans.svelte +419 -0
- package/src/components/finance/EndlessTimeline.svelte +174 -0
- package/src/components/finance/FinanceReadout.svelte +229 -0
- package/src/components/finance/MonthSection.svelte +255 -0
- package/src/components/finance/RiverSlice.svelte +125 -0
- package/src/components/finance/SortUncategorizedSheet.svelte +198 -0
- package/src/components/finance/TimelineRowItem.svelte +319 -0
- package/src/components/finance/TransactionDrawer.svelte +480 -0
- package/src/components/finance/TransactionSelectionBar.svelte +200 -0
- package/src/components/finance/timeline-months.svelte.ts +37 -0
- package/src/components/finance/transaction-draft.ts +121 -0
- package/src/data/assets.ts +48 -0
- package/src/data/budgets.ts +123 -0
- package/src/data/definitions.ts +135 -0
- package/src/data/finance-timeline.ts +305 -0
- package/src/data/forecast.ts +256 -0
- package/src/data/planned-days.ts +57 -0
- package/src/data/repositories.ts +20 -0
- package/src/data/transactions.ts +452 -0
- package/src/finance/categories.ts +29 -0
- package/src/finance/category-pick.ts +8 -0
- package/src/finance/category-suggestions.ts +65 -0
- package/src/finance/floor.ts +29 -0
- package/src/finance/phone-balance.ts +22 -0
- package/src/finance/timeline.ts +113 -0
- package/src/pages/index.svelte +19 -0
- package/src/pages/index.ts +7 -0
- package/src/pages/layout.svelte +13 -0
- package/src/pages/layout.ts +18 -0
- package/src/pages/month.svelte +909 -0
- package/src/pages/month.ts +99 -0
- package/src/server/category-pick.ts +44 -0
- package/src/server/index.ts +11 -0
- package/src/styles.css +12 -0
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { browser } from '$app/env';
|
|
3
|
+
import { goto } from "$app/navigation";
|
|
4
|
+
import { page } from "$app/state";
|
|
5
|
+
import { tick, untrack } from "svelte";
|
|
6
|
+
import { format, parseISO } from "date-fns";
|
|
7
|
+
import { providePageResource } from "@hearthlog/sdk/runtime";
|
|
8
|
+
import { PageDataStatus } from "@hearthlog/sdk/ui";
|
|
9
|
+
import { DataPlaceholder } from "@hearthlog/sdk/ui";
|
|
10
|
+
import { refreshPageData } from "@hearthlog/sdk/runtime";
|
|
11
|
+
import { appClock, contextualHref } from "@hearthlog/sdk/runtime";
|
|
12
|
+
import { runCommandAction as runWorkspaceCommandAction } from "@hearthlog/sdk/runtime";
|
|
13
|
+
import type { AddTransactionEvent, RemoveTransactionEvent, UpdateTransactionEvent } from "@hearthlog/sdk/data";
|
|
14
|
+
import { executeLocalCommand } from "@hearthlog/sdk/data";
|
|
15
|
+
import { events } from "@hearthlog/sdk/runtime";
|
|
16
|
+
import { notify } from "@hearthlog/sdk/runtime";
|
|
17
|
+
import { saveBalanceFloor } from "../finance/floor.js";
|
|
18
|
+
import { clampMonth, monthBounds, monthWindow, outlookDomain, parseShow, showsKind, type TimelineKind, type TimelineShow } from "../finance/timeline.js";
|
|
19
|
+
import { queryDatabase } from "@hearthlog/sdk/data";
|
|
20
|
+
import { monthKeyOf, shiftMonthKey } from "@hearthlog/sdk/axis";
|
|
21
|
+
import { PageHeader } from "@hearthlog/sdk/ui";
|
|
22
|
+
import { RouteTabs } from "@hearthlog/sdk/ui";
|
|
23
|
+
import { MonthNavigator } from "@hearthlog/sdk/ui";
|
|
24
|
+
import { Button } from "@hearthlog/sdk/ui";
|
|
25
|
+
import { CommandOnly } from "@hearthlog/sdk/ui";
|
|
26
|
+
import { MobilePageAction } from "@hearthlog/sdk/ui";
|
|
27
|
+
import BudgetPlans from "../components/finance/BudgetPlans.svelte";
|
|
28
|
+
import FinanceReadout from "../components/finance/FinanceReadout.svelte";
|
|
29
|
+
import EndlessTimeline from "../components/finance/EndlessTimeline.svelte";
|
|
30
|
+
import TransactionSelectionBar from "../components/finance/TransactionSelectionBar.svelte";
|
|
31
|
+
import { TimelineMonths } from "../components/finance/timeline-months.svelte.js";
|
|
32
|
+
import SortUncategorizedSheet, { type CategoryChange } from "../components/finance/SortUncategorizedSheet.svelte";
|
|
33
|
+
import type { RowLike } from "../components/finance/TimelineRowItem.svelte";
|
|
34
|
+
import { createTransactionDraft, formatDayRange, type TransactionDraft } from "../components/finance/transaction-draft.js";
|
|
35
|
+
import IconPlus from "~icons/mdi/plus";
|
|
36
|
+
import IconTune from "~icons/mdi/tune-variant";
|
|
37
|
+
import IconChevronRight from "~icons/mdi/chevron-right";
|
|
38
|
+
import { MediaQuery } from "svelte/reactivity";
|
|
39
|
+
import { Modal } from "@hearthlog/sdk/ui";
|
|
40
|
+
import { PhoneGlance } from "@hearthlog/sdk/ui";
|
|
41
|
+
import AssetsSheet from "../components/finance/AssetsSheet.svelte";
|
|
42
|
+
import { phoneHeader } from "@hearthlog/sdk/runtime";
|
|
43
|
+
import { longPress } from "@hearthlog/sdk/runtime";
|
|
44
|
+
import { formatAmount } from "@hearthlog/sdk/format";
|
|
45
|
+
import { loadPhoneBalance, savePhoneBalance } from "../finance/phone-balance.js";
|
|
46
|
+
import type { FinancePageData } from "./month.js";
|
|
47
|
+
|
|
48
|
+
type TransactionDrawerComponent = typeof import("../components/finance/TransactionDrawer.svelte").default;
|
|
49
|
+
type FinanceData = FinancePageData;
|
|
50
|
+
|
|
51
|
+
const DAY = "yyyy-MM-dd";
|
|
52
|
+
|
|
53
|
+
let { data: routeData } = $props();
|
|
54
|
+
const resource = $derived(routeData.pageResource);
|
|
55
|
+
|
|
56
|
+
// Keep the last loaded data on screen while a refresh loads, so saves swap
|
|
57
|
+
// atomically. PageDataStatus keeps the real resource for perf.
|
|
58
|
+
let shown = $state.raw<FinanceData | null>(null);
|
|
59
|
+
let revision = $state(0);
|
|
60
|
+
const view = $derived(shown ?? resource.data);
|
|
61
|
+
const displayResource = $derived({
|
|
62
|
+
loading: shown ? false : resource.loading,
|
|
63
|
+
error: resource.error,
|
|
64
|
+
measurement: resource.measurement,
|
|
65
|
+
});
|
|
66
|
+
providePageResource(() => displayResource);
|
|
67
|
+
|
|
68
|
+
function runCommandAction(action: () => void): boolean {
|
|
69
|
+
return !displayResource.loading && runWorkspaceCommandAction(action);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------- shallow view state
|
|
73
|
+
let floor = $state(0);
|
|
74
|
+
let floorTouched = false;
|
|
75
|
+
/** The month at the top of the list and the first day showing. */
|
|
76
|
+
let visibleMonth = $state("");
|
|
77
|
+
let visibleDate = $state("");
|
|
78
|
+
let selection = $state.raw(new Map<string, RowLike>());
|
|
79
|
+
let hoveredId = $state<string | null>(null);
|
|
80
|
+
let flashId = $state<string | null>(null);
|
|
81
|
+
|
|
82
|
+
const today = $derived(view.today || format(appClock.now(), DAY));
|
|
83
|
+
const todayMonth = $derived(monthKeyOf(today));
|
|
84
|
+
|
|
85
|
+
let timeline = $state<EndlessTimeline>();
|
|
86
|
+
|
|
87
|
+
function applyData(next: FinanceData): void {
|
|
88
|
+
const previous = shown;
|
|
89
|
+
shown = next;
|
|
90
|
+
revision += 1;
|
|
91
|
+
if (!floorTouched) floor = next.floor;
|
|
92
|
+
const anchor = clampMonth(`${next.year}-${String(next.month).padStart(2, "0")}`, monthBounds(show, next.today, next.outlook.lastDone));
|
|
93
|
+
if (!previous) {
|
|
94
|
+
// A month chosen while the page was loading wins over the address.
|
|
95
|
+
if (!visibleMonth) {
|
|
96
|
+
visibleMonth = anchor;
|
|
97
|
+
visibleDate = anchor === monthKeyOf(next.today) ? next.today : `${anchor}-01`;
|
|
98
|
+
}
|
|
99
|
+
} else if ((previous.year !== next.year || previous.month !== next.month) && anchor !== visibleMonth) {
|
|
100
|
+
// A real navigation to another month while the views are open.
|
|
101
|
+
void tick().then(() => goToMonth(anchor));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
$effect(() => {
|
|
106
|
+
if (resource.loading) return;
|
|
107
|
+
const next = resource.data;
|
|
108
|
+
untrack(() => applyData(next));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const isBudgetTab = $derived(page.url.searchParams.get("tab") === "budget");
|
|
112
|
+
const show = $derived<TimelineShow>(parseShow(page.url.searchParams));
|
|
113
|
+
/** Plans start at today's month; done history ends there or at its last early row. */
|
|
114
|
+
const bounds = $derived(monthBounds(show, today, view.outlook.lastDone));
|
|
115
|
+
// Each filter has its own months; the list opens again when it changes.
|
|
116
|
+
const months = $derived.by(() => {
|
|
117
|
+
const current = show;
|
|
118
|
+
return new TimelineMonths(() => ({ today, show: current }));
|
|
119
|
+
});
|
|
120
|
+
const year = $derived(Number((visibleMonth || todayMonth).slice(0, 4)));
|
|
121
|
+
const month = $derived(Number((visibleMonth || todayMonth).slice(5, 7)));
|
|
122
|
+
const monthLabel = $derived(format(new Date(year, month - 1, 1), "MMMM yyyy"));
|
|
123
|
+
/** Where the list opens: Today in today's month, otherwise the first day of the month in view. */
|
|
124
|
+
const openingDay = $derived(visibleMonth === todayMonth || !visibleMonth ? today : `${visibleMonth}-01`);
|
|
125
|
+
/** New transactions start today, or on the first day in view in another month. */
|
|
126
|
+
const contextDay = $derived(monthKeyOf(visibleDate || today) === todayMonth ? today : visibleDate);
|
|
127
|
+
|
|
128
|
+
function setAddress(mutate: (url: URL) => void): void {
|
|
129
|
+
const url = new URL(window.location.href);
|
|
130
|
+
mutate(url);
|
|
131
|
+
goto(`${url.pathname}${url.search}${url.hash}`, { shallow: true, replace: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The filter is a real navigation (with history); the list opens again for it.
|
|
135
|
+
let lastShow = untrack(() => show);
|
|
136
|
+
$effect(() => {
|
|
137
|
+
const current = show;
|
|
138
|
+
if (current === lastShow) return;
|
|
139
|
+
lastShow = current;
|
|
140
|
+
untrack(() => {
|
|
141
|
+
selection = new Map();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
function showHref(target: TimelineShow): string {
|
|
146
|
+
return contextualHref(`/finance/${year}/${month}${target === "all" ? "" : `?show=${target}`}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The address follows the month in view quietly, so a reload opens there.
|
|
150
|
+
let addressTimer: ReturnType<typeof setTimeout> | undefined;
|
|
151
|
+
function followVisible(visible: { month: string; date: string }): void {
|
|
152
|
+
visibleMonth = visible.month;
|
|
153
|
+
visibleDate = visible.date;
|
|
154
|
+
clearTimeout(addressTimer);
|
|
155
|
+
addressTimer = setTimeout(() => {
|
|
156
|
+
if (isBudgetTab) return;
|
|
157
|
+
const target = `/finance/${Number(visible.month.slice(0, 4))}/${Number(visible.month.slice(5, 7))}`;
|
|
158
|
+
if (window.location.pathname !== target) setAddress((url) => { url.pathname = target; });
|
|
159
|
+
}, 250);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
$effect(() => () => clearTimeout(addressTimer));
|
|
163
|
+
|
|
164
|
+
function goToMonth(month: string, behavior: ScrollBehavior = "auto"): void {
|
|
165
|
+
const key = clampMonth(month, bounds);
|
|
166
|
+
// The label follows at once; a view that is still opening opens there.
|
|
167
|
+
followVisible({ month: key, date: key === todayMonth ? today : `${key}-01` });
|
|
168
|
+
if (key === todayMonth) timeline?.scrollToToday(behavior);
|
|
169
|
+
else timeline?.scrollToMonth(key, behavior);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Scrolls to a day, first switching to the filter for its kind when this one leaves it out. */
|
|
173
|
+
async function goToDate(date: string, kind?: TimelineKind): Promise<void> {
|
|
174
|
+
if (kind && !showsKind(show, kind)) {
|
|
175
|
+
await goto(showHref(kind), { reset: false });
|
|
176
|
+
await tick();
|
|
177
|
+
}
|
|
178
|
+
timeline?.scrollToDate(date);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A saved row's kind: anything marked done or dated before today is done. */
|
|
182
|
+
function kindOf(status: "active" | "archived", date: string): TimelineKind {
|
|
183
|
+
return status === "archived" || date < today ? "done" : "planned";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const financeTabs = $derived([
|
|
187
|
+
{ label: "Transactions", href: contextualHref(`/finance/${year}/${month}`), active: !isBudgetTab },
|
|
188
|
+
{ label: "Budget plans", href: contextualHref(`/finance/${year}/${month}?tab=budget`), active: isBudgetTab },
|
|
189
|
+
]);
|
|
190
|
+
const filters: Array<{ id: TimelineShow; label: string }> = [
|
|
191
|
+
{ id: "all", label: "All" },
|
|
192
|
+
{ id: "planned", label: "Planned" },
|
|
193
|
+
{ id: "done", label: "Done" },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------- one fixed scale
|
|
197
|
+
const domain = $derived.by<[number, number]>(() => {
|
|
198
|
+
const available = view.outlook.available;
|
|
199
|
+
return outlookDomain(Math.min(view.range.min, available), Math.max(view.range.max, available), floor);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
function changeFloor(value: number): void {
|
|
203
|
+
floor = value;
|
|
204
|
+
floorTouched = true;
|
|
205
|
+
void saveBalanceFloor(value).catch(() => notify.error("The floor could not be saved on this device"));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------- selection and hover
|
|
209
|
+
const selectedIds = $derived(new Set(selection.keys()));
|
|
210
|
+
const selectedRows = $derived([...selection.values()]);
|
|
211
|
+
const visibleRows = $derived(months.get(visibleMonth)?.rows ?? []);
|
|
212
|
+
|
|
213
|
+
function snapshot(row: RowLike): RowLike {
|
|
214
|
+
return { id: row.id, name: row.name, amount: row.amount, date: row.date, category: row.category, kind: row.kind };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function selectRow(row: RowLike, selected: boolean): void {
|
|
218
|
+
const next = new Map(selection);
|
|
219
|
+
if (selected) next.set(row.id, snapshot(row));
|
|
220
|
+
else next.delete(row.id);
|
|
221
|
+
selection = next;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function selectAll(): void {
|
|
225
|
+
selection = new Map(visibleRows.map((row) => [row.id, snapshot(row)]));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function forget(ids: string[]): void {
|
|
229
|
+
const next = new Map(selection);
|
|
230
|
+
for (const id of ids) next.delete(id);
|
|
231
|
+
selection = next;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function hoverRow(row: RowLike | null): void {
|
|
235
|
+
hoveredId = row?.id ?? null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------- phones
|
|
239
|
+
// Below 768 px the list is the page: one figure in the top bar, the rest in the Outlook sheet,
|
|
240
|
+
// filters behind the options button, and rows without checkboxes until press and hold.
|
|
241
|
+
const phone = new MediaQuery("max-width: 767px");
|
|
242
|
+
let phoneBalance = $state(false);
|
|
243
|
+
let optionsOpen = $state(false);
|
|
244
|
+
let assetsSheetOpen = $state(false);
|
|
245
|
+
/** Selecting on a phone: entered by press and hold or Select, left by clearing. */
|
|
246
|
+
let selecting = $state(false);
|
|
247
|
+
const selectingNow = $derived(selecting || selection.size > 0);
|
|
248
|
+
/** The click that ends a press and hold is the long press's own; the action swallows it. */
|
|
249
|
+
let pressEnding = false;
|
|
250
|
+
|
|
251
|
+
$effect(() => {
|
|
252
|
+
void loadPhoneBalance().then((on) => { phoneBalance = on; });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
function togglePhoneBalance(): void {
|
|
256
|
+
phoneBalance = !phoneBalance;
|
|
257
|
+
void savePhoneBalance(phoneBalance).catch(() => notify.error("The setting could not be saved on this device"));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The one figure that decides the next step: what is available, or the low point once it drops under the floor. */
|
|
261
|
+
const glance = $derived.by(() => {
|
|
262
|
+
const low = view.outlook.low;
|
|
263
|
+
if (low.date && low.amount < floor) {
|
|
264
|
+
return { label: `Lowest · ${format(parseISO(low.date), "EEE d MMM")}`, value: formatAmount(low.amount), tone: "warning" as const };
|
|
265
|
+
}
|
|
266
|
+
return { label: "Available now", value: formatAmount(view.outlook.available), tone: "default" as const };
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
function rowFromElement(element: HTMLElement): RowLike | null {
|
|
270
|
+
const id = element.dataset.rowId;
|
|
271
|
+
const date = element.dataset.date;
|
|
272
|
+
if (!id || !date) return null;
|
|
273
|
+
const row = months.get(monthKeyOf(date))?.rows.find((candidate) => candidate.id === id);
|
|
274
|
+
return row ? snapshot(row) : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function startSelecting(element: HTMLElement): void {
|
|
278
|
+
pressEnding = true;
|
|
279
|
+
setTimeout(() => { pressEnding = false; }, 800);
|
|
280
|
+
selecting = true;
|
|
281
|
+
const row = rowFromElement(element);
|
|
282
|
+
if (row) selectRow(row, true);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** While selecting on a phone, a tap on a row ticks it instead of opening it. */
|
|
286
|
+
function selectingClick(event: MouseEvent): void {
|
|
287
|
+
if (pressEnding) { pressEnding = false; return; }
|
|
288
|
+
if (!phone.current || !selectingNow) return;
|
|
289
|
+
const target = event.target as HTMLElement;
|
|
290
|
+
const element = target.closest<HTMLElement>("[data-row-id]");
|
|
291
|
+
if (!element || target.closest("[data-row-check]")) return;
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
event.stopPropagation();
|
|
294
|
+
const row = rowFromElement(element);
|
|
295
|
+
if (row) selectRow(row, !selection.has(row.id));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function clearSelection(): void {
|
|
299
|
+
selection = new Map();
|
|
300
|
+
selecting = false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// While selecting on a phone the top bar names the count, with Select all and Cancel.
|
|
304
|
+
$effect(() => {
|
|
305
|
+
if (!phone.current || !selectingNow || isBudgetTab) return;
|
|
306
|
+
const count = selection.size;
|
|
307
|
+
const all = count > 0 && count === visibleRows.length;
|
|
308
|
+
phoneHeader.selection = {
|
|
309
|
+
label: count === 0 ? "Select" : `${count} selected`,
|
|
310
|
+
onSelectAll: all ? undefined : selectAll,
|
|
311
|
+
onCancel: clearSelection,
|
|
312
|
+
};
|
|
313
|
+
return () => { phoneHeader.selection = null; };
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------- drawer
|
|
317
|
+
let drawerOpen = $state(false);
|
|
318
|
+
let drawerDraft = $state<TransactionDraft>(createTransactionDraft(""));
|
|
319
|
+
let TransactionDrawer = $state<TransactionDrawerComponent | null>(null);
|
|
320
|
+
let transactionDrawerLoadPromise: Promise<void> | null = null;
|
|
321
|
+
let handledNewTransactionUrl = $state("");
|
|
322
|
+
let busyId = $state<string | null>(null);
|
|
323
|
+
/** New transactions keep their events until saved, so retrying the same save stays idempotent. */
|
|
324
|
+
let pendingAdd: { key: string; submissionId: string; events: AddTransactionEvent[] } | null = null;
|
|
325
|
+
|
|
326
|
+
function loadTransactionDrawer(): Promise<void> {
|
|
327
|
+
transactionDrawerLoadPromise ??= import("../components/finance/TransactionDrawer.svelte").then((module) => {
|
|
328
|
+
TransactionDrawer = module.default;
|
|
329
|
+
});
|
|
330
|
+
return transactionDrawerLoadPromise;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function openDrawer(next: TransactionDraft): void {
|
|
334
|
+
drawerDraft = next;
|
|
335
|
+
pendingAdd = null;
|
|
336
|
+
drawerOpen = true;
|
|
337
|
+
void loadTransactionDrawer();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function openAddTransaction(date = contextDay, status: "active" | "archived" = "active"): void {
|
|
341
|
+
const next = createTransactionDraft(date);
|
|
342
|
+
next.status = status;
|
|
343
|
+
openDrawer(next);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function openEditTransaction(row: RowLike): void {
|
|
347
|
+
openDrawer({
|
|
348
|
+
id: row.id,
|
|
349
|
+
name: row.name,
|
|
350
|
+
amount: Math.abs(row.amount),
|
|
351
|
+
dueDate: row.date,
|
|
352
|
+
category: row.category,
|
|
353
|
+
status: row.kind === "done" ? "archived" : "active",
|
|
354
|
+
direction: row.amount >= 0 ? "income" : "expense",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------- sort uncategorized
|
|
359
|
+
/** Rows of the month in view that still have no category, counted in SQL. */
|
|
360
|
+
let uncategorized = $state(0);
|
|
361
|
+
let sortOpen = $state(false);
|
|
362
|
+
let sortMonth = $state("");
|
|
363
|
+
|
|
364
|
+
$effect(() => {
|
|
365
|
+
const key = visibleMonth;
|
|
366
|
+
void revision;
|
|
367
|
+
if (!key || !shown || isBudgetTab) return;
|
|
368
|
+
let current = true;
|
|
369
|
+
const { start, end } = monthWindow(key);
|
|
370
|
+
void queryDatabase((repositories) => repositories.transactions.countUncategorizedInMonth(start, end))
|
|
371
|
+
.then((count) => { if (current) uncategorized = count; })
|
|
372
|
+
.catch(() => undefined);
|
|
373
|
+
return () => { current = false; };
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
function openSort(): void {
|
|
377
|
+
sortMonth = visibleMonth || todayMonth;
|
|
378
|
+
sortOpen = true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function writeCategories(changes: CategoryChange[], pick: (change: CategoryChange) => { category: string; source: string }): Promise<void> {
|
|
382
|
+
// One submission identity per action: retrying the same Apply stays idempotent.
|
|
383
|
+
const submissionId = crypto.randomUUID();
|
|
384
|
+
await executeLocalCommand(changes.map((change) => ({
|
|
385
|
+
id: crypto.randomUUID(),
|
|
386
|
+
type: "TRANSACTION_UPDATED" as const,
|
|
387
|
+
timestamp: Date.now(),
|
|
388
|
+
payload: { id: change.id, transaction_category: pick(change).category, category_source: pick(change).source },
|
|
389
|
+
} satisfies UpdateTransactionEvent)), { submissionId });
|
|
390
|
+
await refreshFinanceData();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function applyCategories(changes: CategoryChange[]): Promise<void> {
|
|
394
|
+
try {
|
|
395
|
+
await writeCategories(changes, (change) => ({ category: change.category, source: change.source }));
|
|
396
|
+
} catch (error) {
|
|
397
|
+
console.error("[Finance] Failed to sort transactions:", error);
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
sortOpen = false;
|
|
401
|
+
notify.success(`${plural(changes.length, "transaction", "transactions")} sorted`, {
|
|
402
|
+
label: "Undo",
|
|
403
|
+
run: () => undoCategories(changes).catch(() => undefined),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Restores the categories and provenance the sorted rows had before. */
|
|
408
|
+
async function undoCategories(changes: CategoryChange[]): Promise<void> {
|
|
409
|
+
try {
|
|
410
|
+
await writeCategories(changes, (change) => change.previous);
|
|
411
|
+
notify.success(`${plural(changes.length, "transaction", "transactions")} restored`);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
console.error("[Finance] Failed to undo sorting:", error);
|
|
414
|
+
notify.error("Could not undo the sorting");
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------- mutations
|
|
420
|
+
function refreshFinanceData(): Promise<void> {
|
|
421
|
+
return refreshPageData("app:assets", "app:transactions", "app:budgets");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function flash(id: string): void {
|
|
425
|
+
flashId = id;
|
|
426
|
+
setTimeout(() => { if (flashId === id) flashId = null; }, 1900);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`;
|
|
430
|
+
|
|
431
|
+
/** Names the day, with a way there, when a saved entry lands outside the filter or month in view. */
|
|
432
|
+
function savedNotice(message: string, date: string, kind: TimelineKind): void {
|
|
433
|
+
if (showsKind(show, kind) && monthKeyOf(date) === visibleMonth) {
|
|
434
|
+
notify.success(message);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
notify.success(`${message} · ${format(parseISO(date), "d MMM yyyy")}`, { label: "Show", run: () => void goToDate(date, kind) });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** The category's provenance, sent only when the category was filled or chosen in the drawer. */
|
|
441
|
+
function sourceOf(draft: TransactionDraft): { category_source?: string } {
|
|
442
|
+
return draft.categorySource === undefined ? {} : { category_source: draft.categorySource };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function saveTransaction(drafts: TransactionDraft[]): Promise<void> {
|
|
446
|
+
const [next] = drafts;
|
|
447
|
+
try {
|
|
448
|
+
if (next.id) {
|
|
449
|
+
await executeLocalCommand({
|
|
450
|
+
id: crypto.randomUUID(),
|
|
451
|
+
type: "TRANSACTION_UPDATED",
|
|
452
|
+
timestamp: Date.now(),
|
|
453
|
+
payload: { id: next.id, name: next.name, amount: Number(next.amount), due_date: next.dueDate, transaction_category: next.category, transaction_status: next.status, ...sourceOf(next) },
|
|
454
|
+
} satisfies UpdateTransactionEvent);
|
|
455
|
+
await refreshFinanceData();
|
|
456
|
+
forget([next.id]);
|
|
457
|
+
savedNotice("Transaction updated", next.dueDate, kindOf(next.status, next.dueDate));
|
|
458
|
+
} else {
|
|
459
|
+
const key = JSON.stringify(drafts);
|
|
460
|
+
if (pendingAdd?.key !== key) {
|
|
461
|
+
const timestamp = Date.now();
|
|
462
|
+
pendingAdd = {
|
|
463
|
+
key,
|
|
464
|
+
submissionId: crypto.randomUUID(),
|
|
465
|
+
events: drafts.map((draft) => ({
|
|
466
|
+
id: crypto.randomUUID(),
|
|
467
|
+
type: "TRANSACTION_ADDED",
|
|
468
|
+
timestamp,
|
|
469
|
+
payload: { id: crypto.randomUUID(), name: draft.name, amount: Number(draft.amount), due_date: draft.dueDate, transaction_category: draft.category, transaction_status: draft.status, ...sourceOf(draft) },
|
|
470
|
+
} satisfies AddTransactionEvent)),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
await executeLocalCommand(pendingAdd.events, { submissionId: pendingAdd.submissionId });
|
|
474
|
+
const ids = pendingAdd.events.map((event) => event.payload.id);
|
|
475
|
+
await refreshFinanceData();
|
|
476
|
+
pendingAdd = null;
|
|
477
|
+
flash(ids[0]);
|
|
478
|
+
if (ids.length === 1) {
|
|
479
|
+
savedNotice(next.status === "archived" ? "Transaction recorded as done" : "Transaction planned", next.dueDate, kindOf(next.status, next.dueDate));
|
|
480
|
+
} else {
|
|
481
|
+
const outcome = drafts.every((draft) => draft.status === "archived") ? "recorded as done"
|
|
482
|
+
: drafts.every((draft) => draft.status === "active") ? "planned" : "added";
|
|
483
|
+
notify.success(`${ids.length} transactions ${outcome} · ${formatDayRange(next.dueDate, drafts[drafts.length - 1].dueDate)}`, {
|
|
484
|
+
label: "Undo",
|
|
485
|
+
// Failures are reported by removeTransactions.
|
|
486
|
+
run: () => removeTransactions(ids).catch(() => undefined),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
drawerOpen = false;
|
|
491
|
+
} catch (error) {
|
|
492
|
+
console.error("[Finance] Failed to save transaction:", error);
|
|
493
|
+
throw new Error(drafts.length > 1 ? "The transactions could not be saved. Your values are still here." : "The transaction could not be saved. Your values are still here.");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function setStatus(ids: string[], status: "active" | "archived"): Promise<void> {
|
|
498
|
+
if (ids.length === 0) return;
|
|
499
|
+
// One submission identity per batch: retrying the same action stays idempotent.
|
|
500
|
+
const submissionId = crypto.randomUUID();
|
|
501
|
+
try {
|
|
502
|
+
await executeLocalCommand(ids.map((id) => ({
|
|
503
|
+
id: crypto.randomUUID(),
|
|
504
|
+
type: "TRANSACTION_UPDATED" as const,
|
|
505
|
+
timestamp: Date.now(),
|
|
506
|
+
payload: { id, transaction_status: status },
|
|
507
|
+
})), { submissionId });
|
|
508
|
+
await refreshFinanceData();
|
|
509
|
+
} catch (error) {
|
|
510
|
+
console.error("[Finance] Failed to update transaction status:", error);
|
|
511
|
+
notify.error("Could not update the transaction status");
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Confirms rows as done in one command; Undo restores them to plans. */
|
|
517
|
+
async function markDone(ids: string[], label?: string): Promise<void> {
|
|
518
|
+
if (ids.length === 0) return;
|
|
519
|
+
await setStatus(ids, "archived");
|
|
520
|
+
forget(ids);
|
|
521
|
+
notify.success(label ?? `${plural(ids.length, "payment", "payments")} marked done`, {
|
|
522
|
+
label: "Undo",
|
|
523
|
+
run: () => restore(ids),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function restore(ids: string[]): Promise<void> {
|
|
528
|
+
await setStatus(ids, "active");
|
|
529
|
+
forget(ids);
|
|
530
|
+
notify.success(`${plural(ids.length, "transaction", "transactions")} restored to plans`);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function markRowDone(row: RowLike): Promise<void> {
|
|
534
|
+
busyId = row.id;
|
|
535
|
+
try {
|
|
536
|
+
await markDone([row.id], `${row.name || "Transaction"} marked done`);
|
|
537
|
+
} catch {
|
|
538
|
+
// Reported by setStatus.
|
|
539
|
+
} finally {
|
|
540
|
+
busyId = null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function restoreRow(row: RowLike): Promise<void> {
|
|
545
|
+
busyId = row.id;
|
|
546
|
+
try {
|
|
547
|
+
await restore([row.id]);
|
|
548
|
+
} catch {
|
|
549
|
+
// Reported by setStatus.
|
|
550
|
+
} finally {
|
|
551
|
+
busyId = null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Reschedules rows; a row done only because its day passed becomes a plan again when moved to today or later. */
|
|
556
|
+
async function moveTransactions(rows: RowLike[], dueDate: string): Promise<void> {
|
|
557
|
+
if (rows.length === 0) return;
|
|
558
|
+
const ids = rows.map((row) => row.id);
|
|
559
|
+
const replanned = (row: RowLike) => row.date < today && dueDate >= today;
|
|
560
|
+
const submissionId = crypto.randomUUID();
|
|
561
|
+
try {
|
|
562
|
+
await executeLocalCommand(rows.map((row) => ({
|
|
563
|
+
id: crypto.randomUUID(),
|
|
564
|
+
type: "TRANSACTION_UPDATED" as const,
|
|
565
|
+
timestamp: Date.now(),
|
|
566
|
+
payload: replanned(row) ? { id: row.id, due_date: dueDate, transaction_status: "active" as const } : { id: row.id, due_date: dueDate },
|
|
567
|
+
})), { submissionId });
|
|
568
|
+
await refreshFinanceData();
|
|
569
|
+
forget(ids);
|
|
570
|
+
const kind: TimelineKind = dueDate >= today && rows.some((row) => row.kind === "planned" || replanned(row)) ? "planned" : "done";
|
|
571
|
+
savedNotice(`${plural(ids.length, "transaction", "transactions")} moved to ${format(parseISO(dueDate), "d MMM yyyy")}`, dueDate, kind);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
console.error("[Finance] Failed to move transactions:", error);
|
|
574
|
+
notify.error("Could not move the selected transactions");
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function removeTransactions(ids: string[]): Promise<void> {
|
|
580
|
+
if (ids.length === 0) return;
|
|
581
|
+
const submissionId = crypto.randomUUID();
|
|
582
|
+
try {
|
|
583
|
+
await executeLocalCommand(ids.map((id) => ({
|
|
584
|
+
id: crypto.randomUUID(),
|
|
585
|
+
type: "TRANSACTION_REMOVED" as const,
|
|
586
|
+
timestamp: Date.now(),
|
|
587
|
+
payload: { id },
|
|
588
|
+
} satisfies RemoveTransactionEvent)), { submissionId });
|
|
589
|
+
await refreshFinanceData();
|
|
590
|
+
forget(ids);
|
|
591
|
+
notify.success(ids.length === 1 ? "Transaction deleted" : `${ids.length} transactions deleted`);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
console.error("[Finance] Failed to delete transactions:", error);
|
|
594
|
+
notify.error(ids.length === 1 ? "Could not delete the transaction" : "Could not delete the selected transactions");
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function deleteRow(row: RowLike): Promise<void> {
|
|
600
|
+
busyId = row.id;
|
|
601
|
+
try {
|
|
602
|
+
await removeTransactions([row.id]);
|
|
603
|
+
} catch {
|
|
604
|
+
// Reported by removeTransactions.
|
|
605
|
+
} finally {
|
|
606
|
+
busyId = null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function deleteFromDrawer(): Promise<void> {
|
|
611
|
+
if (!drawerDraft.id) return;
|
|
612
|
+
await removeTransactions([drawerDraft.id]);
|
|
613
|
+
drawerOpen = false;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ---------------------------------------------------------------- effects
|
|
617
|
+
let lastSeenEventsUpdatedAt = 0;
|
|
618
|
+
$effect(() => {
|
|
619
|
+
if (displayResource.loading || !browser) return;
|
|
620
|
+
const updatedAt = events.refreshRequiredAt;
|
|
621
|
+
if (!updatedAt || updatedAt === lastSeenEventsUpdatedAt) return;
|
|
622
|
+
lastSeenEventsUpdatedAt = updatedAt;
|
|
623
|
+
void refreshFinanceData().catch((error) => console.warn("[Finance] Refresh failed", error));
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
$effect(() => {
|
|
627
|
+
if (displayResource.loading || !browser) return;
|
|
628
|
+
if (page.url.searchParams.get("newTransaction") !== "1") return;
|
|
629
|
+
if (handledNewTransactionUrl === page.url.href) return;
|
|
630
|
+
if (!runCommandAction(() => openAddTransaction())) return;
|
|
631
|
+
handledNewTransactionUrl = page.url.href;
|
|
632
|
+
setAddress((url) => url.searchParams.delete("newTransaction"));
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
636
|
+
if (drawerOpen || isBudgetTab || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
637
|
+
const target = event.target as HTMLElement;
|
|
638
|
+
if (target.closest("[data-month-navigation], [data-month-picker], [role='dialog'], [role='menu']")) return;
|
|
639
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target.isContentEditable) return;
|
|
640
|
+
if (event.key === "ArrowLeft") goToMonth(shiftMonthKey(visibleMonth || todayMonth, -1));
|
|
641
|
+
else if (event.key === "ArrowRight") goToMonth(shiftMonthKey(visibleMonth || todayMonth, 1));
|
|
642
|
+
}
|
|
643
|
+
</script>
|
|
644
|
+
|
|
645
|
+
<svelte:head><title>{isBudgetTab ? "Budget plans" : monthLabel} | Finance | stor</title></svelte:head>
|
|
646
|
+
<PageDataStatus resource={resource} />
|
|
647
|
+
<svelte:window onkeydown={handleKeydown}></svelte:window>
|
|
648
|
+
|
|
649
|
+
{#snippet financeNavigation()}
|
|
650
|
+
<RouteTabs label="Finance views" tabs={financeTabs} />
|
|
651
|
+
{/snippet}
|
|
652
|
+
|
|
653
|
+
{#snippet financePeriod()}
|
|
654
|
+
{#if !isBudgetTab}
|
|
655
|
+
<div class="flex items-center justify-between gap-x-3 gap-y-2 sm:flex-wrap sm:justify-start sm:gap-x-5">
|
|
656
|
+
<MonthNavigator
|
|
657
|
+
year={year}
|
|
658
|
+
month={month}
|
|
659
|
+
currentYear={Number(todayMonth.slice(0, 4))}
|
|
660
|
+
currentMonth={Number(todayMonth.slice(5, 7))}
|
|
661
|
+
onNavigate={(targetYear, targetMonth) => goToMonth(`${targetYear}-${String(targetMonth).padStart(2, "0")}`)}
|
|
662
|
+
compact
|
|
663
|
+
phoneArrows={false}
|
|
664
|
+
class="sm:w-auto"
|
|
665
|
+
/>
|
|
666
|
+
<div class="mobile-scroll flex min-w-0 max-w-full items-center gap-2 overflow-x-auto">
|
|
667
|
+
<nav class="stor-segmented-control inline-flex shrink-0" aria-label="Show">
|
|
668
|
+
{#each filters as option (option.id)}
|
|
669
|
+
<a
|
|
670
|
+
href={showHref(option.id)}
|
|
671
|
+
class="stor-segmented-option inline-flex min-h-11 items-center sm:min-h-9"
|
|
672
|
+
aria-current={show === option.id ? "page" : undefined}
|
|
673
|
+
data-state={show === option.id ? "active" : undefined}
|
|
674
|
+
data-sveltekit-reset="false"
|
|
675
|
+
>{option.label}</a>
|
|
676
|
+
{/each}
|
|
677
|
+
</nav>
|
|
678
|
+
{#if uncategorized > 0}
|
|
679
|
+
<CommandOnly>
|
|
680
|
+
<Button variant="secondary" size="sm" class="shrink-0" onclick={openSort}>Sort {uncategorized} uncategorized</Button>
|
|
681
|
+
</CommandOnly>
|
|
682
|
+
{/if}
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
685
|
+
{/if}
|
|
686
|
+
{/snippet}
|
|
687
|
+
|
|
688
|
+
<!-- On desktop the controls sit in the header; on smaller screens they head the view, which fills the screen. -->
|
|
689
|
+
{#snippet headerPeriod()}
|
|
690
|
+
<div class="mt-3 hidden lg:block">{@render financePeriod()}</div>
|
|
691
|
+
{/snippet}
|
|
692
|
+
|
|
693
|
+
<!-- The transactions fit the screen at every size, so the list is the only thing that scrolls. -->
|
|
694
|
+
<div class="flex min-h-0 flex-col {isBudgetTab ? 'lg:h-full' : 'h-full'}">
|
|
695
|
+
<PageHeader title="Finance" class="shrink-0 px-2.5 pt-2 pb-1 max-md:sr-only sm:px-3 sm:pt-4 sm:pb-2" sidebarTabs tabs={financeNavigation} context={headerPeriod}>
|
|
696
|
+
{#if !isBudgetTab}
|
|
697
|
+
<CommandOnly>
|
|
698
|
+
<Button class="hidden lg:inline-flex" data-testid="open-transaction-drawer" onclick={() => openAddTransaction(undefined, show === "done" ? "archived" : "active")}>
|
|
699
|
+
<IconPlus class="h-4 w-4" /> {show === "done" ? "Record past transaction" : "Add transaction"}
|
|
700
|
+
</Button>
|
|
701
|
+
</CommandOnly>
|
|
702
|
+
{/if}
|
|
703
|
+
</PageHeader>
|
|
704
|
+
|
|
705
|
+
{#if isBudgetTab}
|
|
706
|
+
<BudgetPlans
|
|
707
|
+
plans={view.budgetPlans}
|
|
708
|
+
incomeItems={view.budgetIncomeItems}
|
|
709
|
+
expenseItems={view.budgetExpenseItems}
|
|
710
|
+
selectedPlanId={view.selectedPlanId}
|
|
711
|
+
incomeSort={view.incomeSort}
|
|
712
|
+
expenseSort={view.expenseSort}
|
|
713
|
+
/>
|
|
714
|
+
{:else}
|
|
715
|
+
<div class="flex min-h-0 flex-1 flex-col gap-2 px-2.5 pt-1 pb-2 sm:gap-3 sm:px-3 sm:pb-3">
|
|
716
|
+
<!-- Phones keep these figures in the Outlook sheet behind the top bar's figure. -->
|
|
717
|
+
<div class="hidden md:contents">
|
|
718
|
+
<FinanceReadout
|
|
719
|
+
outlook={view.outlook}
|
|
720
|
+
today={today}
|
|
721
|
+
floor={floor}
|
|
722
|
+
assets={view.assets}
|
|
723
|
+
assetsSum={view.assetsSum}
|
|
724
|
+
onFloorChange={changeFloor}
|
|
725
|
+
onJump={(date) => void goToDate(date, "planned")}
|
|
726
|
+
onUpdateAssets={() => (assetsSheetOpen = true)}
|
|
727
|
+
/>
|
|
728
|
+
</div>
|
|
729
|
+
|
|
730
|
+
<div class="flex min-h-[18rem] flex-1 flex-col gap-2 sm:gap-3 lg:min-h-0">
|
|
731
|
+
<div class="hidden shrink-0 md:block lg:hidden">{@render financePeriod()}</div>
|
|
732
|
+
<!-- The list opens where the applied data says, so it waits for it. -->
|
|
733
|
+
{#if displayResource.loading || !shown}
|
|
734
|
+
<section class="stor-panel flex-1 p-4" aria-label="Transactions">
|
|
735
|
+
<DataPlaceholder rows={8} label="Loading transactions" />
|
|
736
|
+
</section>
|
|
737
|
+
{:else}
|
|
738
|
+
<!-- Below lg the list runs on under the page action, down to the dock. -->
|
|
739
|
+
<section
|
|
740
|
+
class="stor-panel flex min-h-0 flex-1 flex-col"
|
|
741
|
+
aria-label="Transactions"
|
|
742
|
+
data-under-action
|
|
743
|
+
data-phone-balance={phoneBalance ? "on" : "off"}
|
|
744
|
+
data-selecting={selectingNow ? "true" : "false"}
|
|
745
|
+
use:longPress={{ selector: "[data-row-id]", onLongPress: startSelecting }}
|
|
746
|
+
onclickcapture={selectingClick}
|
|
747
|
+
>
|
|
748
|
+
<!-- Phones: the month in view, what needs sorting, and the options; the list does the rest. -->
|
|
749
|
+
<div class="flex h-12 shrink-0 items-center gap-1.5 border-b border-[color-mix(in_srgb,var(--stor-border)_62%,transparent)] pl-2 pr-1 md:hidden">
|
|
750
|
+
<MonthNavigator
|
|
751
|
+
year={year}
|
|
752
|
+
month={month}
|
|
753
|
+
currentYear={Number(todayMonth.slice(0, 4))}
|
|
754
|
+
currentMonth={Number(todayMonth.slice(5, 7))}
|
|
755
|
+
onNavigate={(targetYear, targetMonth) => goToMonth(`${targetYear}-${String(targetMonth).padStart(2, "0")}`)}
|
|
756
|
+
compact
|
|
757
|
+
phoneArrows={false}
|
|
758
|
+
class="min-w-0"
|
|
759
|
+
/>
|
|
760
|
+
{#if uncategorized > 0}
|
|
761
|
+
<CommandOnly>
|
|
762
|
+
<button type="button" class="ml-auto h-8 shrink-0 rounded-full bg-[var(--stor-warning-soft)] px-3 text-[12.5px] font-semibold text-[var(--stor-warning)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]" onclick={openSort}>{uncategorized} to sort</button>
|
|
763
|
+
</CommandOnly>
|
|
764
|
+
{/if}
|
|
765
|
+
<button
|
|
766
|
+
type="button"
|
|
767
|
+
class="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-[var(--stor-muted-text)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {uncategorized > 0 ? '' : 'ml-auto'}"
|
|
768
|
+
aria-label="List options"
|
|
769
|
+
aria-haspopup="dialog"
|
|
770
|
+
data-testid="finance-list-options"
|
|
771
|
+
onclick={() => (optionsOpen = true)}
|
|
772
|
+
>
|
|
773
|
+
<IconTune class="h-5 w-5" />
|
|
774
|
+
{#if show !== "all" || phoneBalance}<span class="absolute right-2 top-2 h-2 w-2 rounded-full bg-[var(--stor-action)]" aria-hidden="true"></span>{/if}
|
|
775
|
+
</button>
|
|
776
|
+
</div>
|
|
777
|
+
<TransactionSelectionBar
|
|
778
|
+
selected={selectedRows}
|
|
779
|
+
visibleCount={visibleRows.length}
|
|
780
|
+
today={today}
|
|
781
|
+
onClear={clearSelection}
|
|
782
|
+
onSelectAll={selectAll}
|
|
783
|
+
onMove={async (rows, date) => { await moveTransactions(rows, date); clearSelection(); }}
|
|
784
|
+
onSetDone={async (rows, done) => {
|
|
785
|
+
const ids = rows.map((row) => row.id);
|
|
786
|
+
if (done) await markDone(ids);
|
|
787
|
+
else await restore(ids);
|
|
788
|
+
clearSelection();
|
|
789
|
+
}}
|
|
790
|
+
onDelete={async (rows) => { await removeTransactions(rows.map((row) => row.id)); clearSelection(); }}
|
|
791
|
+
/>
|
|
792
|
+
<div class="min-h-0 flex-1">
|
|
793
|
+
{#key show}
|
|
794
|
+
<EndlessTimeline
|
|
795
|
+
bind:this={timeline}
|
|
796
|
+
store={months}
|
|
797
|
+
today={today}
|
|
798
|
+
anchor={openingDay}
|
|
799
|
+
show={show}
|
|
800
|
+
bounds={bounds}
|
|
801
|
+
domain={domain}
|
|
802
|
+
floor={floor}
|
|
803
|
+
available={view.outlook.available}
|
|
804
|
+
selectedIds={selectedIds}
|
|
805
|
+
busyId={busyId}
|
|
806
|
+
flashId={flashId}
|
|
807
|
+
hoveredId={hoveredId}
|
|
808
|
+
revision={revision}
|
|
809
|
+
onSelect={selectRow}
|
|
810
|
+
onEdit={openEditTransaction}
|
|
811
|
+
onMarkDone={markRowDone}
|
|
812
|
+
onRestore={restoreRow}
|
|
813
|
+
onDelete={deleteRow}
|
|
814
|
+
onHover={hoverRow}
|
|
815
|
+
onVisibleChange={followVisible}
|
|
816
|
+
/>
|
|
817
|
+
{/key}
|
|
818
|
+
</div>
|
|
819
|
+
</section>
|
|
820
|
+
{/if}
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
{/if}
|
|
824
|
+
</div>
|
|
825
|
+
|
|
826
|
+
{#if !isBudgetTab}
|
|
827
|
+
<MobilePageAction hideWhenModalOpen isModalOpen={drawerOpen} hidden={selectedRows.length > 0 || selecting}>
|
|
828
|
+
{#if !selecting}
|
|
829
|
+
<Button class="text-[15px]" data-testid="open-transaction-drawer-mobile" onclick={() => openAddTransaction(undefined, show === "done" ? "archived" : "active")}>
|
|
830
|
+
<IconPlus class="h-5 w-5" /> <span class="stor-action-label">{show === "done" ? "Record past transaction" : "Add transaction"}</span>
|
|
831
|
+
</Button>
|
|
832
|
+
{/if}
|
|
833
|
+
</MobilePageAction>
|
|
834
|
+
|
|
835
|
+
<PhoneGlance label={glance.label} value={glance.value} unit="Kč" tone={glance.tone} title="Outlook">
|
|
836
|
+
<FinanceReadout
|
|
837
|
+
sheet
|
|
838
|
+
onUpdateAssets={() => (assetsSheetOpen = true)}
|
|
839
|
+
outlook={view.outlook}
|
|
840
|
+
today={today}
|
|
841
|
+
floor={floor}
|
|
842
|
+
assets={view.assets}
|
|
843
|
+
assetsSum={view.assetsSum}
|
|
844
|
+
onFloorChange={changeFloor}
|
|
845
|
+
/>
|
|
846
|
+
</PhoneGlance>
|
|
847
|
+
|
|
848
|
+
<AssetsSheet show={assetsSheetOpen} assets={view.assets} onClose={() => (assetsSheetOpen = false)} />
|
|
849
|
+
|
|
850
|
+
<Modal show={optionsOpen} title="Show" onClose={() => (optionsOpen = false)}>
|
|
851
|
+
<nav class="stor-segmented-control grid grid-cols-3" aria-label="Show">
|
|
852
|
+
{#each filters as option (option.id)}
|
|
853
|
+
<a
|
|
854
|
+
href={showHref(option.id)}
|
|
855
|
+
class="stor-segmented-option inline-flex min-h-11 items-center justify-center text-sm"
|
|
856
|
+
aria-current={show === option.id ? "page" : undefined}
|
|
857
|
+
data-state={show === option.id ? "active" : undefined}
|
|
858
|
+
data-sveltekit-reset="false"
|
|
859
|
+
onclick={() => (optionsOpen = false)}
|
|
860
|
+
>{option.label}</a>
|
|
861
|
+
{/each}
|
|
862
|
+
</nav>
|
|
863
|
+
<div class="mt-2 flex flex-col divide-y divide-[color-mix(in_srgb,var(--stor-border)_62%,transparent)]">
|
|
864
|
+
<button type="button" class="flex min-h-16 items-center gap-3 text-left" role="switch" aria-checked={phoneBalance} onclick={togglePhoneBalance}>
|
|
865
|
+
<span class="min-w-0 flex-1">
|
|
866
|
+
<span class="block text-[15px] font-medium text-[var(--stor-ink)]">Running balance</span>
|
|
867
|
+
<span class="block text-[12.5px] text-[var(--stor-muted-text)]">The balance after each plan, the balance line and each month's figures</span>
|
|
868
|
+
</span>
|
|
869
|
+
<span class="relative h-[30px] w-12 shrink-0 rounded-full transition-colors {phoneBalance ? 'bg-[var(--stor-action)]' : 'bg-[var(--stor-surface-well)] shadow-[inset_0_0_0_1px_var(--stor-border)]'}" aria-hidden="true">
|
|
870
|
+
<span class="absolute top-[3px] h-6 w-6 rounded-full bg-white shadow transition-[left] {phoneBalance ? 'left-[21px]' : 'left-[3px]'}"></span>
|
|
871
|
+
</span>
|
|
872
|
+
</button>
|
|
873
|
+
{#if uncategorized > 0}
|
|
874
|
+
<CommandOnly>
|
|
875
|
+
<button type="button" class="flex min-h-16 w-full items-center gap-3 text-left" onclick={() => { optionsOpen = false; openSort(); }}>
|
|
876
|
+
<span class="min-w-0 flex-1">
|
|
877
|
+
<span class="block text-[15px] font-medium text-[var(--stor-ink)]">Sort {uncategorized} uncategorized</span>
|
|
878
|
+
<span class="block text-[12.5px] text-[var(--stor-muted-text)]">Give this month's transactions a category</span>
|
|
879
|
+
</span>
|
|
880
|
+
<IconChevronRight class="h-5 w-5 shrink-0 text-[var(--stor-muted-text)]" />
|
|
881
|
+
</button>
|
|
882
|
+
</CommandOnly>
|
|
883
|
+
{/if}
|
|
884
|
+
<CommandOnly>
|
|
885
|
+
<button type="button" class="flex min-h-16 w-full items-center gap-3 text-left" onclick={() => { optionsOpen = false; selecting = true; }}>
|
|
886
|
+
<span class="min-w-0 flex-1">
|
|
887
|
+
<span class="block text-[15px] font-medium text-[var(--stor-ink)]">Select transactions</span>
|
|
888
|
+
<span class="block text-[12.5px] text-[var(--stor-muted-text)]">Mark done, move or delete several. Or press and hold a row.</span>
|
|
889
|
+
</span>
|
|
890
|
+
<IconChevronRight class="h-5 w-5 shrink-0 text-[var(--stor-muted-text)]" />
|
|
891
|
+
</button>
|
|
892
|
+
</CommandOnly>
|
|
893
|
+
</div>
|
|
894
|
+
</Modal>
|
|
895
|
+
{/if}
|
|
896
|
+
|
|
897
|
+
<CommandOnly onUnavailable={() => drawerOpen = false}>
|
|
898
|
+
{#if TransactionDrawer}
|
|
899
|
+
<TransactionDrawer
|
|
900
|
+
show={drawerOpen}
|
|
901
|
+
initialDraft={drawerDraft}
|
|
902
|
+
onClose={() => drawerOpen = false}
|
|
903
|
+
onSave={saveTransaction}
|
|
904
|
+
onDelete={deleteFromDrawer}
|
|
905
|
+
/>
|
|
906
|
+
{/if}
|
|
907
|
+
</CommandOnly>
|
|
908
|
+
|
|
909
|
+
<SortUncategorizedSheet show={sortOpen} month={sortMonth} onClose={() => sortOpen = false} onApply={applyCategories} />
|