@hearthlog/module-finance 0.0.0-stage → 1.0.0
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 +46 -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,480 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
const drawerId = $props.id();
|
|
3
|
+
const drawerFormId0 = drawerId + "-0";
|
|
4
|
+
|
|
5
|
+
import { Drawer } from "@hearthlog/sdk/ui";
|
|
6
|
+
import { Button } from "@hearthlog/sdk/ui";
|
|
7
|
+
import { DatePicker } from "@hearthlog/sdk/ui";
|
|
8
|
+
import { Input } from "@hearthlog/sdk/ui";
|
|
9
|
+
import { Label } from "@hearthlog/sdk/ui";
|
|
10
|
+
import type { SelectOption } from "@hearthlog/sdk/ui";
|
|
11
|
+
import { Checkbox } from "@hearthlog/sdk/ui";
|
|
12
|
+
import { CommandConfirmDialog } from "@hearthlog/sdk/ui";
|
|
13
|
+
import { SuggestButton } from "@hearthlog/sdk/ui";
|
|
14
|
+
import { SuggestionChip } from "@hearthlog/sdk/ui";
|
|
15
|
+
import { SuggestionMark } from "@hearthlog/sdk/ui";
|
|
16
|
+
import { categoryLabel, TRANSACTION_CATEGORIES } from "../../finance/categories.js";
|
|
17
|
+
import { categorySource, suggestCategoriesDetailed, type CategorySuggestion } from "../../finance/category-suggestions.js";
|
|
18
|
+
import { isAiCapabilityEnabled } from "@hearthlog/sdk/runtime";
|
|
19
|
+
import { canMarkHappened, defaultHappened, expandDraft, formatDayRange, isDoneAhead, isRepeatDays, MAX_REPEAT_DAYS, resolveDraftStatus, type TransactionDraft } from "./transaction-draft.js";
|
|
20
|
+
import { appClock } from "@hearthlog/sdk/runtime";
|
|
21
|
+
import { formatMoney } from "@hearthlog/sdk/format";
|
|
22
|
+
import { addDays, format, parseISO } from "date-fns";
|
|
23
|
+
import { tick } from "svelte";
|
|
24
|
+
import { queryDatabase } from "@hearthlog/sdk/data";
|
|
25
|
+
import IconChevronDown from "~icons/mdi/chevron-down";
|
|
26
|
+
import IconCalendar from "~icons/mdi/calendar-month-outline";
|
|
27
|
+
import { Switch } from "@hearthlog/sdk/ui";
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
show,
|
|
31
|
+
initialDraft,
|
|
32
|
+
onClose,
|
|
33
|
+
onSave,
|
|
34
|
+
onDelete,
|
|
35
|
+
}: {
|
|
36
|
+
show: boolean;
|
|
37
|
+
initialDraft: TransactionDraft;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
/** Saves one transaction, or one per day for a daily repeat. */
|
|
40
|
+
onSave: (drafts: TransactionDraft[]) => Promise<void>;
|
|
41
|
+
/** Deletes the transaction being edited. */
|
|
42
|
+
onDelete?: () => Promise<void>;
|
|
43
|
+
} = $props();
|
|
44
|
+
|
|
45
|
+
let confirmDelete = $state(false);
|
|
46
|
+
// Phones put the amount first with the number pad, offer days and categories as chips, and fold Repeat.
|
|
47
|
+
// The phone form layout is the form layout at every width (docs/design-concepts/desktop-backport-2026-10-05).
|
|
48
|
+
const phone = { current: true };
|
|
49
|
+
let moreOpen = $state(false);
|
|
50
|
+
let pickDate = $state(false);
|
|
51
|
+
let allCategories = $state(false);
|
|
52
|
+
const chip = (on: boolean) => `h-9 rounded-full px-3.5 text-sm font-medium ${on ? "bg-[var(--stor-accent-soft)] font-semibold text-[var(--stor-accent-strong)]" : "bg-[var(--stor-surface-muted)] text-[var(--stor-ink)]"}`;
|
|
53
|
+
let topCategories = $state<string[]>([]);
|
|
54
|
+
|
|
55
|
+
let form = $state<TransactionDraft>({
|
|
56
|
+
name: "",
|
|
57
|
+
amount: 0,
|
|
58
|
+
dueDate: "",
|
|
59
|
+
category: "unknown",
|
|
60
|
+
status: "active",
|
|
61
|
+
direction: "expense",
|
|
62
|
+
});
|
|
63
|
+
let saving = $state(false);
|
|
64
|
+
const today = $derived(show ? format(appClock.now(), "yyyy-MM-dd") : "");
|
|
65
|
+
// Earlier days are done by their date; an entry for today asks whether it
|
|
66
|
+
// already happened. An explicit choice sticks while the date changes.
|
|
67
|
+
let happenedChoice = $state<boolean | null>(null);
|
|
68
|
+
const alreadyHappened = $derived(happenedChoice ?? defaultHappened(initialDraft.status));
|
|
69
|
+
const doneAhead = $derived(isDoneAhead(initialDraft, today));
|
|
70
|
+
const resolvedStatus = $derived(resolveDraftStatus(form, today, alreadyHappened, doneAhead));
|
|
71
|
+
// New transactions can repeat daily: one transaction per day, same values.
|
|
72
|
+
let repeatDaily = $state(false);
|
|
73
|
+
let repeatDays = $state<number | null>(7);
|
|
74
|
+
const repeating = $derived(!initialDraft.id && repeatDaily);
|
|
75
|
+
const days = $derived(repeating ? Number(repeatDays) : 1);
|
|
76
|
+
const entries = $derived(expandDraft(form, days, today, alreadyHappened, doneAhead));
|
|
77
|
+
const canHappen = $derived(entries.some((entry) => canMarkHappened(entry, today)));
|
|
78
|
+
const series = $derived(repeating && entries.length > 1);
|
|
79
|
+
const anyDone = $derived(entries.some((entry) => entry.status === "archived"));
|
|
80
|
+
const allDone = $derived(entries.every((entry) => entry.status === "archived"));
|
|
81
|
+
const seriesSummary = $derived(series
|
|
82
|
+
? `${entries.length} transactions · ${formatMoney(Math.abs(Number(form.amount) || 0) * entries.length)} · ${formatDayRange(entries[0].dueDate, entries[entries.length - 1].dueDate)}`
|
|
83
|
+
: "");
|
|
84
|
+
const happenedHint = $derived(
|
|
85
|
+
series
|
|
86
|
+
? "Record today’s entry as done. Unchecked, it stays planned for today."
|
|
87
|
+
: "Record it as done. Unchecked, it stays planned for today.",
|
|
88
|
+
);
|
|
89
|
+
const phoneAmount = $derived(Math.abs(Number(form.amount)) || 0);
|
|
90
|
+
const saveLabel = $derived(
|
|
91
|
+
initialDraft.id
|
|
92
|
+
? "Save changes"
|
|
93
|
+
: phone.current && !series && phoneAmount > 0
|
|
94
|
+
? `${resolvedStatus === "archived" ? "Record" : "Plan"} ${formatMoney(form.direction === "expense" ? -phoneAmount : phoneAmount)}`
|
|
95
|
+
: series
|
|
96
|
+
? `${allDone ? "Record" : anyDone ? "Add" : "Plan"} ${entries.length} transactions`
|
|
97
|
+
: resolvedStatus === "archived" ? "Record transaction" : "Plan transaction",
|
|
98
|
+
);
|
|
99
|
+
let errorMessage = $state("");
|
|
100
|
+
let loadedKey = "";
|
|
101
|
+
const AUTO_SUGGEST_DELAY_MS = 700;
|
|
102
|
+
|
|
103
|
+
const categoryOptions: SelectOption[] = TRANSACTION_CATEGORIES.map(({ value, label }) => ({ value, label }));
|
|
104
|
+
|
|
105
|
+
// A category is suggested once the name is known, while it is still
|
|
106
|
+
// Uncategorized and untouched. A suggestion only fills the field; Save is
|
|
107
|
+
// the only write, and choosing a category yourself removes the tag.
|
|
108
|
+
let categoryTouched = $state(false);
|
|
109
|
+
let appliedSuggestion = $state<CategorySuggestion | null>(null);
|
|
110
|
+
let offeredSuggestion = $state<CategorySuggestion | null>(null);
|
|
111
|
+
let suggestionRun = 0;
|
|
112
|
+
let lastAsked = "";
|
|
113
|
+
/** What the Suggest button is doing, or why it found nothing. */
|
|
114
|
+
let suggestState = $state<"idle" | "busy" | "none" | "unavailable">("idle");
|
|
115
|
+
const aiEnabled = isAiCapabilityEnabled("category-pick");
|
|
116
|
+
|
|
117
|
+
function resetSuggestion(): void {
|
|
118
|
+
suggestionRun += 1;
|
|
119
|
+
categoryTouched = false;
|
|
120
|
+
appliedSuggestion = null;
|
|
121
|
+
offeredSuggestion = null;
|
|
122
|
+
lastAsked = "";
|
|
123
|
+
suggestState = "idle";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applySuggestion(suggestion: CategorySuggestion): void {
|
|
127
|
+
form.category = suggestion.category;
|
|
128
|
+
form.categorySource = categorySource(suggestion.origin);
|
|
129
|
+
appliedSuggestion = suggestion;
|
|
130
|
+
offeredSuggestion = null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function chooseCategory(value: string): void {
|
|
134
|
+
suggestionRun += 1;
|
|
135
|
+
suggestState = "idle";
|
|
136
|
+
categoryTouched = true;
|
|
137
|
+
appliedSuggestion = null;
|
|
138
|
+
offeredSuggestion = null;
|
|
139
|
+
form.category = value;
|
|
140
|
+
form.categorySource = value === "unknown" ? "" : "manual";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Asks for a suggestion. The automatic ask (`manual` false) is quiet; the button explains its result. */
|
|
144
|
+
async function suggest(manual = false): Promise<void> {
|
|
145
|
+
const name = form.name.trim();
|
|
146
|
+
if (!show || !name) return;
|
|
147
|
+
const unsettled = !categoryTouched && form.category === "unknown";
|
|
148
|
+
if (!manual && !unsettled) return;
|
|
149
|
+
const amount = Math.abs(Number(form.amount)) || 0;
|
|
150
|
+
const key = `${name}|${amount}|${form.direction}`;
|
|
151
|
+
if (!manual && key === lastAsked) return;
|
|
152
|
+
lastAsked = key;
|
|
153
|
+
const run = ++suggestionRun;
|
|
154
|
+
if (manual) {
|
|
155
|
+
suggestState = "busy";
|
|
156
|
+
appliedSuggestion = null;
|
|
157
|
+
offeredSuggestion = null;
|
|
158
|
+
}
|
|
159
|
+
const result = await suggestCategoriesDetailed([{ id: "draft", name, amount, direction: form.direction }]).catch(() => null);
|
|
160
|
+
// An answer that arrives after the user chose a category or closed the drawer is dropped.
|
|
161
|
+
if (run !== suggestionRun || !show) return;
|
|
162
|
+
if (!manual && (categoryTouched || form.category !== "unknown")) return;
|
|
163
|
+
const found = result?.suggestions.get("draft");
|
|
164
|
+
if (manual) suggestState = found ? "idle" : !result || result.aiUnavailable ? "unavailable" : "none";
|
|
165
|
+
if (!found) return;
|
|
166
|
+
if (found.origin.kind === "ai" && found.origin.band === "offer") offeredSuggestion = found;
|
|
167
|
+
else applySuggestion(found);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
$effect(() => {
|
|
171
|
+
const key = `${show}:${initialDraft.id ?? "new"}:${initialDraft.dueDate}`;
|
|
172
|
+
// Closing forgets the loaded draft, so the next new transaction on the same day starts clean.
|
|
173
|
+
if (!show) loadedKey = "";
|
|
174
|
+
if (show && key !== loadedKey) {
|
|
175
|
+
loadedKey = key;
|
|
176
|
+
form = { ...initialDraft };
|
|
177
|
+
happenedChoice = null;
|
|
178
|
+
repeatDaily = false;
|
|
179
|
+
repeatDays = 7;
|
|
180
|
+
errorMessage = "";
|
|
181
|
+
moreOpen = false;
|
|
182
|
+
pickDate = false;
|
|
183
|
+
allCategories = false;
|
|
184
|
+
resetSuggestion();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// The categories used most in the last 90 days for this direction, counted in SQL.
|
|
189
|
+
$effect(() => {
|
|
190
|
+
const direction = form.direction;
|
|
191
|
+
if (!show || !phone.current) return;
|
|
192
|
+
let current = true;
|
|
193
|
+
const since = format(addDays(appClock.now(), -90), "yyyy-MM-dd");
|
|
194
|
+
void queryDatabase((repositories) => repositories.transactions.getTopCategories(since, direction))
|
|
195
|
+
.then((categories) => { if (current) topCategories = categories; })
|
|
196
|
+
.catch(() => undefined);
|
|
197
|
+
return () => { current = false; };
|
|
198
|
+
});
|
|
199
|
+
const categoryChips = $derived.by(() => {
|
|
200
|
+
const chips = topCategories.slice(0, 4);
|
|
201
|
+
if (form.category !== "unknown" && !chips.includes(form.category)) chips.unshift(form.category);
|
|
202
|
+
return chips;
|
|
203
|
+
});
|
|
204
|
+
const tomorrow = $derived(today ? format(addDays(parseISO(today), 1), "yyyy-MM-dd") : "");
|
|
205
|
+
const otherDay = $derived(!!form.dueDate && form.dueDate !== today && form.dueDate !== tomorrow);
|
|
206
|
+
|
|
207
|
+
$effect(() => {
|
|
208
|
+
if (!show) suggestionRun += 1;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Ask a moment after typing stops. Blur asks at once; the same name and amount is never asked twice.
|
|
212
|
+
$effect(() => {
|
|
213
|
+
void form.amount;
|
|
214
|
+
void form.direction;
|
|
215
|
+
if (!show || form.name.trim().length < 3) return;
|
|
216
|
+
const timer = setTimeout(() => void suggest(), AUTO_SUGGEST_DELAY_MS);
|
|
217
|
+
return () => clearTimeout(timer);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
$effect(() => {
|
|
221
|
+
if (!show) return;
|
|
222
|
+
let focusFrame = 0;
|
|
223
|
+
let settleFrame = 0;
|
|
224
|
+
void tick().then(() => {
|
|
225
|
+
focusFrame = requestAnimationFrame(() => {
|
|
226
|
+
settleFrame = requestAnimationFrame(() => {
|
|
227
|
+
document.querySelector<HTMLInputElement>(
|
|
228
|
+
phone.current && !initialDraft.id ? '[data-testid="transaction-amount"]' : '[data-testid="transaction-name"]',
|
|
229
|
+
)?.focus();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
return () => {
|
|
234
|
+
cancelAnimationFrame(focusFrame);
|
|
235
|
+
cancelAnimationFrame(settleFrame);
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
async function submit(event: SubmitEvent) {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
if (saving) return;
|
|
242
|
+
if (!form.name.trim()) {
|
|
243
|
+
errorMessage = "Enter a transaction name.";
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (!Number.isFinite(Number(form.amount)) || Number(form.amount) === 0) {
|
|
247
|
+
errorMessage = "Enter a non-zero amount.";
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (!form.dueDate) {
|
|
251
|
+
errorMessage = "Choose a transaction date.";
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (repeating && !isRepeatDays(days)) {
|
|
255
|
+
errorMessage = `Repeat for 2 to ${MAX_REPEAT_DAYS} days.`;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
saving = true;
|
|
260
|
+
errorMessage = "";
|
|
261
|
+
try {
|
|
262
|
+
await onSave(expandDraft({
|
|
263
|
+
...form,
|
|
264
|
+
name: form.name.trim(),
|
|
265
|
+
amount: form.direction === "expense" ? -Math.abs(Number(form.amount)) : Math.abs(Number(form.amount)),
|
|
266
|
+
}, days, today, alreadyHappened, doneAhead));
|
|
267
|
+
} catch (error) {
|
|
268
|
+
errorMessage =
|
|
269
|
+
error instanceof Error
|
|
270
|
+
? error.message
|
|
271
|
+
: "The transaction could not be saved.";
|
|
272
|
+
} finally {
|
|
273
|
+
saving = false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
</script>
|
|
277
|
+
|
|
278
|
+
<Drawer
|
|
279
|
+
{show}
|
|
280
|
+
title={initialDraft.id ? "Edit transaction" : initialDraft.status === "archived" ? "Record past transaction" : "New transaction"}
|
|
281
|
+
{onClose}
|
|
282
|
+
>
|
|
283
|
+
<form id={drawerFormId0} class="flex flex-col gap-4" onsubmit={submit}>
|
|
284
|
+
<div class="grid grid-cols-2 rounded-full bg-[var(--stor-surface-muted)] p-1" aria-label="Transaction direction">
|
|
285
|
+
<button type="button" class="h-10 rounded-full text-sm font-semibold transition {form.direction === 'expense' ? 'bg-[var(--stor-action)] text-[var(--stor-action-text)] shadow-[var(--stor-shadow-sm)]' : 'text-[var(--stor-muted-text)]'}" aria-pressed={form.direction === "expense"} onclick={() => (form.direction = "expense")}>Expense</button>
|
|
286
|
+
<button type="button" class="h-10 rounded-full text-sm font-semibold transition {form.direction === 'income' ? 'bg-[var(--stor-action)] text-[var(--stor-action-text)] shadow-[var(--stor-shadow-sm)]' : 'text-[var(--stor-muted-text)]'}" aria-pressed={form.direction === "income"} onclick={() => (form.direction = "income")}>Income</button>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<Label class="block max-md:order-2">
|
|
290
|
+
Name
|
|
291
|
+
<Input
|
|
292
|
+
autofocus
|
|
293
|
+
data-testid="transaction-name"
|
|
294
|
+
type="text"
|
|
295
|
+
bind:value={form.name}
|
|
296
|
+
placeholder="Rent, salary, groceries..."
|
|
297
|
+
class="mt-1.5"
|
|
298
|
+
onblur={() => void suggest()}
|
|
299
|
+
/>
|
|
300
|
+
</Label>
|
|
301
|
+
|
|
302
|
+
<Label class="block max-md:order-1">
|
|
303
|
+
<span class="max-md:sr-only">Amount</span>
|
|
304
|
+
<div class="relative mt-1.5 max-md:mt-0">
|
|
305
|
+
<Input
|
|
306
|
+
data-testid="transaction-amount"
|
|
307
|
+
type="number"
|
|
308
|
+
inputmode="decimal"
|
|
309
|
+
step="0.01"
|
|
310
|
+
bind:value={form.amount}
|
|
311
|
+
class="pr-12 font-medium max-md:!h-16 max-md:!rounded-none max-md:!border-0 max-md:!border-b-2 max-md:!border-[var(--stor-action)] max-md:!bg-transparent max-md:!px-0 max-md:!text-[40px] max-md:!font-semibold max-md:!shadow-none max-md:!ring-0 max-md:!outline-none"
|
|
312
|
+
onblur={() => void suggest()}
|
|
313
|
+
/>
|
|
314
|
+
<span class="pointer-events-none absolute inset-y-0 right-4 flex items-center text-sm text-[var(--stor-muted-text)]">Kč</span>
|
|
315
|
+
</div>
|
|
316
|
+
<span class="mt-1 block text-[11px] leading-4 text-[var(--stor-muted-text)] max-md:hidden">Enter a positive amount; Expense or Income sets the direction.</span>
|
|
317
|
+
</Label>
|
|
318
|
+
|
|
319
|
+
<div class="grid gap-3 max-md:order-3 sm:grid-cols-2">
|
|
320
|
+
<div class="block">
|
|
321
|
+
<span class="text-sm font-medium text-[var(--stor-ink)]" id="{drawerId}-day">Date</span>
|
|
322
|
+
<div class="mt-1.5 flex flex-wrap gap-2" role="group" aria-labelledby="{drawerId}-day">
|
|
323
|
+
<button type="button" class={chip(form.dueDate === today)} aria-pressed={form.dueDate === today} onclick={() => { form.dueDate = today; pickDate = false; }}>Today</button>
|
|
324
|
+
<button type="button" class={chip(form.dueDate === tomorrow)} aria-pressed={form.dueDate === tomorrow} onclick={() => { form.dueDate = tomorrow; pickDate = false; }}>Tomorrow</button>
|
|
325
|
+
<button type="button" class="{chip(otherDay)} inline-flex items-center gap-1.5" aria-expanded={pickDate} onclick={() => (pickDate = !pickDate)}>
|
|
326
|
+
<IconCalendar class="h-4 w-4" />{otherDay ? format(parseISO(form.dueDate), "EEE d MMM") : "Pick date"}
|
|
327
|
+
</button>
|
|
328
|
+
</div>
|
|
329
|
+
{#if pickDate}
|
|
330
|
+
<DatePicker
|
|
331
|
+
bind:value={form.dueDate}
|
|
332
|
+
class="mt-2 text-sm"
|
|
333
|
+
placeholder="Choose transaction date"
|
|
334
|
+
/>
|
|
335
|
+
{/if}
|
|
336
|
+
</div>
|
|
337
|
+
<div class="block">
|
|
338
|
+
<div class="flex items-center justify-between gap-2">
|
|
339
|
+
<Label>Category</Label>
|
|
340
|
+
{#if aiEnabled}
|
|
341
|
+
<SuggestButton label="Suggest a category" busy={suggestState === "busy"} disabled={!form.name.trim()} onclick={() => void suggest(true)} />
|
|
342
|
+
{/if}
|
|
343
|
+
</div>
|
|
344
|
+
<!-- The most used categories, then All for the full list; one list, not chips and a select. -->
|
|
345
|
+
<div class="mt-1.5 flex flex-wrap gap-2" role="group" aria-label="Categories">
|
|
346
|
+
{#each allCategories ? categoryOptions.map((option) => option.value) : categoryChips as value (value)}
|
|
347
|
+
<button type="button" class={chip(form.category === value)} aria-pressed={form.category === value} onclick={() => chooseCategory(value)}>{categoryLabel(value)}</button>
|
|
348
|
+
{/each}
|
|
349
|
+
{#if !allCategories}
|
|
350
|
+
<button type="button" class="{chip(false)} inline-flex items-center gap-1" onclick={() => (allCategories = true)}>All<IconChevronDown class="h-4 w-4" /></button>
|
|
351
|
+
{/if}
|
|
352
|
+
</div>
|
|
353
|
+
<div class="mt-1.5 flex min-h-5 items-center" aria-live="polite">
|
|
354
|
+
{#if appliedSuggestion}
|
|
355
|
+
<SuggestionMark
|
|
356
|
+
kind={appliedSuggestion.origin.kind}
|
|
357
|
+
reason={appliedSuggestion.origin.kind === "history" ? `Same as ${appliedSuggestion.origin.count} earlier` : ""}
|
|
358
|
+
/>
|
|
359
|
+
{:else if offeredSuggestion}
|
|
360
|
+
<SuggestionChip label={categoryLabel(offeredSuggestion.category)} onclick={() => { if (offeredSuggestion) { categoryTouched = true; applySuggestion(offeredSuggestion); } }} />
|
|
361
|
+
{:else if suggestState === "none"}
|
|
362
|
+
<span class="text-[11px] leading-4 text-[var(--stor-muted-text)]">No suggestion for this name.</span>
|
|
363
|
+
{:else if suggestState === "unavailable"}
|
|
364
|
+
<span class="text-[11px] leading-4 text-[var(--stor-muted-text)]">Suggestions aren’t available right now.</span>
|
|
365
|
+
{/if}
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{#if !initialDraft.id && phone.current && !moreOpen}
|
|
371
|
+
<button type="button" class="flex items-center gap-1 self-start text-sm font-semibold text-[var(--stor-accent-text)] max-md:order-5" aria-expanded="false" onclick={() => (moreOpen = true)}>
|
|
372
|
+
Repeat<IconChevronDown class="h-4 w-4" />
|
|
373
|
+
</button>
|
|
374
|
+
{:else if !initialDraft.id}
|
|
375
|
+
<div class="grid gap-3 max-md:order-5 sm:grid-cols-2">
|
|
376
|
+
<div class="block">
|
|
377
|
+
<Label id="{drawerId}-repeat">Repeat</Label>
|
|
378
|
+
<div class="stor-segmented-control mt-1.5 grid grid-cols-2 sm:h-9" role="group" aria-labelledby="{drawerId}-repeat">
|
|
379
|
+
<button type="button" class="stor-segmented-option min-h-11 sm:min-h-0" aria-pressed={!repeatDaily} onclick={() => (repeatDaily = false)}>Once</button>
|
|
380
|
+
<button type="button" class="stor-segmented-option min-h-11 sm:min-h-0" aria-pressed={repeatDaily} onclick={() => (repeatDaily = true)}>Daily</button>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
{#if repeatDaily}
|
|
384
|
+
<Label class="block">
|
|
385
|
+
Days
|
|
386
|
+
<Input
|
|
387
|
+
data-testid="transaction-repeat-days"
|
|
388
|
+
type="number"
|
|
389
|
+
inputmode="numeric"
|
|
390
|
+
min="2"
|
|
391
|
+
max={MAX_REPEAT_DAYS}
|
|
392
|
+
step="1"
|
|
393
|
+
bind:value={repeatDays}
|
|
394
|
+
class="mt-1.5"
|
|
395
|
+
/>
|
|
396
|
+
</Label>
|
|
397
|
+
{/if}
|
|
398
|
+
</div>
|
|
399
|
+
{#if seriesSummary}
|
|
400
|
+
<p class="text-xs leading-5 text-[var(--stor-muted-text)] max-md:order-5" data-testid="transaction-repeat-summary">{seriesSummary}</p>
|
|
401
|
+
{/if}
|
|
402
|
+
{/if}
|
|
403
|
+
|
|
404
|
+
{#if canHappen && phone.current}
|
|
405
|
+
<Switch
|
|
406
|
+
class="max-md:order-4"
|
|
407
|
+
checked={alreadyHappened}
|
|
408
|
+
label="Already happened"
|
|
409
|
+
hint={alreadyHappened ? "Recorded as done" : series ? "Off: today's entry stays a plan" : "Off: it stays a plan for today"}
|
|
410
|
+
onCheckedChange={(value) => (happenedChoice = value)}
|
|
411
|
+
/>
|
|
412
|
+
{:else if canHappen}
|
|
413
|
+
<label class="flex cursor-pointer items-start gap-3 rounded-xl bg-[var(--stor-surface-muted)] px-3 py-2.5" for="transaction-already-happened">
|
|
414
|
+
<Checkbox id="transaction-already-happened" checked={alreadyHappened} onCheckedChange={(value) => (happenedChoice = value === true)} class="mt-0.5" aria-describedby="transaction-already-happened-hint" />
|
|
415
|
+
<span class="min-w-0">
|
|
416
|
+
<span class="block text-sm font-semibold text-[var(--stor-ink)]">Already happened</span>
|
|
417
|
+
<span id="transaction-already-happened-hint" class="block text-xs leading-5 text-[var(--stor-muted-text)]">{happenedHint}</span>
|
|
418
|
+
</span>
|
|
419
|
+
</label>
|
|
420
|
+
{/if}
|
|
421
|
+
|
|
422
|
+
{#if !initialDraft.id && anyDone}
|
|
423
|
+
<div class="rounded-xl bg-[var(--stor-info-soft)] px-3 py-2 max-md:order-6 text-xs leading-5 text-[var(--stor-muted-text)]">Done transactions go straight to history and don’t change your current assets.</div>
|
|
424
|
+
{/if}
|
|
425
|
+
|
|
426
|
+
{#if errorMessage}
|
|
427
|
+
<div class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 max-md:order-7" role="alert">
|
|
428
|
+
{errorMessage}
|
|
429
|
+
</div>
|
|
430
|
+
{/if}
|
|
431
|
+
|
|
432
|
+
</form>
|
|
433
|
+
{#snippet footer()}
|
|
434
|
+
{#if initialDraft.id && onDelete}
|
|
435
|
+
<Button
|
|
436
|
+
variant="ghost"
|
|
437
|
+
size="sm"
|
|
438
|
+
class="mr-auto text-[var(--stor-negative)]"
|
|
439
|
+
onclick={() => (confirmDelete = true)}
|
|
440
|
+
disabled={saving}
|
|
441
|
+
>
|
|
442
|
+
Delete
|
|
443
|
+
</Button>
|
|
444
|
+
{/if}
|
|
445
|
+
<Button
|
|
446
|
+
variant="secondary"
|
|
447
|
+
size="sm"
|
|
448
|
+
onclick={onClose}
|
|
449
|
+
disabled={saving}
|
|
450
|
+
>
|
|
451
|
+
Cancel
|
|
452
|
+
</Button>
|
|
453
|
+
<Button
|
|
454
|
+
type="submit" form={drawerFormId0}
|
|
455
|
+
data-testid="save-transaction"
|
|
456
|
+
disabled={saving}
|
|
457
|
+
>
|
|
458
|
+
{saving ? "Saving..." : saveLabel}
|
|
459
|
+
</Button>
|
|
460
|
+
|
|
461
|
+
{/snippet}
|
|
462
|
+
</Drawer>
|
|
463
|
+
|
|
464
|
+
<CommandConfirmDialog
|
|
465
|
+
show={confirmDelete}
|
|
466
|
+
title="Delete transaction"
|
|
467
|
+
message={`Delete “${form.name || "this transaction"}”?`}
|
|
468
|
+
onConfirm={async () => {
|
|
469
|
+
confirmDelete = false;
|
|
470
|
+
saving = true;
|
|
471
|
+
try {
|
|
472
|
+
await onDelete?.();
|
|
473
|
+
} catch {
|
|
474
|
+
errorMessage = "The transaction could not be deleted.";
|
|
475
|
+
} finally {
|
|
476
|
+
saving = false;
|
|
477
|
+
}
|
|
478
|
+
}}
|
|
479
|
+
onCancel={() => (confirmDelete = false)}
|
|
480
|
+
/>
|