@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,419 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatAmount, formatMoney } from "@hearthlog/sdk/format";
|
|
3
|
+
import { goto } from "$app/navigation";
|
|
4
|
+
import { page } from "$app/state";
|
|
5
|
+
import { DropdownMenu } from "bits-ui";
|
|
6
|
+
import type { BudgetItem } from "@hearthlog/sdk/data";
|
|
7
|
+
import type { BudgetItemSelectionSummary, BudgetItemSort, BudgetPlanSummary } from "../../data/budgets.js";
|
|
8
|
+
import { queryDatabase } from "@hearthlog/sdk/data";
|
|
9
|
+
import type { AppEvent } from "@hearthlog/sdk/data";
|
|
10
|
+
import { budgetItemCommands, budgetPlanCommands } from "@hearthlog/sdk/data";
|
|
11
|
+
import { executeLocalCommand } from "@hearthlog/sdk/data";
|
|
12
|
+
import { refreshPageData } from "@hearthlog/sdk/runtime";
|
|
13
|
+
import { contextualHref } from "@hearthlog/sdk/runtime";
|
|
14
|
+
import { notify } from "@hearthlog/sdk/runtime";
|
|
15
|
+
import { Button } from "@hearthlog/sdk/ui";
|
|
16
|
+
import { Select, type SelectOption } from "@hearthlog/sdk/ui";
|
|
17
|
+
import { CommandOnly } from "@hearthlog/sdk/ui";
|
|
18
|
+
import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
|
|
19
|
+
import { MobilePageAction } from "@hearthlog/sdk/ui";
|
|
20
|
+
import { PhoneGlance } from "@hearthlog/sdk/ui";
|
|
21
|
+
import BudgetPlanDrawer from "./BudgetPlanDrawer.svelte";
|
|
22
|
+
import BudgetItemDrawer, { type BudgetItemDraft } from "./BudgetItemDrawer.svelte";
|
|
23
|
+
import BudgetItemsTable from "./BudgetItemsTable.svelte";
|
|
24
|
+
import IconPlus from "~icons/mdi/plus";
|
|
25
|
+
import IconDots from "~icons/mdi/dots-horizontal";
|
|
26
|
+
import IconPencil from "~icons/mdi/pencil-outline";
|
|
27
|
+
import IconContentCopy from "~icons/mdi/content-copy";
|
|
28
|
+
import IconDelete from "~icons/mdi/delete-outline";
|
|
29
|
+
|
|
30
|
+
let { plans, incomeItems, expenseItems, selectedPlanId, incomeSort, expenseSort } = $props<{
|
|
31
|
+
plans: BudgetPlanSummary[];
|
|
32
|
+
incomeItems: BudgetItem[];
|
|
33
|
+
expenseItems: BudgetItem[];
|
|
34
|
+
selectedPlanId: string | null;
|
|
35
|
+
incomeSort: BudgetItemSort | null;
|
|
36
|
+
expenseSort: BudgetItemSort | null;
|
|
37
|
+
}>();
|
|
38
|
+
|
|
39
|
+
const selectedPlan = $derived(plans.find((plan: BudgetPlanSummary) => plan.id === selectedPlanId) ?? plans[0] ?? null);
|
|
40
|
+
const planOptions = $derived<SelectOption[]>(plans.map((plan: BudgetPlanSummary) => ({ value: plan.id, label: plan.name })));
|
|
41
|
+
const remaining = $derived((selectedPlan?.income_total ?? 0) - (selectedPlan?.expense_total ?? 0));
|
|
42
|
+
const moneyFormatter = { format: (value: number) => formatMoney(value) };
|
|
43
|
+
|
|
44
|
+
type PlanDrawerMode = "create" | "rename" | "duplicate";
|
|
45
|
+
let planDrawerMode = $state<PlanDrawerMode | null>(null);
|
|
46
|
+
let itemDrawerOpen = $state(false);
|
|
47
|
+
let itemDrawerType = $state<"income" | "expense">("expense");
|
|
48
|
+
let editingItem = $state<BudgetItem | null>(null);
|
|
49
|
+
let pendingDeletePlan = $state(false);
|
|
50
|
+
let pendingDeleteItem = $state<BudgetItem | null>(null);
|
|
51
|
+
|
|
52
|
+
// --- Selection: row and section checkboxes feed one id set across both tables ---
|
|
53
|
+
let selectedIds = $state(new Set<string>());
|
|
54
|
+
let batchBusy = $state(false);
|
|
55
|
+
let confirmDeleteSelectedOpen = $state(false);
|
|
56
|
+
const emptySelectionSummary: BudgetItemSelectionSummary = {
|
|
57
|
+
count: 0,
|
|
58
|
+
incomeTotal: 0,
|
|
59
|
+
expenseTotal: 0,
|
|
60
|
+
netTotal: 0,
|
|
61
|
+
};
|
|
62
|
+
let selectionSummary = $state<BudgetItemSelectionSummary>({ ...emptySelectionSummary });
|
|
63
|
+
let selectionSummaryLoading = $state(false);
|
|
64
|
+
|
|
65
|
+
const allItems = $derived([...incomeItems, ...expenseItems]);
|
|
66
|
+
const itemById = $derived(new Map(allItems.map((item) => [item.id, item])));
|
|
67
|
+
// Ids that outlived their rows (a refresh removed them) simply drop out here.
|
|
68
|
+
const selectedItems = $derived(
|
|
69
|
+
[...selectedIds].flatMap((id) => {
|
|
70
|
+
const row = itemById.get(id);
|
|
71
|
+
return row ? [row] : [];
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
const selectionCount = $derived(selectedItems.length);
|
|
75
|
+
const allVisibleSelected = $derived(
|
|
76
|
+
allItems.length > 0 && allItems.every((item) => selectedIds.has(item.id)),
|
|
77
|
+
);
|
|
78
|
+
const deleteSelectedTitle = $derived(
|
|
79
|
+
selectionCount === 1 ? "Delete planned item?" : `Delete ${selectionCount} planned items?`,
|
|
80
|
+
);
|
|
81
|
+
const deleteSelectedMessage = $derived.by(() => {
|
|
82
|
+
const names = selectedItems.map((item) => item.name);
|
|
83
|
+
if (names.length === 1) return `This removes ${names[0]} from the plan.`;
|
|
84
|
+
return `This removes these ${names.length} items: ${names.slice(0, 10).join(", ")}${names.length > 10 ? ", …" : ""}.`;
|
|
85
|
+
});
|
|
86
|
+
// A primitive so data refreshes (new object identities) never drop the
|
|
87
|
+
// selection; only switching plans replaces every visible row.
|
|
88
|
+
const activePlanId = $derived(selectedPlan?.id ?? "");
|
|
89
|
+
|
|
90
|
+
$effect(() => {
|
|
91
|
+
void activePlanId;
|
|
92
|
+
selectedIds = new Set();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Totals are a separate SQL query so ticking a checkbox never re-scans rows.
|
|
96
|
+
$effect(() => {
|
|
97
|
+
const ids = selectedItems.map((item) => item.id);
|
|
98
|
+
if (ids.length === 0) {
|
|
99
|
+
selectionSummary = { ...emptySelectionSummary };
|
|
100
|
+
selectionSummaryLoading = false;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
let cancelled = false;
|
|
104
|
+
selectionSummaryLoading = true;
|
|
105
|
+
queryDatabase((repositories) => repositories.budgets.getSelectionSummary(ids))
|
|
106
|
+
.then((result) => {
|
|
107
|
+
if (!cancelled) selectionSummary = result;
|
|
108
|
+
})
|
|
109
|
+
.catch(() => {
|
|
110
|
+
if (!cancelled) selectionSummary = { ...emptySelectionSummary };
|
|
111
|
+
})
|
|
112
|
+
.finally(() => {
|
|
113
|
+
if (!cancelled) selectionSummaryLoading = false;
|
|
114
|
+
});
|
|
115
|
+
return () => {
|
|
116
|
+
cancelled = true;
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
function setSelected(items: BudgetItem[], selected: boolean) {
|
|
121
|
+
const next = new Set(selectedIds);
|
|
122
|
+
for (const item of items) {
|
|
123
|
+
if (selected) next.add(item.id);
|
|
124
|
+
else next.delete(item.id);
|
|
125
|
+
}
|
|
126
|
+
selectedIds = next;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function clearSelection() {
|
|
130
|
+
selectedIds = new Set();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function selectAllVisible() {
|
|
134
|
+
setSelected(allItems, true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function requestDeleteSelected() {
|
|
138
|
+
if (selectionCount === 0) return;
|
|
139
|
+
confirmDeleteSelectedOpen = true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function deleteSelected() {
|
|
143
|
+
confirmDeleteSelectedOpen = false;
|
|
144
|
+
const targets = selectedItems;
|
|
145
|
+
if (batchBusy || targets.length === 0) return;
|
|
146
|
+
batchBusy = true;
|
|
147
|
+
try {
|
|
148
|
+
const count = targets.length;
|
|
149
|
+
// One submission identity per batch: retrying stays idempotent.
|
|
150
|
+
await executeLocalCommand(
|
|
151
|
+
targets.map((item) => ({
|
|
152
|
+
id: crypto.randomUUID(),
|
|
153
|
+
type: "BUDGET_ITEM_REMOVED" as const,
|
|
154
|
+
timestamp: Date.now(),
|
|
155
|
+
payload: { id: item.id },
|
|
156
|
+
})),
|
|
157
|
+
{ submissionId: crypto.randomUUID() },
|
|
158
|
+
);
|
|
159
|
+
await refreshPageData("app:budgets");
|
|
160
|
+
notify.success(`${count} planned item${count === 1 ? "" : "s"} deleted`);
|
|
161
|
+
clearSelection();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error("[Finance] Failed to delete budget items:", error);
|
|
164
|
+
notify.error("Could not delete the selected items");
|
|
165
|
+
} finally {
|
|
166
|
+
batchBusy = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const planDrawerCopy = $derived.by(() => {
|
|
171
|
+
if (planDrawerMode === "rename") return { title: "Rename plan", description: "Update the name of this plan.", action: "Save name", name: selectedPlan?.name ?? "" };
|
|
172
|
+
if (planDrawerMode === "duplicate") return { title: "Duplicate plan", description: "Copy every planned item into an independent scenario.", action: "Duplicate plan", name: selectedPlan ? `${selectedPlan.name} copy` : "" };
|
|
173
|
+
return { title: "New budget plan", description: "Create an independent planning scenario.", action: "Create plan", name: plans.length === 0 ? "Baseline" : "" };
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/** One budget URL, so plan and both section sorts can never drift apart. */
|
|
177
|
+
function budgetHref(planId: string | null, sorts: { incomeSort?: BudgetItemSort | null; expenseSort?: BudgetItemSort | null } = {}): string {
|
|
178
|
+
const params = new URLSearchParams({ tab: "budget" });
|
|
179
|
+
if (planId) params.set("plan", planId);
|
|
180
|
+
const nextIncomeSort = sorts.incomeSort !== undefined ? sorts.incomeSort : incomeSort;
|
|
181
|
+
const nextExpenseSort = sorts.expenseSort !== undefined ? sorts.expenseSort : expenseSort;
|
|
182
|
+
if (nextIncomeSort) params.set("incomeSort", nextIncomeSort);
|
|
183
|
+
if (nextExpenseSort) params.set("expenseSort", nextExpenseSort);
|
|
184
|
+
return contextualHref(`${page.url.pathname}?${params.toString()}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function selectPlan(planId: string) {
|
|
188
|
+
void goto(budgetHref(planId), { reset: false });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function changeSort(type: "income" | "expense", next: BudgetItemSort | null) {
|
|
192
|
+
if (!selectedPlan) return;
|
|
193
|
+
const href = type === "income" ? budgetHref(selectedPlan.id, { incomeSort: next }) : budgetHref(selectedPlan.id, { expenseSort: next });
|
|
194
|
+
void goto(href, { reset: false, replace: true });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function openItem(type: "income" | "expense", item: BudgetItem | null = null) {
|
|
198
|
+
itemDrawerType = type;
|
|
199
|
+
editingItem = item;
|
|
200
|
+
itemDrawerOpen = true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Phones: a new plan starts from the current one, its name a year on when it ends in one.
|
|
204
|
+
// The phone form layout is the form layout at every width (docs/design-concepts/desktop-backport-2026-10-05).
|
|
205
|
+
const phone = { current: true };
|
|
206
|
+
function nextPlanName(name: string): string {
|
|
207
|
+
const match = name.match(/^(.*?)(\d{4})$/);
|
|
208
|
+
return match ? `${match[1]}${Number(match[2]) + 1}` : `${name} copy`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function savePlan(name: string, copy = false) {
|
|
212
|
+
if (planDrawerMode === "rename" && selectedPlan) {
|
|
213
|
+
await budgetPlanCommands.update({ id: selectedPlan.id, name });
|
|
214
|
+
await refreshPageData("app:budgets");
|
|
215
|
+
notify.success("Plan renamed");
|
|
216
|
+
planDrawerMode = null;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const mode = copy ? "duplicate" : planDrawerMode;
|
|
221
|
+
const planId = crypto.randomUUID();
|
|
222
|
+
const events: AppEvent[] = [{
|
|
223
|
+
id: crypto.randomUUID(), type: "BUDGET_PLAN_ADDED", timestamp: Date.now(),
|
|
224
|
+
payload: { id: planId, name },
|
|
225
|
+
}];
|
|
226
|
+
if (mode === "duplicate" && selectedPlan) {
|
|
227
|
+
for (const item of [...incomeItems, ...expenseItems]) {
|
|
228
|
+
events.push({
|
|
229
|
+
id: crypto.randomUUID(), type: "BUDGET_ITEM_ADDED", timestamp: Date.now(),
|
|
230
|
+
payload: { id: crypto.randomUUID(), planId, name: item.name, amount: item.amount, itemType: item.item_type, category: item.category },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
await executeLocalCommand(events, { submissionId: crypto.randomUUID() });
|
|
235
|
+
planDrawerMode = null;
|
|
236
|
+
notify.success(mode === "duplicate" ? "Plan duplicated" : "Plan created");
|
|
237
|
+
await goto(budgetHref(planId));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function saveItem(draft: BudgetItemDraft, addAnother: boolean) {
|
|
241
|
+
if (!selectedPlan) return;
|
|
242
|
+
if (draft.id) {
|
|
243
|
+
await budgetItemCommands.update({ id: draft.id, name: draft.name, amount: draft.amount, itemType: draft.itemType, category: draft.category });
|
|
244
|
+
notify.success("Planned item updated");
|
|
245
|
+
} else {
|
|
246
|
+
await budgetItemCommands.create({ id: crypto.randomUUID(), planId: selectedPlan.id, name: draft.name, amount: draft.amount, itemType: draft.itemType, category: draft.category });
|
|
247
|
+
notify.success(draft.itemType === "income" ? "Planned income added" : "Planned expense added");
|
|
248
|
+
}
|
|
249
|
+
await refreshPageData("app:budgets");
|
|
250
|
+
if (!addAnother) itemDrawerOpen = false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function deleteItem() {
|
|
254
|
+
const item = pendingDeleteItem;
|
|
255
|
+
if (!item) return;
|
|
256
|
+
pendingDeleteItem = null;
|
|
257
|
+
await budgetItemCommands.remove({ id: item.id });
|
|
258
|
+
await refreshPageData("app:budgets");
|
|
259
|
+
notify.success("Planned item deleted");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function deletePlan() {
|
|
263
|
+
if (!selectedPlan) return;
|
|
264
|
+
const events: AppEvent[] = [
|
|
265
|
+
...[...incomeItems, ...expenseItems].map((item) => ({ id: crypto.randomUUID(), type: "BUDGET_ITEM_REMOVED" as const, timestamp: Date.now(), payload: { id: item.id } })),
|
|
266
|
+
{ id: crypto.randomUUID(), type: "BUDGET_PLAN_REMOVED", timestamp: Date.now(), payload: { id: selectedPlan.id } },
|
|
267
|
+
];
|
|
268
|
+
pendingDeletePlan = false;
|
|
269
|
+
await executeLocalCommand(events, { submissionId: crypto.randomUUID() });
|
|
270
|
+
notify.success("Plan deleted");
|
|
271
|
+
await goto(budgetHref(null));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const menuItemClass = "flex min-h-10 w-full cursor-default items-center gap-2 px-3 py-2 text-sm text-stone-700 outline-none data-[highlighted]:bg-[var(--stor-surface-muted)] data-[highlighted]:text-stone-950";
|
|
275
|
+
</script>
|
|
276
|
+
|
|
277
|
+
{#if plans.length === 0}
|
|
278
|
+
<section class="stor-panel mx-2.5 mt-3 flex min-h-80 flex-col items-center justify-center px-6 py-12 text-center sm:mx-3">
|
|
279
|
+
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--stor-accent-soft)] text-2xl font-semibold text-[var(--stor-accent-strong)]">+</div>
|
|
280
|
+
<h2 class="mt-4 text-lg font-semibold text-[var(--stor-ink)]">Plan your budget</h2>
|
|
281
|
+
<p class="mt-2 max-w-md text-sm leading-6 text-[var(--stor-muted-text)]">Create a scenario for expected income and expenses. Plans stay separate from your transactions and apply to every month.</p>
|
|
282
|
+
<CommandOnly><Button class="mt-5" onclick={() => planDrawerMode = "create"}><IconPlus class="h-4 w-4" /> Create first plan</Button></CommandOnly>
|
|
283
|
+
</section>
|
|
284
|
+
{:else if selectedPlan}
|
|
285
|
+
<div class="min-h-0 flex-1 overflow-y-auto p-2.5 pb-24 sm:p-3 lg:pb-3">
|
|
286
|
+
<div class="mx-auto max-w-6xl space-y-3">
|
|
287
|
+
<div class="flex flex-wrap items-center justify-between gap-2 max-md:hidden">
|
|
288
|
+
<div class="flex min-w-0 flex-1 items-center gap-2">
|
|
289
|
+
<Select class="w-full max-w-64" value={selectedPlan.id} options={planOptions} ariaLabel="Selected budget plan" onValueChange={selectPlan} />
|
|
290
|
+
<span class="hidden text-xs text-[var(--stor-muted-text)] sm:inline">{plans.length} {plans.length === 1 ? "plan" : "plans"} · Saved</span>
|
|
291
|
+
<CommandOnly><DropdownMenu.Root>
|
|
292
|
+
<DropdownMenu.Trigger class="flex h-11 w-11 items-center justify-center rounded-xl text-[var(--stor-muted-text)] hover:bg-[var(--stor-accent-soft)] hover:text-[var(--stor-accent-strong)] sm:h-9 sm:w-9" aria-label="Plan actions"><IconDots class="h-5 w-5" /></DropdownMenu.Trigger>
|
|
293
|
+
<DropdownMenu.Portal><DropdownMenu.Content sideOffset={6} align="start" class="stor-menu z-[var(--stor-layer-menu)] min-w-48 overflow-hidden py-1">
|
|
294
|
+
<DropdownMenu.Item class={menuItemClass} onSelect={() => planDrawerMode = "rename"}><IconPencil class="h-4 w-4" /> Rename</DropdownMenu.Item>
|
|
295
|
+
<DropdownMenu.Item class={menuItemClass} onSelect={() => planDrawerMode = "duplicate"}><IconContentCopy class="h-4 w-4" /> Duplicate plan</DropdownMenu.Item>
|
|
296
|
+
<DropdownMenu.Separator class="my-1 h-px bg-[var(--stor-border)]" />
|
|
297
|
+
<DropdownMenu.Item class={`${menuItemClass} text-[var(--stor-negative)]`} onSelect={() => pendingDeletePlan = true}><IconDelete class="h-4 w-4" /> Delete plan</DropdownMenu.Item>
|
|
298
|
+
</DropdownMenu.Content></DropdownMenu.Portal>
|
|
299
|
+
</DropdownMenu.Root></CommandOnly>
|
|
300
|
+
</div>
|
|
301
|
+
<CommandOnly><Button variant="secondary" onclick={() => planDrawerMode = "create"}><IconPlus class="h-4 w-4" /> New plan</Button></CommandOnly>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
{#snippet summary(sheet: boolean)}
|
|
305
|
+
<section class="stor-today-card grid grid-cols-2 gap-4 p-[var(--stor-panel-padding)] lg:grid-cols-3 lg:gap-8 {sheet ? '' : 'max-md:hidden'}" aria-label="Budget summary">
|
|
306
|
+
<div class="min-w-0"><p class="text-xs font-medium text-[var(--stor-muted-text)]">Planned income</p><p class="mt-2 break-words text-lg font-semibold tabular-nums text-[var(--stor-positive)] sm:text-xl lg:text-2xl">{moneyFormatter.format(selectedPlan.income_total)}</p></div>
|
|
307
|
+
<div class="min-w-0"><p class="text-xs font-medium text-[var(--stor-muted-text)]">Planned expenses</p><p class="mt-2 break-words text-lg font-semibold tabular-nums text-[var(--stor-ink)] sm:text-xl lg:text-2xl">{moneyFormatter.format(selectedPlan.expense_total)}</p></div>
|
|
308
|
+
<div class="col-span-2 min-w-0 border-t border-[var(--stor-border)] pt-4 lg:col-span-1 lg:border-l lg:border-t-0 lg:pl-8 lg:pt-0"><p class="text-xs font-medium text-[var(--stor-muted-text)]">Remaining</p><p class="mt-2 break-words text-xl font-semibold tabular-nums sm:text-2xl {remaining > 0 ? 'text-[var(--stor-positive)]' : 'text-[var(--stor-ink)]'}">{remaining > 0 ? "+" : ""}{moneyFormatter.format(remaining)}</p><p class="mt-1 text-xs text-[var(--stor-muted-text)]">Income minus expenses</p></div>
|
|
309
|
+
</section>
|
|
310
|
+
{/snippet}
|
|
311
|
+
{@render summary(false)}
|
|
312
|
+
<PhoneGlance label="Remaining" value={`${remaining > 0 ? "+" : ""}${formatAmount(remaining)}`} unit="Kč" tone={remaining > 0 ? "positive" : "default"} title={selectedPlan.name}>
|
|
313
|
+
{@render summary(true)}
|
|
314
|
+
</PhoneGlance>
|
|
315
|
+
|
|
316
|
+
<!-- style:overflow="visible" keeps the sticky selection bar working, as on the invoice list. -->
|
|
317
|
+
<section class="stor-panel" style:overflow="visible">
|
|
318
|
+
<!-- Phones pick the plan where its items start; New plan, Rename, Duplicate and Delete sit in its menu. -->
|
|
319
|
+
<div class="flex h-12 items-center gap-1.5 border-b border-[color-mix(in_srgb,var(--stor-border)_62%,transparent)] pl-2 pr-1 md:hidden">
|
|
320
|
+
<Select class="min-w-0 flex-1" value={selectedPlan.id} options={planOptions} ariaLabel="Selected budget plan" onValueChange={selectPlan} />
|
|
321
|
+
<CommandOnly><DropdownMenu.Root>
|
|
322
|
+
<DropdownMenu.Trigger class="flex h-11 w-11 items-center justify-center rounded-xl text-[var(--stor-muted-text)]" aria-label="Plan actions"><IconDots class="h-5 w-5" /></DropdownMenu.Trigger>
|
|
323
|
+
<DropdownMenu.Portal><DropdownMenu.Content sideOffset={6} align="end" class="stor-menu z-[var(--stor-layer-menu)] min-w-48 overflow-hidden py-1">
|
|
324
|
+
<DropdownMenu.Item class={menuItemClass} onSelect={() => planDrawerMode = "create"}><IconPlus class="h-4 w-4" /> New plan</DropdownMenu.Item>
|
|
325
|
+
<DropdownMenu.Item class={menuItemClass} onSelect={() => planDrawerMode = "rename"}><IconPencil class="h-4 w-4" /> Rename</DropdownMenu.Item>
|
|
326
|
+
<DropdownMenu.Separator class="my-1 h-px bg-[var(--stor-border)]" />
|
|
327
|
+
<DropdownMenu.Item class={`${menuItemClass} text-[var(--stor-negative)]`} onSelect={() => pendingDeletePlan = true}><IconDelete class="h-4 w-4" /> Delete plan</DropdownMenu.Item>
|
|
328
|
+
</DropdownMenu.Content></DropdownMenu.Portal>
|
|
329
|
+
</DropdownMenu.Root></CommandOnly>
|
|
330
|
+
</div>
|
|
331
|
+
<div class="flex items-center justify-between gap-3 px-4 py-3.5 max-md:hidden"><div><h2 class="font-semibold text-[var(--stor-ink)]">{selectedPlan.name}</h2><p class="text-xs text-[var(--stor-muted-text)]">{selectedPlan.item_count} {selectedPlan.item_count === 1 ? "item" : "items"}</p></div><CommandOnly><Button class="hidden sm:inline-flex" onclick={() => openItem("expense")}><IconPlus class="h-4 w-4" /> Add item</Button></CommandOnly></div>
|
|
332
|
+
{#if selectionCount > 0}
|
|
333
|
+
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 bg-[var(--stor-surface-muted)] px-4 py-2 text-xs text-[var(--stor-muted-text)]" role="status">
|
|
334
|
+
{#if allVisibleSelected}
|
|
335
|
+
<span>All {selectionCount} item{selectionCount === 1 ? "" : "s"} in this view {selectionCount === 1 ? "is" : "are"} selected.</span>
|
|
336
|
+
<button type="button" onclick={clearSelection} class="font-semibold text-[var(--stor-accent-strong)] hover:underline">Clear selection</button>
|
|
337
|
+
{:else}
|
|
338
|
+
<span>{selectionCount} of {allItems.length} item{allItems.length === 1 ? "" : "s"} in this view {selectionCount === 1 ? "is" : "are"} selected.</span>
|
|
339
|
+
<button type="button" onclick={selectAllVisible} class="font-semibold text-[var(--stor-accent-strong)] hover:underline">Select all {allItems.length}</button>
|
|
340
|
+
{/if}
|
|
341
|
+
</div>
|
|
342
|
+
<div class="sticky top-0 z-10 hidden flex-wrap items-center gap-x-6 gap-y-2 bg-[var(--stor-accent-soft)] px-4 py-3 text-sm lg:flex" aria-label="Selected item totals">
|
|
343
|
+
<strong>{selectionCount} selected</strong>
|
|
344
|
+
{#if selectionSummaryLoading}
|
|
345
|
+
<span class="text-[var(--stor-muted-text)]">Calculating totals…</span>
|
|
346
|
+
{:else}
|
|
347
|
+
<div><span class="text-[var(--stor-muted-text)]">Income</span> <strong class="ml-1 tabular-nums">+{moneyFormatter.format(selectionSummary.incomeTotal)}</strong></div>
|
|
348
|
+
<div><span class="text-[var(--stor-muted-text)]">Expenses</span> <strong class="ml-1 tabular-nums">{moneyFormatter.format(-selectionSummary.expenseTotal)}</strong></div>
|
|
349
|
+
<div><span class="text-[var(--stor-muted-text)]">Net</span> <strong class="ml-1 tabular-nums">{moneyFormatter.format(selectionSummary.netTotal)}</strong></div>
|
|
350
|
+
{/if}
|
|
351
|
+
<div class="ml-auto flex items-center gap-2">
|
|
352
|
+
<CommandOnly><button type="button" disabled={batchBusy} onclick={requestDeleteSelected} class="h-9 rounded-lg px-3 font-semibold text-[var(--stor-negative)] disabled:opacity-50">Delete</button></CommandOnly>
|
|
353
|
+
<button type="button" onclick={clearSelection} class="h-9 px-2 text-[var(--stor-accent-strong)]">Clear selection</button>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
{/if}
|
|
357
|
+
<BudgetItemsTable
|
|
358
|
+
type="income"
|
|
359
|
+
label="Income"
|
|
360
|
+
total={selectedPlan.income_total}
|
|
361
|
+
items={incomeItems}
|
|
362
|
+
sort={incomeSort}
|
|
363
|
+
selectedIds={selectedIds}
|
|
364
|
+
onSelectionChange={setSelected}
|
|
365
|
+
onAdd={() => openItem("income")}
|
|
366
|
+
onEdit={(item) => openItem("income", item)}
|
|
367
|
+
onDelete={(item) => (pendingDeleteItem = item)}
|
|
368
|
+
onSortChange={(next) => changeSort("income", next)}
|
|
369
|
+
/>
|
|
370
|
+
<BudgetItemsTable
|
|
371
|
+
type="expense"
|
|
372
|
+
label="Expenses"
|
|
373
|
+
total={selectedPlan.expense_total}
|
|
374
|
+
items={expenseItems}
|
|
375
|
+
sort={expenseSort}
|
|
376
|
+
selectedIds={selectedIds}
|
|
377
|
+
onSelectionChange={setSelected}
|
|
378
|
+
onAdd={() => openItem("expense")}
|
|
379
|
+
onEdit={(item) => openItem("expense", item)}
|
|
380
|
+
onDelete={(item) => (pendingDeleteItem = item)}
|
|
381
|
+
onSortChange={(next) => changeSort("expense", next)}
|
|
382
|
+
/>
|
|
383
|
+
<p class="border-t border-[var(--stor-border)] px-4 py-3 text-xs text-[var(--stor-muted-text)]">Plans do not create transactions.</p>
|
|
384
|
+
</section>
|
|
385
|
+
{#if selectionCount > 0}<div class="h-60 lg:hidden" aria-hidden="true"></div>{/if}
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
{#if selectionCount > 0}
|
|
389
|
+
<div class="fixed inset-x-2 bottom-20 z-30 rounded-xl bg-[var(--stor-surface)] p-3 shadow-[var(--stor-elevation-3)] lg:hidden" aria-label="Selected item totals">
|
|
390
|
+
<div class="flex items-center justify-between gap-2">
|
|
391
|
+
<strong>{selectionCount} selected</strong>
|
|
392
|
+
<div class="flex items-center gap-3 text-sm">
|
|
393
|
+
{#if !allVisibleSelected}<button type="button" onclick={selectAllVisible} class="text-[var(--stor-accent-strong)]">Select all {allItems.length}</button>{/if}
|
|
394
|
+
<button type="button" onclick={clearSelection} class="text-[var(--stor-accent-strong)]">Clear</button>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
{#if selectionSummaryLoading}
|
|
398
|
+
<span class="mt-2 block text-sm text-[var(--stor-muted-text)]">Calculating totals…</span>
|
|
399
|
+
{:else}
|
|
400
|
+
<div class="mt-2 grid grid-cols-3 gap-2 text-sm">
|
|
401
|
+
<div><span class="block text-xs text-[var(--stor-muted-text)]">Income</span><strong class="tabular-nums">+{moneyFormatter.format(selectionSummary.incomeTotal)}</strong></div>
|
|
402
|
+
<div><span class="block text-xs text-[var(--stor-muted-text)]">Expenses</span><strong class="tabular-nums">{moneyFormatter.format(-selectionSummary.expenseTotal)}</strong></div>
|
|
403
|
+
<div><span class="block text-xs text-[var(--stor-muted-text)]">Net</span><strong class="tabular-nums">{moneyFormatter.format(selectionSummary.netTotal)}</strong></div>
|
|
404
|
+
</div>
|
|
405
|
+
{/if}
|
|
406
|
+
<CommandOnly><button type="button" disabled={batchBusy} onclick={requestDeleteSelected} class="mt-3 h-11 w-full rounded-lg bg-[var(--stor-negative-soft)] font-semibold text-[var(--stor-negative)] disabled:opacity-50">Delete</button></CommandOnly>
|
|
407
|
+
</div>
|
|
408
|
+
{/if}
|
|
409
|
+
<MobilePageAction hideWhenModalOpen hidden={selectionCount > 0} isModalOpen={itemDrawerOpen || planDrawerMode !== null || pendingDeletePlan || pendingDeleteItem !== null || confirmDeleteSelectedOpen}><CommandOnly><Button class="h-12 w-full text-[15px]" onclick={() => openItem("expense")} data-testid="open-budget-item-mobile"><IconPlus class="h-5 w-5" /> <span class="stor-action-label">Add item</span></Button></CommandOnly></MobilePageAction>
|
|
410
|
+
{/if}
|
|
411
|
+
|
|
412
|
+
<CommandOnly onUnavailable={() => { planDrawerMode = null; itemDrawerOpen = false; }}>
|
|
413
|
+
<BudgetPlanDrawer show={planDrawerMode !== null} title={planDrawerCopy.title} description={planDrawerCopy.description} actionLabel={planDrawerCopy.action} initialName={planDrawerMode === "create" && phone.current && selectedPlan ? nextPlanName(selectedPlan.name) : planDrawerCopy.name} onClose={() => planDrawerMode = null} onSave={savePlan}
|
|
414
|
+
startFrom={planDrawerMode === "create" && phone.current && selectedPlan ? { label: selectedPlan.name, detail: `Copies ${selectedPlan.item_count} ${selectedPlan.item_count === 1 ? "item" : "items"} · ${moneyFormatter.format(selectedPlan.income_total)} in, ${moneyFormatter.format(selectedPlan.expense_total)} out` } : undefined} />
|
|
415
|
+
{#if selectedPlan}<BudgetItemDrawer show={itemDrawerOpen} planName={selectedPlan.name} initialItem={editingItem} initialType={itemDrawerType} onClose={() => itemDrawerOpen = false} onSave={saveItem} />{/if}
|
|
416
|
+
</CommandOnly>
|
|
417
|
+
<CommandConfirmDialog show={pendingDeletePlan} title={`Delete ${selectedPlan?.name ?? "this plan"}?`} message={`This removes the plan and its ${selectedPlan?.item_count ?? 0} planned items.`} confirmLabel="Delete plan" onConfirm={deletePlan} onCancel={() => pendingDeletePlan = false} />
|
|
418
|
+
<CommandConfirmDialog show={pendingDeleteItem !== null} title="Delete planned item?" message={`This removes ${pendingDeleteItem?.name ?? "the item"} from the plan.`} confirmLabel="Delete item" onConfirm={deleteItem} onCancel={() => pendingDeleteItem = null} />
|
|
419
|
+
<CommandConfirmDialog show={confirmDeleteSelectedOpen} title={deleteSelectedTitle} message={deleteSelectedMessage} confirmLabel={selectionCount === 1 ? "Delete item" : "Delete items"} onConfirm={deleteSelected} onCancel={() => confirmDeleteSelectedOpen = false} />
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import { EndlessList, type EndlessBlock, type EndlessPosition, type EndlessTarget, type EndlessView } from "@hearthlog/sdk/endless";
|
|
4
|
+
import { monthAxis, monthKeyOf, nearestKey } from "@hearthlog/sdk/axis";
|
|
5
|
+
import { MONTH_SPAN, type MonthBounds, type TimelineShow } from "../../finance/timeline.js";
|
|
6
|
+
import MonthSection from "./MonthSection.svelte";
|
|
7
|
+
import type { RowLike } from "./TimelineRowItem.svelte";
|
|
8
|
+
import type { TimelineMonths } from "./timeline-months.svelte.js";
|
|
9
|
+
import { DataPlaceholder } from "@hearthlog/sdk/ui";
|
|
10
|
+
|
|
11
|
+
/** A month before it is measured; loading months keep roughly this height. */
|
|
12
|
+
const ESTIMATE = 400;
|
|
13
|
+
/** Today and scrolled-to days settle at this share of the visible height. */
|
|
14
|
+
const READING_LINE = 0.35;
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
store,
|
|
18
|
+
today,
|
|
19
|
+
anchor,
|
|
20
|
+
show,
|
|
21
|
+
bounds,
|
|
22
|
+
domain,
|
|
23
|
+
floor,
|
|
24
|
+
available,
|
|
25
|
+
selectedIds,
|
|
26
|
+
busyId = null,
|
|
27
|
+
flashId = null,
|
|
28
|
+
hoveredId = null,
|
|
29
|
+
hoveredDate = null,
|
|
30
|
+
revision,
|
|
31
|
+
onSelect,
|
|
32
|
+
onEdit,
|
|
33
|
+
onMarkDone,
|
|
34
|
+
onRestore,
|
|
35
|
+
onDelete,
|
|
36
|
+
onHover,
|
|
37
|
+
onVisibleChange,
|
|
38
|
+
}: {
|
|
39
|
+
store: TimelineMonths;
|
|
40
|
+
today: string;
|
|
41
|
+
/** Where the list opens: Today for today's month, otherwise the month's start. */
|
|
42
|
+
anchor: string;
|
|
43
|
+
show: TimelineShow;
|
|
44
|
+
/** The months this filter lists; the first one is fixed while the list is open. */
|
|
45
|
+
bounds: MonthBounds;
|
|
46
|
+
domain: [number, number];
|
|
47
|
+
floor: number;
|
|
48
|
+
available: number;
|
|
49
|
+
selectedIds: Set<string>;
|
|
50
|
+
busyId?: string | null;
|
|
51
|
+
flashId?: string | null;
|
|
52
|
+
hoveredId?: string | null;
|
|
53
|
+
hoveredDate?: string | null;
|
|
54
|
+
/** Changes after a save or a sync; the months on screen then read again. */
|
|
55
|
+
revision?: number;
|
|
56
|
+
onSelect: (row: RowLike, selected: boolean) => void;
|
|
57
|
+
onEdit: (row: RowLike) => void;
|
|
58
|
+
onMarkDone: (row: RowLike) => void | Promise<void>;
|
|
59
|
+
onRestore: (row: RowLike) => void | Promise<void>;
|
|
60
|
+
onDelete: (row: RowLike) => void | Promise<void>;
|
|
61
|
+
onHover?: (row: RowLike | null) => void;
|
|
62
|
+
/** The first month with rows from the top edge down, and the first day showing there. */
|
|
63
|
+
onVisibleChange?: (visible: { month: string; date: string }) => void;
|
|
64
|
+
} = $props();
|
|
65
|
+
|
|
66
|
+
const river = $derived(show !== "done");
|
|
67
|
+
|
|
68
|
+
// Months are counted from today's month. All runs both ways, plans start
|
|
69
|
+
// there and done history ends at its last month; an open end is wide
|
|
70
|
+
// enough to feel endless without prepending anything. The first month is fixed while the
|
|
71
|
+
// list is open; done history grows when a row is marked done ahead of a
|
|
72
|
+
// later month.
|
|
73
|
+
const base = untrack(() => monthKeyOf(today));
|
|
74
|
+
const first = untrack(() => bounds.first);
|
|
75
|
+
const axis = $derived(monthAxis(base, { first, last: bounds.last, span: MONTH_SPAN }));
|
|
76
|
+
|
|
77
|
+
let list = $state<ReturnType<typeof EndlessList>>();
|
|
78
|
+
|
|
79
|
+
const todayTarget = $derived<EndlessTarget>({
|
|
80
|
+
key: monthKeyOf(today),
|
|
81
|
+
at: today,
|
|
82
|
+
find: (block) => block.querySelector<HTMLElement>("[data-today-line]"),
|
|
83
|
+
ratio: READING_LINE,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** A month opens at its header. */
|
|
87
|
+
function monthTarget(month: string): EndlessTarget {
|
|
88
|
+
return { key: month, at: `${month}-01` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The list opens at Today for today's month, otherwise at the month's header.
|
|
92
|
+
const opening = untrack(() => {
|
|
93
|
+
const month = nearestKey(axis, monthKeyOf(anchor));
|
|
94
|
+
return month === monthKeyOf(today) ? todayTarget : monthTarget(month);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export function scrollToMonth(month: string, behavior: ScrollBehavior = "auto"): void {
|
|
98
|
+
const key = nearestKey(axis, month);
|
|
99
|
+
if (key === monthKeyOf(today) && key !== month) scrollToToday(behavior);
|
|
100
|
+
else list?.scrollTo(monthTarget(key), behavior);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function scrollToToday(behavior: ScrollBehavior = "auto"): void {
|
|
104
|
+
list?.scrollTo(todayTarget, behavior);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Scrolls to the first row on or after a day. */
|
|
108
|
+
export function scrollToDate(date: string, behavior: ScrollBehavior = "auto"): void {
|
|
109
|
+
const month = monthKeyOf(date);
|
|
110
|
+
if (nearestKey(axis, month) !== month) {
|
|
111
|
+
scrollToMonth(month, behavior);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
list?.scrollTo({
|
|
115
|
+
key: month,
|
|
116
|
+
at: date,
|
|
117
|
+
find: (block) => [...block.querySelectorAll<HTMLElement>("[data-row-id]")].find((row) => (row.dataset.date ?? "") >= date) ?? null,
|
|
118
|
+
ratio: 0.12,
|
|
119
|
+
}, behavior);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* From the top edge down, the first month on screen with rows is the one
|
|
124
|
+
* in view, so a run of empty months above never names what you are
|
|
125
|
+
* looking at. The first row showing there names the day.
|
|
126
|
+
*/
|
|
127
|
+
function pick(blocks: EndlessBlock[], { edge }: EndlessView): EndlessPosition {
|
|
128
|
+
const shown = blocks.find(({ key }) => (store.get(key)?.rows.length ?? 0) > 0) ?? blocks[0];
|
|
129
|
+
const row = [...(shown.element?.querySelectorAll<HTMLElement>("[data-row-id]") ?? [])]
|
|
130
|
+
.find((element) => element.getBoundingClientRect().bottom > edge);
|
|
131
|
+
return { key: shown.key, at: row?.dataset.date ?? (shown.key === monthKeyOf(today) ? today : `${shown.key}-01`) };
|
|
132
|
+
}
|
|
133
|
+
</script>
|
|
134
|
+
|
|
135
|
+
<EndlessList
|
|
136
|
+
bind:this={list}
|
|
137
|
+
{axis}
|
|
138
|
+
{store}
|
|
139
|
+
{opening}
|
|
140
|
+
estimate={ESTIMATE}
|
|
141
|
+
{revision}
|
|
142
|
+
anchorSelector="[data-row-id], [data-today-line]"
|
|
143
|
+
home={{ ...todayTarget, label: "Today" }}
|
|
144
|
+
{pick}
|
|
145
|
+
onVisibleChange={(position) => onVisibleChange?.({ month: position.key, date: position.at })}
|
|
146
|
+
class={river ? "[--river:2.75rem] sm:[--river:8rem] lg:[--river:9.5rem] xl:[--river:12rem]" : ""}
|
|
147
|
+
data-timeline-scroll
|
|
148
|
+
>
|
|
149
|
+
{#snippet openingPlaceholder()}
|
|
150
|
+
<DataPlaceholder rows={8} label="Loading transactions" />
|
|
151
|
+
{/snippet}
|
|
152
|
+
{#snippet item(month, data)}
|
|
153
|
+
<MonthSection
|
|
154
|
+
monthKey={month}
|
|
155
|
+
{data}
|
|
156
|
+
{today}
|
|
157
|
+
{show}
|
|
158
|
+
{domain}
|
|
159
|
+
{floor}
|
|
160
|
+
{available}
|
|
161
|
+
{selectedIds}
|
|
162
|
+
{busyId}
|
|
163
|
+
{flashId}
|
|
164
|
+
{hoveredId}
|
|
165
|
+
{hoveredDate}
|
|
166
|
+
{onSelect}
|
|
167
|
+
{onEdit}
|
|
168
|
+
{onMarkDone}
|
|
169
|
+
{onRestore}
|
|
170
|
+
{onDelete}
|
|
171
|
+
{onHover}
|
|
172
|
+
/>
|
|
173
|
+
{/snippet}
|
|
174
|
+
</EndlessList>
|