mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add accounts expansion, analytics, exchange rates, API tokens, PWA support, and UI overhaul
All checks were successful
Deploy to VPS / deploy (push) Successful in 45s
All checks were successful
Deploy to VPS / deploy (push) Successful in 45s
- Expand Account model with account_type (pension, savings, liability, crypto), new banks/currencies (BTC, XMR, FCL, ROP, VOL, MEMP, MPAT, MORTGAGE), and next_payment field - Add exchange rate endpoint (BCCR integration), analytics endpoint, paste-import for transactions, and API token management - Add PWA manifest, service worker, and app icons - Redesign dashboard, transactions, transfers, and login pages with theme support - Add billing cycle selector, confirm dialog, and paste import modal components - One-time DB reset in deploy workflow for schema migration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<script>
|
||||
const t = localStorage.getItem('theme');
|
||||
if (t === 'light' || (!t && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="WealthySmart — Smart personal finance management" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>WealthySmart</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -18,6 +18,11 @@ server {
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# No cache for service worker
|
||||
location /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# Cache immutable assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.12.0"
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
|
||||
318
frontend/pnpm-lock.yaml
generated
318
frontend/pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^7.12.0
|
||||
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
recharts:
|
||||
specifier: ^3.8.0
|
||||
version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1)
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
@@ -220,6 +223,17 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@reduxjs/toolkit@2.11.2':
|
||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||
|
||||
@@ -348,6 +362,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.18':
|
||||
resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -513,6 +533,33 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-path@3.1.1':
|
||||
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -524,6 +571,9 @@ packages:
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@vitejs/plugin-react-swc@4.3.0':
|
||||
resolution: {integrity: sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -540,6 +590,10 @@ packages:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -551,6 +605,53 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -583,11 +684,17 @@ packages:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.45.1:
|
||||
resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
|
||||
|
||||
esbuild@0.27.4:
|
||||
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -645,6 +752,16 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.1.4:
|
||||
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@@ -763,6 +880,21 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-is@19.2.4:
|
||||
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
|
||||
|
||||
react-redux@9.2.0:
|
||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-router-dom@7.13.1:
|
||||
resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -784,6 +916,25 @@ packages:
|
||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
recharts@3.8.0:
|
||||
resolution: {integrity: sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
rollup@4.59.1:
|
||||
resolution: {integrity: sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -806,6 +957,9 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -815,6 +969,14 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -954,6 +1116,18 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.1.4
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.1':
|
||||
@@ -1031,6 +1205,10 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.59.1':
|
||||
optional: true
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.18':
|
||||
optional: true
|
||||
|
||||
@@ -1151,6 +1329,30 @@ snapshots:
|
||||
tailwindcss: 4.2.2
|
||||
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-path@3.1.1': {}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
'@types/d3-time@3.0.4': {}
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
@@ -1161,6 +1363,8 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@vitejs/plugin-react-swc@4.3.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||
@@ -1184,6 +1388,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -1192,6 +1398,46 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@@ -1222,6 +1468,8 @@ snapshots:
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
es-toolkit@1.45.1: {}
|
||||
|
||||
esbuild@0.27.4:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.4
|
||||
@@ -1251,6 +1499,8 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.27.4
|
||||
'@esbuild/win32-x64': 0.27.4
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
@@ -1302,6 +1552,12 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.4: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
@@ -1388,6 +1644,17 @@ snapshots:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-is@19.2.4: {}
|
||||
|
||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
redux: 5.0.1
|
||||
|
||||
react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -1404,6 +1671,34 @@ snapshots:
|
||||
|
||||
react@19.2.4: {}
|
||||
|
||||
recharts@3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.45.1
|
||||
eventemitter3: 5.0.4
|
||||
immer: 10.2.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-is: 19.2.4
|
||||
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
rollup@4.59.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -1445,6 +1740,8 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -1452,6 +1749,27 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.4
|
||||
|
||||
BIN
frontend/public/icons/icon-192.png
Normal file
BIN
frontend/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
BIN
frontend/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
27
frontend/public/manifest.json
Normal file
27
frontend/public/manifest.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "WealthySmart",
|
||||
"short_name": "WealthySmart",
|
||||
"description": "Smart personal finance management",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
52
frontend/public/sw.js
Normal file
52
frontend/public/sw.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const CACHE_NAME = 'wealthysmart-v1';
|
||||
const STATIC_ASSETS = ['/', '/index.html'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Network-first for API calls
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for static assets
|
||||
if (url.pathname.startsWith('/assets/')) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => cached || fetch(request).then((res) => {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
return res;
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for navigation, fallback to cached index.html
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match('/index.html'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: network with cache fallback
|
||||
event.respondWith(fetch(request).catch(() => caches.match(request)));
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './AuthContext';
|
||||
import { ThemeProvider } from './ThemeContext';
|
||||
import Layout from './components/Layout';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Transactions from './pages/Transactions';
|
||||
import Transfers from './pages/Transfers';
|
||||
import Analytics from './pages/Analytics';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -29,6 +31,7 @@ function AppRoutes() {
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/transactions" element={<Transactions />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/transfers" element={<Transfers />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -38,9 +41,11 @@ function AppRoutes() {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
31
frontend/src/ThemeContext.tsx
Normal file
31
frontend/src/ThemeContext.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
const ThemeContext = createContext<{
|
||||
theme: Theme;
|
||||
toggleTheme: () => void;
|
||||
}>({ theme: 'dark', toggleTheme: () => {} });
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('theme') as Theme;
|
||||
if (saved) return saved;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
@@ -38,6 +38,8 @@ export interface Account {
|
||||
currency: string;
|
||||
label: string;
|
||||
balance: number;
|
||||
account_type: string;
|
||||
next_payment: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +50,12 @@ export interface Category {
|
||||
auto_match_patterns: string | null;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
duplicates: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
amount: number;
|
||||
|
||||
54
frontend/src/components/BillingCycleSelector.tsx
Normal file
54
frontend/src/components/BillingCycleSelector.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Calendar, ChevronDown } from 'lucide-react';
|
||||
import api from '../api';
|
||||
|
||||
export interface CycleOption {
|
||||
year: number;
|
||||
month: number;
|
||||
label: string;
|
||||
count: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: { year: number; month: number } | null;
|
||||
onChange: (cycle: { year: number; month: number } | null) => void;
|
||||
}
|
||||
|
||||
export default function BillingCycleSelector({ value, onChange }: Props) {
|
||||
const [cycles, setCycles] = useState<CycleOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/transactions/cycles').then((r) => setCycles(r.data));
|
||||
}, []);
|
||||
|
||||
const selectedKey = value ? `${value.year}-${value.month}` : '';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-text-muted" />
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedKey}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
onChange(null);
|
||||
} else {
|
||||
const [y, m] = e.target.value.split('-').map(Number);
|
||||
onChange({ year: y, month: m });
|
||||
}
|
||||
}}
|
||||
className="appearance-none bg-input-bg border border-border rounded-lg pl-3 pr-9 py-2 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors"
|
||||
>
|
||||
<option value="">All time</option>
|
||||
{cycles.map((c) => (
|
||||
<option key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
|
||||
{c.label} ({c.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/ConfirmDialog.tsx
Normal file
55
frontend/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({ title, message, confirmLabel = 'Delete', onConfirm, onCancel, loading }: Props) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-surface border border-border rounded-xl w-full max-w-sm animate-fade-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<button onClick={onCancel} className="text-text-muted hover:text-text-primary transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-5">
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 dark:text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary pt-2">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 px-5 pb-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-red-500 hover:bg-red-600 text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Deleting...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,29 @@ import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
CreditCard,
|
||||
BarChart3,
|
||||
ArrowLeftRight,
|
||||
LogOut,
|
||||
Wallet,
|
||||
Menu,
|
||||
X,
|
||||
Sun,
|
||||
Moon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/transactions', icon: CreditCard, label: 'Transactions' },
|
||||
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||
{ to: '/transfers', icon: ArrowLeftRight, label: 'Cash & Transfers' },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const { logout } = useAuth();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
@@ -28,16 +34,16 @@ export default function Layout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white">
|
||||
<div className="min-h-screen bg-surface text-text-primary">
|
||||
{/* Top bar */}
|
||||
<header className="border-b border-slate-800/60 backdrop-blur-sm sticky top-0 z-50 bg-slate-950/90">
|
||||
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-surface/90">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
|
||||
<Wallet className="w-4 h-4 text-slate-950" strokeWidth={2.5} />
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#606C38] to-[#DDA15E] flex items-center justify-center">
|
||||
<Wallet className="w-4 h-4 text-[#FEFAE0]" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight hidden sm:inline">
|
||||
Wealthy<span className="text-emerald-400">Smart</span>
|
||||
Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -51,8 +57,8 @@ export default function Layout() {
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -63,15 +69,21 @@ export default function Layout() {
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="hidden md:flex items-center gap-2 text-slate-500 hover:text-slate-300 text-sm transition-colors"
|
||||
className="hidden md:flex items-center gap-2 text-text-muted hover:text-text-secondary text-sm transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="md:hidden text-slate-400"
|
||||
className="md:hidden text-text-muted"
|
||||
>
|
||||
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
@@ -80,7 +92,7 @@ export default function Layout() {
|
||||
|
||||
{/* Mobile nav */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden border-t border-slate-800/60 px-4 pb-4 space-y-1">
|
||||
<div className="md:hidden border-t border-border px-4 pb-4 space-y-1">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
@@ -90,8 +102,8 @@ export default function Layout() {
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: 'text-text-muted hover:text-text-primary hover:bg-surface-hover'
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -101,7 +113,7 @@ export default function Layout() {
|
||||
))}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-800/50 w-full"
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-text-muted hover:text-text-primary hover:bg-surface-hover w-full"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
|
||||
141
frontend/src/components/PasteImportModal.tsx
Normal file
141
frontend/src/components/PasteImportModal.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { X, ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import api, { type ImportResult } from '../api';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
export default function PasteImportModal({ onClose, onImported }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const [bank, setBank] = useState('BAC');
|
||||
const [source, setSource] = useState('CREDIT_CARD');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<ImportResult | null>(null);
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!text.trim()) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const { data } = await api.post<ImportResult>('/import/paste', { text, bank, source });
|
||||
setResult(data);
|
||||
if (data.imported > 0) onImported();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
|
||||
const labelClass = 'block text-xs font-medium text-text-secondary mb-1 uppercase tracking-wider';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface border border-border rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardPaste className="w-4 h-4 text-[#606C38] dark:text-[#7a8a4a]" />
|
||||
<h3 className="font-semibold">Import Bank Statement</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4">
|
||||
{!result ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Bank</label>
|
||||
<select className={inputClass} value={bank} onChange={(e) => setBank(e.target.value)}>
|
||||
<option value="BAC">BAC</option>
|
||||
<option value="BCR">BCR</option>
|
||||
<option value="DAVIVIENDA">Davivienda</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Source</label>
|
||||
<select className={inputClass} value={source} onChange={(e) => setSource(e.target.value)}>
|
||||
<option value="CREDIT_CARD">Credit Card</option>
|
||||
<option value="CASH">Cash</option>
|
||||
<option value="TRANSFER">Transfer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Statement Text</label>
|
||||
<textarea
|
||||
className={`${inputClass} h-48 font-mono text-xs resize-y`}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={`Paste bank statement lines here...\n\nFormat: DD/MM/YYYY\tMERCHANT\\CITY\\COUNTRY\tAMOUNT CRC`}
|
||||
/>
|
||||
<p className="text-xs text-text-faint mt-1">
|
||||
One transaction per line. Tab-separated columns.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !text.trim()}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-[#606C38]/10 border border-[#606C38]/20 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a] flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-[#606C38] dark:text-[#7a8a4a]">Import Complete</p>
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
{result.imported} imported
|
||||
{result.duplicates > 0 && ` · ${result.duplicates} duplicates skipped`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 dark:text-amber-400" />
|
||||
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||
{result.errors.length} errors
|
||||
</span>
|
||||
</div>
|
||||
<ul className="text-xs text-text-secondary space-y-1 font-mono max-h-32 overflow-y-auto">
|
||||
{result.errors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
category_id: '' as string | number,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/categories/').then((r) => setCategories(r.data));
|
||||
@@ -53,6 +54,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
@@ -70,30 +72,39 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 409) {
|
||||
setError('Duplicate transaction: a transaction with this reference already exists.');
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors';
|
||||
const labelClass = 'block text-xs font-medium text-slate-400 mb-1 uppercase tracking-wider';
|
||||
'w-full bg-input-bg border border-border rounded-lg px-3 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors';
|
||||
const labelClass = 'block text-xs font-medium text-text-secondary mb-1 uppercase tracking-wider';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800/60">
|
||||
<div className="bg-surface border border-border rounded-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h3 className="font-semibold">
|
||||
{transaction ? 'Edit Transaction' : 'New Transaction'}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-white transition-colors">
|
||||
<button onClick={onClose} className="text-text-muted hover:text-text-primary transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-500 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className={labelClass}>Merchant</label>
|
||||
@@ -223,14 +234,14 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-slate-400 border border-slate-800 hover:bg-slate-800/50 transition-colors"
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-text-secondary border border-border hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-emerald-500 hover:bg-emerald-400 text-slate-950 transition-colors disabled:opacity-50"
|
||||
className="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : transaction ? 'Update' : 'Create'}
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-surface: #FFFDF5;
|
||||
--color-surface-secondary: #fefae0;
|
||||
--color-surface-card: rgba(96, 108, 56, 0.08);
|
||||
--color-surface-hover: rgba(96, 108, 56, 0.12);
|
||||
--color-border: rgba(96, 108, 56, 0.25);
|
||||
--color-border-subtle: rgba(96, 108, 56, 0.15);
|
||||
--color-text-primary: #283618;
|
||||
--color-text-secondary: #606C38;
|
||||
--color-text-muted: #8a9462;
|
||||
--color-text-faint: #c2c9a7;
|
||||
--color-input-bg: #f5f1d0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-surface: #020617;
|
||||
--color-surface-secondary: #0f172a;
|
||||
--color-surface-card: rgba(15, 23, 42, 0.6);
|
||||
--color-surface-hover: rgba(30, 41, 59, 0.3);
|
||||
--color-border: rgba(30, 41, 59, 0.6);
|
||||
--color-border-subtle: rgba(30, 41, 59, 0.4);
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-faint: #334155;
|
||||
--color-input-bg: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -8,3 +8,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js');
|
||||
}
|
||||
|
||||
273
frontend/src/pages/Analytics.tsx
Normal file
273
frontend/src/pages/Analytics.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
} from 'recharts';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
import api from '../api';
|
||||
import BillingCycleSelector from '../components/BillingCycleSelector';
|
||||
import { useTheme } from '../ThemeContext';
|
||||
|
||||
interface CategorySpending {
|
||||
category_id: number | null;
|
||||
category_name: string;
|
||||
total: number;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface MonthlyTrend {
|
||||
year: number;
|
||||
month: number;
|
||||
label: string;
|
||||
total_crc: number;
|
||||
total_usd: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface DailySpending {
|
||||
date: string;
|
||||
total: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#606C38', '#BC6C25', '#8b5cf6', '#DDA15E', '#ef4444',
|
||||
'#ec4899', '#283618', '#f97316', '#6366f1', '#8a9462',
|
||||
'#e879f9', '#c2c9a7', '#fb923c', '#a78bfa', '#7a8a4a',
|
||||
'#fbbf24',
|
||||
];
|
||||
|
||||
function formatCRC(value: number) {
|
||||
return `₡${Math.round(value).toLocaleString('es-CR')}`;
|
||||
}
|
||||
|
||||
export default function Analytics() {
|
||||
const { theme } = useTheme();
|
||||
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
||||
const [byCategory, setByCategory] = useState<CategorySpending[]>([]);
|
||||
const [trend, setTrend] = useState<MonthlyTrend[]>([]);
|
||||
const [daily, setDaily] = useState<DailySpending[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const params: Record<string, string> = {};
|
||||
if (cycle) {
|
||||
params.cycle_year = String(cycle.year);
|
||||
params.cycle_month = String(cycle.month);
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
api.get('/analytics/by-category', { params }),
|
||||
api.get('/analytics/monthly-trend'),
|
||||
api.get('/analytics/daily-spending', { params }),
|
||||
])
|
||||
.then(([catRes, trendRes, dailyRes]) => {
|
||||
setByCategory(catRes.data);
|
||||
setTrend(trendRes.data);
|
||||
setDaily(dailyRes.data);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [cycle]);
|
||||
|
||||
const tooltipStyle = {
|
||||
background: theme === 'dark' ? '#1e293b' : '#FEFAE0',
|
||||
border: `1px solid ${theme === 'dark' ? '#334155' : 'rgba(96,108,56,0.25)'}`,
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
color: theme === 'dark' ? '#e2e8f0' : '#283618',
|
||||
};
|
||||
|
||||
const tickColor = theme === 'dark' ? '#64748b' : '#8a9462';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-[#606C38] dark:text-[#7a8a4a]" />
|
||||
<h1 className="text-2xl font-bold">Analytics</h1>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mt-1">Spending breakdown and trends</p>
|
||||
</div>
|
||||
<BillingCycleSelector value={cycle} onChange={setCycle} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Spending by Category - Donut */}
|
||||
<div className="bg-surface-card border border-border rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
Spending by Category
|
||||
</h2>
|
||||
{byCategory.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
|
||||
No data for this period
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={byCategory}
|
||||
dataKey="total"
|
||||
nameKey="category_name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
strokeWidth={0}
|
||||
>
|
||||
{byCategory.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: any) => formatCRC(Number(value))}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 mt-2 w-full max-w-md">
|
||||
{byCategory.slice(0, 10).map((cat, i) => (
|
||||
<div key={cat.category_name} className="flex items-center gap-2 text-xs">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: COLORS[i % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-text-secondary truncate">{cat.category_name}</span>
|
||||
<span className="text-text-faint ml-auto">{cat.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monthly Trend - Bar */}
|
||||
<div className="bg-surface-card border border-border rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
Monthly Spending (CRC)
|
||||
</h2>
|
||||
{trend.length === 0 ? (
|
||||
<div className="h-64 flex items-center justify-center text-text-faint text-sm">
|
||||
No data
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={trend}>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: tickColor, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: tickColor, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `₡${(v / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: any) => formatCRC(Number(value))}
|
||||
/>
|
||||
<Bar dataKey="total_crc" fill="#606C38" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Spending - Line */}
|
||||
<div className="bg-surface-card border border-border rounded-xl p-5 lg:col-span-2">
|
||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
Daily Spending
|
||||
</h2>
|
||||
{daily.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-text-faint text-sm">
|
||||
No data for this period
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={daily}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: tickColor, fontSize: 10 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => {
|
||||
const d = new Date(v);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: tickColor, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={(v) => `₡${(v / 1000).toFixed(0)}k`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: any) => formatCRC(Number(value))}
|
||||
labelFormatter={(label) => new Date(label).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="total"
|
||||
stroke="#BC6C25"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#BC6C25', r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top categories summary */}
|
||||
{byCategory.length > 0 && (
|
||||
<div className="bg-surface-card border border-border rounded-xl p-5">
|
||||
<h2 className="text-sm font-semibold text-text-secondary uppercase tracking-wider mb-4">
|
||||
Top Categories
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{byCategory.slice(0, 8).map((cat, i) => (
|
||||
<div key={cat.category_name} className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ background: COLORS[i % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-sm flex-1">{cat.category_name}</span>
|
||||
<span className="text-xs text-text-muted">{cat.count} txns</span>
|
||||
<span className="text-sm font-mono font-medium w-32 text-right">
|
||||
{formatCRC(cat.total)}
|
||||
</span>
|
||||
<div className="w-24 bg-surface-hover rounded-full h-1.5">
|
||||
<div
|
||||
className="h-1.5 rounded-full"
|
||||
style={{
|
||||
width: `${cat.percentage}%`,
|
||||
background: COLORS[i % COLORS.length],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
TrendingDown,
|
||||
RefreshCw,
|
||||
CreditCard,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import api, { type Account, type Transaction } from '../api';
|
||||
|
||||
function formatAmount(amount: number, currency: string) {
|
||||
const abs = Math.abs(amount);
|
||||
if (currency === 'BTC') return abs.toFixed(8);
|
||||
if (currency === 'XMR') return abs.toFixed(4);
|
||||
if (currency === 'USD') {
|
||||
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
@@ -19,16 +24,87 @@ function formatAmount(amount: number, currency: string) {
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// --- Reusable card for an account balance ---
|
||||
function AccountCard({
|
||||
account,
|
||||
editingId,
|
||||
editValue,
|
||||
setEditValue,
|
||||
startEditing,
|
||||
saveBalance,
|
||||
cancelEditing,
|
||||
}: {
|
||||
account: Account;
|
||||
editingId: number | null;
|
||||
editValue: string;
|
||||
setEditValue: (v: string) => void;
|
||||
startEditing: (a: Account) => void;
|
||||
saveBalance: (id: number) => void;
|
||||
cancelEditing: () => void;
|
||||
}) {
|
||||
const badgeLabel = account.account_type === 'CRYPTO' ? account.label : (account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank);
|
||||
const isEditing = editingId === account.id;
|
||||
|
||||
return (
|
||||
<div className="group animate-fade-in bg-surface dark:bg-slate-900 border border-border dark:border-slate-700 rounded-xl p-5 shadow-sm dark:shadow-none hover:bg-surface-hover dark:hover:bg-slate-800/60 transition-colors h-[104px] flex flex-col justify-between">
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-sm font-bold font-mono text-text-secondary dark:text-slate-300 bg-surface-secondary dark:bg-slate-800 px-2.5 py-0.5 rounded">
|
||||
{badgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveBalance(account.id);
|
||||
if (e.key === 'Escape') cancelEditing();
|
||||
}}
|
||||
autoFocus
|
||||
className="w-full text-2xl font-bold font-mono tracking-tight bg-input-bg border border-[#606C38]/40 rounded-lg px-2 py-1 focus:outline-none focus:border-[#606C38] transition-colors"
|
||||
/>
|
||||
<button onClick={() => saveBalance(account.id)} className="p-1 text-[#606C38] dark:text-[#7a8a4a]">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={cancelEditing} className="p-1 text-text-muted hover:text-text-secondary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 group/balance cursor-pointer" onClick={() => startEditing(account)}>
|
||||
<p className="text-2xl font-bold font-mono tracking-tight">
|
||||
{formatAmount(account.balance, account.currency)}
|
||||
</p>
|
||||
<Pencil className="w-3.5 h-3.5 text-text-faint opacity-0 group-hover/balance:opacity-100 hover:text-[#606C38] dark:hover:text-[#7a8a4a] transition-all" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Total card ---
|
||||
function TotalCard({ total, currency }: { total: number; currency: string }) {
|
||||
return (
|
||||
<div className="border rounded-xl p-5 shadow-sm dark:shadow-none h-[104px] flex flex-col justify-between bg-[#fdf3e3] dark:bg-[#BC6C25]/10 border-[#e8c08a] dark:border-[#BC6C25]/20 text-[#8a5218] dark:text-[#DDA15E]">
|
||||
<span className="text-xs font-bold uppercase tracking-wider opacity-80">Total</span>
|
||||
<p className="text-2xl font-bold font-mono tracking-tight">{formatAmount(total, currency)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [recent, setRecent] = useState<Transaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
@@ -44,18 +120,41 @@ export default function Dashboard() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
api.get('/exchange-rate/').then((r) => setExchangeRate(r.data)).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
useEffect(() => { fetchData(); }, []);
|
||||
|
||||
const totalCRC = accounts
|
||||
.filter((a) => a.currency === 'CRC')
|
||||
.reduce((s, a) => s + a.balance, 0);
|
||||
const totalUSD = accounts
|
||||
.filter((a) => a.currency === 'USD')
|
||||
.reduce((s, a) => s + a.balance, 0);
|
||||
const startEditing = (account: Account) => {
|
||||
setEditingId(account.id);
|
||||
setEditValue(String(account.balance));
|
||||
};
|
||||
const cancelEditing = () => { setEditingId(null); setEditValue(''); };
|
||||
const saveBalance = async (accountId: number) => {
|
||||
const parsed = parseFloat(editValue);
|
||||
if (isNaN(parsed)) return cancelEditing();
|
||||
try {
|
||||
await api.patch(`/accounts/${accountId}`, { balance: parsed });
|
||||
setEditingId(null);
|
||||
setEditValue('');
|
||||
fetchData();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const cardProps = { editingId, editValue, setEditValue, startEditing, saveBalance, cancelEditing };
|
||||
|
||||
// Group accounts by type
|
||||
const bankAccounts = accounts.filter((a) => a.account_type === 'BANK');
|
||||
const pensionAccounts = accounts.filter((a) => a.account_type === 'PENSION');
|
||||
const savingsAccounts = accounts.filter((a) => a.account_type === 'SAVINGS');
|
||||
const liabilityAccounts = accounts.filter((a) => a.account_type === 'LIABILITY');
|
||||
const cryptoAccounts = accounts.filter((a) => a.account_type === 'CRYPTO');
|
||||
|
||||
const bankOrder = ['BAC', 'BCR', 'DAVIVIENDA'];
|
||||
|
||||
// Bank totals for exchange rate combined total
|
||||
const bankCRC = bankAccounts.filter((a) => a.currency === 'CRC').reduce((s, a) => s + a.balance, 0);
|
||||
const bankUSD = bankAccounts.filter((a) => a.currency === 'USD').reduce((s, a) => s + a.balance, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -63,129 +162,195 @@ export default function Dashboard() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Financial overview</p>
|
||||
<p className="text-sm text-text-muted mt-1">Financial overview</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="p-2 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800/50 transition-colors"
|
||||
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Account balances */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{accounts.map((account, i) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="relative group animate-fade-in"
|
||||
>
|
||||
<div className="absolute -inset-[1px] rounded-xl bg-gradient-to-br from-emerald-500/20 to-cyan-500/20 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative bg-slate-900/60 border border-slate-800/60 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
{account.label}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-slate-600 bg-slate-800/60 px-2 py-0.5 rounded">
|
||||
{account.bank}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold font-mono tracking-tight">
|
||||
{formatAmount(account.balance, account.currency)}
|
||||
</p>
|
||||
{/* Bank accounts — grouped by currency */}
|
||||
{(['CRC', 'USD'] as const).map((currency) => {
|
||||
const accts = bankAccounts
|
||||
.filter((a) => a.currency === currency)
|
||||
.sort((a, b) => bankOrder.indexOf(a.bank) - bankOrder.indexOf(b.bank));
|
||||
if (accts.length === 0) return null;
|
||||
const total = accts.reduce((s, a) => s + a.balance, 0);
|
||||
|
||||
return (
|
||||
<div key={currency} className="space-y-2">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">{currency} Accounts</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{accts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
|
||||
<TotalCard total={total} currency={currency} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Totals */}
|
||||
{accounts.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="bg-gradient-to-br from-emerald-500/10 to-cyan-500/5 border border-emerald-500/20 rounded-xl p-5"
|
||||
>
|
||||
<span className="text-xs font-medium text-emerald-400/80 uppercase tracking-wider">
|
||||
Total CRC
|
||||
</span>
|
||||
<p className="text-2xl font-bold font-mono tracking-tight text-emerald-400 mt-3">
|
||||
{formatAmount(totalCRC, 'CRC')}
|
||||
{/* Pension accounts */}
|
||||
{pensionAccounts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Pension</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{pensionAccounts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
|
||||
<TotalCard
|
||||
total={pensionAccounts.reduce((s, a) => s + a.balance, 0)}
|
||||
currency="CRC"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Savings accounts */}
|
||||
{savingsAccounts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Savings</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{savingsAccounts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
|
||||
<TotalCard
|
||||
total={savingsAccounts.reduce((s, a) => s + a.balance, 0)}
|
||||
currency="CRC"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liabilities */}
|
||||
{liabilityAccounts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Liabilities</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{liabilityAccounts.map((account) => {
|
||||
const bankShort = account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank;
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="animate-fade-in bg-red-50 dark:bg-red-500/5 border border-red-200 dark:border-red-500/20 rounded-xl p-5 shadow-sm dark:shadow-none hover:bg-red-100/50 dark:hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-bold text-red-700 dark:text-red-400/80 uppercase tracking-wider">
|
||||
Balance
|
||||
</span>
|
||||
<span className="text-sm font-bold font-mono text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-500/10 px-2.5 py-0.5 rounded">
|
||||
{bankShort}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text-2xl font-bold font-mono tracking-tight text-red-700 dark:text-red-400 cursor-pointer group/balance"
|
||||
onClick={() => startEditing(account)}
|
||||
>
|
||||
{editingId === account.id ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') saveBalance(account.id);
|
||||
if (e.key === 'Escape') cancelEditing();
|
||||
}}
|
||||
autoFocus
|
||||
className="w-full text-2xl font-bold font-mono tracking-tight bg-input-bg border border-red-500/40 rounded-lg px-2 py-1 focus:outline-none focus:border-red-500 transition-colors text-text-primary"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<button onClick={(e) => { e.stopPropagation(); saveBalance(account.id); }} className="p-1 text-red-500">
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); cancelEditing(); }} className="p-1 text-text-muted">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
formatAmount(account.balance, account.currency)
|
||||
)}
|
||||
</p>
|
||||
{account.next_payment != null && (
|
||||
<p className="text-sm font-mono text-red-600/70 dark:text-red-400/60 mt-2">
|
||||
Next payment: {formatAmount(account.next_payment, account.currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crypto */}
|
||||
{cryptoAccounts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">Crypto</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{cryptoAccounts.map((a) => <AccountCard key={a.id} account={a} {...cardProps} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exchange rate + combined total */}
|
||||
{exchangeRate && (
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 bg-surface-card border border-border rounded-xl p-4">
|
||||
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">USD/CRC Exchange Rate</span>
|
||||
<div className="flex items-baseline gap-3 mt-1">
|
||||
<span className="text-lg font-bold font-mono">Buy: ₡{exchangeRate.buy_rate.toFixed(2)}</span>
|
||||
<span className="text-lg font-bold font-mono text-text-secondary">Sell: ₡{exchangeRate.sell_rate.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{accounts.length > 0 && (
|
||||
<div className="flex-1 bg-gradient-to-br from-violet-500/10 to-[#606C38]/5 border border-violet-500/20 rounded-xl p-4">
|
||||
<span className="text-xs font-medium text-violet-600 dark:text-violet-400/80 uppercase tracking-wider">Combined Total (CRC)</span>
|
||||
<p className="text-2xl font-bold font-mono tracking-tight text-violet-600 dark:text-violet-400 mt-1">
|
||||
{formatAmount(bankCRC + bankUSD * exchangeRate.sell_rate, 'CRC')}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="bg-gradient-to-br from-cyan-500/10 to-emerald-500/5 border border-cyan-500/20 rounded-xl p-5 animate-fade-in"
|
||||
>
|
||||
<span className="text-xs font-medium text-cyan-400/80 uppercase tracking-wider">
|
||||
Total USD
|
||||
</span>
|
||||
<p className="text-2xl font-bold font-mono tracking-tight text-cyan-400 mt-3">
|
||||
{formatAmount(totalUSD, 'USD')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent transactions */}
|
||||
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800/40">
|
||||
<div className="bg-surface-card border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-slate-500" />
|
||||
<CreditCard className="w-4 h-4 text-text-muted" />
|
||||
<h2 className="font-semibold text-sm">Recent Charges</h2>
|
||||
</div>
|
||||
<Link
|
||||
to="/transactions"
|
||||
className="flex items-center gap-1 text-xs font-medium text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||
className="flex items-center gap-1 text-xs font-medium text-[#606C38] dark:text-[#7a8a4a] hover:text-[#4a5a2a] dark:hover:text-[#8a9462] transition-colors"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recent.length === 0 && !loading ? (
|
||||
<div className="px-5 py-12 text-center text-slate-600 text-sm">
|
||||
No transactions yet. Add your first one!
|
||||
</div>
|
||||
<div className="px-5 py-12 text-center text-text-faint text-sm">No transactions yet. Add your first one!</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-800/40">
|
||||
{recent.map((tx, i) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between px-5 py-3.5 hover:bg-slate-800/20 transition-colors animate-fade-in"
|
||||
>
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{recent.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-surface-hover transition-colors animate-fade-in">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
tx.transaction_type === 'DEVOLUCION'
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'bg-red-500/10 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
tx.transaction_type === 'DEVOLUCION' ? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]' : 'bg-red-500/10 text-red-500 dark:text-red-400'
|
||||
}`}>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-text-muted">
|
||||
{formatDate(tx.date)}
|
||||
{tx.category && (
|
||||
<span className="ml-2 text-slate-600">
|
||||
{tx.category.name}
|
||||
</span>
|
||||
)}
|
||||
{tx.category && <span className="ml-2 text-text-faint">{tx.category.name}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono text-sm font-medium flex-shrink-0 ml-4 ${
|
||||
tx.transaction_type === 'DEVOLUCION'
|
||||
? 'text-emerald-400'
|
||||
: 'text-white'
|
||||
}`}
|
||||
>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
||||
{formatAmount(tx.amount, tx.currency)}
|
||||
<span className={`font-mono text-sm font-medium flex-shrink-0 ml-4 ${
|
||||
tx.transaction_type === 'DEVOLUCION' ? 'text-[#606C38] dark:text-[#7a8a4a]' : ''
|
||||
}`}>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}{formatAmount(tx.amount, tx.currency)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -29,46 +29,46 @@ export default function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 flex items-center justify-center px-4">
|
||||
<div className="min-h-screen bg-surface flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm animate-fade-in">
|
||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-400 to-cyan-400 flex items-center justify-center">
|
||||
<Wallet className="w-6 h-6 text-slate-950" strokeWidth={2.5} />
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-[#606C38] to-[#DDA15E] flex items-center justify-center">
|
||||
<Wallet className="w-6 h-6 text-[#FEFAE0]" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-2xl font-bold tracking-tight text-white">
|
||||
Wealthy<span className="text-emerald-400">Smart</span>
|
||||
<span className="text-2xl font-bold tracking-tight">
|
||||
Wealthy<span className="text-[#606C38] dark:text-[#7a8a4a]">Smart</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors"
|
||||
className="w-full bg-input-bg border border-border rounded-lg px-4 py-3 text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors"
|
||||
placeholder="Enter username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5 uppercase tracking-wider">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/20 transition-colors"
|
||||
className="w-full bg-input-bg border border-border rounded-lg px-4 py-3 text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 focus:ring-1 focus:ring-[#606C38]/20 transition-colors"
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||
<div className="flex items-center gap-2 text-red-500 dark:text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@ export default function Login() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 bg-emerald-500 hover:bg-emerald-400 disabled:opacity-50 text-slate-950 font-semibold px-6 py-3 rounded-lg transition-colors"
|
||||
className="w-full flex items-center justify-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] disabled:opacity-50 text-white dark:text-[#FEFAE0] font-semibold px-6 py-3 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
{!loading && <ArrowRight className="w-4 h-4" />}
|
||||
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ChevronDown,
|
||||
ClipboardPaste,
|
||||
} from 'lucide-react';
|
||||
|
||||
import api, { type Transaction, type Category } from '../api';
|
||||
import TransactionModal from '../components/TransactionModal';
|
||||
import PasteImportModal from '../components/PasteImportModal';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import BillingCycleSelector from '../components/BillingCycleSelector';
|
||||
|
||||
function formatAmount(amount: number, currency: string) {
|
||||
const abs = Math.abs(amount);
|
||||
@@ -27,7 +31,11 @@ export default function Transactions() {
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -35,12 +43,16 @@ export default function Transactions() {
|
||||
const params: Record<string, string> = { source: 'CREDIT_CARD', limit: '200' };
|
||||
if (search) params.search = search;
|
||||
if (categoryFilter) params.category_id = categoryFilter;
|
||||
if (cycle) {
|
||||
params.cycle_year = String(cycle.year);
|
||||
params.cycle_month = String(cycle.month);
|
||||
}
|
||||
const { data } = await api.get('/transactions/', { params });
|
||||
setTransactions(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, categoryFilter]);
|
||||
}, [search, categoryFilter, cycle]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/categories/').then((r) => setCategories(r.data));
|
||||
@@ -51,47 +63,72 @@ export default function Transactions() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchTransactions]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this transaction?')) return;
|
||||
await api.delete(`/transactions/${id}`);
|
||||
fetchTransactions();
|
||||
const handleDelete = async () => {
|
||||
if (deleteId === null) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/transactions/${deleteId}`);
|
||||
setDeleteId(null);
|
||||
fetchTransactions();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const total = transactions.reduce((sum, tx) => {
|
||||
const signed = tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount;
|
||||
return sum + signed;
|
||||
}, 0);
|
||||
const totalCRC = transactions
|
||||
.filter((tx) => tx.currency === 'CRC')
|
||||
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
|
||||
const totalUSD = transactions
|
||||
.filter((tx) => tx.currency === 'USD')
|
||||
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Credit Card Transactions</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{transactions.length} transactions · Total:{' '}
|
||||
<span className="font-mono text-white">{formatAmount(total, 'CRC')}</span>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
{transactions.length} transactions
|
||||
{totalCRC !== 0 && (
|
||||
<> · <span className="font-mono text-text-primary">{formatAmount(totalCRC, 'CRC')}</span></>
|
||||
)}
|
||||
{totalUSD !== 0 && (
|
||||
<> · <span className="font-mono text-text-primary">{formatAmount(totalUSD, 'USD')}</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Transaction
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="flex items-center gap-2 border border-border hover:bg-surface-hover text-text-secondary font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="w-4 h-4" />
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Transaction
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing cycle */}
|
||||
<BillingCycleSelector value={cycle} onChange={setCycle} />
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900/60 border border-slate-800/60 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 transition-colors"
|
||||
className="w-full bg-input-bg border border-border rounded-lg pl-10 pr-4 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 transition-colors"
|
||||
placeholder="Search merchants..."
|
||||
/>
|
||||
</div>
|
||||
@@ -99,7 +136,7 @@ export default function Transactions() {
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="appearance-none bg-slate-900/60 border border-slate-800/60 rounded-lg pl-4 pr-10 py-2.5 text-sm text-white focus:outline-none focus:border-emerald-500/50 transition-colors"
|
||||
className="appearance-none bg-input-bg border border-border rounded-lg pl-4 pr-10 py-2.5 text-sm text-text-primary focus:outline-none focus:border-[#606C38]/50 transition-colors"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((c) => (
|
||||
@@ -108,42 +145,42 @@ export default function Transactions() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl overflow-hidden">
|
||||
<div className="bg-surface-card border border-border rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800/40">
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
<tr className="border-b border-border-subtle">
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Merchant
|
||||
</th>
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider hidden md:table-cell">
|
||||
<th className="text-left px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider hidden md:table-cell">
|
||||
Category
|
||||
</th>
|
||||
<th className="text-right px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
<th className="text-right px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider">
|
||||
Amount
|
||||
</th>
|
||||
<th className="text-right px-5 py-3 text-xs font-medium text-slate-500 uppercase tracking-wider w-20">
|
||||
<th className="text-right px-5 py-3 text-xs font-medium text-text-muted uppercase tracking-wider w-20">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/30">
|
||||
<tbody className="divide-y divide-border-subtle">
|
||||
|
||||
{transactions.map((tx) => (
|
||||
<tr
|
||||
key={tx.id}
|
||||
className="hover:bg-slate-800/20 transition-colors group"
|
||||
className="hover:bg-surface-hover transition-colors group"
|
||||
>
|
||||
<td className="px-5 py-3 whitespace-nowrap">
|
||||
<span className="font-mono text-slate-400 text-xs">
|
||||
<span className="font-mono text-text-secondary text-xs">
|
||||
{new Date(tx.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -156,8 +193,8 @@ export default function Transactions() {
|
||||
<div
|
||||
className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
tx.transaction_type === 'DEVOLUCION'
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'bg-red-500/10 text-red-400'
|
||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: 'bg-red-500/10 text-red-500 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? (
|
||||
@@ -171,19 +208,19 @@ export default function Transactions() {
|
||||
</td>
|
||||
<td className="px-5 py-3 hidden md:table-cell">
|
||||
{tx.category ? (
|
||||
<span className="text-xs bg-slate-800/60 text-slate-400 px-2 py-1 rounded">
|
||||
<span className="text-xs bg-surface-hover text-text-secondary px-2 py-1 rounded">
|
||||
{tx.category.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-600">—</span>
|
||||
<span className="text-xs text-text-faint">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right whitespace-nowrap">
|
||||
<span
|
||||
className={`font-mono font-medium ${
|
||||
tx.transaction_type === 'DEVOLUCION'
|
||||
? 'text-emerald-400'
|
||||
: 'text-white'
|
||||
? 'text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
|
||||
@@ -197,13 +234,13 @@ export default function Transactions() {
|
||||
setEditing(tx);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-slate-700/50 text-slate-500 hover:text-white transition-colors"
|
||||
className="p-1.5 rounded hover:bg-surface-hover text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(tx.id)}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors"
|
||||
onClick={() => setDeleteId(tx.id)}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -216,7 +253,7 @@ export default function Transactions() {
|
||||
</div>
|
||||
|
||||
{transactions.length === 0 && !loading && (
|
||||
<div className="px-5 py-16 text-center text-slate-600 text-sm">
|
||||
<div className="px-5 py-16 text-center text-text-faint text-sm">
|
||||
No transactions found
|
||||
</div>
|
||||
)}
|
||||
@@ -230,6 +267,23 @@ export default function Transactions() {
|
||||
onSaved={fetchTransactions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importOpen && (
|
||||
<PasteImportModal
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={fetchTransactions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteId !== null && (
|
||||
<ConfirmDialog
|
||||
title="Delete Transaction"
|
||||
message="This transaction will be permanently deleted. This action cannot be undone."
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteId(null)}
|
||||
loading={deleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Plus, Search, Pencil, Trash2, ArrowLeftRight } from 'lucide-react';
|
||||
|
||||
import api, { type Transaction } from '../api';
|
||||
import TransactionModal from '../components/TransactionModal';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
|
||||
function formatAmount(amount: number, currency: string) {
|
||||
const abs = Math.abs(amount);
|
||||
@@ -21,6 +22,8 @@ export default function Transfers() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -39,10 +42,16 @@ export default function Transfers() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchTransactions]);
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Delete this transaction?')) return;
|
||||
await api.delete(`/transactions/${id}`);
|
||||
fetchTransactions();
|
||||
const handleDelete = async () => {
|
||||
if (deleteId === null) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.delete(`/transactions/${deleteId}`);
|
||||
setDeleteId(null);
|
||||
fetchTransactions();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -50,7 +59,7 @@ export default function Transfers() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Cash & Transfers</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Track non-credit-card expenses
|
||||
</p>
|
||||
</div>
|
||||
@@ -59,7 +68,7 @@ export default function Transfers() {
|
||||
setEditing(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
|
||||
className="flex items-center gap-2 bg-[#606C38] hover:bg-[#7a8a4a] text-white dark:text-[#FEFAE0] font-semibold px-4 py-2.5 rounded-lg text-sm transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add {sourceTab === 'CASH' ? 'Cash Expense' : 'Transfer'}
|
||||
@@ -67,15 +76,15 @@ export default function Transfers() {
|
||||
</div>
|
||||
|
||||
{/* Source tabs */}
|
||||
<div className="flex gap-1 bg-slate-900/40 border border-slate-800/60 rounded-lg p-1 w-fit">
|
||||
<div className="flex gap-1 bg-surface-card border border-border rounded-lg p-1 w-fit">
|
||||
{(['CASH', 'TRANSFER'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setSourceTab(tab)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
sourceTab === tab
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-slate-500 hover:text-white'
|
||||
? 'bg-[#606C38]/10 text-[#606C38] dark:text-[#7a8a4a]'
|
||||
: 'text-text-muted hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab === 'CASH' ? 'Cash' : 'Transfers'}
|
||||
@@ -85,20 +94,20 @@ export default function Transfers() {
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900/60 border border-slate-800/60 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-emerald-500/50 transition-colors"
|
||||
className="w-full bg-input-bg border border-border rounded-lg pl-10 pr-4 py-2.5 text-sm text-text-primary placeholder-text-faint focus:outline-none focus:border-[#606C38]/50 transition-colors"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="bg-slate-900/40 border border-slate-800/60 rounded-xl divide-y divide-slate-800/30">
|
||||
<div className="bg-surface-card border border-border rounded-xl divide-y divide-border-subtle">
|
||||
{transactions.length === 0 && !loading ? (
|
||||
<div className="px-5 py-16 text-center text-slate-600 text-sm">
|
||||
<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-slate-700" />
|
||||
<div className="px-5 py-16 text-center text-text-faint text-sm">
|
||||
<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-text-faint" />
|
||||
No {sourceTab.toLowerCase()} transactions yet
|
||||
</div>
|
||||
) : (
|
||||
@@ -106,25 +115,25 @@ export default function Transfers() {
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-slate-800/20 transition-colors group"
|
||||
className="flex items-center justify-between px-5 py-4 hover:bg-surface-hover transition-colors group"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{tx.merchant}</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
{new Date(tx.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
{tx.category && (
|
||||
<span className="ml-2 bg-slate-800/60 text-slate-400 px-2 py-0.5 rounded">
|
||||
<span className="ml-2 bg-surface-hover text-text-secondary px-2 py-0.5 rounded">
|
||||
{tx.category.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0 ml-4">
|
||||
<span className="font-mono text-sm font-medium text-white">
|
||||
<span className="font-mono text-sm font-medium">
|
||||
{formatAmount(tx.amount, tx.currency)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@@ -133,13 +142,13 @@ export default function Transfers() {
|
||||
setEditing(tx);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="p-1.5 rounded hover:bg-slate-700/50 text-slate-500 hover:text-white transition-colors"
|
||||
className="p-1.5 rounded hover:bg-surface-hover text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(tx.id)}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-slate-500 hover:text-red-400 transition-colors"
|
||||
onClick={() => setDeleteId(tx.id)}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -159,6 +168,16 @@ export default function Transfers() {
|
||||
onSaved={fetchTransactions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteId !== null && (
|
||||
<ConfirmDialog
|
||||
title="Delete Transaction"
|
||||
message="This transaction will be permanently deleted. This action cannot be undone."
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteId(null)}
|
||||
loading={deleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,4 +10,12 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user