spendwise 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 +21 -0
- package/README.md +119 -0
- package/package.json +62 -0
- package/src/main/api-core.js +231 -0
- package/src/main/auth.js +29 -0
- package/src/main/backup.js +48 -0
- package/src/main/db-open.js +77 -0
- package/src/main/ipc.js +187 -0
- package/src/main/main.js +125 -0
- package/src/main/migrate.js +181 -0
- package/src/main/storage/local.js +55 -0
- package/src/main/update-check.js +99 -0
- package/src/main/webserver.js +294 -0
- package/src/preload.cjs +27 -0
- package/src/renderer/assets/icon.ico +0 -0
- package/src/renderer/assets/icon.png +0 -0
- package/src/renderer/css/app.css +2 -0
- package/src/renderer/fonts/roca.otf +0 -0
- package/src/renderer/fonts/roca.woff +0 -0
- package/src/renderer/fonts/roca.woff2 +0 -0
- package/src/renderer/index.html +1136 -0
- package/src/renderer/js/app.js +185 -0
- package/src/renderer/js/boot.js +4 -0
- package/src/renderer/js/charts.js +116 -0
- package/src/renderer/js/components.js +39 -0
- package/src/renderer/js/helpers.js +76 -0
- package/src/renderer/js/views/insights.js +259 -0
- package/src/renderer/js/views/month.js +687 -0
- package/src/renderer/js/views/settings.js +353 -0
- package/src/renderer/js/web-api.js +50 -0
- package/src/renderer/manifest.json +18 -0
- package/src/renderer/vendor/alpine-collapse.min.js +1 -0
- package/src/renderer/vendor/alpine.min.js +5 -0
- package/src/renderer/vendor/apexcharts.min.js +38 -0
- package/src/server/cli.mjs +164 -0
- package/src/shared/engine.js +728 -0
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<!-- unsafe-eval is required by Alpine's expression evaluator; everything is local, no remote origins -->
|
|
7
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'">
|
|
8
|
+
<title>Spend Wise</title>
|
|
9
|
+
<link rel="icon" type="image/png" href="assets/icon.ico">
|
|
10
|
+
<link rel="apple-touch-icon" href="assets/icon.png">
|
|
11
|
+
<!-- installable web app (served builds only; harmless under file://).
|
|
12
|
+
use-credentials: the manifest sits behind the session cookie, and a
|
|
13
|
+
credential-less fetch would just get the login page back -->
|
|
14
|
+
<link rel="manifest" href="manifest.json" crossorigin="use-credentials">
|
|
15
|
+
<meta name="theme-color" content="#059669">
|
|
16
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
17
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
18
|
+
<meta name="apple-mobile-web-app-title" content="Spend Wise">
|
|
19
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
20
|
+
<link rel="stylesheet" href="css/app.css">
|
|
21
|
+
|
|
22
|
+
<script src="vendor/apexcharts.min.js"></script>
|
|
23
|
+
<script src="../shared/engine.js"></script>
|
|
24
|
+
<script src="js/helpers.js"></script>
|
|
25
|
+
<script src="js/components.js"></script>
|
|
26
|
+
<script src="js/charts.js"></script>
|
|
27
|
+
<script src="js/views/month.js"></script>
|
|
28
|
+
<script src="js/views/insights.js"></script>
|
|
29
|
+
<script src="js/views/settings.js"></script>
|
|
30
|
+
<script src="js/app.js"></script>
|
|
31
|
+
<script defer src="vendor/alpine-collapse.min.js"></script>
|
|
32
|
+
<script defer src="vendor/alpine.min.js"></script>
|
|
33
|
+
</head>
|
|
34
|
+
<body class="bg-zinc-50 text-zinc-900 antialiased">
|
|
35
|
+
|
|
36
|
+
<!-- icon sprite. Icons from Font Awesome Free v7.3.0 (fontawesome.com),
|
|
37
|
+
license: https://fontawesome.com/license/free - Copyright 2026 Fonticons, Inc. -->
|
|
38
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="hidden" aria-hidden="true">
|
|
39
|
+
<symbol id="i-plus" viewBox="0 0 640 640"><path fill="currentColor" d="M352 128C352 110.3 337.7 96 320 96C302.3 96 288 110.3 288 128L288 288L128 288C110.3 288 96 302.3 96 320C96 337.7 110.3 352 128 352L288 352L288 512C288 529.7 302.3 544 320 544C337.7 544 352 529.7 352 512L352 352L512 352C529.7 352 544 337.7 544 320C544 302.3 529.7 288 512 288L352 288L352 128z"/></symbol>
|
|
40
|
+
<symbol id="i-minus" viewBox="0 0 640 640"><path fill="currentColor" d="M96 320C96 302.3 110.3 288 128 288L512 288C529.7 288 544 302.3 544 320C544 337.7 529.7 352 512 352L128 352C110.3 352 96 337.7 96 320z"/></symbol>
|
|
41
|
+
<symbol id="i-xmark" viewBox="0 0 640 640"><path fill="currentColor" d="M183.1 137.4C170.6 124.9 150.3 124.9 137.8 137.4C125.3 149.9 125.3 170.2 137.8 182.7L275.2 320L137.9 457.4C125.4 469.9 125.4 490.2 137.9 502.7C150.4 515.2 170.7 515.2 183.2 502.7L320.5 365.3L457.9 502.6C470.4 515.1 490.7 515.1 503.2 502.6C515.7 490.1 515.7 469.8 503.2 457.3L365.8 320L503.1 182.6C515.6 170.1 515.6 149.8 503.1 137.3C490.6 124.8 470.3 124.8 457.8 137.3L320.5 274.7L183.1 137.4z"/></symbol>
|
|
42
|
+
<symbol id="i-percent" viewBox="0 0 640 640"><path fill="currentColor" d="M192 144C218.5 144 240 165.5 240 192C240 218.5 218.5 240 192 240C165.5 240 144 218.5 144 192C144 165.5 165.5 144 192 144zM192 288C245 288 288 245 288 192C288 139 245 96 192 96C139 96 96 139 96 192C96 245 139 288 192 288zM448 400C474.5 400 496 421.5 496 448C496 474.5 474.5 496 448 496C421.5 496 400 474.5 400 448C400 421.5 421.5 400 448 400zM448 544C501 544 544 501 544 448C544 395 501 352 448 352C395 352 352 395 352 448C352 501 395 544 448 544zM537 137C546.4 127.6 546.4 112.4 537 103.1C527.6 93.8 512.4 93.7 503.1 103.1L103 503C93.6 512.4 93.6 527.6 103 536.9C112.4 546.2 127.6 546.3 136.9 536.9L537 137z"/></symbol>
|
|
43
|
+
<symbol id="i-tag" viewBox="0 0 640 640"><path fill="currentColor" d="M96.5 160L96.5 309.5C96.5 326.5 103.2 342.8 115.2 354.8L307.2 546.8C332.2 571.8 372.7 571.8 397.7 546.8L547.2 397.3C572.2 372.3 572.2 331.8 547.2 306.8L355.2 114.8C343.2 102.7 327 96 310 96L160.5 96C125.2 96 96.5 124.7 96.5 160zM208.5 176C226.2 176 240.5 190.3 240.5 208C240.5 225.7 226.2 240 208.5 240C190.8 240 176.5 225.7 176.5 208C176.5 190.3 190.8 176 208.5 176z"/></symbol>
|
|
44
|
+
<symbol id="i-tags" viewBox="0 0 640 640"><path fill="currentColor" d="M433.2 103.1L581.4 253.4C609.1 281.5 609.1 326.5 581.4 354.6L425 512.9C415.7 522.3 400.5 522.4 391.1 513.1C381.7 503.8 381.6 488.6 390.9 479.2L547.3 320.8C556.5 311.5 556.5 296.4 547.3 287.1L399 136.9C389.7 127.5 389.8 112.3 399.2 103C408.6 93.7 423.8 93.8 433.1 103.2zM64.1 293.5L64.1 160C64.1 124.7 92.8 96 128.1 96L261.6 96C278.6 96 294.9 102.7 306.9 114.7L450.9 258.7C475.9 283.7 475.9 324.2 450.9 349.2L317.4 482.7C292.4 507.7 251.9 507.7 226.9 482.7L82.9 338.7C70.9 326.7 64.2 310.4 64.2 293.4zM208.1 208C208.1 190.3 193.8 176 176.1 176C158.4 176 144.1 190.3 144.1 208C144.1 225.7 158.4 240 176.1 240C193.8 240 208.1 225.7 208.1 208z"/></symbol>
|
|
45
|
+
<symbol id="i-pin" viewBox="0 0 640 640"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M160 96C160 78.3 174.3 64 192 64L448 64C465.7 64 480 78.3 480 96C480 113.7 465.7 128 448 128L418.5 128L428.8 262.1C465.9 283.3 494.6 318.5 507 361.8L510.8 375.2C513.6 384.9 511.6 395.2 505.6 403.3C499.6 411.4 490 416 480 416L160 416C150 416 140.5 411.3 134.5 403.3C128.5 395.3 126.5 384.9 129.3 375.2L133 361.8C145.4 318.5 174 283.3 211.2 262.1L221.5 128L192 128C174.3 128 160 113.7 160 96zM288 464L352 464L352 576C352 593.7 337.7 608 320 608C302.3 608 288 593.7 288 576L288 464z"/></svg></symbol>
|
|
46
|
+
<symbol id="i-clock" viewBox="0 0 640 640"><path fill="currentColor" d="M320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320C64 178.6 178.6 64 320 64zM296 184L296 320C296 328 300 335.5 306.7 340L402.7 404C413.7 411.4 428.6 408.4 436 397.3C443.4 386.2 440.4 371.4 429.3 364L344 307.2L344 184C344 170.7 333.3 160 320 160C306.7 160 296 170.7 296 184z"/></symbol>
|
|
47
|
+
<symbol id="i-check" viewBox="0 0 640 640"><path fill="currentColor" d="M530.8 134.1C545.1 144.5 548.3 164.5 537.9 178.8L281.9 530.8C276.4 538.4 267.9 543.1 258.5 543.9C249.1 544.7 240 541.2 233.4 534.6L105.4 406.6C92.9 394.1 92.9 373.8 105.4 361.3C117.9 348.8 138.2 348.8 150.7 361.3L252.2 462.8L486.2 141.1C496.6 126.8 516.6 123.6 530.9 134z"/></symbol>
|
|
48
|
+
<symbol id="i-bulb" viewBox="0 0 640 640"><path fill="currentColor" d="M420.9 448C428.2 425.7 442.8 405.5 459.3 388.1C492 353.7 512 307.2 512 256C512 150 426 64 320 64C214 64 128 150 128 256C128 307.2 148 353.7 180.7 388.1C197.2 405.5 211.9 425.7 219.1 448L420.8 448zM416 496L224 496L224 512C224 556.2 259.8 592 304 592L336 592C380.2 592 416 556.2 416 512L416 496zM312 176C272.2 176 240 208.2 240 248C240 261.3 229.3 272 216 272C202.7 272 192 261.3 192 248C192 181.7 245.7 128 312 128C325.3 128 336 138.7 336 152C336 165.3 325.3 176 312 176z"/></symbol>
|
|
49
|
+
<symbol id="i-kebab" viewBox="0 0 640 640"><circle fill="currentColor" cx="320" cy="168" r="56"/><circle fill="currentColor" cx="320" cy="320" r="56"/><circle fill="currentColor" cx="320" cy="472" r="56"/></symbol>
|
|
50
|
+
<symbol id="i-chevron" viewBox="0 0 640 640"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path fill="currentColor" d="M297.4 470.6C309.9 483.1 330.2 483.1 342.7 470.6L534.7 278.6C547.2 266.1 547.2 245.8 534.7 233.3C522.2 220.8 501.9 220.8 489.4 233.3L320 402.7L150.6 233.4C138.1 220.9 117.8 220.9 105.3 233.4C92.8 245.9 92.8 266.2 105.3 278.7L297.3 470.7z"/></svg></symbol>
|
|
51
|
+
</svg>
|
|
52
|
+
|
|
53
|
+
<div x-data="appRoot()" x-cloak>
|
|
54
|
+
|
|
55
|
+
<!-- lock screen: casual privacy for shared computers (web logs in at the server) -->
|
|
56
|
+
<template x-if="$store.ui.locked">
|
|
57
|
+
<div class="fixed inset-0 z-[100] bg-zinc-100 flex items-center justify-center" x-data="{ pw: '', busy: false, bad: false }">
|
|
58
|
+
<form class="bg-white border border-zinc-200 rounded-xl shadow-lg p-8 w-80 text-center" :class="bad && 'animate-shake'"
|
|
59
|
+
@submit.prevent="busy = true; bad = false; window.api.authVerify({ password: pw }).then((r) => {
|
|
60
|
+
busy = false;
|
|
61
|
+
if (r.ok && r.valid) { $store.ui.locked = false; } else { bad = true; pw = ''; }
|
|
62
|
+
})">
|
|
63
|
+
<img src="assets/icon.ico" alt="" class="h-10 w-10 mx-auto mb-2" draggable="false">
|
|
64
|
+
<h2 class="font-black font-brand text-lg text-emerald-700 mb-4 select-none">Spend Wise</h2>
|
|
65
|
+
<input type="password" x-model="pw" placeholder="App password" x-init="$nextTick(() => $el.focus())"
|
|
66
|
+
class="w-full border border-zinc-300 rounded-md px-3 py-2 text-sm text-center focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
67
|
+
<button type="submit" :disabled="busy || !pw"
|
|
68
|
+
class="mt-3 w-full bg-emerald-600 hover:bg-emerald-500 text-white rounded-md py-2 text-sm font-bold cursor-pointer transition-colors disabled:bg-zinc-200 disabled:text-zinc-400">Unlock</button>
|
|
69
|
+
<p x-show="bad" class="text-xs text-red-600 mt-2">Wrong password</p>
|
|
70
|
+
</form>
|
|
71
|
+
</div>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<!-- ================================================== header -->
|
|
75
|
+
<header class="bg-white border-b border-zinc-200 sticky top-0 z-30">
|
|
76
|
+
<div class="max-w-screen-2xl mx-auto px-3 sm:px-6 py-3 flex items-center justify-between">
|
|
77
|
+
<div class="flex items-center gap-2.5 select-none shrink-0">
|
|
78
|
+
<!-- the piggy's visual mass sits ~10% low in its square (light
|
|
79
|
+
slot/ears on top, heavy body below), so a geometrically
|
|
80
|
+
centred icon reads low. -2.5px puts its centre of mass on
|
|
81
|
+
the header's centreline, where the wordmark's caps already sit. -->
|
|
82
|
+
<img src="assets/icon.ico" alt="" class="h-7 w-7 block shrink-0 relative -top-[3px]" draggable="false">
|
|
83
|
+
<!-- Roca's font box is lopsided (19px ascent / 10px descent at
|
|
84
|
+
20px), so items-center centres the BOX and leaves the visible
|
|
85
|
+
letters riding high. Same 0.15em correction as the group
|
|
86
|
+
titles - measured, and scales with font-size. -->
|
|
87
|
+
<h1 class="font-black font-brand tracking-tight text-2xl leading-none text-emerald-700 hidden sm:block relative top-[0.125em]">Spend Wise</h1>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="flex items-center gap-2 sm:gap-4">
|
|
90
|
+
<!-- month picker (month view only): status tag + selector.
|
|
91
|
+
x-if, not x-show: a hidden element's children still
|
|
92
|
+
evaluate, and these read $store.data.db.months before the
|
|
93
|
+
db has loaded -->
|
|
94
|
+
<template x-if="$store.ui.view === 'month' && $store.data.db && $store.ui.currentKey && $store.data.db.months[$store.ui.currentKey]">
|
|
95
|
+
<div class="flex items-center gap-2">
|
|
96
|
+
<span x-show="JSON.stringify($store.data.db.months[$store.ui.currentKey]) !== $store.ui.savedSnapshot"
|
|
97
|
+
class="text-amber-500 select-none" title="Unsaved changes">●</span>
|
|
98
|
+
<!-- on phones the open/closed pill lives in the This Month panel instead
|
|
99
|
+
(which sits inside main, so it goes with it while switching) -->
|
|
100
|
+
<span x-show="!$store.ui.switching"
|
|
101
|
+
class="hidden sm:inline-block text-[10px] uppercase tracking-widest font-bold px-2.5 py-1 rounded-full select-none"
|
|
102
|
+
:class="$store.data.db.months[$store.ui.currentKey].status === 'closed'
|
|
103
|
+
? ($store.ui.unlockedKeys.includes($store.ui.currentKey) ? 'bg-amber-100 text-amber-700' : 'bg-zinc-200 text-zinc-500')
|
|
104
|
+
: 'bg-emerald-100 text-emerald-700'"
|
|
105
|
+
x-text="$store.data.db.months[$store.ui.currentKey].status === 'closed'
|
|
106
|
+
? ($store.ui.unlockedKeys.includes($store.ui.currentKey) ? 'closed · editing' : 'closed') : 'open'"></span>
|
|
107
|
+
<select :value="$store.ui.currentKey"
|
|
108
|
+
@change="$store.ui.monthGoto && $store.ui.monthGoto($event.target.value).then(() => { $el.value = $store.ui.currentKey })"
|
|
109
|
+
class="border border-zinc-300 bg-white rounded-md px-2.5 py-1 text-sm font-semibold cursor-pointer focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
110
|
+
<template x-for="k in FinEngine.monthKeys($store.data.db).slice().reverse()" :key="k">
|
|
111
|
+
<option :value="k" :selected="k === $store.ui.currentKey" x-text="FinEngine.keyLabel(k)"></option>
|
|
112
|
+
</template>
|
|
113
|
+
</select>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
<nav class="flex gap-1 items-center">
|
|
117
|
+
<button type="button" @click="switchView('month')"
|
|
118
|
+
class="px-2.5 sm:px-4 py-1.5 rounded-md text-sm font-semibold transition-colors cursor-pointer"
|
|
119
|
+
:class="$store.ui.view === 'month' ? 'bg-emerald-600 text-white' : 'text-zinc-500 hover:bg-zinc-100'">Month</button>
|
|
120
|
+
<button type="button" @click="switchView('insights')" title="Insights"
|
|
121
|
+
class="px-2.5 sm:px-4 py-1.5 rounded-md text-sm font-semibold transition-colors cursor-pointer flex items-center"
|
|
122
|
+
:class="$store.ui.view === 'insights' ? 'bg-emerald-600 text-white' : 'text-zinc-500 hover:bg-zinc-100'">
|
|
123
|
+
<span class="hidden sm:inline">Insights</span>
|
|
124
|
+
<svg class="w-4 h-4 sm:hidden"><use href="#i-bulb"/></svg>
|
|
125
|
+
</button>
|
|
126
|
+
<button type="button" @click="switchView('settings')"
|
|
127
|
+
:title="$store.data.update.available ? 'Settings — version ' + $store.data.update.latest + ' is available' : 'Settings'"
|
|
128
|
+
class="relative p-1.5 rounded-md transition-colors cursor-pointer"
|
|
129
|
+
:class="$store.ui.view === 'settings' ? 'bg-emerald-600 text-white' : 'text-zinc-500 hover:bg-zinc-100'">
|
|
130
|
+
<!-- the launch toast fades; this doesn't, so the nudge survives
|
|
131
|
+
without ever blocking the books -->
|
|
132
|
+
<span x-show="$store.data.update.available" aria-hidden="true"
|
|
133
|
+
class="absolute top-0.5 right-0.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-white"></span>
|
|
134
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5" aria-hidden="true">
|
|
135
|
+
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
|
136
|
+
</svg>
|
|
137
|
+
</button>
|
|
138
|
+
</nav>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</header>
|
|
142
|
+
|
|
143
|
+
<!-- offline (web clients only): every save needs the server, so the app
|
|
144
|
+
goes read-only rather than letting edits pile up with nowhere to go -->
|
|
145
|
+
<div x-show="$store.ui.offline" x-cloak
|
|
146
|
+
class="bg-amber-100 border-b border-amber-300 text-amber-900 text-xs text-center py-2 px-4 sticky top-14 z-20 select-none">
|
|
147
|
+
<span class="font-bold">Offline</span> - can’t reach the server. Editing is paused until the connection
|
|
148
|
+
returns; anything you already typed is still here.
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<!-- hidden while a month change renders, so the "loading…" below shows through -->
|
|
152
|
+
<main class="max-w-screen-2xl mx-auto px-2.5 sm:px-6 py-4 sm:py-6" x-show="$store.data.loaded && !$store.ui.switching">
|
|
153
|
+
|
|
154
|
+
<!-- ============================================ month view -->
|
|
155
|
+
<div x-data="monthView()" x-show="$store.ui.view === 'month'" @keydown.window="hotkeys($event)"
|
|
156
|
+
@scroll.window="tagSuggestOpen = false; rowMenuFor = null"
|
|
157
|
+
@db-refreshed.window="refreshRateCache(); resetHistory(); renderChart()"
|
|
158
|
+
@resize.window.debounce.500ms="renderChart()">
|
|
159
|
+
|
|
160
|
+
<template x-if="month">
|
|
161
|
+
<div>
|
|
162
|
+
<div class="flex flex-col xl:flex-row gap-6 items-start">
|
|
163
|
+
|
|
164
|
+
<!-- ================================ groups -->
|
|
165
|
+
<!-- NOT a blanket disabled fieldset: tags are field-lifetime
|
|
166
|
+
metadata (no money math, self-persisting), so tag controls
|
|
167
|
+
stay usable on closed months - every OTHER control carries
|
|
168
|
+
:disabled="frozen" individually, since HTML offers no
|
|
169
|
+
per-child opt-out inside a disabled fieldset -->
|
|
170
|
+
<fieldset class="flex-1 w-full min-w-0 border-0 m-0 p-0 order-2 xl:order-none">
|
|
171
|
+
<template x-for="group in month.groups" :key="group.groupId">
|
|
172
|
+
<section class="mb-5 bg-white rounded-lg border border-zinc-200 shadow-sm overflow-hidden">
|
|
173
|
+
<header @click="toggleCollapsed(group.groupId)"
|
|
174
|
+
class="flex justify-between items-center px-4 py-2.5 bg-emerald-600 text-white cursor-pointer select-none">
|
|
175
|
+
<!-- the amount is the preferred item: it never wraps or shrinks;
|
|
176
|
+
the title takes whatever room is left (min-w-0 lets it).
|
|
177
|
+
top-[0.15em]: Roca's line box is taller above the caps than
|
|
178
|
+
below them, so items-center leaves the title riding high -
|
|
179
|
+
the nudge drops it onto the amount's optical centre (holds
|
|
180
|
+
for wrapped titles too, and scales with font-size). -->
|
|
181
|
+
<span class="uppercase tracking-widest font-bold text-sm flex-1 min-w-0 relative" x-text="group.title"></span>
|
|
182
|
+
<span class="flex items-center gap-3 shrink-0 whitespace-nowrap">
|
|
183
|
+
<span class="font-mono text-sm"
|
|
184
|
+
x-text="(group.kind === 'income' ? '+ ' : '− ') + fmt(groupTotal(group))"></span>
|
|
185
|
+
<span class="w-4 flex items-center justify-center">
|
|
186
|
+
<svg x-show="collapsed[group.groupId]" class="w-3.5 h-3.5 rotate-180"><use href="#i-chevron"/></svg>
|
|
187
|
+
<svg x-show="!collapsed[group.groupId]" class="w-3.5 h-3.5"><use href="#i-chevron"/></svg>
|
|
188
|
+
</span>
|
|
189
|
+
</span>
|
|
190
|
+
</header>
|
|
191
|
+
<div x-show="!collapsed[group.groupId]" x-collapse.duration.200ms>
|
|
192
|
+
<div class="p-2.5 sm:p-4">
|
|
193
|
+
<p class="text-xs text-zinc-400 mb-3 grayscale" x-text="groupHint(group.kind)"></p>
|
|
194
|
+
|
|
195
|
+
<!-- column headers for envelope/goal groups -->
|
|
196
|
+
<div x-show="(group.kind === 'envelope' || group.kind === 'goal') && group.fields.length"
|
|
197
|
+
class="max-sm:hidden flex items-center gap-1.5 mb-1 text-[10px] uppercase tracking-widest text-zinc-350 select-none">
|
|
198
|
+
<span class="flex-1 min-w-28"></span>
|
|
199
|
+
<span class="w-24 shrink-0 text-center">monthly</span>
|
|
200
|
+
<span class="w-24 shrink-0 text-right" title="Sum of expense rows assigned to this envelope">spent</span>
|
|
201
|
+
<span class="w-24 shrink-0 text-right">available</span>
|
|
202
|
+
<span x-show="group.kind === 'goal'" class="w-24 shrink-0 text-center">target</span>
|
|
203
|
+
<span class="w-7 shrink-0"></span>
|
|
204
|
+
<span class="w-8 shrink-0"></span>
|
|
205
|
+
<span class="w-7 shrink-0"></span>
|
|
206
|
+
<span class="w-7 shrink-0"></span>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<template x-for="(field, fi) in group.fields" :key="field.id">
|
|
210
|
+
<!-- max-sm:mb-5: on phones a row is two lines, so the space BETWEEN
|
|
211
|
+
rows has to beat the space within one - otherwise line 2 reads as
|
|
212
|
+
belonging to the row underneath it.
|
|
213
|
+
@focusin (it bubbles, unlike focus) closes an editor left open on
|
|
214
|
+
another row. The editors' own controls live inside this wrapper,
|
|
215
|
+
so using them keeps focus in-row and the editor stays put. -->
|
|
216
|
+
<div class="mb-2.5 max-sm:mb-5" @focusin="rowFocused(field)">
|
|
217
|
+
<!-- phones: rows wrap into [pin][label][value][paid][⋮] on line 1,
|
|
218
|
+
then spent/available/target/link on line 2 (max-sm
|
|
219
|
+
order utilities); desktop keeps the single DOM-order row -->
|
|
220
|
+
<!-- max-sm:justify-between spreads LINE 2's detail pairs across the
|
|
221
|
+
width; line 1 is unaffected because the label input (flex-1)
|
|
222
|
+
already eats its free space -->
|
|
223
|
+
<div class="flex items-center gap-1.5 flex-wrap sm:flex-nowrap max-sm:justify-between">
|
|
224
|
+
<!-- pin — income/expense only. Budgets are standing accounts:
|
|
225
|
+
they always carry forward, so there's nothing to toggle.
|
|
226
|
+
Retiring one is a delete (empty it first to return the
|
|
227
|
+
remaining balance to income). -->
|
|
228
|
+
<!-- pin stays disabled on a closed month even while it's unlocked
|
|
229
|
+
for editing: `pinned` is read only by rollover, at close-out,
|
|
230
|
+
and that already happened here — toggling it now would look
|
|
231
|
+
meaningful but change nothing downstream. -->
|
|
232
|
+
<template x-if="!FinEngine.isEnvelopeKind(group.kind)">
|
|
233
|
+
<button type="button" @click="field.pinned = !field.pinned" :disabled="frozen || isClosed"
|
|
234
|
+
:title="isClosed
|
|
235
|
+
? (field.pinned ? 'Was carried into the next month' : 'Was not carried forward')
|
|
236
|
+
: (field.pinned ? 'Carries into future months' : 'This month only - click to pin')"
|
|
237
|
+
class="max-sm:order-1 w-7 h-7 shrink-0 rounded flex items-center justify-center cursor-pointer transition-colors"
|
|
238
|
+
:class="field.pinned ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-300 hover:text-zinc-500'"><svg class="w-3.5 h-3.5"><use href="#i-pin"/></svg></button>
|
|
239
|
+
</template>
|
|
240
|
+
|
|
241
|
+
<div class="relative flex-1 min-w-28 max-sm:order-2">
|
|
242
|
+
<input type="text" x-model="field.label" placeholder="Label" :disabled="frozen"
|
|
243
|
+
class="w-full border rounded px-2.5 py-1.5 text-sm bg-zinc-50 focus:bg-white focus:outline-none focus:ring-1"
|
|
244
|
+
:class="group.kind === 'envelope' && overBudget(field)
|
|
245
|
+
? 'border-red-400 focus:ring-red-400'
|
|
246
|
+
: 'border-zinc-200 focus:ring-emerald-500'">
|
|
247
|
+
<!-- goal budgets: subtle funding underline (partial-width
|
|
248
|
+
"border" - a real border can't stop partway).
|
|
249
|
+
green while filling/funded, amber when full and idle -->
|
|
250
|
+
<div x-show="group.kind === 'goal' && FinEngine.num(field.target) > 0"
|
|
251
|
+
class="absolute bottom-[3px] left-[3px] right-[3px] h-[3px] pointer-events-none">
|
|
252
|
+
<div class="h-full rounded-full transition-all duration-300"
|
|
253
|
+
:class="progress(field).reached && contrib(group, field) === 0 ? 'bg-amber-400' : 'bg-emerald-400'"
|
|
254
|
+
:style="'width:' + progress(field).pct + '%'"></div>
|
|
255
|
+
</div>
|
|
256
|
+
<!-- envelope budgets: budget-health fuel gauge -
|
|
257
|
+
% of available budget still unspent. hidden until
|
|
258
|
+
spending starts, amber at ≤20% left, red dot
|
|
259
|
+
(min-width) when empty; overspend also reddens
|
|
260
|
+
the label input's border -->
|
|
261
|
+
<div x-show="group.kind === 'envelope' && health(field).show"
|
|
262
|
+
class="absolute bottom-[3px] left-[3px] right-[3px] h-[3px] pointer-events-none">
|
|
263
|
+
<div class="h-full rounded-full transition-all duration-300"
|
|
264
|
+
:class="health(field).empty ? 'bg-red-400' : (health(field).pct <= 20 ? 'bg-amber-400' : 'bg-emerald-400')"
|
|
265
|
+
:style="'width:max(' + health(field).pct + '%, 6px)'"></div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<!-- phones: the wrap between line 1 and line 2 -->
|
|
270
|
+
<div class="basis-full h-0 sm:hidden max-sm:order-5" aria-hidden="true"></div>
|
|
271
|
+
|
|
272
|
+
<template x-if="!isAuto(field) && !isSched(group, field)">
|
|
273
|
+
<div class="relative shrink-0 max-sm:order-3">
|
|
274
|
+
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-zinc-400 text-xs select-none">$</span>
|
|
275
|
+
<!-- cents are for income/expense amounts only; budget
|
|
276
|
+
allotments stay whole dollars. Group totals round
|
|
277
|
+
the cents away (income down, spending up). -->
|
|
278
|
+
<input type="number" x-model.number="field.value" :disabled="frozen"
|
|
279
|
+
:step="FinEngine.isEnvelopeKind(group.kind) ? '1' : '0.01'"
|
|
280
|
+
:title="group.kind === 'expense' && field.budgetId && !FinEngine.num(field.value)
|
|
281
|
+
? 'Linked to an envelope but $0 - it draws nothing until an amount is entered'
|
|
282
|
+
: (group.kind === 'envelope' || group.kind === 'goal' ? 'Monthly allotment' : 'Amount')"
|
|
283
|
+
class="w-24 max-sm:w-20 border border-zinc-200 rounded pl-5 pr-1 py-1.5 text-sm text-right bg-zinc-50 focus:bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
284
|
+
:class="group.kind === 'expense' && field.budgetId && !FinEngine.num(field.value) && 'ring-1 ring-amber-400 bg-amber-50'">
|
|
285
|
+
</div>
|
|
286
|
+
</template>
|
|
287
|
+
<template x-if="isAuto(field) && !isSched(group, field)">
|
|
288
|
+
<span class="w-24 max-sm:w-20 max-sm:order-3 shrink-0 text-right text-sm tabular-nums font-semibold text-violet-600 pr-1 select-none"
|
|
289
|
+
:title="'Auto-funded: ' + field.auto.pct + '% of the selected groups’ net'"
|
|
290
|
+
x-text="fmt(rateOf(field))"></span>
|
|
291
|
+
</template>
|
|
292
|
+
<template x-if="isSched(group, field)">
|
|
293
|
+
<span class="w-24 max-sm:w-20 max-sm:order-3 shrink-0 text-right text-sm tabular-nums font-semibold text-violet-600 pr-1 select-none"
|
|
294
|
+
:title="'Scheduled deposit: ' + fmt(field.target) + ' due ' + FinEngine.keyLabel(field.dueKey) + ' - headroom spread over the months remaining'"
|
|
295
|
+
x-text="fmt(field.value)"></span>
|
|
296
|
+
</template>
|
|
297
|
+
|
|
298
|
+
<!-- envelope / goal: derived spent · available. Two separate
|
|
299
|
+
items so justify-between can space them apart on phones -
|
|
300
|
+
sm:contents dissolves each pair on desktop, leaving the
|
|
301
|
+
value spans as direct row children (the fixed columns). -->
|
|
302
|
+
<template x-if="group.kind === 'envelope' || group.kind === 'goal'">
|
|
303
|
+
<div class="max-sm:order-6 max-sm:flex max-sm:items-center max-sm:gap-1.5 shrink-0 sm:contents">
|
|
304
|
+
<span class="sm:hidden shrink-0 text-[10px] uppercase tracking-widest text-zinc-350 select-none">spent</span>
|
|
305
|
+
<span class="w-24 max-sm:w-auto shrink-0 text-right text-xs tabular-nums select-none"
|
|
306
|
+
:class="effSpent(field) > 0 ? (overBudget(field) ? 'text-red-600 font-semibold' : 'text-emerald-600') : 'text-zinc-300'"
|
|
307
|
+
:title="'Spent from this envelope - expense rows assigned to it via the “from” dropdown' + (FinEngine.num(field.spent) > 0 ? ' (includes ' + fmt(field.spent) + ' carried from the legacy import)' : '')"
|
|
308
|
+
x-text="fmt(effSpent(field))"></span>
|
|
309
|
+
</div>
|
|
310
|
+
</template>
|
|
311
|
+
<template x-if="group.kind === 'envelope' || group.kind === 'goal'">
|
|
312
|
+
<div class="max-sm:order-6 max-sm:flex max-sm:items-center max-sm:gap-1.5 shrink-0 sm:contents">
|
|
313
|
+
<span class="sm:hidden shrink-0 text-[10px] uppercase tracking-widest text-zinc-350 select-none">available</span>
|
|
314
|
+
<span class="w-24 max-sm:w-auto shrink-0 text-right text-xs tabular-nums font-semibold select-none"
|
|
315
|
+
:class="overBudget(field) ? 'text-red-600' : (progress(field).reached ? 'text-amber-600' : 'text-zinc-600')"
|
|
316
|
+
:title="progress(field).reached ? 'At the cap - holds here until spent' : 'Available in this envelope right now'"
|
|
317
|
+
x-text="fmt(field.avail)"></span>
|
|
318
|
+
</div>
|
|
319
|
+
</template>
|
|
320
|
+
|
|
321
|
+
<!-- goal: target (label on phones - the placeholder only
|
|
322
|
+
says "target" while the field is empty) -->
|
|
323
|
+
<template x-if="group.kind === 'goal'">
|
|
324
|
+
<div class="flex items-center gap-1.5 shrink-0 max-sm:order-7">
|
|
325
|
+
<span class="sm:hidden shrink-0 text-[10px] uppercase tracking-widest text-zinc-350 select-none">target</span>
|
|
326
|
+
<div class="relative" title="Goal target">
|
|
327
|
+
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-zinc-400 text-xs select-none">$</span>
|
|
328
|
+
<input type="number" x-model.number="field.target" placeholder="target" :disabled="frozen"
|
|
329
|
+
class="w-24 max-sm:w-20 border border-zinc-200 rounded pl-5 pr-1 py-1.5 text-sm text-right bg-zinc-50 focus:bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
</template>
|
|
333
|
+
|
|
334
|
+
<!-- expense: draw from envelope. On phones it needs a label -
|
|
335
|
+
an unlinked row is just a lone "-" otherwise. "from" is
|
|
336
|
+
what this control is called elsewhere in the UI copy. -->
|
|
337
|
+
<template x-if="group.kind === 'expense'">
|
|
338
|
+
<div class="flex items-center gap-1.5 shrink-0 max-sm:order-7">
|
|
339
|
+
<span class="sm:hidden shrink-0 text-[10px] uppercase tracking-widest text-zinc-350 select-none">from</span>
|
|
340
|
+
<!-- accept only values that resolve to a real envelope: a
|
|
341
|
+
committed option with no value attribute falls back to
|
|
342
|
+
its TEXT ("-"), which must never land in budgetId -->
|
|
343
|
+
<select @change="field.budgetId = budgetOptions.some((o) => o.id && o.id === $event.target.value) ? $event.target.value : null"
|
|
344
|
+
:disabled="frozen" title="Draw this expense from an envelope"
|
|
345
|
+
x-effect="budgetOptions; field.budgetId; $nextTick(() => { $el.value = field.budgetId || ''; })"
|
|
346
|
+
class="w-[calc(100%-24px)] sm:w-[198px] shrink-0 text-xs border border-zinc-200 rounded py-2 px-1 bg-zinc-50 cursor-pointer focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
347
|
+
:class="field.budgetId && 'text-emerald-700 font-semibold'">
|
|
348
|
+
<template x-for="opt in budgetOptions" :key="opt.id">
|
|
349
|
+
<!-- x-effect, not :value - Alpine drops the value
|
|
350
|
+
ATTRIBUTE for an empty string -->
|
|
351
|
+
<option x-effect="$el.value = opt.id" :selected="opt.id === (field.budgetId || '')" x-text="opt.label"></option>
|
|
352
|
+
</template>
|
|
353
|
+
</select>
|
|
354
|
+
</div>
|
|
355
|
+
</template>
|
|
356
|
+
|
|
357
|
+
<!-- paid (not for envelopes) -->
|
|
358
|
+
<template x-if="group.kind === 'income' || group.kind === 'expense'">
|
|
359
|
+
<button type="button" @click="field.accounted = !field.accounted" :disabled="frozen"
|
|
360
|
+
:title="field.accounted ? 'Marked as paid / received' : 'Mark as paid / received'"
|
|
361
|
+
class="w-7 h-7 shrink-0 max-sm:order-4 rounded flex items-center justify-center cursor-pointer transition-colors"
|
|
362
|
+
:class="field.accounted ? 'bg-emerald-500 text-white' : 'bg-zinc-100 text-zinc-300 hover:text-zinc-500'"><svg class="w-3.5 h-3.5"><use href="#i-check"/></svg></button>
|
|
363
|
+
</template>
|
|
364
|
+
|
|
365
|
+
<!-- envelope: auto-fund -->
|
|
366
|
+
<template x-if="group.kind === 'envelope'">
|
|
367
|
+
<button type="button" @click="toggleAutoEditor(field)" :disabled="frozen"
|
|
368
|
+
title="Auto-fund: derive this monthly amount from a percentage of other groups (e.g. 33% of self-employment net)"
|
|
369
|
+
class="max-sm:hidden w-7 h-7 shrink-0 rounded flex items-center justify-center cursor-pointer transition-colors"
|
|
370
|
+
:class="isAuto(field) ? 'bg-violet-100 text-violet-700' : 'bg-zinc-100 text-zinc-300 hover:text-zinc-500'"><svg class="w-3.5 h-3.5"><use href="#i-percent"/></svg></button>
|
|
371
|
+
</template>
|
|
372
|
+
|
|
373
|
+
<!-- goal: schedule -->
|
|
374
|
+
<template x-if="group.kind === 'goal'">
|
|
375
|
+
<button type="button" @click="toggleSchedEditor(field)" :disabled="frozen"
|
|
376
|
+
title="Schedule: set a due date and frequency and the monthly deposit is calculated for you - great for recurring bills"
|
|
377
|
+
class="max-sm:hidden w-7 h-7 shrink-0 rounded flex items-center justify-center cursor-pointer transition-colors"
|
|
378
|
+
:class="field.dueKey ? 'bg-violet-100 text-violet-700' : 'bg-zinc-100 text-zinc-300 hover:text-zinc-500'"><svg class="w-3.5 h-3.5"><use href="#i-clock"/></svg></button>
|
|
379
|
+
</template>
|
|
380
|
+
|
|
381
|
+
<!-- tags -->
|
|
382
|
+
<button type="button" @click="toggleTagEditor(field)" title="Tags" :disabled="offline"
|
|
383
|
+
class="max-sm:hidden w-8 h-7 shrink-0 rounded flex items-center justify-center cursor-pointer transition-colors"
|
|
384
|
+
:class="(field.tags && field.tags.length) ? 'bg-emerald-100 text-emerald-700' : 'bg-zinc-100 text-zinc-300 hover:text-zinc-500'">
|
|
385
|
+
<svg x-show="!(field.tags && field.tags.length > 1)" class="w-3.5 h-3.5"><use href="#i-tag"/></svg>
|
|
386
|
+
<svg x-show="field.tags && field.tags.length > 1" class="w-4 h-4"><use href="#i-tags"/></svg>
|
|
387
|
+
</button>
|
|
388
|
+
|
|
389
|
+
<button type="button" @click="removeField(group, field)" :disabled="frozen" title="Remove this field"
|
|
390
|
+
class="max-sm:hidden w-7 h-7 shrink-0 rounded flex items-center justify-center cursor-pointer bg-zinc-100 text-zinc-400 hover:bg-red-100 hover:text-red-600 transition-colors"><svg class="w-3.5 h-3.5"><use href="#i-xmark"/></svg></button>
|
|
391
|
+
<button type="button" @click="addField(group, fi)" :disabled="frozen" title="Add a field below"
|
|
392
|
+
class="max-sm:hidden w-7 h-7 shrink-0 rounded flex items-center justify-center cursor-pointer bg-zinc-100 text-zinc-400 hover:bg-emerald-100 hover:text-emerald-700 transition-colors"><svg class="w-3.5 h-3.5"><use href="#i-plus"/></svg></button>
|
|
393
|
+
|
|
394
|
+
<!-- phones: row actions menu (everything but the checkmark) -->
|
|
395
|
+
<!-- .stop is load-bearing: without it the menu's @click.outside
|
|
396
|
+
(registered the microtask after this handler renders it) sees
|
|
397
|
+
THIS click finish bubbling to document and closes the menu
|
|
398
|
+
instantly. Only reproduces with real taps - a synthetic
|
|
399
|
+
.click() keeps the stack busy so the menu renders too late
|
|
400
|
+
to catch the event. -->
|
|
401
|
+
<button type="button" @click.stop="toggleRowMenu(field, $event)" title="Row actions"
|
|
402
|
+
class="sm:hidden max-sm:order-4 w-7 h-7 shrink-0 rounded flex items-center justify-center cursor-pointer bg-zinc-100 text-zinc-400 active:bg-zinc-200"><svg class="w-3.5 h-3.5"><use href="#i-kebab"/></svg></button>
|
|
403
|
+
<template x-if="rowMenuFor === field.id">
|
|
404
|
+
<div class="fixed z-50 w-56 bg-white border border-zinc-200 rounded-md shadow-lg py-1"
|
|
405
|
+
:style="{ left: rowMenuPos.x + 'px', top: rowMenuPos.y + 'px' }"
|
|
406
|
+
@click.outside="rowMenuFor = null">
|
|
407
|
+
<template x-if="group.kind === 'envelope'">
|
|
408
|
+
<button type="button" :disabled="frozen" @click="rowMenuFor = null; toggleAutoEditor(field)"
|
|
409
|
+
class="w-full text-left px-3 py-2.5 text-sm flex items-center gap-2.5 cursor-pointer text-zinc-600 hover:bg-zinc-50 disabled:opacity-40">
|
|
410
|
+
<svg class="w-3.5 h-3.5" :class="isAuto(field) && 'text-violet-600'"><use href="#i-percent"/></svg> Auto-fund…
|
|
411
|
+
</button>
|
|
412
|
+
</template>
|
|
413
|
+
<template x-if="group.kind === 'goal'">
|
|
414
|
+
<button type="button" :disabled="frozen" @click="rowMenuFor = null; toggleSchedEditor(field)"
|
|
415
|
+
class="w-full text-left px-3 py-2.5 text-sm flex items-center gap-2.5 cursor-pointer text-zinc-600 hover:bg-zinc-50 disabled:opacity-40">
|
|
416
|
+
<svg class="w-3.5 h-3.5" :class="field.dueKey && 'text-violet-600'"><use href="#i-clock"/></svg> Schedule…
|
|
417
|
+
</button>
|
|
418
|
+
</template>
|
|
419
|
+
<button type="button" :disabled="offline" @click="rowMenuFor = null; toggleTagEditor(field)"
|
|
420
|
+
class="w-full text-left px-3 py-2.5 text-sm flex items-center gap-2.5 cursor-pointer text-zinc-600 hover:bg-zinc-50 disabled:opacity-40">
|
|
421
|
+
<svg class="w-3.5 h-3.5" :class="field.tags && field.tags.length && 'text-emerald-600'"><use href="#i-tag"/></svg> Tags…
|
|
422
|
+
</button>
|
|
423
|
+
<button type="button" :disabled="frozen" @click="rowMenuFor = null; addField(group, fi)"
|
|
424
|
+
class="w-full text-left px-3 py-2.5 text-sm flex items-center gap-2.5 cursor-pointer text-zinc-600 hover:bg-zinc-50 disabled:opacity-40">
|
|
425
|
+
<svg class="w-3.5 h-3.5"><use href="#i-plus"/></svg> Add a field below
|
|
426
|
+
</button>
|
|
427
|
+
<button type="button" :disabled="frozen" @click="rowMenuFor = null; removeField(group, field)"
|
|
428
|
+
class="w-full text-left px-3 py-2.5 text-sm flex items-center gap-2.5 cursor-pointer text-red-600 hover:bg-red-50 disabled:opacity-40">
|
|
429
|
+
<svg class="w-3.5 h-3.5"><use href="#i-xmark"/></svg> Remove field
|
|
430
|
+
</button>
|
|
431
|
+
</div>
|
|
432
|
+
</template>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<!-- goal schedule editor (⧗ toggle) -->
|
|
436
|
+
<div x-show="schedEditorFor === field.id" class="flex flex-wrap items-center gap-2 mt-1.5 pl-9 pr-20 text-[11px] text-zinc-400">
|
|
437
|
+
<span class="select-none">due</span>
|
|
438
|
+
<input type="month" x-model="field.dueKey" :disabled="frozen" title="When the full target is needed - the monthly deposit is derived from this."
|
|
439
|
+
class="border border-zinc-200 rounded px-1.5 py-0.5 bg-white text-[11px] focus:outline-none focus:ring-1 focus:ring-violet-400">
|
|
440
|
+
<span class="select-none">every</span>
|
|
441
|
+
<input type="number" min="0" x-model.number="field.freqMonths" placeholder="12" :disabled="frozen"
|
|
442
|
+
title="Months between due dates (12 = yearly). 0 or blank = one-time - the due date won’t advance."
|
|
443
|
+
class="w-12 border border-zinc-200 rounded px-1.5 py-0.5 text-right bg-white text-[11px] focus:outline-none focus:ring-1 focus:ring-violet-400">
|
|
444
|
+
<span class="select-none">months</span>
|
|
445
|
+
<span x-show="isSched(group, field)" class="text-violet-600 font-semibold tabular-nums select-none"
|
|
446
|
+
x-text="'= ' + fmt(field.value) + ' this month'"></span>
|
|
447
|
+
<button type="button" x-show="field.dueKey" @click="clearSchedule(field)" :disabled="frozen"
|
|
448
|
+
class="text-zinc-400 hover:text-red-600 cursor-pointer">clear</button>
|
|
449
|
+
<template x-if="FinEngine.num(field.target) > 0">
|
|
450
|
+
<div class="flex items-center gap-2 flex-1 min-w-40">
|
|
451
|
+
<span class="text-[11px] whitespace-nowrap select-none"
|
|
452
|
+
:class="progress(field).reached ? 'text-amber-600 font-bold' : 'text-zinc-400'"
|
|
453
|
+
x-text="progress(field).reached
|
|
454
|
+
? (contrib(group, field) > 0 ? '● funded' : '● at cap - contributing $0 until spent')
|
|
455
|
+
: progress(field).pct + '% of ' + fmt(field.target)
|
|
456
|
+
+ (!field.dueKey && contrib(group, field) < rateOf(field) ? ' · +' + fmt(contrib(group, field)) + ' this month (capped)' : '')"></span>
|
|
457
|
+
</div>
|
|
458
|
+
</template>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<!-- auto-fund editor -->
|
|
462
|
+
<div x-show="autoEditorFor === field.id" class="flex flex-wrap items-center gap-2 mt-1.5 pl-9 text-xs text-zinc-500">
|
|
463
|
+
<span class="select-none">auto-fund</span>
|
|
464
|
+
<input type="number" min="0" max="100" step="0.01" :value="field.auto ? field.auto.pct : ''" @input="setAutoPct(field, $event.target.value)" :disabled="frozen"
|
|
465
|
+
title="Decimals allowed - e.g. 33.33 for one third"
|
|
466
|
+
class="w-16 border border-zinc-200 rounded px-1.5 py-0.5 text-right bg-white focus:outline-none focus:ring-1 focus:ring-violet-400">
|
|
467
|
+
<span class="select-none">% of the net of</span>
|
|
468
|
+
<template x-for="g in sourceGroups" :key="g.groupId">
|
|
469
|
+
<label class="flex items-center gap-1 cursor-pointer bg-zinc-100 hover:bg-zinc-200 rounded-full px-2.5 py-1 select-none transition-colors">
|
|
470
|
+
<input type="checkbox" class="accent-violet-600" :disabled="frozen"
|
|
471
|
+
:checked="field.auto && field.auto.groups.includes(g.groupId)"
|
|
472
|
+
@change="toggleAutoGroup(field, g.groupId)">
|
|
473
|
+
<span x-text="g.title"></span>
|
|
474
|
+
</label>
|
|
475
|
+
</template>
|
|
476
|
+
<span x-show="isAuto(field)" class="text-violet-600 font-semibold tabular-nums select-none"
|
|
477
|
+
x-text="'= ' + fmt(rateOf(field)) + ' this month'"></span>
|
|
478
|
+
<button type="button" x-show="field.auto" @click="clearAuto(field)" :disabled="frozen"
|
|
479
|
+
class="text-zinc-400 hover:text-red-600 cursor-pointer">clear</button>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<!-- tag editor -->
|
|
483
|
+
<div x-show="tagEditorFor === field.id" class="flex flex-wrap items-center gap-1.5 mt-1.5 pl-9">
|
|
484
|
+
<template x-for="tag in field.tags" :key="tag">
|
|
485
|
+
<span class="inline-flex items-center gap-1 bg-emerald-100 text-emerald-700 text-[11px] px-2 py-0.5 rounded-full">
|
|
486
|
+
<span x-text="tag"></span>
|
|
487
|
+
<button type="button" @click="removeTag(field, tag)" :disabled="offline" class="cursor-pointer hover:text-emerald-900 flex items-center"><svg class="w-2.5 h-2.5"><use href="#i-xmark"/></svg></button>
|
|
488
|
+
</span>
|
|
489
|
+
</template>
|
|
490
|
+
<input type="text" x-model="newTag" placeholder="add tag ⏎" :disabled="offline"
|
|
491
|
+
@keydown="tagKeydown(field, $event)"
|
|
492
|
+
@focus="openTagSuggest($event)"
|
|
493
|
+
@input="tagSuggestOpen || openTagSuggest($event)"
|
|
494
|
+
@blur="tagSuggestOpen = false"
|
|
495
|
+
class="w-28 border border-zinc-200 rounded-full px-2.5 py-0.5 text-[11px] bg-white focus:outline-none focus:ring-1 focus:ring-emerald-400">
|
|
496
|
+
<!-- suggestion dropdown: x-if (not x-show - a string :style
|
|
497
|
+
would clobber x-show's display:none, and 100+ hidden
|
|
498
|
+
copies would render buttons); fixed-positioned because
|
|
499
|
+
the group card clips overflow; mousedown.prevent keeps
|
|
500
|
+
the input focused so blur doesn't eat the click -->
|
|
501
|
+
<template x-if="tagSuggestOpen && tagEditorFor === field.id && tagSuggestions(field).length > 0">
|
|
502
|
+
<div class="tag-suggest fixed z-50 w-44 bg-white border border-zinc-200 rounded-md shadow-lg overflow-hidden"
|
|
503
|
+
:style="{ left: tagSuggestPos.x + 'px', top: tagSuggestPos.y + 'px' }">
|
|
504
|
+
<template x-for="(t, ti) in tagSuggestions(field)" :key="t">
|
|
505
|
+
<button type="button" @mousedown.prevent="pickTag(field, t)"
|
|
506
|
+
class="w-full text-left px-2.5 py-1.5 text-[11px] cursor-pointer flex items-center gap-1.5"
|
|
507
|
+
:class="ti === tagSuggestIndex ? 'bg-emerald-100 text-emerald-800' : 'text-zinc-600 hover:bg-zinc-50'">
|
|
508
|
+
<svg class="w-2.5 h-2.5 shrink-0 text-emerald-500"><use href="#i-tag"/></svg>
|
|
509
|
+
<span x-text="t"></span>
|
|
510
|
+
</button>
|
|
511
|
+
</template>
|
|
512
|
+
</div>
|
|
513
|
+
</template>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
</template>
|
|
517
|
+
|
|
518
|
+
<div x-show="!group.fields.length" class="text-center py-2">
|
|
519
|
+
<button type="button" @click="addField(group)" :disabled="frozen"
|
|
520
|
+
class="text-sm text-zinc-400 hover:text-emerald-600 cursor-pointer border border-dashed border-zinc-300 hover:border-emerald-400 rounded-md px-4 py-2 transition-colors inline-flex items-center gap-1.5"><svg class="w-3 h-3"><use href="#i-plus"/></svg>add the first field</button>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
</section>
|
|
525
|
+
</template>
|
|
526
|
+
</fieldset>
|
|
527
|
+
|
|
528
|
+
<!-- =============================== sidebar
|
|
529
|
+
`contents` below xl dissolves the aside so its panels
|
|
530
|
+
interleave with the groups by `order`: This Month +
|
|
531
|
+
action buttons first, groups second, savings last -->
|
|
532
|
+
<aside class="contents xl:block xl:w-96 xl:shrink-0 xl:space-y-5 xl:sticky xl:top-20">
|
|
533
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4 w-full hidden sm:block xl:w-auto order-3 xl:order-none">
|
|
534
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-1 select-none">Savings</h3>
|
|
535
|
+
<!-- phones see charts in Insights; the monthly sparkline is desktop-only -->
|
|
536
|
+
<div x-ref="savingsChart" class="-mx-2"></div>
|
|
537
|
+
<div class="text-4xl font-black font-brand text-center text-zinc-800" x-text="fmt(currentSavings)"></div>
|
|
538
|
+
<div class="text-center text-xs text-zinc-400 mb-3" x-text="fmt(withBudgets) + ' incl. unspent envelopes'"></div>
|
|
539
|
+
<div class="flex items-center justify-between border-t border-zinc-100 pt-3">
|
|
540
|
+
<span class="text-xs text-zinc-500 select-none">Savings at month start</span>
|
|
541
|
+
<span class="text-sm font-semibold tabular-nums text-zinc-700 select-none"
|
|
542
|
+
title="Derived from the previous month’s close-out. The very first month starts from “Initial savings” in Settings."
|
|
543
|
+
x-text="fmt(month.startingSavings)"></span>
|
|
544
|
+
</div>
|
|
545
|
+
</section>
|
|
546
|
+
|
|
547
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4 w-full xl:w-auto order-1 xl:order-none">
|
|
548
|
+
<div class="flex items-center justify-between mb-2">
|
|
549
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 select-none">This Month</h3>
|
|
550
|
+
<span class="sm:hidden text-[10px] uppercase tracking-widest font-bold px-2.5 py-1 rounded-full select-none"
|
|
551
|
+
:class="isClosed ? (unlocked ? 'bg-amber-100 text-amber-700' : 'bg-zinc-200 text-zinc-500') : 'bg-emerald-100 text-emerald-700'"
|
|
552
|
+
x-text="isClosed ? (unlocked ? 'closed · editing' : 'closed') : 'open'"></span>
|
|
553
|
+
</div>
|
|
554
|
+
<div class="text-4xl font-black font-brand text-center" :class="net >= 0 ? 'text-emerald-600' : 'text-red-600'" x-text="fmtSigned(net)"></div>
|
|
555
|
+
</section>
|
|
556
|
+
|
|
557
|
+
<div class="space-y-2 w-full xl:w-auto order-1 xl:order-none">
|
|
558
|
+
<template x-if="!isClosed">
|
|
559
|
+
<div class="space-y-2">
|
|
560
|
+
<button type="button" @click="save()" :disabled="offline" title="Save (Ctrl+S) · Undo (Ctrl+Z) · Redo (Ctrl+Y)"
|
|
561
|
+
class="w-full bg-emerald-600 hover:bg-emerald-500 border border-emerald-600 hover:border-emerald-500 text-white font-bold uppercase tracking-widest text-sm rounded-md px-5 py-3.5 cursor-pointer transition-colors disabled:bg-zinc-200 disabled:text-zinc-400"
|
|
562
|
+
x-text="isDirty ? 'Update This Month ●' : 'Update This Month'"></button>
|
|
563
|
+
<button type="button" x-show="isLatest" @click="closeOut()" :disabled="offline"
|
|
564
|
+
class="w-full bg-white border border-zinc-300 hover:border-emerald-500 hover:text-emerald-700 text-zinc-600 font-bold uppercase tracking-widest text-sm rounded-md px-5 py-3.5 cursor-pointer transition-colors disabled:text-zinc-300 disabled:border-zinc-200">Close Out This Month</button>
|
|
565
|
+
</div>
|
|
566
|
+
</template>
|
|
567
|
+
<template x-if="readonly">
|
|
568
|
+
<div class="bg-zinc-100 border border-zinc-200 rounded-md p-4 text-center">
|
|
569
|
+
<p class="text-sm text-zinc-500 mb-3">This month is closed. Editing it will recompute every later month.</p>
|
|
570
|
+
<button type="button" @click="unlock()" :disabled="offline"
|
|
571
|
+
class="bg-white border border-zinc-300 hover:border-amber-500 hover:text-amber-700 text-zinc-600 font-bold uppercase tracking-widest text-xs rounded-md px-4 py-2.5 cursor-pointer transition-colors disabled:text-zinc-300">Edit this month</button>
|
|
572
|
+
</div>
|
|
573
|
+
</template>
|
|
574
|
+
<template x-if="isClosed && unlocked">
|
|
575
|
+
<div class="space-y-2">
|
|
576
|
+
<button type="button" @click="save()" :disabled="offline"
|
|
577
|
+
class="w-full bg-amber-500 hover:bg-amber-400 border border-amber-500 hover:border-amber-400 text-white font-bold uppercase tracking-widest text-sm rounded-md px-5 py-3.5 cursor-pointer transition-colors disabled:bg-zinc-200 disabled:text-zinc-400">Save & Recompute Forward</button>
|
|
578
|
+
<button type="button" @click="cancelEditing()"
|
|
579
|
+
class="w-full bg-white border border-zinc-300 hover:border-zinc-500 hover:text-zinc-700 text-zinc-600 font-bold uppercase tracking-widest text-sm rounded-md px-5 py-3.5 cursor-pointer transition-colors disabled:text-zinc-300 disabled:border-zinc-200">Cancel editing</button>
|
|
580
|
+
</div>
|
|
581
|
+
</template>
|
|
582
|
+
<div class="mt-4 uppercase font-bold text-xs tracking-widest text-zinc-400 flex justify-between opacity-60"><span>Save <kbd class="border border-zinc-300 rounded-sm py-px px-1">Ctrl+S</kbd></span><span>Undo <kbd class="border border-zinc-300 rounded-sm py-px px-1">Ctrl+Z</kbd></span><span>Redo <kbd class="border border-zinc-300 rounded-sm py-px px-1">Ctrl+Shift+Z</kbd></span></div>
|
|
583
|
+
</div>
|
|
584
|
+
</aside>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
</div>
|
|
588
|
+
</template>
|
|
589
|
+
</div>
|
|
590
|
+
|
|
591
|
+
<!-- ========================================= insights view -->
|
|
592
|
+
<div x-data="insightsView()" x-show="$store.ui.view === 'insights'"
|
|
593
|
+
x-effect="if ($store.ui.view === 'insights' && $store.data.db) { range; $store.data.db.meta.rev; $nextTick(() => render()); } else { Charts.teardownInsights(); }">
|
|
594
|
+
<!-- x-if (not x-show): the aggregation bindings below deeply track
|
|
595
|
+
every month in range - alive-but-hidden they made every edit
|
|
596
|
+
in the Month view pay their re-run cost -->
|
|
597
|
+
<template x-if="$store.ui.view === 'insights' && $store.data.db">
|
|
598
|
+
<div>
|
|
599
|
+
|
|
600
|
+
<div class="flex items-center gap-2 mb-6">
|
|
601
|
+
<h2 class="text-lg font-black font-brand text-zinc-700 mr-4 select-none">Insights</h2>
|
|
602
|
+
<template x-for="r in [['12mo','Last 12 months'],['ytd','This year'],['all','All time']]" :key="r[0]">
|
|
603
|
+
<button type="button" @click="range = r[0]" x-text="r[1]"
|
|
604
|
+
class="px-3 py-1.5 rounded-md text-sm font-semibold cursor-pointer transition-colors"
|
|
605
|
+
:class="range === r[0] ? 'bg-emerald-600 text-white' : 'bg-white border border-zinc-200 text-zinc-500 hover:bg-zinc-100'"></button>
|
|
606
|
+
</template>
|
|
607
|
+
</div>
|
|
608
|
+
|
|
609
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
610
|
+
<template x-for="card in [['Avg monthly income', stats().avgIncome, 'text-emerald-600'], ['Avg monthly spend', stats().avgSpend, 'text-red-500'], ['Avg monthly net', stats().avgNet, stats().avgNet >= 0 ? 'text-emerald-600' : 'text-red-500'], ['Months in range', stats().months, 'text-zinc-700']]">
|
|
611
|
+
<div class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4">
|
|
612
|
+
<div class="text-[11px] uppercase tracking-widest font-bold text-zinc-400 select-none" x-text="card[0]"></div>
|
|
613
|
+
<div class="text-2xl font-black font-brand mt-1" :class="card[2]" x-text="card[0] === 'Months in range' ? card[1] : fmt(card[1])"></div>
|
|
614
|
+
</div>
|
|
615
|
+
</template>
|
|
616
|
+
</div>
|
|
617
|
+
|
|
618
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4 mb-6">
|
|
619
|
+
<div class="flex items-baseline justify-between mb-3">
|
|
620
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 select-none">Savings over time & projection</h3>
|
|
621
|
+
<span x-show="projected12 !== null" class="text-xs font-semibold text-violet-600 select-none"
|
|
622
|
+
title="Linear trend of the selected timespan, projected forward"
|
|
623
|
+
x-text="'≈ ' + fmt(projected12) + ' in 12 months at the current trend'"></span>
|
|
624
|
+
</div>
|
|
625
|
+
<div id="insights-chart-savings"></div>
|
|
626
|
+
</section>
|
|
627
|
+
|
|
628
|
+
<div class="grid lg:grid-cols-2 gap-6 mb-6">
|
|
629
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4">
|
|
630
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-3 select-none">Spend by tag</h3>
|
|
631
|
+
<!-- charts mount by id, not x-ref: $refs go stale across x-if re-creation -->
|
|
632
|
+
<div id="insights-chart-donut"></div>
|
|
633
|
+
<p x-show="!tagRows().length" class="text-center text-zinc-400 text-sm py-10">
|
|
634
|
+
No spending in this range yet. Tag fields with the <span class="bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded inline-flex items-center align-middle"><svg class="w-3 h-3"><use href="#i-tag"/></svg></span> button -
|
|
635
|
+
tagging a budget also covers the expenses that draw from it.
|
|
636
|
+
</p>
|
|
637
|
+
</section>
|
|
638
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4">
|
|
639
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-3 select-none">Monthly spend, top tags</h3>
|
|
640
|
+
<div id="insights-chart-bar"></div>
|
|
641
|
+
</section>
|
|
642
|
+
</div>
|
|
643
|
+
|
|
644
|
+
<!-- income streams: only rendered content when tagged income
|
|
645
|
+
exists in range - untagged income gets no insights -->
|
|
646
|
+
<div id="insights-income-block" x-show="incomeRows().length" class="grid lg:grid-cols-2 gap-6 mb-6">
|
|
647
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4">
|
|
648
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-3 select-none">Income by tag</h3>
|
|
649
|
+
<div id="insights-chart-income-donut"></div>
|
|
650
|
+
<table class="w-full text-sm mt-3">
|
|
651
|
+
<thead>
|
|
652
|
+
<tr class="text-left text-[11px] uppercase tracking-widest text-zinc-400 select-none">
|
|
653
|
+
<th class="pb-2 font-bold">Tag</th>
|
|
654
|
+
<th class="pb-2 font-bold text-right">Total</th>
|
|
655
|
+
<th class="pb-2 font-bold text-right">Monthly avg</th>
|
|
656
|
+
<th class="pb-2 font-bold text-right">% of tagged</th>
|
|
657
|
+
</tr>
|
|
658
|
+
</thead>
|
|
659
|
+
<tbody>
|
|
660
|
+
<template x-for="row in incomeRows()" :key="row.tag">
|
|
661
|
+
<tr class="border-t border-zinc-100">
|
|
662
|
+
<td class="py-1.5"><span class="bg-emerald-100 text-emerald-700 text-[11px] px-2 py-0.5 rounded-full" x-text="row.tag"></span></td>
|
|
663
|
+
<td class="py-1.5 text-right font-semibold" x-text="fmt(row.total)"></td>
|
|
664
|
+
<td class="py-1.5 text-right text-zinc-500" x-text="fmt(row.avg)"></td>
|
|
665
|
+
<td class="py-1.5 text-right text-zinc-400" x-text="row.pct + '%'"></td>
|
|
666
|
+
</tr>
|
|
667
|
+
</template>
|
|
668
|
+
</tbody>
|
|
669
|
+
</table>
|
|
670
|
+
</section>
|
|
671
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4">
|
|
672
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-3 select-none">Income streams over time</h3>
|
|
673
|
+
<div id="insights-chart-income-bar"></div>
|
|
674
|
+
</section>
|
|
675
|
+
</div>
|
|
676
|
+
|
|
677
|
+
<div class="grid lg:grid-cols-2 gap-6">
|
|
678
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4">
|
|
679
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-3 select-none">Tag breakdown</h3>
|
|
680
|
+
<table class="w-full text-sm">
|
|
681
|
+
<thead>
|
|
682
|
+
<tr class="text-left text-[11px] uppercase tracking-widest text-zinc-400 select-none">
|
|
683
|
+
<th class="pb-2 font-bold">Tag</th>
|
|
684
|
+
<th class="pb-2 font-bold text-right">Total</th>
|
|
685
|
+
<th class="pb-2 font-bold text-right">Monthly avg</th>
|
|
686
|
+
<th class="pb-2 font-bold text-right">% of spend</th>
|
|
687
|
+
</tr>
|
|
688
|
+
</thead>
|
|
689
|
+
<tbody>
|
|
690
|
+
<template x-for="row in tagRows()" :key="row.tag">
|
|
691
|
+
<tr class="border-t border-zinc-100">
|
|
692
|
+
<td class="py-1.5"><span class="bg-emerald-100 text-emerald-700 text-[11px] px-2 py-0.5 rounded-full" x-text="row.tag"></span></td>
|
|
693
|
+
<td class="py-1.5 text-right font-semibold" x-text="fmt(row.total)"></td>
|
|
694
|
+
<td class="py-1.5 text-right text-zinc-500" x-text="fmt(row.avg)"></td>
|
|
695
|
+
<td class="py-1.5 text-right text-zinc-400" x-text="row.pct + '%'"></td>
|
|
696
|
+
</tr>
|
|
697
|
+
</template>
|
|
698
|
+
<tr x-show="!tagRows().length"><td colspan="4" class="py-4 text-center text-zinc-400 text-sm">No tagged spending yet - tag expenses with the # button in the Month view.</td></tr>
|
|
699
|
+
</tbody>
|
|
700
|
+
</table>
|
|
701
|
+
</section>
|
|
702
|
+
|
|
703
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-4">
|
|
704
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-3 select-none">Envelope health (latest month)</h3>
|
|
705
|
+
<div class="space-y-3">
|
|
706
|
+
<template x-for="env in envelopes()" :key="env.label + env.kind">
|
|
707
|
+
<div>
|
|
708
|
+
<div class="flex justify-between text-sm mb-1">
|
|
709
|
+
<span class="font-semibold text-zinc-700" x-text="env.label"></span>
|
|
710
|
+
<span :class="env.left < 0 ? 'text-red-600 font-bold' : 'text-zinc-500'"
|
|
711
|
+
x-text="fmt(env.spent) + ' spent of ' + fmt(env.avail)"></span>
|
|
712
|
+
</div>
|
|
713
|
+
<div class="w-full bg-zinc-100 rounded-full h-2 overflow-hidden">
|
|
714
|
+
<div class="h-full rounded-full" :class="env.left < 0 ? 'bg-red-500' : (env.progress && env.progress.reached ? 'bg-amber-400' : 'bg-emerald-500')"
|
|
715
|
+
:style="'width:' + Math.min(100, env.avail > 0 ? Math.round(env.spent / env.avail * 100) : (env.spent > 0 ? 100 : 0)) + '%'"></div>
|
|
716
|
+
</div>
|
|
717
|
+
</div>
|
|
718
|
+
</template>
|
|
719
|
+
<p x-show="!envelopes().length" class="text-center text-zinc-400 text-sm py-4">No envelopes in the latest month.</p>
|
|
720
|
+
</div>
|
|
721
|
+
</section>
|
|
722
|
+
</div>
|
|
723
|
+
|
|
724
|
+
</div>
|
|
725
|
+
</template>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
<!-- ========================================= settings view -->
|
|
729
|
+
<div x-data="settingsView(true)" x-show="$store.ui.view === 'settings'" @keydown.window="hotkeys($event)">
|
|
730
|
+
<template x-if="$store.ui.view === 'settings'">
|
|
731
|
+
<div>
|
|
732
|
+
<h2 class="text-lg font-black font-brand text-zinc-700 mb-6 select-none">Settings</h2>
|
|
733
|
+
|
|
734
|
+
<div class="grid lg:grid-cols-2 gap-6 items-start">
|
|
735
|
+
<!-- groups -->
|
|
736
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-5">
|
|
737
|
+
<template x-if="draft">
|
|
738
|
+
<div class="flex items-center justify-between gap-4 mb-5 pb-5 border-b border-zinc-100">
|
|
739
|
+
<div>
|
|
740
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 select-none">Initial savings</h3>
|
|
741
|
+
<p class="text-[11px] text-zinc-400 mt-0.5">What you had before your very first month. Changing it recomputes every month’s savings (a backup is taken first).</p>
|
|
742
|
+
</div>
|
|
743
|
+
<template x-if="!initialLocked">
|
|
744
|
+
<div class="relative shrink-0">
|
|
745
|
+
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-zinc-400 text-xs select-none">$</span>
|
|
746
|
+
<input type="number" x-model.number="draft.initialSavings"
|
|
747
|
+
class="w-32 border border-zinc-200 rounded pl-5 pr-1 py-1.5 text-sm text-right bg-zinc-50 focus:bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
748
|
+
:class="initialUnlocked && 'ring-1 ring-amber-400'">
|
|
749
|
+
</div>
|
|
750
|
+
</template>
|
|
751
|
+
<template x-if="initialLocked">
|
|
752
|
+
<div class="flex items-center gap-2 shrink-0">
|
|
753
|
+
<span class="text-sm font-semibold tabular-nums text-zinc-700 select-none"
|
|
754
|
+
title="Locked once you have more than one month of history" x-text="fmt(draft.initialSavings)"></span>
|
|
755
|
+
<button type="button" @click="unlockInitial()"
|
|
756
|
+
class="text-xs border border-zinc-300 rounded px-2.5 py-1 text-zinc-500 hover:border-amber-500 hover:text-amber-700 cursor-pointer transition-colors">edit…</button>
|
|
757
|
+
</div>
|
|
758
|
+
</template>
|
|
759
|
+
</div>
|
|
760
|
+
</template>
|
|
761
|
+
|
|
762
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-1 select-none">Field groups</h3>
|
|
763
|
+
<p class="text-xs text-zinc-400 mb-4">Shape the app to your life - add groups like “Self-Employment Income” or “Rental Expenses”. Changes apply to open and future months; closed months keep their history.</p>
|
|
764
|
+
|
|
765
|
+
<template x-if="draft">
|
|
766
|
+
<div>
|
|
767
|
+
<template x-for="(group, gi) in draft.groups" :key="group.id">
|
|
768
|
+
<div class="border border-zinc-200 rounded-md p-3 mb-2.5 bg-zinc-50">
|
|
769
|
+
<div class="flex items-center gap-2">
|
|
770
|
+
<div class="flex flex-col shrink-0">
|
|
771
|
+
<button type="button" @click="move(gi, -1)" class="text-zinc-300 hover:text-zinc-600 cursor-pointer leading-none text-xs" :class="gi === 0 && 'opacity-0 pointer-events-none'">▲</button>
|
|
772
|
+
<button type="button" @click="move(gi, 1)" class="text-zinc-300 hover:text-zinc-600 cursor-pointer leading-none text-xs" :class="gi === draft.groups.length - 1 && 'opacity-0 pointer-events-none'">▼</button>
|
|
773
|
+
</div>
|
|
774
|
+
<input type="text" x-model="group.title"
|
|
775
|
+
class="flex-1 min-w-0 border border-zinc-200 rounded px-2.5 py-1.5 text-sm font-semibold bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
776
|
+
<span class="text-xs text-zinc-400 shrink-0 select-none" x-text="kindLabel(group.kind)"></span>
|
|
777
|
+
<button type="button" @click="removeGroup(group)" title="Remove group"
|
|
778
|
+
class="w-7 h-7 shrink-0 rounded flex items-center justify-center cursor-pointer bg-white border border-zinc-200 text-zinc-400 hover:bg-red-100 hover:text-red-600 transition-colors"><svg class="w-3.5 h-3.5"><use href="#i-xmark"/></svg></button>
|
|
779
|
+
</div>
|
|
780
|
+
<label x-show="group.kind === 'envelope'" class="flex items-center gap-2 mt-2.5 ml-6 cursor-pointer select-none">
|
|
781
|
+
<input type="checkbox" x-model="group.strictOverspend" class="accent-emerald-600">
|
|
782
|
+
<span class="text-xs text-zinc-500">Strict overspend - going over an envelope also reduces next month’s available envelope balance by the difference (pocket the savings)</span>
|
|
783
|
+
</label>
|
|
784
|
+
</div>
|
|
785
|
+
</template>
|
|
786
|
+
|
|
787
|
+
<div class="flex flex-wrap gap-2 mt-4">
|
|
788
|
+
<template x-for="k in [['income','+ Income'],['envelope','+ Envelope budgets'],['goal','+ Goal budgets'],['expense','+ Expenses']]" :key="k[0]">
|
|
789
|
+
<button type="button" @click="addGroup(k[0])" x-text="k[1]"
|
|
790
|
+
class="text-xs border border-dashed border-zinc-300 hover:border-emerald-400 hover:text-emerald-700 text-zinc-500 rounded-md px-3 py-1.5 cursor-pointer transition-colors"></button>
|
|
791
|
+
</template>
|
|
792
|
+
</div>
|
|
793
|
+
|
|
794
|
+
<div class="mt-5 flex items-center gap-3">
|
|
795
|
+
<button type="button" @click="save()" :disabled="!dirty || $store.ui.offline"
|
|
796
|
+
class="bg-emerald-600 hover:bg-emerald-500 text-white font-bold uppercase tracking-widest text-xs rounded-md px-5 py-2.5 cursor-pointer transition-colors disabled:bg-zinc-200 disabled:text-zinc-400 disabled:cursor-default">Save settings</button>
|
|
797
|
+
<button type="button" x-show="dirty" @click="reset()"
|
|
798
|
+
class="text-xs text-zinc-400 hover:text-zinc-600 cursor-pointer">discard changes</button>
|
|
799
|
+
</div>
|
|
800
|
+
</div>
|
|
801
|
+
</template>
|
|
802
|
+
</section>
|
|
803
|
+
|
|
804
|
+
<!-- right column: access + data, wrapped as one grid child so
|
|
805
|
+
Data always stays on the right and never wraps under the
|
|
806
|
+
field groups when the (desktop-only) access pane shows.
|
|
807
|
+
flex gap (not space-y) so the gap collapses when the access
|
|
808
|
+
pane is display:none in web mode. -->
|
|
809
|
+
<div class="flex flex-col gap-6">
|
|
810
|
+
<!-- data management -->
|
|
811
|
+
<section class="bg-white rounded-lg border border-zinc-200 shadow-sm p-5">
|
|
812
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-4 select-none">Data</h3>
|
|
813
|
+
|
|
814
|
+
<div class="text-xs text-zinc-500 mb-4 break-all">
|
|
815
|
+
<span class="font-bold text-zinc-400 uppercase tracking-widest text-[10px] block mb-1 select-none">Database file</span>
|
|
816
|
+
<!-- worth copying, so it opts out of the app-wide select-none -->
|
|
817
|
+
<span class="select-text" x-text="dbPath"></span>
|
|
818
|
+
</div>
|
|
819
|
+
|
|
820
|
+
<div class="flex flex-wrap gap-2">
|
|
821
|
+
<button type="button" @click="changeLocation()" class="text-xs border border-zinc-300 hover:bg-zinc-100 text-zinc-600 rounded-md px-3 py-2 cursor-pointer transition-colors">Move / share database…</button>
|
|
822
|
+
<button type="button" @click="exportDb()" class="text-xs border border-zinc-300 hover:bg-zinc-100 text-zinc-600 rounded-md px-3 py-2 cursor-pointer transition-colors">Export a copy…</button>
|
|
823
|
+
<button type="button" @click="loadBackups()" class="text-xs border border-zinc-300 hover:bg-zinc-100 text-zinc-600 rounded-md px-3 py-2 cursor-pointer transition-colors">Backups…</button>
|
|
824
|
+
<button type="button" @click="importLegacy()" class="text-xs border border-amber-300 text-amber-700 hover:bg-amber-50 rounded-md px-3 py-2 cursor-pointer transition-colors">Import legacy data…</button>
|
|
825
|
+
</div>
|
|
826
|
+
|
|
827
|
+
<p class="text-[11px] text-zinc-400 mt-4 leading-relaxed">
|
|
828
|
+
Your data is one JSON file. Point it at a synced folder (Dropbox, OneDrive, etc.) to use the same
|
|
829
|
+
books on several machines - the app detects when another session saved first and refuses to
|
|
830
|
+
overwrite it. Automatic backups are taken before every close-out, recompute, restore, and import.
|
|
831
|
+
</p>
|
|
832
|
+
|
|
833
|
+
<!-- updates -->
|
|
834
|
+
<div class="mt-4 pt-4 border-t border-zinc-100">
|
|
835
|
+
<span class="font-bold text-zinc-400 uppercase tracking-widest text-[10px] block mb-2 select-none">Updates</span>
|
|
836
|
+
|
|
837
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
838
|
+
<label class="flex items-center gap-2 text-xs text-zinc-600 cursor-pointer select-none">
|
|
839
|
+
<input type="checkbox" :checked="updateCheckEnabled()" @change="setUpdateCheck($event.target.checked)"
|
|
840
|
+
class="accent-emerald-600 cursor-pointer">
|
|
841
|
+
Check GitHub for new releases
|
|
842
|
+
</label>
|
|
843
|
+
<button type="button" @click="checkNow()" x-show="updateCheckEnabled()"
|
|
844
|
+
:disabled="$store.data.update.checking"
|
|
845
|
+
class="text-xs border border-zinc-300 hover:bg-zinc-100 text-zinc-600 rounded-md px-3 py-1.5 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-default"
|
|
846
|
+
x-text="$store.data.update.checking ? 'Checking…' : 'Check now'"></button>
|
|
847
|
+
</div>
|
|
848
|
+
|
|
849
|
+
<!-- an update exists -->
|
|
850
|
+
<div x-show="$store.data.update.available" class="mt-3 flex flex-wrap items-center gap-2 rounded-md bg-emerald-50 border border-emerald-200 px-3 py-2">
|
|
851
|
+
<span class="text-xs text-emerald-800 select-none">
|
|
852
|
+
Version <span class="font-semibold" x-text="$store.data.update.latest"></span> is available.
|
|
853
|
+
</span>
|
|
854
|
+
<button type="button" @click="openReleases()"
|
|
855
|
+
class="text-xs font-semibold bg-emerald-600 hover:bg-emerald-700 text-white rounded px-2.5 py-1 cursor-pointer transition-colors">Release notes</button>
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<!-- up to date / offline / off: quiet one-liners -->
|
|
859
|
+
<p x-show="updateCheckEnabled() && $store.data.update.checked && !$store.data.update.available && !$store.data.update.unreachable"
|
|
860
|
+
class="text-[11px] text-zinc-400 mt-2 select-none">You're on the latest release.</p>
|
|
861
|
+
<p x-show="updateCheckEnabled() && $store.data.update.checked && $store.data.update.unreachable"
|
|
862
|
+
class="text-[11px] text-zinc-400 mt-2 select-none">Couldn't reach GitHub - no matter, nothing else needs it.</p>
|
|
863
|
+
<p x-show="!updateCheckEnabled()" class="text-[11px] text-zinc-400 mt-2 leading-relaxed">
|
|
864
|
+
Update checks are off.
|
|
865
|
+
</p>
|
|
866
|
+
</div>
|
|
867
|
+
|
|
868
|
+
<div class="text-[11px] text-zinc-400 mt-4 pt-4 border-t border-zinc-100 select-none"
|
|
869
|
+
title="Application version - if this is older than your latest install, the window is running stale code">
|
|
870
|
+
Spend Wise · v<span class="font-semibold" x-text="$store.data.version || 'dev'"></span> · by Michael Niles
|
|
871
|
+
</div>
|
|
872
|
+
</section>
|
|
873
|
+
|
|
874
|
+
<!-- privacy & access -->
|
|
875
|
+
<section x-show="!isWeb" class="bg-white rounded-lg border border-zinc-200 shadow-sm p-5">
|
|
876
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-1 select-none">App password</h3>
|
|
877
|
+
<p class="text-xs text-zinc-400 mb-4 leading-relaxed">
|
|
878
|
+
Locks the app on launch - casual privacy on a shared computer - and is the login for web
|
|
879
|
+
access.
|
|
880
|
+
</p>
|
|
881
|
+
|
|
882
|
+
<div class="flex flex-wrap items-end gap-2">
|
|
883
|
+
<label x-show="hasPassword" class="block">
|
|
884
|
+
<span class="text-[10px] uppercase tracking-widest font-bold text-zinc-400 block mb-1 select-none">Current</span>
|
|
885
|
+
<input type="password" x-model="pwCurrent" autocomplete="current-password"
|
|
886
|
+
class="w-36 border border-zinc-200 rounded px-2.5 py-1.5 text-sm bg-zinc-50 focus:bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
887
|
+
</label>
|
|
888
|
+
<label class="block">
|
|
889
|
+
<span class="text-[10px] uppercase tracking-widest font-bold text-zinc-400 block mb-1 select-none" x-text="hasPassword ? 'New' : 'Password'"></span>
|
|
890
|
+
<input type="password" x-model="pwNew" autocomplete="new-password"
|
|
891
|
+
class="w-36 border border-zinc-200 rounded px-2.5 py-1.5 text-sm bg-zinc-50 focus:bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
892
|
+
</label>
|
|
893
|
+
<label class="block">
|
|
894
|
+
<span class="text-[10px] uppercase tracking-widest font-bold text-zinc-400 block mb-1 select-none">Confirm</span>
|
|
895
|
+
<input type="password" x-model="pwConfirm" autocomplete="new-password" @keydown.enter="setPassword()"
|
|
896
|
+
class="w-36 border border-zinc-200 rounded px-2.5 py-1.5 text-sm bg-zinc-50 focus:bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
897
|
+
</label>
|
|
898
|
+
<button type="button" @click="setPassword()"
|
|
899
|
+
class="text-xs border border-zinc-300 hover:bg-zinc-100 text-zinc-600 rounded-md px-3 py-2 cursor-pointer transition-colors"
|
|
900
|
+
x-text="hasPassword ? 'Change password' : 'Set password'"></button>
|
|
901
|
+
<button type="button" x-show="hasPassword" @click="removePassword()"
|
|
902
|
+
class="text-xs border border-red-200 text-red-600 hover:bg-red-50 rounded-md px-3 py-2 cursor-pointer transition-colors">Remove…</button>
|
|
903
|
+
</div>
|
|
904
|
+
|
|
905
|
+
<template x-if="web.loaded">
|
|
906
|
+
<div class="mt-5 pt-5 border-t border-zinc-100">
|
|
907
|
+
<h3 class="uppercase font-bold tracking-widest text-xs text-zinc-400 mb-1 select-none">Web access</h3>
|
|
908
|
+
<p class="text-xs text-zinc-400 mb-4 leading-relaxed">
|
|
909
|
+
Serves this same app to browsers on your network - phones, laptops, family members -
|
|
910
|
+
editing the very same data live (edits merge automatically). Requires the app password;
|
|
911
|
+
every browser signs in with it. For access away from home, put the machine on a
|
|
912
|
+
private network like Tailscale rather than exposing the port to the internet.
|
|
913
|
+
</p>
|
|
914
|
+
<div class="flex flex-wrap items-center gap-3">
|
|
915
|
+
<button type="button" @click="toggleWeb()"
|
|
916
|
+
class="text-xs font-bold rounded-md px-4 py-2 cursor-pointer transition-colors"
|
|
917
|
+
:class="web.enabled ? 'bg-emerald-600 hover:bg-emerald-500 text-white' : 'border border-zinc-300 hover:bg-zinc-100 text-zinc-600'"
|
|
918
|
+
x-text="web.enabled ? 'Web access: on' : 'Turn on web access'"></button>
|
|
919
|
+
<label class="flex items-center gap-1.5 text-xs text-zinc-500">
|
|
920
|
+
port
|
|
921
|
+
<input type="number" min="1024" max="65535" x-model.number="web.port" @change="applyWebPort()"
|
|
922
|
+
class="w-20 border border-zinc-200 rounded px-2 py-1.5 text-sm text-right bg-zinc-50 focus:bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
923
|
+
</label>
|
|
924
|
+
<span x-show="web.enabled && web.running" class="text-[10px] uppercase tracking-widest font-bold px-2.5 py-1 rounded-full bg-emerald-100 text-emerald-700 select-none">serving</span>
|
|
925
|
+
</div>
|
|
926
|
+
<div x-show="web.running" class="mt-3 text-xs text-zinc-500 space-y-0.5">
|
|
927
|
+
<template x-for="url in web.urls" :key="url">
|
|
928
|
+
<div class="font-mono select-text" x-text="url"></div>
|
|
929
|
+
</template>
|
|
930
|
+
</div>
|
|
931
|
+
|
|
932
|
+
<details class="mt-4 group">
|
|
933
|
+
<summary class="text-xs text-zinc-500 cursor-pointer select-none hover:text-zinc-700 marker:text-zinc-300">Behind a reverse proxy?</summary>
|
|
934
|
+
<div class="mt-3 pl-1 space-y-3 border-l-2 border-zinc-100">
|
|
935
|
+
<p class="text-xs text-zinc-400 leading-relaxed pl-3">
|
|
936
|
+
Only needed if a proxy (nginx, Caddy, Apache, Cloudflare Tunnel) sits in front of
|
|
937
|
+
this app. Leave the hop count at 0 for direct LAN or Tailscale access - setting it
|
|
938
|
+
higher than the proxies you actually run lets a client forge its IP and dodge the
|
|
939
|
+
login lockout.
|
|
940
|
+
</p>
|
|
941
|
+
<label class="flex items-center gap-1.5 text-xs text-zinc-500 pl-3">
|
|
942
|
+
trusted proxies in front
|
|
943
|
+
<input type="number" min="0" max="10" x-model.number="web.trustProxy" @change="applyWebProxy()"
|
|
944
|
+
class="w-16 border border-zinc-200 rounded px-2 py-1.5 text-sm text-right bg-zinc-50 focus:bg-white focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
945
|
+
</label>
|
|
946
|
+
<label class="flex items-center gap-2 text-xs text-zinc-500 pl-3 cursor-pointer">
|
|
947
|
+
<input type="checkbox" x-model="web.secureCookie" @change="applyWebProxy()"
|
|
948
|
+
class="rounded border-zinc-300 text-emerald-600 focus:ring-emerald-500">
|
|
949
|
+
force Secure cookies (auto-detected from the proxy when hops > 0)
|
|
950
|
+
</label>
|
|
951
|
+
</div>
|
|
952
|
+
</details>
|
|
953
|
+
</div>
|
|
954
|
+
</template>
|
|
955
|
+
</section>
|
|
956
|
+
</div>
|
|
957
|
+
</div>
|
|
958
|
+
</div>
|
|
959
|
+
</template>
|
|
960
|
+
</div>
|
|
961
|
+
|
|
962
|
+
<!-- =========================================== modals -->
|
|
963
|
+
|
|
964
|
+
<!-- recompute summary -->
|
|
965
|
+
<x-modal toggle="recompute" wide="true">
|
|
966
|
+
<h2 class="text-lg font-black font-brand text-zinc-800 mb-1">Recomputed forward</h2>
|
|
967
|
+
<p class="text-sm text-zinc-500 mb-4">Your edit changed the derived numbers of these months. Everything you typed by hand was left alone.</p>
|
|
968
|
+
<div class="max-h-96 overflow-y-auto pr-1">
|
|
969
|
+
<template x-for="ch in $store.ui.recomputeChanges" :key="ch.key">
|
|
970
|
+
<div class="border-t border-zinc-100 py-2.5">
|
|
971
|
+
<div class="font-bold text-sm text-zinc-700" x-text="ch.label"></div>
|
|
972
|
+
<div x-show="ch.startingSavings" class="text-xs text-zinc-500 mt-1">
|
|
973
|
+
Starting savings:
|
|
974
|
+
<span class="line-through text-zinc-400" x-text="ch.startingSavings && fmt(ch.startingSavings.from)"></span> →
|
|
975
|
+
<span class="font-semibold text-zinc-700" x-text="ch.startingSavings && fmt(ch.startingSavings.to)"></span>
|
|
976
|
+
</div>
|
|
977
|
+
<template x-for="env in ch.envelopes" :key="env.id">
|
|
978
|
+
<div class="text-xs text-zinc-500 mt-1">
|
|
979
|
+
<span x-text="env.label || 'envelope'"></span> balance:
|
|
980
|
+
<span class="line-through text-zinc-400" x-text="fmt(env.from)"></span> →
|
|
981
|
+
<span class="font-semibold text-zinc-700" x-text="fmt(env.to)"></span>
|
|
982
|
+
</div>
|
|
983
|
+
</template>
|
|
984
|
+
</div>
|
|
985
|
+
</template>
|
|
986
|
+
</div>
|
|
987
|
+
<div class="mt-5 text-right">
|
|
988
|
+
<button type="button" @click="$store.ui.modals.recompute = false"
|
|
989
|
+
class="bg-emerald-600 hover:bg-emerald-500 text-white font-bold uppercase tracking-widest text-xs rounded-md px-5 py-2.5 cursor-pointer transition-colors">Got it</button>
|
|
990
|
+
</div>
|
|
991
|
+
</x-modal>
|
|
992
|
+
|
|
993
|
+
<!-- import: map group kinds before running -->
|
|
994
|
+
<x-modal toggle="importMap">
|
|
995
|
+
<div x-data="settingsView()">
|
|
996
|
+
<h2 class="text-lg font-black font-brand text-zinc-800 mb-1">Map your groups</h2>
|
|
997
|
+
<p class="text-sm text-zinc-500 mb-4">These groups were found in your legacy data. Choose how each should behave going forward - historical months keep their numbers either way.</p>
|
|
998
|
+
<div class="space-y-2">
|
|
999
|
+
<template x-for="g in $store.ui.importMap.groups" :key="g.title">
|
|
1000
|
+
<div class="flex items-center justify-between gap-3 border border-zinc-200 rounded-md px-3 py-2 bg-zinc-50">
|
|
1001
|
+
<span class="text-sm font-semibold text-zinc-700 truncate" x-text="g.title"></span>
|
|
1002
|
+
<select x-model="g.kind"
|
|
1003
|
+
class="shrink-0 text-xs border border-zinc-200 rounded py-1.5 px-1 bg-white cursor-pointer focus:outline-none focus:ring-1 focus:ring-emerald-500">
|
|
1004
|
+
<option value="income">Income (+)</option>
|
|
1005
|
+
<option value="expense">Expenses (−)</option>
|
|
1006
|
+
<option value="envelope">Envelope budgets (−)</option>
|
|
1007
|
+
<option value="goal">Goal budgets (−)</option>
|
|
1008
|
+
</select>
|
|
1009
|
+
</div>
|
|
1010
|
+
</template>
|
|
1011
|
+
</div>
|
|
1012
|
+
<div class="mt-6 flex justify-end gap-2">
|
|
1013
|
+
<button type="button" @click="$store.ui.modals.importMap = false"
|
|
1014
|
+
class="bg-zinc-100 hover:bg-zinc-200 text-zinc-600 font-bold uppercase tracking-widest text-xs rounded-md px-4 py-2.5 cursor-pointer transition-colors">Cancel</button>
|
|
1015
|
+
<button type="button" @click="runImport()"
|
|
1016
|
+
class="bg-emerald-600 hover:bg-emerald-500 text-white font-bold uppercase tracking-widest text-xs rounded-md px-4 py-2.5 cursor-pointer transition-colors">Import</button>
|
|
1017
|
+
</div>
|
|
1018
|
+
</div>
|
|
1019
|
+
</x-modal>
|
|
1020
|
+
|
|
1021
|
+
<!-- import summary -->
|
|
1022
|
+
<x-modal toggle="importSummary">
|
|
1023
|
+
<h2 class="text-lg font-black font-brand text-zinc-800 mb-1">Legacy data imported</h2>
|
|
1024
|
+
<template x-if="$store.ui.importSummary">
|
|
1025
|
+
<div class="text-sm text-zinc-600 space-y-2 mt-3">
|
|
1026
|
+
<p><span class="font-bold" x-text="$store.ui.importSummary.months"></span> months imported,
|
|
1027
|
+
<span x-text="FinEngine.keyLabel($store.ui.importSummary.firstMonth)"></span> through
|
|
1028
|
+
<span x-text="FinEngine.keyLabel($store.ui.importSummary.lastMonth)"></span>.</p>
|
|
1029
|
+
<p>Groups: <span x-text="$store.ui.importSummary.groups.join(', ')"></span></p>
|
|
1030
|
+
<p x-show="$store.ui.importSummary.warnings.length" class="text-amber-600 text-xs"
|
|
1031
|
+
x-text="$store.ui.importSummary.warnings.join('; ')"></p>
|
|
1032
|
+
</div>
|
|
1033
|
+
</template>
|
|
1034
|
+
<div class="mt-5 text-right">
|
|
1035
|
+
<button type="button" @click="$store.ui.modals.importSummary = false"
|
|
1036
|
+
class="bg-emerald-600 hover:bg-emerald-500 text-white font-bold uppercase tracking-widest text-xs rounded-md px-5 py-2.5 cursor-pointer transition-colors">Nice</button>
|
|
1037
|
+
</div>
|
|
1038
|
+
</x-modal>
|
|
1039
|
+
|
|
1040
|
+
<!-- backups -->
|
|
1041
|
+
<x-modal toggle="backups">
|
|
1042
|
+
<div x-data="settingsView()">
|
|
1043
|
+
<h2 class="text-lg font-black font-brand text-zinc-800 mb-1">Backups</h2>
|
|
1044
|
+
<p class="text-sm text-zinc-500 mb-4">Automatic snapshots taken before risky operations. The 20 most recent are kept.</p>
|
|
1045
|
+
<div class="max-h-80 overflow-y-auto pr-1" x-init="$watch('$store.ui.modals.backups', async (open) => { if (open) { const res = await window.api.listBackups(); if (res.ok) backups = res.backups; } })">
|
|
1046
|
+
<template x-for="b in backups" :key="b.name">
|
|
1047
|
+
<div class="flex items-center justify-between border-t border-zinc-100 py-2 gap-3">
|
|
1048
|
+
<div class="min-w-0">
|
|
1049
|
+
<div class="text-xs font-semibold text-zinc-700 truncate" x-text="b.name"></div>
|
|
1050
|
+
<div class="text-[11px] text-zinc-400" x-text="new Date(b.mtime).toLocaleString() + ' · ' + Math.round(b.size / 1024) + ' KB'"></div>
|
|
1051
|
+
</div>
|
|
1052
|
+
<button type="button" @click="restoreBackup(b)"
|
|
1053
|
+
class="text-[11px] shrink-0 border border-zinc-300 hover:border-amber-500 hover:text-amber-700 text-zinc-500 rounded px-2.5 py-1.5 cursor-pointer transition-colors">Restore</button>
|
|
1054
|
+
</div>
|
|
1055
|
+
</template>
|
|
1056
|
+
<p x-show="!backups.length" class="text-center text-zinc-400 text-sm py-6">No backups yet.</p>
|
|
1057
|
+
</div>
|
|
1058
|
+
<div class="mt-4 flex justify-between">
|
|
1059
|
+
<button type="button" @click="openBackupsFolder()" class="text-xs text-zinc-400 hover:text-zinc-600 cursor-pointer">open folder</button>
|
|
1060
|
+
<button type="button" @click="$store.ui.modals.backups = false"
|
|
1061
|
+
class="bg-zinc-200 hover:bg-zinc-300 text-zinc-600 font-bold uppercase tracking-widest text-xs rounded-md px-5 py-2.5 cursor-pointer transition-colors">Close</button>
|
|
1062
|
+
</div>
|
|
1063
|
+
</div>
|
|
1064
|
+
</x-modal>
|
|
1065
|
+
|
|
1066
|
+
<!-- confirm (top-most) -->
|
|
1067
|
+
<div x-show="$store.ui.confirm.open" x-transition.opacity.duration.150ms
|
|
1068
|
+
class="fixed inset-0 z-50 flex items-center justify-center"
|
|
1069
|
+
@keydown.escape.window="$store.ui.confirm.resolve && $store.ui.confirm.resolve(false)">
|
|
1070
|
+
<div class="absolute inset-0 bg-zinc-900/50 backdrop-blur-[2px] cursor-pointer"
|
|
1071
|
+
@click="$store.ui.confirm.resolve && $store.ui.confirm.resolve(false)"></div>
|
|
1072
|
+
<div class="relative bg-white rounded-xl shadow-2xl p-7 mx-4 w-full max-w-lg">
|
|
1073
|
+
<h2 class="text-base font-black font-brand text-zinc-800 mb-2" x-text="$store.ui.confirm.title"></h2>
|
|
1074
|
+
<p class="text-sm text-zinc-500 leading-relaxed" x-text="$store.ui.confirm.body"></p>
|
|
1075
|
+
<div class="mt-6 flex justify-end gap-2 flex-wrap">
|
|
1076
|
+
<button type="button" @click="$store.ui.confirm.resolve && $store.ui.confirm.resolve(false)"
|
|
1077
|
+
class="bg-zinc-100 hover:bg-zinc-200 text-zinc-600 font-bold uppercase tracking-widest text-xs rounded-md px-4 py-2.5 cursor-pointer transition-colors"
|
|
1078
|
+
x-text="$store.ui.confirm.cancelText"></button>
|
|
1079
|
+
<button type="button" x-show="$store.ui.confirm.altText"
|
|
1080
|
+
@click="$store.ui.confirm.resolve && $store.ui.confirm.resolve('alt')"
|
|
1081
|
+
class="bg-red-600 hover:bg-red-500 text-white font-bold uppercase tracking-widest text-xs rounded-md px-4 py-2.5 cursor-pointer transition-colors"
|
|
1082
|
+
x-text="$store.ui.confirm.altText"></button>
|
|
1083
|
+
<button type="button" @click="$store.ui.confirm.resolve && $store.ui.confirm.resolve(true)"
|
|
1084
|
+
class="text-white font-bold uppercase tracking-widest text-xs rounded-md px-4 py-2.5 cursor-pointer transition-colors"
|
|
1085
|
+
:class="$store.ui.confirm.danger && !$store.ui.confirm.altText ? 'bg-red-600 hover:bg-red-500' : 'bg-emerald-600 hover:bg-emerald-500'"
|
|
1086
|
+
x-text="$store.ui.confirm.confirmText"></button>
|
|
1087
|
+
</div>
|
|
1088
|
+
</div>
|
|
1089
|
+
</div>
|
|
1090
|
+
|
|
1091
|
+
<!-- toast -->
|
|
1092
|
+
<div x-show="$store.ui.toast.show" @click="$store.ui.toast.show = false"
|
|
1093
|
+
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-3" x-transition:enter-end="opacity-100 translate-y-0"
|
|
1094
|
+
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
|
|
1095
|
+
class="fixed bottom-7 left-1/2 -translate-x-1/2 z-[60] bg-white rounded-lg shadow-xl border border-zinc-200 flex items-stretch overflow-hidden cursor-pointer max-w-md w-full mx-4">
|
|
1096
|
+
<div class="w-1.5 shrink-0"
|
|
1097
|
+
:class="{ 'bg-sky-500': $store.ui.toast.type === 'info', 'bg-emerald-500': $store.ui.toast.type === 'success', 'bg-red-500': $store.ui.toast.type === 'error' }"></div>
|
|
1098
|
+
<div class="px-4 py-3 text-sm text-zinc-700" x-text="$store.ui.toast.message"></div>
|
|
1099
|
+
</div>
|
|
1100
|
+
|
|
1101
|
+
</main>
|
|
1102
|
+
|
|
1103
|
+
<!-- loading splash -->
|
|
1104
|
+
<div x-show="!$store.data.loaded || $store.ui.switching" class="fixed inset-0 flex items-center justify-center text-zinc-300 font-black font-brand text-xl select-none">
|
|
1105
|
+
loading…
|
|
1106
|
+
</div>
|
|
1107
|
+
</div>
|
|
1108
|
+
|
|
1109
|
+
<!-- ============================================ component templates -->
|
|
1110
|
+
|
|
1111
|
+
<template x-component="modal">
|
|
1112
|
+
<div x-data="xProps($el, { toggle: '', wide: '' })" x-init="_initProps()"
|
|
1113
|
+
x-show="$store.ui.modals[toggle]" x-transition.opacity.duration.150ms
|
|
1114
|
+
class="fixed inset-0 z-40 flex items-center justify-center"
|
|
1115
|
+
@keydown.escape.window="$store.ui.modals[toggle] = false">
|
|
1116
|
+
<div class="absolute inset-0 bg-zinc-900/50 backdrop-blur-[2px] cursor-pointer" @click="$store.ui.modals[toggle] = false"></div>
|
|
1117
|
+
<div class="relative bg-white rounded-xl shadow-2xl max-h-[88vh] overflow-y-auto p-8 mx-4 w-full" :class="boolAttr(wide) ? 'max-w-2xl' : 'max-w-xl'">
|
|
1118
|
+
<slot></slot>
|
|
1119
|
+
</div>
|
|
1120
|
+
</div>
|
|
1121
|
+
</template>
|
|
1122
|
+
|
|
1123
|
+
<!-- prop is named `pct`, NOT `value`: Alpine special-cases :value bindings
|
|
1124
|
+
(property, no attribute) so an attribute-observing component never sees it -->
|
|
1125
|
+
<template x-component="progress">
|
|
1126
|
+
<div x-data="xProps($el, { pct: '0', reached: '' })" x-init="_initProps()"
|
|
1127
|
+
class="w-full bg-zinc-200 rounded-full h-2 overflow-hidden">
|
|
1128
|
+
<div class="h-full rounded-full transition-all duration-300"
|
|
1129
|
+
:class="boolAttr(reached) ? 'bg-amber-400' : 'bg-emerald-500'"
|
|
1130
|
+
:style="'width:' + Math.min(100, Number(pct) || 0) + '%'"></div>
|
|
1131
|
+
</div>
|
|
1132
|
+
</template>
|
|
1133
|
+
|
|
1134
|
+
<script src="js/boot.js"></script>
|
|
1135
|
+
</body>
|
|
1136
|
+
</html>
|