Spaces:
Sleeping
Sleeping
Commit ·
2dddd1f
0
Parent(s):
v.1
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +33 -0
- DEPLOYMENT.md +48 -0
- client/.gitignore +24 -0
- client/README.md +73 -0
- client/eslint.config.js +23 -0
- client/index.html +13 -0
- client/package-lock.json +0 -0
- client/package.json +47 -0
- client/public/vite.svg +1 -0
- client/src/App.css +42 -0
- client/src/App.tsx +44 -0
- client/src/api.ts +58 -0
- client/src/assets/react.svg +1 -0
- client/src/components/ui/AmountInput.tsx +83 -0
- client/src/components/ui/badge.tsx +31 -0
- client/src/components/ui/button.tsx +50 -0
- client/src/components/ui/card.tsx +50 -0
- client/src/components/ui/input.tsx +23 -0
- client/src/components/ui/label.tsx +18 -0
- client/src/components/ui/modal.tsx +31 -0
- client/src/components/ui/select.tsx +24 -0
- client/src/features/analytics/useAnalyticsStats.ts +347 -0
- client/src/features/dashboard/components/CashFlowWidget.tsx +47 -0
- client/src/features/dashboard/components/NetWorthWidget.tsx +52 -0
- client/src/features/dashboard/useDashboardStats.ts +152 -0
- client/src/features/transactions/components/TransactionForm.tsx +161 -0
- client/src/hooks/queries.ts +110 -0
- client/src/index.css +48 -0
- client/src/layouts/MainLayout.tsx +19 -0
- client/src/layouts/MobileNav.tsx +44 -0
- client/src/layouts/Sidebar.tsx +63 -0
- client/src/lib/react-query.ts +11 -0
- client/src/lib/utils.ts +6 -0
- client/src/main.tsx +14 -0
- client/src/pages/Analytics.tsx +595 -0
- client/src/pages/Dashboard.tsx +186 -0
- client/src/pages/Loans.tsx +407 -0
- client/src/pages/Login.tsx +172 -0
- client/src/pages/Settings.tsx +292 -0
- client/src/pages/Transactions.tsx +234 -0
- client/src/pages/WalletsView.tsx +335 -0
- client/src/store/useAppStore.ts +11 -0
- client/src/store/useAuthStore.ts +46 -0
- client/src/types.ts +43 -0
- client/tsconfig.app.json +28 -0
- client/tsconfig.json +7 -0
- client/tsconfig.node.json +26 -0
- client/vite.config.ts +11 -0
- package-lock.json +0 -0
- package.json +18 -0
.gitignore
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Node.js
|
| 2 |
+
node_modules/
|
| 3 |
+
npm-debug.log*
|
| 4 |
+
yarn-debug.log*
|
| 5 |
+
yarn-error.log*
|
| 6 |
+
.pnpm-debug.log*
|
| 7 |
+
|
| 8 |
+
# Databases
|
| 9 |
+
*.sqlite
|
| 10 |
+
*.sqlite-journal
|
| 11 |
+
*.db
|
| 12 |
+
backup.xlsx
|
| 13 |
+
|
| 14 |
+
# Environment variables
|
| 15 |
+
.env
|
| 16 |
+
.env.local
|
| 17 |
+
.env.development.local
|
| 18 |
+
.env.test.local
|
| 19 |
+
.env.production.local
|
| 20 |
+
|
| 21 |
+
# Build outputs
|
| 22 |
+
/client/dist
|
| 23 |
+
/server/dist
|
| 24 |
+
|
| 25 |
+
# IDEs
|
| 26 |
+
.vscode/
|
| 27 |
+
.idea/
|
| 28 |
+
*.swp
|
| 29 |
+
*.swo
|
| 30 |
+
|
| 31 |
+
# OS files
|
| 32 |
+
.DS_Store
|
| 33 |
+
Thumbs.db
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment Guide (Free Hosting)
|
| 2 |
+
|
| 3 |
+
Follow these steps to host your application for free on the web.
|
| 4 |
+
|
| 5 |
+
## 1. Push Code to GitHub
|
| 6 |
+
1. Initialize git in your project root:
|
| 7 |
+
```bash
|
| 8 |
+
git init
|
| 9 |
+
git add .
|
| 10 |
+
git commit -m "Initial commit"
|
| 11 |
+
```
|
| 12 |
+
2. Create a **Private** repository on [GitHub](https://github.com/new).
|
| 13 |
+
3. Push your code to GitHub following the instructions on the screen.
|
| 14 |
+
|
| 15 |
+
## 2. Database (Turso)
|
| 16 |
+
1. Go to [Turso.tech](https://turso.tech) and sign up (Free, no credit card).
|
| 17 |
+
2. Create a new database (e.g., `wallets-db`).
|
| 18 |
+
3. Get your **Database URL** and **Auth Token**.
|
| 19 |
+
4. You will use these in the next step.
|
| 20 |
+
|
| 21 |
+
## 3. Backend (Render)
|
| 22 |
+
1. Go to [Render.com](https://render.com) and sign up.
|
| 23 |
+
2. Create a new **Web Service**.
|
| 24 |
+
3. Connect your GitHub repository.
|
| 25 |
+
4. Set the following:
|
| 26 |
+
- **Language**: `Node`
|
| 27 |
+
- **Build Command**: `cd server && npm install`
|
| 28 |
+
- **Start Command**: `cd server && npx tsx src/index.ts`
|
| 29 |
+
5. Add **Environment Variables**:
|
| 30 |
+
- `DATABASE_URL`: (From Turso)
|
| 31 |
+
- `DATABASE_AUTH_TOKEN`: (From Turso)
|
| 32 |
+
- `JWT_SECRET`: (Any random string like `my-secret-key-123`)
|
| 33 |
+
- `EMAIL_USER`: (Your Gmail if using backup)
|
| 34 |
+
- `EMAIL_PASS`: (Your Gmail App Password)
|
| 35 |
+
- `EMAIL_TO`: (Where to send backups)
|
| 36 |
+
6. Render will give you a URL like `https://wallets-api.onrender.com`.
|
| 37 |
+
|
| 38 |
+
## 4. Frontend (Vercel)
|
| 39 |
+
1. Go to [Vercel.com](https://vercel.com) and sign up.
|
| 40 |
+
2. Create a new **Project**.
|
| 41 |
+
3. Connect your GitHub repository.
|
| 42 |
+
4. Set the **Root Directory** to `client`.
|
| 43 |
+
5. Add **Environment Variable**:
|
| 44 |
+
- `VITE_API_URL`: `https://your-render-api-url.onrender.com/api`
|
| 45 |
+
6. Deploy!
|
| 46 |
+
|
| 47 |
+
## 5. Mobile Access
|
| 48 |
+
Just open your Vercel URL (e.g., `https://my-wallets.vercel.app`) on your phone browser. It will work from anywhere!
|
client/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
client/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + TypeScript + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
| 17 |
+
|
| 18 |
+
```js
|
| 19 |
+
export default defineConfig([
|
| 20 |
+
globalIgnores(['dist']),
|
| 21 |
+
{
|
| 22 |
+
files: ['**/*.{ts,tsx}'],
|
| 23 |
+
extends: [
|
| 24 |
+
// Other configs...
|
| 25 |
+
|
| 26 |
+
// Remove tseslint.configs.recommended and replace with this
|
| 27 |
+
tseslint.configs.recommendedTypeChecked,
|
| 28 |
+
// Alternatively, use this for stricter rules
|
| 29 |
+
tseslint.configs.strictTypeChecked,
|
| 30 |
+
// Optionally, add this for stylistic rules
|
| 31 |
+
tseslint.configs.stylisticTypeChecked,
|
| 32 |
+
|
| 33 |
+
// Other configs...
|
| 34 |
+
],
|
| 35 |
+
languageOptions: {
|
| 36 |
+
parserOptions: {
|
| 37 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 38 |
+
tsconfigRootDir: import.meta.dirname,
|
| 39 |
+
},
|
| 40 |
+
// other options...
|
| 41 |
+
},
|
| 42 |
+
},
|
| 43 |
+
])
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
| 47 |
+
|
| 48 |
+
```js
|
| 49 |
+
// eslint.config.js
|
| 50 |
+
import reactX from 'eslint-plugin-react-x'
|
| 51 |
+
import reactDom from 'eslint-plugin-react-dom'
|
| 52 |
+
|
| 53 |
+
export default defineConfig([
|
| 54 |
+
globalIgnores(['dist']),
|
| 55 |
+
{
|
| 56 |
+
files: ['**/*.{ts,tsx}'],
|
| 57 |
+
extends: [
|
| 58 |
+
// Other configs...
|
| 59 |
+
// Enable lint rules for React
|
| 60 |
+
reactX.configs['recommended-typescript'],
|
| 61 |
+
// Enable lint rules for React DOM
|
| 62 |
+
reactDom.configs.recommended,
|
| 63 |
+
],
|
| 64 |
+
languageOptions: {
|
| 65 |
+
parserOptions: {
|
| 66 |
+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
| 67 |
+
tsconfigRootDir: import.meta.dirname,
|
| 68 |
+
},
|
| 69 |
+
// other options...
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
])
|
| 73 |
+
```
|
client/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import tseslint from 'typescript-eslint'
|
| 6 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 7 |
+
|
| 8 |
+
export default defineConfig([
|
| 9 |
+
globalIgnores(['dist']),
|
| 10 |
+
{
|
| 11 |
+
files: ['**/*.{ts,tsx}'],
|
| 12 |
+
extends: [
|
| 13 |
+
js.configs.recommended,
|
| 14 |
+
tseslint.configs.recommended,
|
| 15 |
+
reactHooks.configs.flat.recommended,
|
| 16 |
+
reactRefresh.configs.vite,
|
| 17 |
+
],
|
| 18 |
+
languageOptions: {
|
| 19 |
+
ecmaVersion: 2020,
|
| 20 |
+
globals: globals.browser,
|
| 21 |
+
},
|
| 22 |
+
},
|
| 23 |
+
])
|
client/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>client</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
client/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
client/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "client",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc -b && vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@hookform/resolvers": "^5.2.2",
|
| 14 |
+
"@tailwindcss/vite": "^4.2.1",
|
| 15 |
+
"@tanstack/react-query": "^5.90.21",
|
| 16 |
+
"axios": "^1.13.6",
|
| 17 |
+
"class-variance-authority": "^0.7.1",
|
| 18 |
+
"clsx": "^2.1.1",
|
| 19 |
+
"date-fns": "^4.1.0",
|
| 20 |
+
"lucide-react": "^0.577.0",
|
| 21 |
+
"react": "^19.2.4",
|
| 22 |
+
"react-dom": "^19.2.4",
|
| 23 |
+
"react-hook-form": "^7.71.2",
|
| 24 |
+
"react-is": "^19.2.4",
|
| 25 |
+
"react-router-dom": "^7.13.1",
|
| 26 |
+
"recharts": "^3.8.0",
|
| 27 |
+
"tailwind-merge": "^3.5.0",
|
| 28 |
+
"tailwindcss": "^4.2.1",
|
| 29 |
+
"xlsx": "^0.18.5",
|
| 30 |
+
"zod": "^4.3.6",
|
| 31 |
+
"zustand": "^5.0.11"
|
| 32 |
+
},
|
| 33 |
+
"devDependencies": {
|
| 34 |
+
"@eslint/js": "^9.39.1",
|
| 35 |
+
"@types/node": "^24.10.1",
|
| 36 |
+
"@types/react": "^19.2.7",
|
| 37 |
+
"@types/react-dom": "^19.2.3",
|
| 38 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 39 |
+
"eslint": "^9.39.1",
|
| 40 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 41 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 42 |
+
"globals": "^16.5.0",
|
| 43 |
+
"typescript": "~5.9.3",
|
| 44 |
+
"typescript-eslint": "^8.48.0",
|
| 45 |
+
"vite": "^7.3.1"
|
| 46 |
+
}
|
| 47 |
+
}
|
client/public/vite.svg
ADDED
|
|
client/src/App.css
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#root {
|
| 2 |
+
max-width: 1280px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 2rem;
|
| 5 |
+
text-align: center;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.logo {
|
| 9 |
+
height: 6em;
|
| 10 |
+
padding: 1.5em;
|
| 11 |
+
will-change: filter;
|
| 12 |
+
transition: filter 300ms;
|
| 13 |
+
}
|
| 14 |
+
.logo:hover {
|
| 15 |
+
filter: drop-shadow(0 0 2em #646cffaa);
|
| 16 |
+
}
|
| 17 |
+
.logo.react:hover {
|
| 18 |
+
filter: drop-shadow(0 0 2em #61dafbaa);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@keyframes logo-spin {
|
| 22 |
+
from {
|
| 23 |
+
transform: rotate(0deg);
|
| 24 |
+
}
|
| 25 |
+
to {
|
| 26 |
+
transform: rotate(360deg);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 31 |
+
a:nth-of-type(2) .logo {
|
| 32 |
+
animation: logo-spin infinite 20s linear;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.card {
|
| 37 |
+
padding: 2em;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.read-the-docs {
|
| 41 |
+
color: #888;
|
| 42 |
+
}
|
client/src/App.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MainLayout } from './layouts/MainLayout';
|
| 2 |
+
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
| 3 |
+
import { useAuthStore } from './store/useAuthStore';
|
| 4 |
+
|
| 5 |
+
import Transactions from './pages/Transactions';
|
| 6 |
+
import Dashboard from './pages/Dashboard';
|
| 7 |
+
import Loans from './pages/Loans';
|
| 8 |
+
import Settings from './pages/Settings';
|
| 9 |
+
import WalletsView from './pages/WalletsView';
|
| 10 |
+
import Analytics from './pages/Analytics';
|
| 11 |
+
import Login from './pages/Login';
|
| 12 |
+
|
| 13 |
+
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
| 14 |
+
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
| 15 |
+
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
function App() {
|
| 19 |
+
return (
|
| 20 |
+
<BrowserRouter>
|
| 21 |
+
<Routes>
|
| 22 |
+
<Route path="/login" element={<Login />} />
|
| 23 |
+
<Route
|
| 24 |
+
path="/"
|
| 25 |
+
element={
|
| 26 |
+
<ProtectedRoute>
|
| 27 |
+
<MainLayout />
|
| 28 |
+
</ProtectedRoute>
|
| 29 |
+
}
|
| 30 |
+
>
|
| 31 |
+
<Route index element={<Dashboard />} />
|
| 32 |
+
<Route path="transactions" element={<Transactions />} />
|
| 33 |
+
<Route path="wallets" element={<WalletsView />} />
|
| 34 |
+
<Route path="analytics" element={<Analytics />} />
|
| 35 |
+
<Route path="loans" element={<Loans />} />
|
| 36 |
+
<Route path="settings" element={<Settings />} />
|
| 37 |
+
</Route>
|
| 38 |
+
<Route path="*" element={<Navigate to="/" replace />} />
|
| 39 |
+
</Routes>
|
| 40 |
+
</BrowserRouter>
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export default App;
|
client/src/api.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
+
import type { Transaction, Wallet, Exchange, Loan } from './types';
|
| 3 |
+
|
| 4 |
+
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
|
| 5 |
+
|
| 6 |
+
const apiClient = axios.create({
|
| 7 |
+
baseURL: API_URL,
|
| 8 |
+
headers: {
|
| 9 |
+
'Content-Type': 'application/json'
|
| 10 |
+
}
|
| 11 |
+
});
|
| 12 |
+
|
| 13 |
+
// Add a request interceptor to add the auth token
|
| 14 |
+
apiClient.interceptors.request.use(
|
| 15 |
+
(config) => {
|
| 16 |
+
const token = localStorage.getItem('auth_token');
|
| 17 |
+
if (token) {
|
| 18 |
+
config.headers.Authorization = `Bearer ${token}`;
|
| 19 |
+
}
|
| 20 |
+
return config;
|
| 21 |
+
},
|
| 22 |
+
(error) => Promise.reject(error)
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
// Add a response interceptor for generic error handling
|
| 26 |
+
apiClient.interceptors.response.use(
|
| 27 |
+
(response) => response,
|
| 28 |
+
(error) => {
|
| 29 |
+
if (error.response?.status === 401 || error.response?.status === 403) {
|
| 30 |
+
// Token expired or unauthorized, logout
|
| 31 |
+
localStorage.removeItem('auth_token');
|
| 32 |
+
localStorage.removeItem('auth_username');
|
| 33 |
+
window.location.href = '/login';
|
| 34 |
+
}
|
| 35 |
+
console.error('API Error:', error.response?.data || error.message);
|
| 36 |
+
return Promise.reject(error);
|
| 37 |
+
}
|
| 38 |
+
);
|
| 39 |
+
|
| 40 |
+
export const api = {
|
| 41 |
+
getRates: () => apiClient.get<Record<string, number>>('/rates').then(res => res.data),
|
| 42 |
+
|
| 43 |
+
getWallets: () => apiClient.get<Wallet[]>('/wallets').then(res => res.data),
|
| 44 |
+
|
| 45 |
+
getTransactions: () => apiClient.get<Transaction[]>('/transactions').then(res => res.data),
|
| 46 |
+
createTransaction: (data: Omit<Transaction, 'id'>) => apiClient.post('/transactions', data).then(res => res.data),
|
| 47 |
+
|
| 48 |
+
getExchanges: () => apiClient.get<Exchange[]>('/exchanges').then(res => res.data),
|
| 49 |
+
createExchange: (data: Omit<Exchange, 'id'>) => apiClient.post('/exchanges', data).then(res => res.data),
|
| 50 |
+
|
| 51 |
+
getLoans: () => apiClient.get<Loan[]>('/loans').then(res => res.data),
|
| 52 |
+
createLoan: (data: Omit<Loan, 'id'>) => apiClient.post('/loans', data).then(res => res.data),
|
| 53 |
+
updateLoan: (id: number, data: Omit<Loan, 'id'>) => apiClient.put(`/loans/${id}`, data).then(res => res.data),
|
| 54 |
+
deleteLoan: (id: number) => apiClient.delete(`/loans/${id}`).then(res => res.data),
|
| 55 |
+
payLoan: (id: number, amount: number) => apiClient.post(`/loans/${id}/pay`, { amount }).then(res => res.data),
|
| 56 |
+
|
| 57 |
+
getDashboardAnalytics: () => apiClient.get<any>('/analytics/dashboard').then(res => res.data)
|
| 58 |
+
};
|
client/src/assets/react.svg
ADDED
|
|
client/src/components/ui/AmountInput.tsx
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Input } from "./input"
|
| 3 |
+
|
| 4 |
+
interface AmountInputProps extends Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'> {
|
| 5 |
+
value?: string | number;
|
| 6 |
+
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
| 10 |
+
({ value, onChange, name, ...props }, ref) => {
|
| 11 |
+
const formatNumber = (val: string) => {
|
| 12 |
+
if (!val) return "";
|
| 13 |
+
// Remove all commas first
|
| 14 |
+
const clean = val.replace(/,/g, "");
|
| 15 |
+
const parts = clean.split(".");
|
| 16 |
+
// Format the integer part
|
| 17 |
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
| 18 |
+
return parts.join(".");
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const [cursor, setCursor] = React.useState<number | null>(null);
|
| 22 |
+
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
| 23 |
+
|
| 24 |
+
// Sync ref
|
| 25 |
+
React.useImperativeHandle(ref, () => inputRef.current!);
|
| 26 |
+
|
| 27 |
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 28 |
+
const inputVal = e.target.value;
|
| 29 |
+
|
| 30 |
+
// Allow only numbers, dots and commas
|
| 31 |
+
if (/[^\d.,]/.test(inputVal)) return;
|
| 32 |
+
|
| 33 |
+
const stripped = inputVal.replace(/,/g, "");
|
| 34 |
+
const formatted = formatNumber(stripped);
|
| 35 |
+
|
| 36 |
+
// Track cursor position changes due to formatting
|
| 37 |
+
const selectionStart = e.target.selectionStart || 0;
|
| 38 |
+
const commasBefore = (inputVal.substring(0, selectionStart).match(/,/g) || []).length;
|
| 39 |
+
const commasAfter = (formatted.substring(0, selectionStart).match(/,/g) || []).length;
|
| 40 |
+
|
| 41 |
+
const diff = commasAfter - commasBefore;
|
| 42 |
+
setCursor(selectionStart + diff);
|
| 43 |
+
|
| 44 |
+
if (onChange) {
|
| 45 |
+
// We pass the stripped value back to handlers (like react-hook-form)
|
| 46 |
+
// so they can treat it as a normal numeric string
|
| 47 |
+
const event = {
|
| 48 |
+
...e,
|
| 49 |
+
target: {
|
| 50 |
+
...e.target,
|
| 51 |
+
name: name,
|
| 52 |
+
value: stripped
|
| 53 |
+
}
|
| 54 |
+
} as React.ChangeEvent<HTMLInputElement>;
|
| 55 |
+
onChange(event);
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
// Restore cursor position after render
|
| 60 |
+
React.useLayoutEffect(() => {
|
| 61 |
+
if (inputRef.current && cursor !== null) {
|
| 62 |
+
inputRef.current.setSelectionRange(cursor, cursor);
|
| 63 |
+
}
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
const displayValue = formatNumber(value?.toString() || "");
|
| 67 |
+
|
| 68 |
+
return (
|
| 69 |
+
<Input
|
| 70 |
+
{...props}
|
| 71 |
+
ref={inputRef}
|
| 72 |
+
type="text"
|
| 73 |
+
inputMode="decimal"
|
| 74 |
+
value={displayValue}
|
| 75 |
+
onChange={handleChange}
|
| 76 |
+
/>
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
AmountInput.displayName = "AmountInput";
|
| 82 |
+
|
| 83 |
+
export { AmountInput };
|
client/src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
import { cn } from "../../lib/utils"
|
| 4 |
+
|
| 5 |
+
const badgeVariants = cva(
|
| 6 |
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default: "border-transparent bg-blue-500/20 text-blue-400 hover:bg-blue-500/30",
|
| 11 |
+
secondary: "border-transparent bg-white/10 text-neutral-100 hover:bg-white/20",
|
| 12 |
+
destructive: "border-transparent bg-rose-500/20 text-rose-400 hover:bg-rose-500/30",
|
| 13 |
+
success: "border-transparent bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30",
|
| 14 |
+
outline: "text-neutral-100 border-white/20",
|
| 15 |
+
},
|
| 16 |
+
},
|
| 17 |
+
defaultVariants: {
|
| 18 |
+
variant: "default",
|
| 19 |
+
},
|
| 20 |
+
}
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
export interface BadgeProps
|
| 24 |
+
extends React.HTMLAttributes<HTMLDivElement>,
|
| 25 |
+
VariantProps<typeof badgeVariants> {}
|
| 26 |
+
|
| 27 |
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
| 28 |
+
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export { Badge, badgeVariants }
|
client/src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
import { cn } from "../../lib/utils"
|
| 4 |
+
|
| 5 |
+
const buttonVariants = cva(
|
| 6 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4",
|
| 7 |
+
{
|
| 8 |
+
variants: {
|
| 9 |
+
variant: {
|
| 10 |
+
default: "bg-blue-600 text-white shadow-lg shadow-blue-500/20 hover:bg-blue-500",
|
| 11 |
+
destructive: "bg-rose-500 text-white shadow-sm hover:bg-rose-600",
|
| 12 |
+
outline: "border border-white/10 bg-transparent hover:bg-white/5",
|
| 13 |
+
secondary: "bg-white/5 text-neutral-100 hover:bg-white/10",
|
| 14 |
+
ghost: "hover:bg-white/5 hover:text-neutral-100",
|
| 15 |
+
link: "text-blue-500 underline-offset-4 hover:underline",
|
| 16 |
+
},
|
| 17 |
+
size: {
|
| 18 |
+
default: "h-10 px-4 py-2",
|
| 19 |
+
sm: "h-8 rounded-lg px-3 text-xs",
|
| 20 |
+
lg: "h-12 rounded-xl px-8",
|
| 21 |
+
icon: "h-10 w-10",
|
| 22 |
+
},
|
| 23 |
+
},
|
| 24 |
+
defaultVariants: {
|
| 25 |
+
variant: "default",
|
| 26 |
+
size: "default",
|
| 27 |
+
},
|
| 28 |
+
}
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
export interface ButtonProps
|
| 32 |
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
| 33 |
+
VariantProps<typeof buttonVariants> {
|
| 34 |
+
asChild?: boolean
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
| 38 |
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
| 39 |
+
return (
|
| 40 |
+
<button
|
| 41 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 42 |
+
ref={ref}
|
| 43 |
+
{...props}
|
| 44 |
+
/>
|
| 45 |
+
)
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
Button.displayName = "Button"
|
| 49 |
+
|
| 50 |
+
export { Button, buttonVariants }
|
client/src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cn } from "../../lib/utils"
|
| 3 |
+
|
| 4 |
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 5 |
+
({ className, ...props }, ref) => (
|
| 6 |
+
<div
|
| 7 |
+
ref={ref}
|
| 8 |
+
className={cn("rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md shadow-xl text-neutral-100 relative overflow-hidden", className)}
|
| 9 |
+
{...props}
|
| 10 |
+
/>
|
| 11 |
+
)
|
| 12 |
+
)
|
| 13 |
+
Card.displayName = "Card"
|
| 14 |
+
|
| 15 |
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 16 |
+
({ className, ...props }, ref) => (
|
| 17 |
+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
| 18 |
+
)
|
| 19 |
+
)
|
| 20 |
+
CardHeader.displayName = "CardHeader"
|
| 21 |
+
|
| 22 |
+
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
| 23 |
+
({ className, ...props }, ref) => (
|
| 24 |
+
<h3 ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
|
| 25 |
+
)
|
| 26 |
+
)
|
| 27 |
+
CardTitle.displayName = "CardTitle"
|
| 28 |
+
|
| 29 |
+
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
| 30 |
+
({ className, ...props }, ref) => (
|
| 31 |
+
<p ref={ref} className={cn("text-sm text-neutral-400", className)} {...props} />
|
| 32 |
+
)
|
| 33 |
+
)
|
| 34 |
+
CardDescription.displayName = "CardDescription"
|
| 35 |
+
|
| 36 |
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 37 |
+
({ className, ...props }, ref) => (
|
| 38 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
| 39 |
+
)
|
| 40 |
+
)
|
| 41 |
+
CardContent.displayName = "CardContent"
|
| 42 |
+
|
| 43 |
+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
| 44 |
+
({ className, ...props }, ref) => (
|
| 45 |
+
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
| 46 |
+
)
|
| 47 |
+
)
|
| 48 |
+
CardFooter.displayName = "CardFooter"
|
| 49 |
+
|
| 50 |
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
client/src/components/ui/input.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cn } from "../../lib/utils"
|
| 3 |
+
|
| 4 |
+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
| 5 |
+
|
| 6 |
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
| 7 |
+
({ className, type, ...props }, ref) => {
|
| 8 |
+
return (
|
| 9 |
+
<input
|
| 10 |
+
type={type}
|
| 11 |
+
className={cn(
|
| 12 |
+
"flex h-10 w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm ring-offset-neutral-950 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-neutral-500 focus-visible:outline-none focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 text-white",
|
| 13 |
+
className
|
| 14 |
+
)}
|
| 15 |
+
ref={ref}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
)
|
| 19 |
+
}
|
| 20 |
+
)
|
| 21 |
+
Input.displayName = "Input"
|
| 22 |
+
|
| 23 |
+
export { Input }
|
client/src/components/ui/label.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cn } from "../../lib/utils"
|
| 3 |
+
|
| 4 |
+
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
| 5 |
+
({ className, ...props }, ref) => (
|
| 6 |
+
<label
|
| 7 |
+
ref={ref}
|
| 8 |
+
className={cn(
|
| 9 |
+
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-neutral-300",
|
| 10 |
+
className
|
| 11 |
+
)}
|
| 12 |
+
{...props}
|
| 13 |
+
/>
|
| 14 |
+
)
|
| 15 |
+
)
|
| 16 |
+
Label.displayName = "Label"
|
| 17 |
+
|
| 18 |
+
export { Label }
|
client/src/components/ui/modal.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cn } from "../../lib/utils"
|
| 3 |
+
import { X } from "lucide-react"
|
| 4 |
+
|
| 5 |
+
export interface ModalProps {
|
| 6 |
+
isOpen: boolean;
|
| 7 |
+
onClose: () => void;
|
| 8 |
+
title?: string;
|
| 9 |
+
children: React.ReactNode;
|
| 10 |
+
className?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
| 14 |
+
if (!isOpen) return null;
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div className="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center p-4 sm:p-0 backdrop-blur-sm shadow-2xl">
|
| 18 |
+
<div className={cn("bg-neutral-900 border border-white/10 shadow-2xl rounded-2xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200", className)}>
|
| 19 |
+
<div className="flex items-center justify-between p-4 border-b border-white/5">
|
| 20 |
+
{title && <h2 className="text-lg font-semibold text-white">{title}</h2>}
|
| 21 |
+
<button onClick={onClose} className="rounded-full p-1.5 hover:bg-white/10 transition-colors text-neutral-400 hover:text-white ml-auto">
|
| 22 |
+
<X className="w-5 h-5" />
|
| 23 |
+
</button>
|
| 24 |
+
</div>
|
| 25 |
+
<div className="p-4 sm:p-6 overflow-y-auto max-h-[80vh]">
|
| 26 |
+
{children}
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
)
|
| 31 |
+
}
|
client/src/components/ui/select.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cn } from "../../lib/utils"
|
| 3 |
+
|
| 4 |
+
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
| 5 |
+
|
| 6 |
+
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
| 7 |
+
({ className, children, ...props }, ref) => {
|
| 8 |
+
return (
|
| 9 |
+
<select
|
| 10 |
+
className={cn(
|
| 11 |
+
"flex h-10 w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm ring-offset-neutral-950 focus-visible:outline-none focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 text-white appearance-none",
|
| 12 |
+
className
|
| 13 |
+
)}
|
| 14 |
+
ref={ref}
|
| 15 |
+
{...props}
|
| 16 |
+
>
|
| 17 |
+
{children}
|
| 18 |
+
</select>
|
| 19 |
+
)
|
| 20 |
+
}
|
| 21 |
+
)
|
| 22 |
+
Select.displayName = "Select"
|
| 23 |
+
|
| 24 |
+
export { Select }
|
client/src/features/analytics/useAnalyticsStats.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useMemo } from 'react';
|
| 2 |
+
import { useTransactions, useRates, useWallets, useExchanges, useLoans } from '../../hooks/queries';
|
| 3 |
+
import { useAppStore } from '../../store/useAppStore';
|
| 4 |
+
import {
|
| 5 |
+
startOfMonth,
|
| 6 |
+
endOfMonth,
|
| 7 |
+
eachDayOfInterval,
|
| 8 |
+
format,
|
| 9 |
+
subDays,
|
| 10 |
+
isSameDay,
|
| 11 |
+
startOfDay
|
| 12 |
+
} from 'date-fns';
|
| 13 |
+
|
| 14 |
+
export function useAnalyticsStats() {
|
| 15 |
+
const { data: transactions = [], isLoading: txLoading } = useTransactions();
|
| 16 |
+
const { data: wallets = [], isLoading: wlLoading } = useWallets();
|
| 17 |
+
const { data: exchanges = [], isLoading: exLoading } = useExchanges();
|
| 18 |
+
const { data: loans = [], isLoading: lnLoading } = useLoans();
|
| 19 |
+
const { data: rates, isLoading: ratesLoading } = useRates();
|
| 20 |
+
const mainCurrency = useAppStore(s => s.mainCurrency);
|
| 21 |
+
|
| 22 |
+
// New State for Year/Month Selector
|
| 23 |
+
const [selectedYear, setSelectedYear] = useState(() => new Date().getFullYear());
|
| 24 |
+
const [selectedMonth, setSelectedMonth] = useState<number | null>(() => new Date().getMonth() + 1); // 1-12
|
| 25 |
+
|
| 26 |
+
const exchangeRates = rates || { 'USD': 1, 'IQD': 1500, 'RMB': 7.2 };
|
| 27 |
+
|
| 28 |
+
const toMain = (amount: number, currency: string) => {
|
| 29 |
+
const rate = exchangeRates[currency] || 1;
|
| 30 |
+
return (amount / rate) * (exchangeRates[mainCurrency] || 1);
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const stats = useMemo(() => {
|
| 34 |
+
// Filter interval based on selectedYear/Month
|
| 35 |
+
let start: Date;
|
| 36 |
+
let end: Date;
|
| 37 |
+
|
| 38 |
+
if (selectedMonth !== null) {
|
| 39 |
+
start = startOfMonth(new Date(selectedYear, selectedMonth - 1));
|
| 40 |
+
end = endOfMonth(new Date(selectedYear, selectedMonth - 1));
|
| 41 |
+
} else {
|
| 42 |
+
start = new Date(selectedYear, 0, 1);
|
| 43 |
+
end = new Date(selectedYear, 11, 31, 23, 59, 59);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
let periodIncome = 0;
|
| 47 |
+
let periodExpense = 0;
|
| 48 |
+
let totalDeposits = 0;
|
| 49 |
+
let totalWithdrawals = 0;
|
| 50 |
+
|
| 51 |
+
const categories: Record<string, { total: number, count: number }> = {};
|
| 52 |
+
const incomeCategories: Record<string, { total: number, count: number }> = {};
|
| 53 |
+
const dailyData: Record<string, { date: string, income: number, expense: number, balance: number }> = {};
|
| 54 |
+
const monthlyData: Record<string, { month: string, income: number, expense: number, balance: number }> = {};
|
| 55 |
+
|
| 56 |
+
// Initialize daily data
|
| 57 |
+
const days = eachDayOfInterval({ start, end });
|
| 58 |
+
days.forEach(day => {
|
| 59 |
+
const dateStr = format(day, 'yyyy-MM-dd');
|
| 60 |
+
dailyData[dateStr] = { date: dateStr, income: 0, expense: 0, balance: 0 };
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
const filteredTxs = transactions.filter((t: any) => {
|
| 64 |
+
const txDate = new Date(t.date);
|
| 65 |
+
return txDate >= start && txDate <= end;
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
filteredTxs.forEach((tx: any) => {
|
| 69 |
+
const amountInMain = toMain(tx.amount, tx.currency);
|
| 70 |
+
const dateStr = tx.date.split('T')[0];
|
| 71 |
+
const monthKey = dateStr.substring(0, 7); // YYYY-MM
|
| 72 |
+
|
| 73 |
+
if (!monthlyData[monthKey]) {
|
| 74 |
+
monthlyData[monthKey] = { month: monthKey, income: 0, expense: 0, balance: 0 };
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if (tx.type === 'income') {
|
| 78 |
+
periodIncome += amountInMain;
|
| 79 |
+
totalDeposits += amountInMain;
|
| 80 |
+
if (dailyData[dateStr]) dailyData[dateStr].income += amountInMain;
|
| 81 |
+
monthlyData[monthKey].income += amountInMain;
|
| 82 |
+
const cat = tx.category || 'Other';
|
| 83 |
+
if (!incomeCategories[cat]) incomeCategories[cat] = { total: 0, count: 0 };
|
| 84 |
+
incomeCategories[cat].total += amountInMain;
|
| 85 |
+
incomeCategories[cat].count += 1;
|
| 86 |
+
} else if (tx.type === 'expense') {
|
| 87 |
+
periodExpense += amountInMain;
|
| 88 |
+
totalWithdrawals += amountInMain;
|
| 89 |
+
if (dailyData[dateStr]) dailyData[dateStr].expense += amountInMain;
|
| 90 |
+
monthlyData[monthKey].expense += amountInMain;
|
| 91 |
+
const cat = tx.category || 'Other';
|
| 92 |
+
if (!categories[cat]) categories[cat] = { total: 0, count: 0 };
|
| 93 |
+
categories[cat].total += amountInMain;
|
| 94 |
+
categories[cat].count += 1;
|
| 95 |
+
}
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
// 1. Advanced Loan Metrics (Global)
|
| 99 |
+
let totalLent = 0;
|
| 100 |
+
let totalBorrowed = 0;
|
| 101 |
+
loans.forEach((l: any) => {
|
| 102 |
+
const amountInMain = toMain(l.amount, l.currency);
|
| 103 |
+
const paidInMain = toMain(l.paid || 0, l.currency);
|
| 104 |
+
const remaining = amountInMain - paidInMain;
|
| 105 |
+
if (l.type === 'borrowed_from_me') {
|
| 106 |
+
totalLent += remaining;
|
| 107 |
+
} else {
|
| 108 |
+
totalBorrowed += remaining;
|
| 109 |
+
}
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
// 2. Spending Deltas (Today vs Yesterday)
|
| 113 |
+
const today = startOfDay(new Date());
|
| 114 |
+
const yesterday = subDays(today, 1);
|
| 115 |
+
const todaySpend = transactions
|
| 116 |
+
.filter((t: any) => t.type === 'expense' && isSameDay(new Date(t.date), today))
|
| 117 |
+
.reduce((acc, t) => acc + toMain(t.amount, t.currency), 0);
|
| 118 |
+
const yesterdaySpend = transactions
|
| 119 |
+
.filter((t: any) => t.type === 'expense' && isSameDay(new Date(t.date), yesterday))
|
| 120 |
+
.reduce((acc, t) => acc + toMain(t.amount, t.currency), 0);
|
| 121 |
+
|
| 122 |
+
const dailySpendChange = yesterdaySpend > 0
|
| 123 |
+
? ((todaySpend - yesterdaySpend) / yesterdaySpend) * 100
|
| 124 |
+
: todaySpend > 0 ? 100 : 0;
|
| 125 |
+
|
| 126 |
+
// 3. Asset & Net Worth Calculation
|
| 127 |
+
const cBalances: Record<string, number> = { 'USD': 0, 'IQD': 0, 'RMB': 0 };
|
| 128 |
+
wallets.forEach((w: any) => {
|
| 129 |
+
const txBal = transactions.reduce((acc: number, tx: any) => {
|
| 130 |
+
let effectiveAmount = tx.amount;
|
| 131 |
+
if (tx.currency !== w.currency) {
|
| 132 |
+
const txRate = exchangeRates[tx.currency] || 1;
|
| 133 |
+
const walletRate = exchangeRates[w.currency] || 1;
|
| 134 |
+
effectiveAmount = (tx.amount / txRate) * walletRate;
|
| 135 |
+
}
|
| 136 |
+
if (tx.type === 'income' && tx.wallet_id === w.id) return acc + effectiveAmount;
|
| 137 |
+
if (tx.type === 'expense' && tx.wallet_id === w.id) return acc - effectiveAmount;
|
| 138 |
+
if (tx.type === 'transfer') {
|
| 139 |
+
if (tx.wallet_id === w.id) return acc - effectiveAmount;
|
| 140 |
+
if (tx.to_wallet_id === w.id) return acc + effectiveAmount;
|
| 141 |
+
}
|
| 142 |
+
return acc;
|
| 143 |
+
}, 0);
|
| 144 |
+
|
| 145 |
+
const exBal = exchanges.reduce((acc: number, ex: any) => {
|
| 146 |
+
let bal = 0;
|
| 147 |
+
if (ex.from_wallet_id === w.id) bal -= ex.from_amount;
|
| 148 |
+
if (ex.to_wallet_id === w.id) bal += ex.to_amount;
|
| 149 |
+
return acc + bal;
|
| 150 |
+
}, 0);
|
| 151 |
+
|
| 152 |
+
cBalances[w.currency] = (cBalances[w.currency] || 0) + (txBal + exBal);
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
const liquidAssets = Object.entries(cBalances).reduce((acc, [curr, amt]) => {
|
| 156 |
+
const rate = exchangeRates[curr] || 1;
|
| 157 |
+
return acc + (amt / rate * (exchangeRates[mainCurrency] || 1));
|
| 158 |
+
}, 0);
|
| 159 |
+
|
| 160 |
+
const netWorth = liquidAssets + totalLent - totalBorrowed;
|
| 161 |
+
|
| 162 |
+
// Cumulative Net Worth Trend (Historical)
|
| 163 |
+
const netCashFlowSinceStart = transactions.reduce((acc, tx) => {
|
| 164 |
+
if (new Date(tx.date) < start) return acc; // Simplified: only trend within selected period
|
| 165 |
+
const amt = toMain(tx.amount, tx.currency);
|
| 166 |
+
if (tx.type === 'income') return acc + amt;
|
| 167 |
+
if (tx.type === 'expense') return acc - amt;
|
| 168 |
+
return acc;
|
| 169 |
+
}, 0);
|
| 170 |
+
|
| 171 |
+
const startingNetWorth = liquidAssets - netCashFlowSinceStart;
|
| 172 |
+
let cumulative = startingNetWorth;
|
| 173 |
+
const cumulativeHistory = Object.values(dailyData).map(d => {
|
| 174 |
+
cumulative += d.income - d.expense;
|
| 175 |
+
return { date: d.date, balance: cumulative };
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
// 4. Advanced Risk & Efficiency Metrics
|
| 179 |
+
const numDays = days.length || 1;
|
| 180 |
+
const avgDailySpend = periodExpense / numDays;
|
| 181 |
+
|
| 182 |
+
// Sharpe Ratio Calculation (Daily Volatility of Balances)
|
| 183 |
+
const dailyReturns = [];
|
| 184 |
+
for (let i = 1; i < cumulativeHistory.length; i++) {
|
| 185 |
+
const prev = cumulativeHistory[i-1].balance;
|
| 186 |
+
const curr = cumulativeHistory[i].balance;
|
| 187 |
+
if (prev !== 0) dailyReturns.push((curr - prev) / Math.abs(prev));
|
| 188 |
+
}
|
| 189 |
+
const avgReturn = dailyReturns.length > 0 ? dailyReturns.reduce((a, b) => a + b, 0) / dailyReturns.length : 0;
|
| 190 |
+
const stdDev = dailyReturns.length > 0 ? Math.sqrt(dailyReturns.reduce((a, b) => a + Math.pow(b - avgReturn, 2), 0) / dailyReturns.length) : 0;
|
| 191 |
+
const sharpeRatio = stdDev !== 0 ? (avgReturn / stdDev) * Math.sqrt(365) : 0; // Annualized
|
| 192 |
+
|
| 193 |
+
// Max Drawdown
|
| 194 |
+
let peak = -Infinity;
|
| 195 |
+
let maxDrawdown = 0;
|
| 196 |
+
cumulativeHistory.forEach(d => {
|
| 197 |
+
if (d.balance > peak) peak = d.balance;
|
| 198 |
+
const drawdown = peak !== 0 ? (peak - d.balance) / peak : 0;
|
| 199 |
+
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
// Velocity of Money (Total Flow / Avg Balance)
|
| 203 |
+
const avgBalance = cumulativeHistory.reduce((a, b) => a + b.balance, 0) / cumulativeHistory.length || 1;
|
| 204 |
+
const velocity = (periodIncome + periodExpense) / avgBalance;
|
| 205 |
+
|
| 206 |
+
// 5. 50/30/20 Analysis
|
| 207 |
+
const RULES = { needs: 0, wants: 0, savings: 0 };
|
| 208 |
+
const NEEDS_CATS = ['market', 'bills', 'health', 'transport', 'tax', 'rent', 'utilities', 'bills'];
|
| 209 |
+
|
| 210 |
+
Object.entries(categories).forEach(([name, data]) => {
|
| 211 |
+
if (NEEDS_CATS.includes(name.toLowerCase())) RULES.needs += data.total;
|
| 212 |
+
else RULES.wants += data.total;
|
| 213 |
+
});
|
| 214 |
+
RULES.savings = Math.max(0, periodIncome - periodExpense);
|
| 215 |
+
|
| 216 |
+
const totalRuleBase = (RULES.needs + RULES.wants + RULES.savings) || 1;
|
| 217 |
+
const ruleAnalysis = {
|
| 218 |
+
needs: (RULES.needs / totalRuleBase) * 100,
|
| 219 |
+
wants: (RULES.wants / totalRuleBase) * 100,
|
| 220 |
+
savings: (RULES.savings / totalRuleBase) * 100
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
// 6. Trend Projection (Linear Regression: y = mx + b)
|
| 224 |
+
const n = cumulativeHistory.length;
|
| 225 |
+
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
|
| 226 |
+
cumulativeHistory.forEach((d, i) => {
|
| 227 |
+
sumX += i;
|
| 228 |
+
sumY += d.balance;
|
| 229 |
+
sumXY += i * d.balance;
|
| 230 |
+
sumX2 += i * i;
|
| 231 |
+
});
|
| 232 |
+
const m = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX) || 0;
|
| 233 |
+
const b = (sumY - m * sumX) / n;
|
| 234 |
+
|
| 235 |
+
const projectionDays = selectedMonth ? (endOfMonth(new Date(selectedYear, selectedMonth - 1)).getDate()) : 365;
|
| 236 |
+
const projectedBalance = m * projectionDays + b;
|
| 237 |
+
|
| 238 |
+
// 7. Spending Heatmap Data (Day of Week vs Hour)
|
| 239 |
+
const heatmap: Record<string, number> = {};
|
| 240 |
+
transactions.forEach((tx: any) => {
|
| 241 |
+
if (tx.type !== 'expense') return;
|
| 242 |
+
const d = new Date(tx.date);
|
| 243 |
+
if (d < start || d > end) return;
|
| 244 |
+
const key = `${d.getDay()}-${d.getHours()}`;
|
| 245 |
+
heatmap[key] = (heatmap[key] || 0) + toMain(tx.amount, tx.currency);
|
| 246 |
+
});
|
| 247 |
+
|
| 248 |
+
// 8. Formatting Return Data
|
| 249 |
+
const savingsRate = periodIncome > 0 ? ((periodIncome - periodExpense) / periodIncome) * 100 : 0;
|
| 250 |
+
const debtRatio = (liquidAssets + totalLent) > 0 ? (totalBorrowed / (liquidAssets + totalLent)) * 100 : 0;
|
| 251 |
+
const expenseRatio = periodIncome > 0 ? (periodExpense / periodIncome) * 100 : 0;
|
| 252 |
+
|
| 253 |
+
// Top spending categories
|
| 254 |
+
const totalExpenseForPct = periodExpense || 1;
|
| 255 |
+
const spendingByCategory = Object.entries(categories)
|
| 256 |
+
.map(([name, data]) => ({
|
| 257 |
+
name,
|
| 258 |
+
value: data.total,
|
| 259 |
+
count: data.count,
|
| 260 |
+
pct: (data.total / totalExpenseForPct) * 100
|
| 261 |
+
}))
|
| 262 |
+
.sort((a, b) => b.value - a.value);
|
| 263 |
+
|
| 264 |
+
const incomeBreakdown = Object.entries(incomeCategories)
|
| 265 |
+
.map(([name, data]) => ({
|
| 266 |
+
name,
|
| 267 |
+
value: data.total,
|
| 268 |
+
count: data.count,
|
| 269 |
+
pct: (data.total / (periodIncome || 1)) * 100
|
| 270 |
+
}))
|
| 271 |
+
.sort((a, b) => b.value - a.value);
|
| 272 |
+
|
| 273 |
+
const comparisonTableData = selectedMonth
|
| 274 |
+
? Object.values(dailyData).sort((a, b) => b.date.localeCompare(a.date)).map(d => ({
|
| 275 |
+
date: d.date,
|
| 276 |
+
income: d.income,
|
| 277 |
+
expense: d.expense,
|
| 278 |
+
balance: d.income - d.expense
|
| 279 |
+
}))
|
| 280 |
+
: Object.values(monthlyData).sort((a, b) => b.month.localeCompare(a.month)).map(m => ({
|
| 281 |
+
date: m.month,
|
| 282 |
+
income: m.income,
|
| 283 |
+
expense: m.expense,
|
| 284 |
+
balance: m.income - m.expense
|
| 285 |
+
}));
|
| 286 |
+
|
| 287 |
+
const largestTransaction = filteredTxs.length > 0
|
| 288 |
+
? filteredTxs.reduce((prev: any, curr: any) => (toMain(curr.amount, curr.currency) > toMain(prev.amount, prev.currency) ? curr : prev))
|
| 289 |
+
: null;
|
| 290 |
+
|
| 291 |
+
return {
|
| 292 |
+
periodIncome,
|
| 293 |
+
periodExpense,
|
| 294 |
+
netCashFlow: periodIncome - periodExpense,
|
| 295 |
+
totalDeposits,
|
| 296 |
+
totalWithdrawals,
|
| 297 |
+
liquidAssets,
|
| 298 |
+
totalLent,
|
| 299 |
+
totalBorrowed,
|
| 300 |
+
netWorth,
|
| 301 |
+
spendingByCategory,
|
| 302 |
+
incomeBreakdown,
|
| 303 |
+
avgDailySpend,
|
| 304 |
+
savingsRate,
|
| 305 |
+
debtRatio,
|
| 306 |
+
expenseRatio,
|
| 307 |
+
todaySpend,
|
| 308 |
+
dailySpendChange,
|
| 309 |
+
cumulativeHistory,
|
| 310 |
+
comparisonTableData,
|
| 311 |
+
sharpeRatio,
|
| 312 |
+
maxDrawdown,
|
| 313 |
+
velocity,
|
| 314 |
+
ruleAnalysis,
|
| 315 |
+
projectedBalance,
|
| 316 |
+
heatmap,
|
| 317 |
+
trendLine: { m, b },
|
| 318 |
+
largestTransaction: largestTransaction ? {
|
| 319 |
+
...largestTransaction,
|
| 320 |
+
mainAmount: toMain(largestTransaction.amount, largestTransaction.currency)
|
| 321 |
+
} : null,
|
| 322 |
+
currencyDistribution: Object.entries(cBalances).map(([name, value]) => {
|
| 323 |
+
const rate = exchangeRates[name] || 1;
|
| 324 |
+
return {
|
| 325 |
+
name,
|
| 326 |
+
value: value / rate * (exchangeRates[mainCurrency] || 1),
|
| 327 |
+
originalValue: value
|
| 328 |
+
};
|
| 329 |
+
}).filter(d => d.value !== 0),
|
| 330 |
+
};
|
| 331 |
+
}, [transactions, wallets, exchanges, loans, rates, mainCurrency, selectedYear, selectedMonth, exchangeRates]);
|
| 332 |
+
|
| 333 |
+
// Available Years
|
| 334 |
+
const availableYears = useMemo(() => {
|
| 335 |
+
const currentYear = new Date().getFullYear();
|
| 336 |
+
return [currentYear, currentYear - 1, currentYear - 2];
|
| 337 |
+
}, []);
|
| 338 |
+
|
| 339 |
+
return {
|
| 340 |
+
isLoaded: !txLoading && !wlLoading && !exLoading && !lnLoading && !ratesLoading,
|
| 341 |
+
mainCurrency,
|
| 342 |
+
selectedYear, setSelectedYear,
|
| 343 |
+
selectedMonth, setSelectedMonth,
|
| 344 |
+
availableYears,
|
| 345 |
+
...stats
|
| 346 |
+
};
|
| 347 |
+
}
|
client/src/features/dashboard/components/CashFlowWidget.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card"
|
| 2 |
+
import { BarChart3 } from 'lucide-react'
|
| 3 |
+
import { cn } from "../../../lib/utils"
|
| 4 |
+
|
| 5 |
+
interface Props {
|
| 6 |
+
periodIncome: number;
|
| 7 |
+
periodExpense: number;
|
| 8 |
+
mainCurrency: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function CashFlowWidget({ periodIncome, periodExpense, mainCurrency }: Props) {
|
| 12 |
+
const netSavings = periodIncome - periodExpense;
|
| 13 |
+
const isPositive = netSavings > 0;
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<Card>
|
| 17 |
+
<CardHeader>
|
| 18 |
+
<CardTitle className="flex items-center gap-2 text-neutral-200">
|
| 19 |
+
<BarChart3 className="w-5 h-5 text-purple-400" /> Cash Flow (Selected Period)
|
| 20 |
+
</CardTitle>
|
| 21 |
+
</CardHeader>
|
| 22 |
+
<CardContent className="space-y-6">
|
| 23 |
+
<div className="flex justify-between items-center bg-white/5 p-4 rounded-xl border border-white/5">
|
| 24 |
+
<div>
|
| 25 |
+
<p className="text-sm text-neutral-400 mb-1 text-left">Total Income</p>
|
| 26 |
+
<p className="text-xl font-semibold text-emerald-400 text-left">+{periodIncome.toLocaleString(undefined, { maximumFractionDigits: 0 })} {mainCurrency}</p>
|
| 27 |
+
</div>
|
| 28 |
+
<div className="text-right">
|
| 29 |
+
<p className="text-sm text-neutral-400 mb-1">Total Expenses</p>
|
| 30 |
+
<p className="text-xl font-semibold text-rose-400">-{periodExpense.toLocaleString(undefined, { maximumFractionDigits: 0 })} {mainCurrency}</p>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div className="p-4 rounded-xl border border-white/5 relative overflow-hidden">
|
| 35 |
+
<div className={cn("absolute inset-0 opacity-10", isPositive ? "bg-emerald-500" : "bg-rose-500")} />
|
| 36 |
+
<div className="relative z-10 flex justify-between items-center">
|
| 37 |
+
<span className="font-medium">Net Savings</span>
|
| 38 |
+
<span className={cn("font-bold text-lg", isPositive ? "text-emerald-400" : "text-rose-400")}>
|
| 39 |
+
{isPositive ? '+' : ''}
|
| 40 |
+
{netSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })} {mainCurrency}
|
| 41 |
+
</span>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</CardContent>
|
| 45 |
+
</Card>
|
| 46 |
+
);
|
| 47 |
+
}
|
client/src/features/dashboard/components/NetWorthWidget.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card"
|
| 2 |
+
import { Wallet as WalletIcon, TrendingUp, HandCoins, Building2 } from 'lucide-react'
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
totalNetWorth: number;
|
| 6 |
+
cashAssetsTotal: number;
|
| 7 |
+
loansOwedToYouTotal: number;
|
| 8 |
+
debtsYouOweTotal: number;
|
| 9 |
+
mainCurrency: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function NetWorthWidget({ totalNetWorth, cashAssetsTotal, loansOwedToYouTotal, debtsYouOweTotal, mainCurrency }: Props) {
|
| 13 |
+
return (
|
| 14 |
+
<Card className="mb-6 md:mb-8 group relative border-blue-500/20 shrink-0">
|
| 15 |
+
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity hidden sm:block">
|
| 16 |
+
<Building2 className="w-32 h-32 text-blue-500" />
|
| 17 |
+
</div>
|
| 18 |
+
<CardHeader className="pb-2">
|
| 19 |
+
<CardDescription className="text-sm md:text-base text-neutral-400 font-medium mb-1 md:mb-2 text-left">Total Net Worth</CardDescription>
|
| 20 |
+
<CardTitle className="text-3xl md:text-5xl font-bold tracking-tight text-white mb-4 md:mb-6 text-left">
|
| 21 |
+
{totalNetWorth.toLocaleString(undefined, { maximumFractionDigits: 2 })} <span className="text-xl md:text-2xl text-blue-400">{mainCurrency}</span>
|
| 22 |
+
</CardTitle>
|
| 23 |
+
</CardHeader>
|
| 24 |
+
|
| 25 |
+
<CardContent>
|
| 26 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 md:gap-6 pt-4 md:pt-6 border-t border-white/10 relative z-10 text-left">
|
| 27 |
+
<div>
|
| 28 |
+
<div className="flex items-center gap-2 text-emerald-400 mb-1">
|
| 29 |
+
<WalletIcon className="w-4 h-4" />
|
| 30 |
+
<span className="text-xs md:text-sm font-medium">Cash Assets</span>
|
| 31 |
+
</div>
|
| 32 |
+
<p className="text-lg md:text-2xl font-semibold">{cashAssetsTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} {mainCurrency}</p>
|
| 33 |
+
</div>
|
| 34 |
+
<div>
|
| 35 |
+
<div className="flex items-center gap-2 text-indigo-400 mb-1">
|
| 36 |
+
<HandCoins className="w-4 h-4" />
|
| 37 |
+
<span className="text-xs md:text-sm font-medium">Owed to You</span>
|
| 38 |
+
</div>
|
| 39 |
+
<p className="text-lg md:text-2xl font-semibold">{loansOwedToYouTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} {mainCurrency}</p>
|
| 40 |
+
</div>
|
| 41 |
+
<div>
|
| 42 |
+
<div className="flex items-center gap-2 text-rose-400 mb-1">
|
| 43 |
+
<TrendingUp className="w-4 h-4 rotate-180" />
|
| 44 |
+
<span className="text-xs md:text-sm font-medium">Your Debts</span>
|
| 45 |
+
</div>
|
| 46 |
+
<p className="text-lg md:text-2xl font-semibold">{debtsYouOweTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} {mainCurrency}</p>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
</CardContent>
|
| 50 |
+
</Card>
|
| 51 |
+
);
|
| 52 |
+
}
|
client/src/features/dashboard/useDashboardStats.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useMemo } from 'react';
|
| 2 |
+
import { useTransactions, useRates, useWallets, useExchanges, useLoans } from '../../hooks/queries';
|
| 3 |
+
import { useAppStore } from '../../store/useAppStore';
|
| 4 |
+
|
| 5 |
+
export function useDashboardStats() {
|
| 6 |
+
const { data: transactions = [], isLoading: txLoading } = useTransactions();
|
| 7 |
+
const { data: wallets = [], isLoading: wlLoading } = useWallets();
|
| 8 |
+
const { data: exchanges = [], isLoading: exLoading } = useExchanges();
|
| 9 |
+
const { data: loans = [], isLoading: lnLoading } = useLoans();
|
| 10 |
+
const { data: rates, isLoading: ratesLoading } = useRates();
|
| 11 |
+
const mainCurrency = useAppStore(s => s.mainCurrency);
|
| 12 |
+
|
| 13 |
+
const [startDate, setStartDate] = useState(() => {
|
| 14 |
+
const now = new Date();
|
| 15 |
+
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0];
|
| 16 |
+
});
|
| 17 |
+
const [endDate, setEndDate] = useState(() => new Date().toISOString().split('T')[0]);
|
| 18 |
+
|
| 19 |
+
const exchangeRates = rates || { 'USD': 1, 'IQD': 1539.5, 'RMB': 6.86 };
|
| 20 |
+
|
| 21 |
+
const {
|
| 22 |
+
cashAssetsTotal,
|
| 23 |
+
loansOwedToYouTotal,
|
| 24 |
+
debtsYouOweTotal,
|
| 25 |
+
currencyBalances,
|
| 26 |
+
rawWalletBalances
|
| 27 |
+
} = useMemo(() => {
|
| 28 |
+
const cBalances: Record<string, number> = { 'USD': 0, 'IQD': 0, 'RMB': 0 };
|
| 29 |
+
|
| 30 |
+
const computedWalletBalances = wallets.map((w: any) => {
|
| 31 |
+
const isExcluded = w.name.toLowerCase().trim() === 'kj wallets';
|
| 32 |
+
const txBal = transactions.reduce((acc: number, tx: any) => {
|
| 33 |
+
let effectiveAmount = tx.amount;
|
| 34 |
+
if (tx.currency !== w.currency) {
|
| 35 |
+
const txRate = exchangeRates[tx.currency] || 1;
|
| 36 |
+
const walletRate = exchangeRates[w.currency] || 1;
|
| 37 |
+
effectiveAmount = (tx.amount / txRate) * walletRate;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (tx.type === 'income' && tx.wallet_id === w.id) return acc + effectiveAmount;
|
| 41 |
+
if (tx.type === 'expense' && tx.wallet_id === w.id) return acc - effectiveAmount;
|
| 42 |
+
if (tx.type === 'transfer') {
|
| 43 |
+
if (tx.wallet_id === w.id) return acc - effectiveAmount;
|
| 44 |
+
if (tx.to_wallet_id === w.id) return acc + effectiveAmount;
|
| 45 |
+
}
|
| 46 |
+
return acc;
|
| 47 |
+
}, 0);
|
| 48 |
+
|
| 49 |
+
const exBal = exchanges.reduce((acc: number, ex: any) => {
|
| 50 |
+
let bal = 0;
|
| 51 |
+
if (ex.from_wallet_id === w.id) bal -= ex.from_amount;
|
| 52 |
+
if (ex.to_wallet_id === w.id) bal += ex.to_amount;
|
| 53 |
+
return acc + bal;
|
| 54 |
+
}, 0);
|
| 55 |
+
|
| 56 |
+
const total = txBal + exBal;
|
| 57 |
+
|
| 58 |
+
if (!isExcluded) {
|
| 59 |
+
if (cBalances[w.currency] === undefined) cBalances[w.currency] = 0;
|
| 60 |
+
cBalances[w.currency] += total;
|
| 61 |
+
}
|
| 62 |
+
return { ...w, balance: total };
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
const loansOwedToYou: Record<string, number> = { 'USD': 0, 'IQD': 0, 'RMB': 0 };
|
| 66 |
+
const debtsYouOwe: Record<string, number> = { 'USD': 0, 'IQD': 0, 'RMB': 0 };
|
| 67 |
+
|
| 68 |
+
loans.forEach((loan: any) => {
|
| 69 |
+
if (loansOwedToYou[loan.currency] === undefined) loansOwedToYou[loan.currency] = 0;
|
| 70 |
+
if (debtsYouOwe[loan.currency] === undefined) debtsYouOwe[loan.currency] = 0;
|
| 71 |
+
|
| 72 |
+
const remaining = loan.amount - (loan.paid || 0);
|
| 73 |
+
if (loan.type === 'borrowed_from_me') {
|
| 74 |
+
loansOwedToYou[loan.currency] += remaining;
|
| 75 |
+
} else if (loan.type === 'owed_by_me') {
|
| 76 |
+
debtsYouOwe[loan.currency] += remaining;
|
| 77 |
+
}
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
const calculateMainCurrency = (balances: Record<string, number>) => {
|
| 81 |
+
let total = 0;
|
| 82 |
+
Object.entries(balances).forEach(([currency, amount]) => {
|
| 83 |
+
const rate = exchangeRates[currency] || 1;
|
| 84 |
+
total += (amount / rate);
|
| 85 |
+
});
|
| 86 |
+
return total * (exchangeRates[mainCurrency] || 1);
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
cashAssetsTotal: calculateMainCurrency(cBalances) || 0,
|
| 91 |
+
loansOwedToYouTotal: calculateMainCurrency(loansOwedToYou) || 0,
|
| 92 |
+
debtsYouOweTotal: calculateMainCurrency(debtsYouOwe) || 0,
|
| 93 |
+
currencyBalances: cBalances,
|
| 94 |
+
rawWalletBalances: computedWalletBalances
|
| 95 |
+
};
|
| 96 |
+
}, [wallets, transactions, exchanges, loans, exchangeRates, mainCurrency]);
|
| 97 |
+
|
| 98 |
+
const { periodIncome, periodExpense, spendingByCategory, topExpenseCategory } = useMemo(() => {
|
| 99 |
+
const start = new Date(startDate).toISOString();
|
| 100 |
+
const endDay = new Date(endDate);
|
| 101 |
+
endDay.setHours(23, 59, 59, 999);
|
| 102 |
+
const end = endDay.toISOString();
|
| 103 |
+
|
| 104 |
+
let income = 0;
|
| 105 |
+
let expense = 0;
|
| 106 |
+
const categories: Record<string, number> = {};
|
| 107 |
+
const excludedWalletIds = wallets
|
| 108 |
+
.filter((w: any) => w.name.toLowerCase().trim() === 'kj wallets')
|
| 109 |
+
.map((w: any) => w.id);
|
| 110 |
+
|
| 111 |
+
transactions.filter((t: any) => t.date >= start && t.date <= end).forEach((tx: any) => {
|
| 112 |
+
if (excludedWalletIds.includes(tx.wallet_id)) return;
|
| 113 |
+
|
| 114 |
+
const rate = exchangeRates[tx.currency] || 1;
|
| 115 |
+
const amountInMain = tx.amount / rate * (exchangeRates[mainCurrency] || 1);
|
| 116 |
+
|
| 117 |
+
if (tx.type === 'income') income += amountInMain;
|
| 118 |
+
if (tx.type === 'expense') {
|
| 119 |
+
expense += amountInMain;
|
| 120 |
+
const cat = tx.category || 'Other';
|
| 121 |
+
categories[cat] = (categories[cat] || 0) + amountInMain;
|
| 122 |
+
}
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
let topCategory = { name: 'None', amount: 0 };
|
| 126 |
+
Object.entries(categories).forEach(([name, amount]) => {
|
| 127 |
+
if (amount > topCategory.amount) topCategory = { name, amount };
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
return { periodIncome: income, periodExpense: expense, spendingByCategory: categories, topExpenseCategory: topCategory };
|
| 131 |
+
}, [transactions, exchangeRates, mainCurrency, startDate, endDate]);
|
| 132 |
+
|
| 133 |
+
const totalNetWorth = (cashAssetsTotal + loansOwedToYouTotal - debtsYouOweTotal) || 0;
|
| 134 |
+
|
| 135 |
+
return {
|
| 136 |
+
isLoaded: !txLoading && !wlLoading && !exLoading && !lnLoading && !ratesLoading,
|
| 137 |
+
mainCurrency,
|
| 138 |
+
totalNetWorth,
|
| 139 |
+
cashAssetsTotal,
|
| 140 |
+
loansOwedToYouTotal,
|
| 141 |
+
debtsYouOweTotal,
|
| 142 |
+
currencyBalances,
|
| 143 |
+
rawWalletBalances,
|
| 144 |
+
periodIncome,
|
| 145 |
+
periodExpense,
|
| 146 |
+
spendingByCategory,
|
| 147 |
+
topExpenseCategory,
|
| 148 |
+
startDate, setStartDate,
|
| 149 |
+
endDate, setEndDate,
|
| 150 |
+
exchangeRates
|
| 151 |
+
};
|
| 152 |
+
}
|
client/src/features/transactions/components/TransactionForm.tsx
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useForm } from "react-hook-form";
|
| 2 |
+
import { zodResolver } from "@hookform/resolvers/zod";
|
| 3 |
+
import * as z from "zod";
|
| 4 |
+
import { Button } from "../../../components/ui/button";
|
| 5 |
+
import { Input } from "../../../components/ui/input";
|
| 6 |
+
import { AmountInput } from "../../../components/ui/AmountInput";
|
| 7 |
+
import { Select } from "../../../components/ui/select";
|
| 8 |
+
import { Label } from "../../../components/ui/label";
|
| 9 |
+
import { cn } from "../../../lib/utils";
|
| 10 |
+
import type { Wallet } from "../../../types";
|
| 11 |
+
import { useEffect } from "react";
|
| 12 |
+
|
| 13 |
+
export const transactionSchema = z.object({
|
| 14 |
+
type: z.enum(['expense', 'income', 'transfer']),
|
| 15 |
+
amount: z.coerce.number().positive("Amount must be positive"),
|
| 16 |
+
currency: z.string().min(1, "Currency is required"),
|
| 17 |
+
wallet_id: z.coerce.number().min(1, "Wallet is required"),
|
| 18 |
+
to_wallet_id: z.coerce.number().optional().nullable(),
|
| 19 |
+
category: z.string().optional(),
|
| 20 |
+
note: z.string().optional()
|
| 21 |
+
}).refine(data => {
|
| 22 |
+
if (data.type === 'transfer' && !data.to_wallet_id) return false;
|
| 23 |
+
return true;
|
| 24 |
+
}, {
|
| 25 |
+
message: "Destination wallet is required",
|
| 26 |
+
path: ["to_wallet_id"]
|
| 27 |
+
}).refine(data => {
|
| 28 |
+
if (data.type === 'transfer' && data.wallet_id === data.to_wallet_id) return false;
|
| 29 |
+
return true;
|
| 30 |
+
}, {
|
| 31 |
+
message: "Cannot transfer to same wallet",
|
| 32 |
+
path: ["to_wallet_id"]
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
export type TransactionFormValues = z.infer<typeof transactionSchema>;
|
| 36 |
+
|
| 37 |
+
const CATEGORIES = ['Food', 'Transport', 'Utilities', 'Shopping', 'Entertainment', 'Health', 'Salary', 'Investment', 'Other'];
|
| 38 |
+
|
| 39 |
+
export function TransactionForm({
|
| 40 |
+
wallets,
|
| 41 |
+
onSubmit,
|
| 42 |
+
isSubmitting,
|
| 43 |
+
submitError
|
| 44 |
+
}: {
|
| 45 |
+
wallets: Wallet[],
|
| 46 |
+
onSubmit: (values: TransactionFormValues) => void,
|
| 47 |
+
isSubmitting: boolean,
|
| 48 |
+
submitError: string
|
| 49 |
+
}) {
|
| 50 |
+
const form = useForm<TransactionFormValues>({
|
| 51 |
+
resolver: zodResolver(transactionSchema),
|
| 52 |
+
defaultValues: {
|
| 53 |
+
type: 'expense',
|
| 54 |
+
amount: '' as any,
|
| 55 |
+
currency: wallets[0]?.currency || 'USD',
|
| 56 |
+
wallet_id: wallets[0]?.id || 0,
|
| 57 |
+
category: 'Other',
|
| 58 |
+
note: ''
|
| 59 |
+
}
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
const type = form.watch('type');
|
| 63 |
+
const walletId = form.watch('wallet_id');
|
| 64 |
+
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
const w = wallets.find(w => w.id === Number(walletId));
|
| 67 |
+
if (w) {
|
| 68 |
+
form.setValue('currency', w.currency);
|
| 69 |
+
}
|
| 70 |
+
}, [walletId, wallets, form]);
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<div className="glass-panel p-6 mb-8 border border-blue-500/30 shadow-blue-500/5 relative overflow-hidden">
|
| 74 |
+
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 to-indigo-500" />
|
| 75 |
+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
| 76 |
+
<div className="flex flex-wrap gap-2 md:gap-4 mb-4 md:mb-6">
|
| 77 |
+
{['expense', 'income', 'transfer'].map(t => (
|
| 78 |
+
<button
|
| 79 |
+
key={t} type="button"
|
| 80 |
+
onClick={() => form.setValue('type', t as any)}
|
| 81 |
+
className={cn(
|
| 82 |
+
"flex-1 min-w-[30%] py-2 rounded-lg font-medium capitalize border transition-all text-sm md:text-base",
|
| 83 |
+
type === t
|
| 84 |
+
? "bg-blue-500/20 border-blue-500/50 text-blue-400"
|
| 85 |
+
: "bg-white/5 border-white/10 text-neutral-400 hover:text-neutral-200"
|
| 86 |
+
)}
|
| 87 |
+
>
|
| 88 |
+
{t}
|
| 89 |
+
</button>
|
| 90 |
+
))}
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 94 |
+
<div>
|
| 95 |
+
<Label>Amount</Label>
|
| 96 |
+
<AmountInput {...form.register('amount')} />
|
| 97 |
+
{form.formState.errors.amount && <p className="text-rose-400 text-xs mt-1">{form.formState.errors.amount.message}</p>}
|
| 98 |
+
</div>
|
| 99 |
+
<div>
|
| 100 |
+
<Label>Currency</Label>
|
| 101 |
+
<Select {...form.register('currency')}>
|
| 102 |
+
<option value="USD">USD</option>
|
| 103 |
+
<option value="IQD">IQD</option>
|
| 104 |
+
<option value="RMB">RMB</option>
|
| 105 |
+
</Select>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<div>
|
| 109 |
+
<Label>Wallet</Label>
|
| 110 |
+
<Select {...form.register('wallet_id')}>
|
| 111 |
+
{wallets.map(w => <option key={w.id} value={w.id}>{w.name} ({w.currency})</option>)}
|
| 112 |
+
</Select>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
{type === 'transfer' && (
|
| 116 |
+
<div>
|
| 117 |
+
<Label>To Wallet</Label>
|
| 118 |
+
<Select {...form.register('to_wallet_id')}>
|
| 119 |
+
<option value="">Select Destination</option>
|
| 120 |
+
{wallets.filter(w => w.id.toString() !== walletId.toString()).map(w => <option key={w.id} value={w.id}>{w.name}</option>)}
|
| 121 |
+
</Select>
|
| 122 |
+
{form.formState.errors.to_wallet_id && <p className="text-rose-400 text-xs mt-1">{form.formState.errors.to_wallet_id.message}</p>}
|
| 123 |
+
</div>
|
| 124 |
+
)}
|
| 125 |
+
|
| 126 |
+
{type !== 'transfer' && (
|
| 127 |
+
<div>
|
| 128 |
+
<Label>Category</Label>
|
| 129 |
+
<Select {...form.register('category')}>
|
| 130 |
+
{CATEGORIES.map(cat => <option key={cat} value={cat}>{cat}</option>)}
|
| 131 |
+
</Select>
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
|
| 135 |
+
<div className={cn("col-span-2", type === 'transfer' && "md:col-span-2")}>
|
| 136 |
+
<Label>Note (Optional)</Label>
|
| 137 |
+
<Input type="text" {...form.register('note')} placeholder="e.g. Paid university fee" />
|
| 138 |
+
</div>
|
| 139 |
+
</div>
|
| 140 |
+
|
| 141 |
+
{submitError && (
|
| 142 |
+
<div className="bg-rose-500/10 border border-rose-500/20 text-rose-400 p-3 rounded-xl text-sm mb-4">
|
| 143 |
+
{submitError}
|
| 144 |
+
</div>
|
| 145 |
+
)}
|
| 146 |
+
|
| 147 |
+
<div className="flex justify-end pt-2 md:pt-4">
|
| 148 |
+
<Button type="submit" disabled={isSubmitting} className="w-full md:w-auto">
|
| 149 |
+
{isSubmitting ? (
|
| 150 |
+
<span className="flex items-center gap-2">
|
| 151 |
+
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> Saving...
|
| 152 |
+
</span>
|
| 153 |
+
) : (
|
| 154 |
+
<>Save {type}</>
|
| 155 |
+
)}
|
| 156 |
+
</Button>
|
| 157 |
+
</div>
|
| 158 |
+
</form>
|
| 159 |
+
</div>
|
| 160 |
+
);
|
| 161 |
+
}
|
client/src/hooks/queries.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
| 2 |
+
import { api } from '../api';
|
| 3 |
+
import type { Loan } from '../types';
|
| 4 |
+
|
| 5 |
+
export const useRates = () => useQuery({
|
| 6 |
+
queryKey: ['rates'],
|
| 7 |
+
queryFn: api.getRates,
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
+
export const useWallets = () => useQuery({
|
| 11 |
+
queryKey: ['wallets'],
|
| 12 |
+
queryFn: api.getWallets,
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const useTransactions = () => useQuery({
|
| 16 |
+
queryKey: ['transactions'],
|
| 17 |
+
queryFn: api.getTransactions,
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
export const useCreateTransaction = () => {
|
| 21 |
+
const queryClient = useQueryClient();
|
| 22 |
+
return useMutation({
|
| 23 |
+
mutationFn: api.createTransaction,
|
| 24 |
+
onSuccess: () => {
|
| 25 |
+
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
| 26 |
+
queryClient.invalidateQueries({ queryKey: ['dashboard-analytics'] });
|
| 27 |
+
queryClient.invalidateQueries({ queryKey: ['wallets'] });
|
| 28 |
+
},
|
| 29 |
+
});
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
export const useExchanges = () => useQuery({
|
| 33 |
+
queryKey: ['exchanges'],
|
| 34 |
+
queryFn: api.getExchanges,
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
export const useCreateExchange = () => {
|
| 38 |
+
const queryClient = useQueryClient();
|
| 39 |
+
return useMutation({
|
| 40 |
+
mutationFn: api.createExchange,
|
| 41 |
+
onSuccess: () => {
|
| 42 |
+
queryClient.invalidateQueries({ queryKey: ['exchanges'] });
|
| 43 |
+
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
| 44 |
+
queryClient.invalidateQueries({ queryKey: ['dashboard-analytics'] });
|
| 45 |
+
queryClient.invalidateQueries({ queryKey: ['wallets'] });
|
| 46 |
+
},
|
| 47 |
+
});
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
export const useLoans = () => useQuery({
|
| 51 |
+
queryKey: ['loans'],
|
| 52 |
+
queryFn: api.getLoans,
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
export const useCreateLoan = () => {
|
| 56 |
+
const queryClient = useQueryClient();
|
| 57 |
+
return useMutation({
|
| 58 |
+
mutationFn: api.createLoan,
|
| 59 |
+
onSuccess: () => {
|
| 60 |
+
queryClient.invalidateQueries({ queryKey: ['loans'] });
|
| 61 |
+
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
| 62 |
+
queryClient.invalidateQueries({ queryKey: ['dashboard-analytics'] });
|
| 63 |
+
queryClient.invalidateQueries({ queryKey: ['wallets'] });
|
| 64 |
+
},
|
| 65 |
+
});
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
export const useUpdateLoan = () => {
|
| 69 |
+
const queryClient = useQueryClient();
|
| 70 |
+
return useMutation({
|
| 71 |
+
mutationFn: ({ id, data }: { id: number, data: Omit<Loan, 'id'> }) => api.updateLoan(id, data),
|
| 72 |
+
onSuccess: () => {
|
| 73 |
+
queryClient.invalidateQueries({ queryKey: ['loans'] });
|
| 74 |
+
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
| 75 |
+
queryClient.invalidateQueries({ queryKey: ['dashboard-analytics'] });
|
| 76 |
+
queryClient.invalidateQueries({ queryKey: ['wallets'] });
|
| 77 |
+
},
|
| 78 |
+
});
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
export const useDeleteLoan = () => {
|
| 82 |
+
const queryClient = useQueryClient();
|
| 83 |
+
return useMutation({
|
| 84 |
+
mutationFn: (id: number) => api.deleteLoan(id),
|
| 85 |
+
onSuccess: () => {
|
| 86 |
+
queryClient.invalidateQueries({ queryKey: ['loans'] });
|
| 87 |
+
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
| 88 |
+
queryClient.invalidateQueries({ queryKey: ['dashboard-analytics'] });
|
| 89 |
+
queryClient.invalidateQueries({ queryKey: ['wallets'] });
|
| 90 |
+
},
|
| 91 |
+
});
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
export const usePayLoan = () => {
|
| 95 |
+
const queryClient = useQueryClient();
|
| 96 |
+
return useMutation({
|
| 97 |
+
mutationFn: ({ id, amount }: { id: number, amount: number }) => api.payLoan(id, amount),
|
| 98 |
+
onSuccess: () => {
|
| 99 |
+
queryClient.invalidateQueries({ queryKey: ['loans'] });
|
| 100 |
+
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
| 101 |
+
queryClient.invalidateQueries({ queryKey: ['dashboard-analytics'] });
|
| 102 |
+
queryClient.invalidateQueries({ queryKey: ['wallets'] });
|
| 103 |
+
},
|
| 104 |
+
});
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
export const useDashboardAnalytics = () => useQuery({
|
| 108 |
+
queryKey: ['dashboard-analytics'],
|
| 109 |
+
queryFn: api.getDashboardAnalytics,
|
| 110 |
+
});
|
client/src/index.css
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
|
| 3 |
+
@theme {
|
| 4 |
+
--color-glass-dark: rgba(255, 255, 255, 0.05);
|
| 5 |
+
--color-glass-border: rgba(255, 255, 255, 0.1);
|
| 6 |
+
--color-accent: #3b82f6; /* Beautiful dynamic blue */
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
@layer base {
|
| 10 |
+
body {
|
| 11 |
+
@apply bg-neutral-950 text-neutral-100 antialiased font-sans;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/* Hide native browser date picker icon */
|
| 15 |
+
input[type="date"]::-webkit-calendar-picker-indicator {
|
| 16 |
+
display: none;
|
| 17 |
+
-webkit-appearance: none;
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.glass-panel {
|
| 22 |
+
@apply bg-glass-dark backdrop-blur-md border border-glass-border shadow-xl rounded-2xl transition-all duration-300;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.glass-panel:hover {
|
| 26 |
+
@apply border-white/20 shadow-2xl shadow-blue-500/10;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
@keyframes fade-in-up {
|
| 30 |
+
from {
|
| 31 |
+
opacity: 0;
|
| 32 |
+
transform: translateY(10px);
|
| 33 |
+
}
|
| 34 |
+
to {
|
| 35 |
+
opacity: 1;
|
| 36 |
+
transform: translateY(0);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.animate-fade-in-up {
|
| 41 |
+
animation: fade-in-up 0.5s ease-out forwards;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.delay-100 { animation-delay: 100ms; }
|
| 45 |
+
.delay-200 { animation-delay: 200ms; }
|
| 46 |
+
.delay-300 { animation-delay: 300ms; }
|
| 47 |
+
.delay-400 { animation-delay: 400ms; }
|
| 48 |
+
.delay-500 { animation-delay: 500ms; }
|
client/src/layouts/MainLayout.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Outlet } from 'react-router-dom';
|
| 2 |
+
import { Sidebar } from './Sidebar';
|
| 3 |
+
import { MobileNav } from './MobileNav';
|
| 4 |
+
|
| 5 |
+
export function MainLayout() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="flex flex-col md:flex-row h-screen bg-neutral-950 text-neutral-100 overflow-hidden font-sans">
|
| 8 |
+
<Sidebar />
|
| 9 |
+
|
| 10 |
+
<main className="flex-1 overflow-y-auto p-4 md:pl-0 h-full">
|
| 11 |
+
<div id="main-content-area" className="h-full glass-panel overflow-y-auto relative no-scrollbar">
|
| 12 |
+
<Outlet />
|
| 13 |
+
</div>
|
| 14 |
+
</main>
|
| 15 |
+
|
| 16 |
+
<MobileNav />
|
| 17 |
+
</div>
|
| 18 |
+
);
|
| 19 |
+
}
|
client/src/layouts/MobileNav.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Link, useLocation } from 'react-router-dom';
|
| 2 |
+
import { cn } from '../lib/utils';
|
| 3 |
+
import { NAV_ITEMS } from './Sidebar';
|
| 4 |
+
import { LogOut } from 'lucide-react';
|
| 5 |
+
import { useAuthStore } from '../store/useAuthStore';
|
| 6 |
+
|
| 7 |
+
export function MobileNav() {
|
| 8 |
+
const location = useLocation();
|
| 9 |
+
|
| 10 |
+
return (
|
| 11 |
+
<aside className="w-full glass-panel flex flex-row pt-2 pb-safe md:hidden z-50 border-t border-white/10 order-last shrink-0 rounded-b-none border-x-0 border-b-0">
|
| 12 |
+
<nav className="flex-1 px-2 flex flex-row overflow-x-auto justify-around space-x-2 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:'none'] [scrollbar-width:'none']">
|
| 13 |
+
{NAV_ITEMS.map((item) => {
|
| 14 |
+
const Icon = item.icon;
|
| 15 |
+
const isActive = location.pathname === item.path;
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<Link
|
| 19 |
+
key={item.path}
|
| 20 |
+
to={item.path}
|
| 21 |
+
className={cn(
|
| 22 |
+
'flex flex-col items-center gap-1 px-3 py-2 rounded-xl transition-all duration-200 min-w-16',
|
| 23 |
+
isActive
|
| 24 |
+
? 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
| 25 |
+
: 'text-neutral-400 border border-transparent'
|
| 26 |
+
)}
|
| 27 |
+
>
|
| 28 |
+
<Icon className={cn("w-5 h-5", isActive ? "text-blue-400" : "text-neutral-500")} />
|
| 29 |
+
<span className={cn("font-medium text-[10px] whitespace-nowrap", isActive ? "" : "text-neutral-500")}>{item.name}</span>
|
| 30 |
+
</Link>
|
| 31 |
+
);
|
| 32 |
+
})}
|
| 33 |
+
|
| 34 |
+
<button
|
| 35 |
+
onClick={() => useAuthStore.getState().logout()}
|
| 36 |
+
className="flex flex-col items-center gap-1 px-3 py-2 rounded-xl transition-all duration-200 min-w-16 text-neutral-400 hover:text-rose-400 hover:bg-rose-500/10 border border-transparent"
|
| 37 |
+
>
|
| 38 |
+
<LogOut className="w-5 h-5" />
|
| 39 |
+
<span className="font-medium text-[10px] whitespace-nowrap">Logout</span>
|
| 40 |
+
</button>
|
| 41 |
+
</nav>
|
| 42 |
+
</aside>
|
| 43 |
+
);
|
| 44 |
+
}
|
client/src/layouts/Sidebar.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Link, useLocation } from 'react-router-dom';
|
| 2 |
+
import { LayoutDashboard, Receipt, Wallet, HandCoins, Settings, PieChart, LogOut } from 'lucide-react';
|
| 3 |
+
import { useAuthStore } from '../store/useAuthStore';
|
| 4 |
+
import { cn } from '../lib/utils';
|
| 5 |
+
|
| 6 |
+
export const NAV_ITEMS = [
|
| 7 |
+
{ name: 'Dashboard', path: '/', icon: LayoutDashboard },
|
| 8 |
+
{ name: 'Transactions', path: '/transactions', icon: Receipt },
|
| 9 |
+
{ name: 'Wallets', path: '/wallets', icon: Wallet },
|
| 10 |
+
{ name: 'Analytics', path: '/analytics', icon: PieChart },
|
| 11 |
+
{ name: 'Loans', path: '/loans', icon: HandCoins },
|
| 12 |
+
{ name: 'Settings', path: '/settings', icon: Settings },
|
| 13 |
+
];
|
| 14 |
+
|
| 15 |
+
export function Sidebar() {
|
| 16 |
+
const location = useLocation();
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<aside className="w-64 glass-panel m-4 flex flex-col pt-6 pb-4 hidden md:flex z-50 shrink-0">
|
| 20 |
+
<div className="px-6 mb-8 flex items-center gap-3">
|
| 21 |
+
<div className="w-8 h-8 flex-shrink-0 rounded-lg bg-blue-500/20 flex items-center justify-center border border-blue-500/30">
|
| 22 |
+
<Wallet className="w-5 h-5 text-blue-400" />
|
| 23 |
+
</div>
|
| 24 |
+
<h1 className="text-xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent truncate">
|
| 25 |
+
MoneyManager
|
| 26 |
+
</h1>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<nav className="flex-1 px-4 flex flex-col overflow-y-auto space-y-1">
|
| 30 |
+
{NAV_ITEMS.map((item) => {
|
| 31 |
+
const Icon = item.icon;
|
| 32 |
+
const isActive = location.pathname === item.path;
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<Link
|
| 36 |
+
key={item.path}
|
| 37 |
+
to={item.path}
|
| 38 |
+
className={cn(
|
| 39 |
+
'flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200',
|
| 40 |
+
isActive
|
| 41 |
+
? 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
| 42 |
+
: 'text-neutral-400 hover:text-neutral-200 hover:bg-white/5 border border-transparent'
|
| 43 |
+
)}
|
| 44 |
+
>
|
| 45 |
+
<Icon className={cn("w-5 h-5", isActive ? "text-blue-400" : "text-neutral-500")} />
|
| 46 |
+
<span className={cn("font-medium text-sm whitespace-nowrap", isActive ? "" : "text-neutral-400")}>{item.name}</span>
|
| 47 |
+
</Link>
|
| 48 |
+
);
|
| 49 |
+
})}
|
| 50 |
+
</nav>
|
| 51 |
+
|
| 52 |
+
<div className="px-4 mt-auto">
|
| 53 |
+
<button
|
| 54 |
+
onClick={() => useAuthStore.getState().logout()}
|
| 55 |
+
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200 text-neutral-400 hover:text-rose-400 hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20"
|
| 56 |
+
>
|
| 57 |
+
<LogOut className="w-5 h-5" />
|
| 58 |
+
<span className="font-medium text-sm">Logout</span>
|
| 59 |
+
</button>
|
| 60 |
+
</div>
|
| 61 |
+
</aside>
|
| 62 |
+
);
|
| 63 |
+
}
|
client/src/lib/react-query.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { QueryClient } from '@tanstack/react-query';
|
| 2 |
+
|
| 3 |
+
export const queryClient = new QueryClient({
|
| 4 |
+
defaultOptions: {
|
| 5 |
+
queries: {
|
| 6 |
+
retry: 1,
|
| 7 |
+
refetchOnWindowFocus: false,
|
| 8 |
+
staleTime: 1000 * 60 * 5, // 5 minutes
|
| 9 |
+
},
|
| 10 |
+
},
|
| 11 |
+
});
|
client/src/lib/utils.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { clsx, type ClassValue } from "clsx"
|
| 2 |
+
import { twMerge } from "tailwind-merge"
|
| 3 |
+
|
| 4 |
+
export function cn(...inputs: ClassValue[]) {
|
| 5 |
+
return twMerge(clsx(inputs))
|
| 6 |
+
}
|
client/src/main.tsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import { QueryClientProvider } from '@tanstack/react-query'
|
| 4 |
+
import { queryClient } from './lib/react-query'
|
| 5 |
+
import './index.css'
|
| 6 |
+
import App from './App.tsx'
|
| 7 |
+
|
| 8 |
+
createRoot(document.getElementById('root')!).render(
|
| 9 |
+
<StrictMode>
|
| 10 |
+
<QueryClientProvider client={queryClient}>
|
| 11 |
+
<App />
|
| 12 |
+
</QueryClientProvider>
|
| 13 |
+
</StrictMode>,
|
| 14 |
+
)
|
client/src/pages/Analytics.tsx
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useAnalyticsStats } from '../features/analytics/useAnalyticsStats';
|
| 2 |
+
import {
|
| 3 |
+
BarChart3,
|
| 4 |
+
TrendingUp,
|
| 5 |
+
Crown,
|
| 6 |
+
Pizza,
|
| 7 |
+
Car,
|
| 8 |
+
Coffee,
|
| 9 |
+
ShoppingBag,
|
| 10 |
+
MoreHorizontal,
|
| 11 |
+
Plane,
|
| 12 |
+
Wallet,
|
| 13 |
+
ShoppingBasket,
|
| 14 |
+
Activity,
|
| 15 |
+
Zap,
|
| 16 |
+
ArrowUpRight,
|
| 17 |
+
ArrowDownRight,
|
| 18 |
+
Scale,
|
| 19 |
+
PiggyBank,
|
| 20 |
+
HandCoins,
|
| 21 |
+
Banknote,
|
| 22 |
+
BrainCircuit,
|
| 23 |
+
Target,
|
| 24 |
+
ZapOff,
|
| 25 |
+
Lightbulb
|
| 26 |
+
} from 'lucide-react';
|
| 27 |
+
import {
|
| 28 |
+
ResponsiveContainer,
|
| 29 |
+
Tooltip,
|
| 30 |
+
AreaChart,
|
| 31 |
+
Area,
|
| 32 |
+
XAxis,
|
| 33 |
+
YAxis,
|
| 34 |
+
CartesianGrid
|
| 35 |
+
} from 'recharts';
|
| 36 |
+
import { cn } from '../lib/utils';
|
| 37 |
+
import { useMemo } from 'react';
|
| 38 |
+
|
| 39 |
+
// Icon mapping for categories
|
| 40 |
+
const CATEGORY_ICONS: Record<string, any> = {
|
| 41 |
+
'food': Pizza,
|
| 42 |
+
'market': ShoppingBag,
|
| 43 |
+
'transport': Car,
|
| 44 |
+
'cafe': Coffee,
|
| 45 |
+
'other': MoreHorizontal,
|
| 46 |
+
'salary': Crown,
|
| 47 |
+
'transfer': Wallet,
|
| 48 |
+
'travel': Plane,
|
| 49 |
+
'shopping': ShoppingBasket,
|
| 50 |
+
'health': Activity,
|
| 51 |
+
'bills': Zap,
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const MONTHS = [
|
| 55 |
+
{ id: 1, label: 'JAN' }, { id: 2, label: 'FEB' }, { id: 3, label: 'MAR' },
|
| 56 |
+
{ id: 4, label: 'APR' }, { id: 5, label: 'MAY' }, { id: 6, label: 'JUN' },
|
| 57 |
+
{ id: 7, label: 'JUL' }, { id: 8, label: 'AUG' }, { id: 9, label: 'SEP' },
|
| 58 |
+
{ id: 10, label: 'OCT' }, { id: 11, label: 'NOV' }, { id: 12, label: 'DEC' }
|
| 59 |
+
];
|
| 60 |
+
|
| 61 |
+
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6'];
|
| 62 |
+
|
| 63 |
+
export default function Analytics() {
|
| 64 |
+
const stats = useAnalyticsStats();
|
| 65 |
+
|
| 66 |
+
const {
|
| 67 |
+
isLoaded,
|
| 68 |
+
mainCurrency,
|
| 69 |
+
selectedYear,
|
| 70 |
+
setSelectedYear,
|
| 71 |
+
selectedMonth,
|
| 72 |
+
setSelectedMonth,
|
| 73 |
+
spendingByCategory,
|
| 74 |
+
avgDailySpend,
|
| 75 |
+
netWorth,
|
| 76 |
+
liquidAssets,
|
| 77 |
+
totalLent,
|
| 78 |
+
totalBorrowed,
|
| 79 |
+
savingsRate,
|
| 80 |
+
debtRatio,
|
| 81 |
+
expenseRatio,
|
| 82 |
+
todaySpend,
|
| 83 |
+
dailySpendChange,
|
| 84 |
+
totalDeposits,
|
| 85 |
+
totalWithdrawals,
|
| 86 |
+
availableYears,
|
| 87 |
+
sharpeRatio,
|
| 88 |
+
maxDrawdown,
|
| 89 |
+
velocity,
|
| 90 |
+
ruleAnalysis,
|
| 91 |
+
projectedBalance,
|
| 92 |
+
heatmap,
|
| 93 |
+
cumulativeHistory,
|
| 94 |
+
largestTransaction
|
| 95 |
+
} = stats;
|
| 96 |
+
|
| 97 |
+
const financialInsight = useMemo(() => {
|
| 98 |
+
if (!spendingByCategory.length) return null;
|
| 99 |
+
const topCat = spendingByCategory[0];
|
| 100 |
+
const leakMsg = topCat.pct > 30 ? `Spend concentration in ${topCat.name} (${topCat.pct.toFixed(0)}%) exceeds risk parameters.` : null;
|
| 101 |
+
const velocityMsg = velocity < 1 ? "Capital circulation is low. Consider redeploying stagnant assets." : "Capital velocity is healthy.";
|
| 102 |
+
const todayMsg = todaySpend > avgDailySpend ? `Today's spend (${todaySpend.toLocaleString()}) is ${(todaySpend / (avgDailySpend || 1)).toFixed(1)}x your daily average.` : "Spending is within historical daily norms.";
|
| 103 |
+
|
| 104 |
+
return { leakMsg, velocityMsg, todayMsg, topCat };
|
| 105 |
+
}, [spendingByCategory, velocity, todaySpend, avgDailySpend]);
|
| 106 |
+
|
| 107 |
+
if (!isLoaded) {
|
| 108 |
+
return (
|
| 109 |
+
<div className="flex items-center justify-center h-screen bg-[#0a0a0a]">
|
| 110 |
+
<div className="flex flex-col items-center gap-4">
|
| 111 |
+
<div className="relative">
|
| 112 |
+
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-indigo-500"></div>
|
| 113 |
+
<div className="absolute inset-0 flex items-center justify-center">
|
| 114 |
+
<BrainCircuit className="w-6 h-6 text-indigo-400 animate-pulse" />
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
<span className="text-gray-500 text-[10px] font-black uppercase tracking-[0.5em] animate-pulse">Processing Intelligence...</span>
|
| 118 |
+
</div>
|
| 119 |
+
</div>
|
| 120 |
+
);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
return (
|
| 124 |
+
<div className="min-h-screen bg-[#0a0a0a] text-white p-4 font-sans pb-24 overflow-y-auto no-scrollbar selection:bg-indigo-500/30">
|
| 125 |
+
{/* Header / Date Control Section */}
|
| 126 |
+
<header className="mb-8 pt-2">
|
| 127 |
+
<div className="flex items-center justify-between mb-8">
|
| 128 |
+
<div>
|
| 129 |
+
<div className="flex items-center gap-2 mb-2">
|
| 130 |
+
<BrainCircuit className="w-4 h-4 text-indigo-500" />
|
| 131 |
+
<span className="text-gray-500 text-[10px] font-black uppercase tracking-[0.3em]">Quantitative Intelligence</span>
|
| 132 |
+
</div>
|
| 133 |
+
<h1 className="text-3xl font-black text-gray-100 flex items-baseline gap-2">
|
| 134 |
+
Analytics
|
| 135 |
+
<span className="text-indigo-600/50 text-base font-bold italic">v2.0</span>
|
| 136 |
+
</h1>
|
| 137 |
+
</div>
|
| 138 |
+
<div className="flex flex-col items-end">
|
| 139 |
+
<p className="text-[10px] font-black text-gray-600 uppercase tracking-widest leading-none mb-1">Status</p>
|
| 140 |
+
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 border border-emerald-500/20 rounded-full">
|
| 141 |
+
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse" />
|
| 142 |
+
<span className="text-[8px] font-black text-emerald-500 uppercase">Live Engine</span>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
{/* Filters Row */}
|
| 148 |
+
<div className="flex items-center gap-4 mb-6">
|
| 149 |
+
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar flex-1">
|
| 150 |
+
{availableYears.map((year: number) => (
|
| 151 |
+
<button
|
| 152 |
+
key={year}
|
| 153 |
+
onClick={() => setSelectedYear(year)}
|
| 154 |
+
className={cn(
|
| 155 |
+
"px-6 py-2.5 rounded-2xl text-[10px] font-black transition-all border shrink-0 uppercase tracking-widest",
|
| 156 |
+
selectedYear === year
|
| 157 |
+
? "bg-indigo-600 text-white border-indigo-500 shadow-[0_0_25px_rgba(99,102,241,0.4)]"
|
| 158 |
+
: "bg-[#111] text-gray-500 border-white/5 hover:text-white"
|
| 159 |
+
)}
|
| 160 |
+
>
|
| 161 |
+
{year}
|
| 162 |
+
</button>
|
| 163 |
+
))}
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
{/* Month Selection Grid */}
|
| 168 |
+
<div className="grid grid-cols-6 gap-1.5 p-1.5 bg-[#111] rounded-[24px] border border-white/5 shadow-inner">
|
| 169 |
+
<button
|
| 170 |
+
onClick={() => setSelectedMonth(null)}
|
| 171 |
+
className={cn(
|
| 172 |
+
"py-3 rounded-[18px] text-[9px] font-black transition-all col-span-2 uppercase tracking-widest",
|
| 173 |
+
selectedMonth === null
|
| 174 |
+
? "bg-white text-black shadow-xl"
|
| 175 |
+
: "text-gray-500 hover:text-gray-300"
|
| 176 |
+
)}
|
| 177 |
+
>
|
| 178 |
+
ANNUAL
|
| 179 |
+
</button>
|
| 180 |
+
{MONTHS.map((m: any) => (
|
| 181 |
+
<button
|
| 182 |
+
key={m.id}
|
| 183 |
+
onClick={() => setSelectedMonth(m.id)}
|
| 184 |
+
className={cn(
|
| 185 |
+
"py-3 rounded-[18px] text-[9px] font-black transition-all uppercase tracking-tight",
|
| 186 |
+
selectedMonth === m.id
|
| 187 |
+
? "bg-white text-black shadow-xl"
|
| 188 |
+
: "text-gray-500 hover:text-gray-300"
|
| 189 |
+
)}
|
| 190 |
+
>
|
| 191 |
+
{m.label}
|
| 192 |
+
</button>
|
| 193 |
+
))}
|
| 194 |
+
</div>
|
| 195 |
+
</header>
|
| 196 |
+
|
| 197 |
+
{/* Intelligence Insights - Leak/Anomaly Detection */}
|
| 198 |
+
{financialInsight && (
|
| 199 |
+
<section className="mb-10 px-2">
|
| 200 |
+
<div className="bg-indigo-600/10 border border-indigo-500/20 rounded-[32px] p-6 relative overflow-hidden group">
|
| 201 |
+
<div className="absolute top-0 right-0 p-6 opacity-10 group-hover:opacity-20 transition-all">
|
| 202 |
+
<Lightbulb className="w-16 h-16 text-indigo-400" />
|
| 203 |
+
</div>
|
| 204 |
+
<div className="flex items-center gap-3 mb-3">
|
| 205 |
+
<div className="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center">
|
| 206 |
+
<Zap className="w-4 h-4 text-indigo-400" />
|
| 207 |
+
</div>
|
| 208 |
+
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-[0.2em]">Strategy Insight</span>
|
| 209 |
+
</div>
|
| 210 |
+
<h4 className="text-sm font-black text-gray-100 mb-2">{financialInsight.leakMsg || "Spending Efficiency is Optimal"}</h4>
|
| 211 |
+
<div className="flex flex-col gap-1">
|
| 212 |
+
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{financialInsight.velocityMsg}</p>
|
| 213 |
+
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">{financialInsight.todayMsg}</p>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
</section>
|
| 217 |
+
)}
|
| 218 |
+
|
| 219 |
+
{/* Hero KPI Cards - Net Worth Focus */}
|
| 220 |
+
<section className="mb-10">
|
| 221 |
+
<div className="bg-gradient-to-br from-[#1a1a1a] to-[#111] p-8 rounded-[40px] border border-white/5 shadow-2xl relative overflow-hidden group">
|
| 222 |
+
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity">
|
| 223 |
+
<TrendingUp className="w-48 h-48 text-indigo-500 -rotate-12" />
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<div className="flex justify-between items-start mb-10">
|
| 227 |
+
<div>
|
| 228 |
+
<p className="text-[10px] font-black text-indigo-400 uppercase tracking-[0.4em] mb-4">NAV (Net Asset Value)</p>
|
| 229 |
+
<div className="flex items-baseline gap-3">
|
| 230 |
+
<h2 className="text-5xl font-black text-white tracking-tighter">
|
| 231 |
+
{netWorth.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
| 232 |
+
</h2>
|
| 233 |
+
<span className="text-gray-600 font-black text-lg">{mainCurrency}</span>
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
<div className="text-right">
|
| 237 |
+
<p className="text-[10px] font-black text-gray-600 uppercase tracking-widest mb-2">Projected EOM</p>
|
| 238 |
+
<p className={cn(
|
| 239 |
+
"text-xl font-black tracking-tight",
|
| 240 |
+
projectedBalance > netWorth ? "text-emerald-400" : "text-rose-400"
|
| 241 |
+
)}>
|
| 242 |
+
{projectedBalance.toLocaleString(undefined, { maximumFractionDigits: 0 })}
|
| 243 |
+
</p>
|
| 244 |
+
</div>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<div className="grid grid-cols-4 gap-4">
|
| 248 |
+
<div className="bg-white/5 p-4 rounded-2xl border border-white/5 backdrop-blur-md">
|
| 249 |
+
<p className="text-[8px] font-bold text-gray-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
|
| 250 |
+
<Target className="w-2.5 h-2.5 text-emerald-400" /> Savings
|
| 251 |
+
</p>
|
| 252 |
+
<p className={cn("text-xs font-black", savingsRate >= 20 ? "text-emerald-400" : "text-amber-400")}>
|
| 253 |
+
{savingsRate.toFixed(1)}%
|
| 254 |
+
</p>
|
| 255 |
+
</div>
|
| 256 |
+
<div className="bg-white/5 p-4 rounded-2xl border border-white/5 backdrop-blur-md">
|
| 257 |
+
<p className="text-[8px] font-bold text-gray-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
|
| 258 |
+
<ZapOff className="w-2.5 h-2.5 text-rose-400" /> Debt
|
| 259 |
+
</p>
|
| 260 |
+
<p className="text-xs font-black text-rose-400">
|
| 261 |
+
{debtRatio.toFixed(1)}%
|
| 262 |
+
</p>
|
| 263 |
+
</div>
|
| 264 |
+
<div className="bg-white/5 p-4 rounded-2xl border border-white/5 backdrop-blur-md">
|
| 265 |
+
<p className="text-[8px] font-bold text-gray-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
|
| 266 |
+
<Scale className="w-2.5 h-2.5 text-amber-400" /> Expense
|
| 267 |
+
</p>
|
| 268 |
+
<p className="text-xs font-black text-amber-400">
|
| 269 |
+
{expenseRatio.toFixed(1)}%
|
| 270 |
+
</p>
|
| 271 |
+
</div>
|
| 272 |
+
<div className="bg-white/5 p-4 rounded-2xl border border-white/5 backdrop-blur-md">
|
| 273 |
+
<p className="text-[8px] font-bold text-gray-500 uppercase tracking-widest mb-1.5 flex items-center gap-1.5">
|
| 274 |
+
<Activity className="w-2.5 h-2.5 text-indigo-400" /> Velocity
|
| 275 |
+
</p>
|
| 276 |
+
<p className="text-xs font-black text-indigo-400">
|
| 277 |
+
{velocity.toFixed(2)}x
|
| 278 |
+
</p>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
</section>
|
| 283 |
+
|
| 284 |
+
{/* Risk Metrics Section - Quant Finance Essentials */}
|
| 285 |
+
<section className="grid grid-cols-2 gap-4 mb-10">
|
| 286 |
+
<div className="bg-[#111] p-6 rounded-[32px] border border-white/5 shadow-xl relative overflow-hidden group hover:border-indigo-500/30 transition-all">
|
| 287 |
+
<div className="flex items-center justify-between mb-4">
|
| 288 |
+
<div className="w-10 h-10 rounded-2xl bg-indigo-500/10 flex items-center justify-center">
|
| 289 |
+
<BarChart3 className="w-5 h-5 text-indigo-400" />
|
| 290 |
+
</div>
|
| 291 |
+
<div className="text-right">
|
| 292 |
+
<p className="text-[8px] font-black text-gray-600 uppercase tracking-widest mb-0.5">Risk Adjusted</p>
|
| 293 |
+
<span className="text-[10px] font-black text-indigo-400 uppercase tracking-tighter self-end bg-indigo-500/10 px-2 py-0.5 rounded-full border border-indigo-500/20">
|
| 294 |
+
Sharpe: {sharpeRatio.toFixed(2)}
|
| 295 |
+
</span>
|
| 296 |
+
</div>
|
| 297 |
+
</div>
|
| 298 |
+
<p className="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1">Daily Volatility</p>
|
| 299 |
+
<p className="text-2xl font-black tracking-tighter">
|
| 300 |
+
{dailySpendChange > 0 ? '+' : ''}{dailySpendChange.toFixed(1)}%
|
| 301 |
+
</p>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<div className="bg-[#111] p-6 rounded-[32px] border border-white/5 shadow-xl relative overflow-hidden group hover:border-rose-500/30 transition-all">
|
| 305 |
+
<div className="flex items-center justify-between mb-4">
|
| 306 |
+
<div className="w-10 h-10 rounded-2xl bg-rose-500/10 flex items-center justify-center">
|
| 307 |
+
<ZapOff className="w-5 h-5 text-rose-400" />
|
| 308 |
+
</div>
|
| 309 |
+
<div className="text-right">
|
| 310 |
+
<p className="text-[8px] font-black text-gray-600 uppercase tracking-widest mb-0.5">Tolerance</p>
|
| 311 |
+
<span className="text-[10px] font-black text-rose-400 uppercase tracking-tighter self-end bg-rose-500/10 px-2 py-0.5 rounded-full border border-rose-500/20">
|
| 312 |
+
Drawdown
|
| 313 |
+
</span>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
<p className="text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1">Max Exposure</p>
|
| 317 |
+
<p className="text-2xl font-black tracking-tighter text-rose-400">
|
| 318 |
+
-{(maxDrawdown * 100).toFixed(1)}%
|
| 319 |
+
</p>
|
| 320 |
+
</div>
|
| 321 |
+
</section>
|
| 322 |
+
|
| 323 |
+
{/* Cash Flow Dynamics - Advanced Area + Line Projection */}
|
| 324 |
+
<section className="mb-16">
|
| 325 |
+
<h3 className="text-xs font-black text-gray-500 uppercase tracking-[0.3em] mb-8 flex items-center gap-3">
|
| 326 |
+
<TrendingUp className="w-4 h-4 text-indigo-500" /> Capital Flow Trend
|
| 327 |
+
</h3>
|
| 328 |
+
<div className="bg-[#111] p-8 rounded-[40px] border border-white/5 shadow-2xl relative overflow-hidden group">
|
| 329 |
+
<div className="h-[320px] w-full relative">
|
| 330 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 331 |
+
<AreaChart data={cumulativeHistory}>
|
| 332 |
+
<defs>
|
| 333 |
+
<linearGradient id="colorBalance" x1="0" y1="0" x2="0" y2="1">
|
| 334 |
+
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.2}/>
|
| 335 |
+
<stop offset="95%" stopColor="#6366f1" stopOpacity={0}/>
|
| 336 |
+
</linearGradient>
|
| 337 |
+
</defs>
|
| 338 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#ffffff" strokeOpacity={0.03} />
|
| 339 |
+
<XAxis
|
| 340 |
+
dataKey="date"
|
| 341 |
+
tick={{ fontSize: 8, fill: '#444', fontWeight: 'bold' }}
|
| 342 |
+
axisLine={false}
|
| 343 |
+
tickLine={false}
|
| 344 |
+
tickFormatter={(val) => val.split('-').slice(1).join('/')}
|
| 345 |
+
/>
|
| 346 |
+
<YAxis hide />
|
| 347 |
+
<Tooltip
|
| 348 |
+
contentStyle={{ backgroundColor: '#111', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '16px', color: '#fff', fontSize: '10px' }}
|
| 349 |
+
itemStyle={{ fontWeight: 'black' }}
|
| 350 |
+
/>
|
| 351 |
+
<Area
|
| 352 |
+
type="monotone"
|
| 353 |
+
dataKey="balance"
|
| 354 |
+
stroke="#6366f1"
|
| 355 |
+
strokeWidth={3}
|
| 356 |
+
fill="url(#colorBalance)"
|
| 357 |
+
animationDuration={2500}
|
| 358 |
+
/>
|
| 359 |
+
</AreaChart>
|
| 360 |
+
</ResponsiveContainer>
|
| 361 |
+
</div>
|
| 362 |
+
|
| 363 |
+
<div className="grid grid-cols-2 gap-8 mt-8 pt-8 border-t border-white/5">
|
| 364 |
+
<div>
|
| 365 |
+
<p className="text-[10px] font-black text-emerald-500 uppercase tracking-widest mb-2 flex items-center gap-2">
|
| 366 |
+
<ArrowUpRight className="w-3 h-3" /> Inflow
|
| 367 |
+
</p>
|
| 368 |
+
<p className="text-2xl font-black text-white">{totalDeposits.toLocaleString()}</p>
|
| 369 |
+
</div>
|
| 370 |
+
<div>
|
| 371 |
+
<p className="text-[10px] font-black text-rose-500 uppercase tracking-widest mb-2 flex items-center gap-2">
|
| 372 |
+
<ArrowDownRight className="w-3 h-3" /> Outflow
|
| 373 |
+
</p>
|
| 374 |
+
<p className="text-2xl font-black text-white">{totalWithdrawals.toLocaleString()}</p>
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
</section>
|
| 379 |
+
|
| 380 |
+
{/* 50/30/20 Rule Analysis Section */}
|
| 381 |
+
<section className="mb-16">
|
| 382 |
+
<div className="flex items-center justify-between mb-8">
|
| 383 |
+
<h3 className="text-xs font-black text-gray-500 uppercase tracking-[0.3em] flex items-center gap-3">
|
| 384 |
+
<Target className="w-4 h-4 text-amber-500" /> Allocation Strategy
|
| 385 |
+
</h3>
|
| 386 |
+
<div className="px-3 py-1 bg-white/5 rounded-full border border-white/10 text-[8px] font-black text-amber-500 uppercase tracking-widest">
|
| 387 |
+
50/30/20 Framework
|
| 388 |
+
</div>
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
+
<div className="bg-[#111] p-8 rounded-[40px] border border-white/5 shadow-xl relative overflow-hidden mb-6">
|
| 392 |
+
<div className="flex h-12 w-full gap-1 mb-8 rounded-2xl overflow-hidden shadow-inner bg-white/5 p-1">
|
| 393 |
+
<div
|
| 394 |
+
className="h-full bg-indigo-500 transition-all duration-1000 shadow-[0_0_15px_rgba(99,102,241,0.3)] rounded-l-xl"
|
| 395 |
+
style={{ width: `${ruleAnalysis.needs}%` }}
|
| 396 |
+
/>
|
| 397 |
+
<div
|
| 398 |
+
className="h-full bg-amber-500 transition-all duration-1000 shadow-[0_0_15px_rgba(245,158,11,0.3)]"
|
| 399 |
+
style={{ width: `${ruleAnalysis.wants}%` }}
|
| 400 |
+
/>
|
| 401 |
+
<div
|
| 402 |
+
className="h-full bg-emerald-500 transition-all duration-1000 shadow-[0_0_15px_rgba(16,185,129,0.3)] rounded-r-xl"
|
| 403 |
+
style={{ width: `${ruleAnalysis.savings}%` }}
|
| 404 |
+
/>
|
| 405 |
+
</div>
|
| 406 |
+
|
| 407 |
+
<div className="grid grid-cols-3 gap-4">
|
| 408 |
+
<div className="space-y-1">
|
| 409 |
+
<p className="text-[8px] font-black text-gray-500 uppercase tracking-[0.1em]">Needs (Target 50%)</p>
|
| 410 |
+
<p className="text-xl font-black text-indigo-400">{ruleAnalysis.needs.toFixed(0)}%</p>
|
| 411 |
+
</div>
|
| 412 |
+
<div className="space-y-1">
|
| 413 |
+
<p className="text-[8px] font-black text-gray-500 uppercase tracking-[0.1em]">Wants (Target 30%)</p>
|
| 414 |
+
<p className="text-xl font-black text-amber-400">{ruleAnalysis.wants.toFixed(0)}%</p>
|
| 415 |
+
</div>
|
| 416 |
+
<div className="space-y-1">
|
| 417 |
+
<p className="text-[8px] font-black text-gray-500 uppercase tracking-[0.1em]">Savings (Target 20%)</p>
|
| 418 |
+
<p className="text-xl font-black text-emerald-400">{ruleAnalysis.savings.toFixed(0)}%</p>
|
| 419 |
+
</div>
|
| 420 |
+
</div>
|
| 421 |
+
</div>
|
| 422 |
+
</section>
|
| 423 |
+
|
| 424 |
+
{/* Spending Density Heatmap */}
|
| 425 |
+
<section className="mb-16">
|
| 426 |
+
<h3 className="text-xs font-black text-gray-500 uppercase tracking-[0.3em] mb-8 flex items-center gap-3">
|
| 427 |
+
<Activity className="w-4 h-4 text-emerald-500" /> Behavioral Heatmap
|
| 428 |
+
</h3>
|
| 429 |
+
<div className="bg-[#111] p-8 rounded-[40px] border border-white/5 shadow-2xl overflow-hidden">
|
| 430 |
+
<p className="text-[10px] font-black text-gray-600 uppercase tracking-widest mb-6">Spending Concentration (Day vs Hour)</p>
|
| 431 |
+
<div className="flex flex-col gap-2">
|
| 432 |
+
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((day, dIdx) => (
|
| 433 |
+
<div key={day} className="flex items-center gap-2">
|
| 434 |
+
<span className="text-[8px] font-black text-gray-600 w-8">{day}</span>
|
| 435 |
+
<div className="flex gap-1 flex-1">
|
| 436 |
+
{Array.from({ length: 24 }).map((_, hIdx) => {
|
| 437 |
+
const value = heatmap[`${dIdx}-${hIdx}`] || 0;
|
| 438 |
+
const opacity = Math.min(1, value / (avgDailySpend || 1));
|
| 439 |
+
return (
|
| 440 |
+
<div
|
| 441 |
+
key={hIdx}
|
| 442 |
+
className="flex-1 aspect-square rounded-[2px] transition-all hover:scale-110 active:scale-95 cursor-pointer"
|
| 443 |
+
style={{
|
| 444 |
+
backgroundColor: value > 0 ? '#6366f1' : 'rgba(255,255,255,0.02)',
|
| 445 |
+
opacity: value > 0 ? 0.2 + (opacity * 0.8) : 1
|
| 446 |
+
}}
|
| 447 |
+
title={`Time: ${hIdx}:00, Spend: ${value.toLocaleString()}`}
|
| 448 |
+
/>
|
| 449 |
+
);
|
| 450 |
+
})}
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
))}
|
| 454 |
+
</div>
|
| 455 |
+
<div className="flex justify-between mt-6 text-[8px] font-black text-gray-600 uppercase tracking-[0.2em] px-10">
|
| 456 |
+
<span>00:00</span>
|
| 457 |
+
<span>12:00</span>
|
| 458 |
+
<span>23:00</span>
|
| 459 |
+
</div>
|
| 460 |
+
</div>
|
| 461 |
+
</section>
|
| 462 |
+
|
| 463 |
+
{/* Spending Analysis Section */}
|
| 464 |
+
<section className="mb-16">
|
| 465 |
+
<div className="flex items-center justify-between mb-8">
|
| 466 |
+
<h3 className="text-xs font-black text-gray-500 uppercase tracking-[0.3em] flex items-center gap-3">
|
| 467 |
+
<Pizza className="w-4 h-4 text-orange-500" /> Expenditure DNA
|
| 468 |
+
</h3>
|
| 469 |
+
<div className="flex gap-2">
|
| 470 |
+
{largestTransaction && (
|
| 471 |
+
<div className="px-3 py-1 bg-rose-500/10 rounded-full border border-rose-500/20 text-[8px] font-black text-rose-500 uppercase tracking-widest flex items-center gap-1.5">
|
| 472 |
+
<Zap className="w-2 h-2" /> Outlier: {largestTransaction.mainAmount.toLocaleString()}
|
| 473 |
+
</div>
|
| 474 |
+
)}
|
| 475 |
+
<div className="px-3 py-1 bg-white/5 rounded-full border border-white/10 uppercase font-black text-[8px] tracking-widest text-orange-400">
|
| 476 |
+
{spendingByCategory.length} ACTIVE CLUSTERS
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
</div>
|
| 480 |
+
|
| 481 |
+
<div className="grid grid-cols-1 gap-4">
|
| 482 |
+
{spendingByCategory.map((cat: any, index: number) => {
|
| 483 |
+
const Icon = CATEGORY_ICONS[cat.name.toLowerCase()] || MoreHorizontal;
|
| 484 |
+
const color = COLORS[index % COLORS.length];
|
| 485 |
+
return (
|
| 486 |
+
<div key={cat.name} className="bg-[#111] p-6 rounded-[32px] border border-white/5 group hover:border-white/10 transition-all relative overflow-hidden">
|
| 487 |
+
<div className="absolute top-0 left-0 w-1 h-full opacity-50 transition-all group-hover:w-2" style={{ backgroundColor: color }}></div>
|
| 488 |
+
<div className="flex items-center gap-6 mb-5">
|
| 489 |
+
<div className="w-14 h-14 rounded-2xl flex items-center justify-center relative overflow-hidden shrink-0 shadow-lg group-hover:scale-105 transition-transform">
|
| 490 |
+
<div className="absolute inset-0 opacity-10" style={{ backgroundColor: color }}></div>
|
| 491 |
+
<Icon className="w-7 h-7 relative z-10" style={{ color: color }} />
|
| 492 |
+
</div>
|
| 493 |
+
<div className="flex-1 min-w-0">
|
| 494 |
+
<div className="flex justify-between items-center mb-1.5">
|
| 495 |
+
<h4 className="text-base font-black text-gray-100 capitalize tracking-tight">{cat.name}</h4>
|
| 496 |
+
<span className="text-base font-black tracking-tight">{cat.value.toLocaleString(undefined, { maximumFractionDigits: 0 })} <span className="text-[10px] text-gray-600 ml-1">{mainCurrency}</span></span>
|
| 497 |
+
</div>
|
| 498 |
+
<div className="flex justify-between text-[10px] font-bold text-gray-500 uppercase tracking-tighter">
|
| 499 |
+
<span>{cat.count} Transactions Logged</span>
|
| 500 |
+
<span className="text-indigo-400 font-black">{cat.pct.toFixed(1)}% Global Share</span>
|
| 501 |
+
</div>
|
| 502 |
+
</div>
|
| 503 |
+
</div>
|
| 504 |
+
<div className="w-full h-1.5 bg-white/5 rounded-full overflow-hidden shadow-inner">
|
| 505 |
+
<div className="h-full transition-all duration-1000 ease-out" style={{ width: `${cat.pct}%`, backgroundColor: color }} />
|
| 506 |
+
</div>
|
| 507 |
+
</div>
|
| 508 |
+
);
|
| 509 |
+
})}
|
| 510 |
+
{spendingByCategory.length === 0 && (
|
| 511 |
+
<div className="bg-[#111] p-12 rounded-[32px] border border-white/5 text-center">
|
| 512 |
+
<Activity className="w-12 h-12 text-gray-800 mx-auto mb-4" />
|
| 513 |
+
<p className="text-[10px] font-black text-gray-600 uppercase tracking-widest leading-relaxed">System analysis indicates<br/>no expenditure recorded for this period.</p>
|
| 514 |
+
</div>
|
| 515 |
+
)}
|
| 516 |
+
</div>
|
| 517 |
+
</section>
|
| 518 |
+
|
| 519 |
+
{/* Loans & Liabilities Section */}
|
| 520 |
+
<section className="pb-12 text-center">
|
| 521 |
+
<h3 className="text-xs font-black text-gray-500 uppercase tracking-[0.3em] mb-8 flex items-center justify-center gap-3">
|
| 522 |
+
<HandCoins className="w-4 h-4 text-amber-500" /> Debt & Equity Summary
|
| 523 |
+
</h3>
|
| 524 |
+
|
| 525 |
+
<div className="grid grid-cols-2 gap-4 mb-8">
|
| 526 |
+
<div className="bg-[#111] p-6 rounded-[32px] border border-white/5 relative overflow-hidden group hover:border-emerald-500/20 transition-all">
|
| 527 |
+
<div className="absolute -top-4 -right-4 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
| 528 |
+
<PiggyBank className="w-16 h-16" />
|
| 529 |
+
</div>
|
| 530 |
+
<p className="text-[9px] font-black text-emerald-500 uppercase tracking-widest mb-3">Portfolio Credit</p>
|
| 531 |
+
<p className="text-2xl font-black text-gray-100 tracking-tighter">{totalLent.toLocaleString()}</p>
|
| 532 |
+
<p className="text-[8px] text-gray-600 mt-2 font-bold uppercase tracking-tight">Owed to you</p>
|
| 533 |
+
</div>
|
| 534 |
+
<div className="bg-[#111] p-6 rounded-[32px] border border-white/5 relative overflow-hidden group hover:border-rose-500/20 transition-all">
|
| 535 |
+
<div className="absolute -top-4 -right-4 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
| 536 |
+
<Banknote className="w-16 h-16" />
|
| 537 |
+
</div>
|
| 538 |
+
<p className="text-[9px] font-black text-rose-500 uppercase tracking-widest mb-3">Liabilities</p>
|
| 539 |
+
<p className="text-2xl font-black text-gray-100 tracking-tighter">{totalBorrowed.toLocaleString()}</p>
|
| 540 |
+
<p className="text-[8px] text-gray-600 mt-2 font-bold uppercase tracking-tight">System Debt</p>
|
| 541 |
+
</div>
|
| 542 |
+
</div>
|
| 543 |
+
|
| 544 |
+
<div className="bg-[#111] p-8 rounded-[40px] border border-white/5 shadow-2xl relative overflow-hidden">
|
| 545 |
+
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-emerald-500 to-rose-500"></div>
|
| 546 |
+
<div className="flex items-center justify-between mb-8">
|
| 547 |
+
<p className="text-[10px] font-black text-gray-500 uppercase tracking-[0.3em]">Account Solvency</p>
|
| 548 |
+
<div className={cn(
|
| 549 |
+
"text-[10px] font-black px-4 py-1.5 rounded-full shadow-lg",
|
| 550 |
+
netWorth > 0 ? "bg-emerald-500 text-black border-none" : "bg-rose-500 text-white border-none"
|
| 551 |
+
)}>
|
| 552 |
+
{netWorth > 0 ? 'CRITICAL SOLVENCY ACHIEVED' : 'LEVERAGED EXPOSURE'}
|
| 553 |
+
</div>
|
| 554 |
+
</div>
|
| 555 |
+
|
| 556 |
+
{/* Simple visual indicator bar */}
|
| 557 |
+
<div className="w-full h-4 bg-white/5 rounded-full overflow-hidden flex shadow-inner mb-4">
|
| 558 |
+
<div
|
| 559 |
+
className="h-full bg-emerald-500 transition-all duration-1000 shadow-[0_0_15px_rgba(16,185,129,0.3)]"
|
| 560 |
+
style={{ width: `${(liquidAssets / (liquidAssets + totalBorrowed || 1)) * 100}%` }}
|
| 561 |
+
/>
|
| 562 |
+
<div
|
| 563 |
+
className="h-full bg-rose-500 transition-all duration-1000 shadow-[0_0_15px_rgba(239,68,68,0.3)]"
|
| 564 |
+
style={{ width: `${(totalBorrowed / (liquidAssets + totalBorrowed || 1)) * 100}%` }}
|
| 565 |
+
/>
|
| 566 |
+
</div>
|
| 567 |
+
<div className="flex justify-between text-[10px] font-black uppercase tracking-widest text-gray-600">
|
| 568 |
+
<span className="flex items-center gap-2"><div className="w-2 h-2 bg-emerald-500 rounded-full"></div> Assets</span>
|
| 569 |
+
<span className="flex items-center gap-2">Liabilities <div className="w-2 h-2 bg-rose-500 rounded-full"></div></span>
|
| 570 |
+
</div>
|
| 571 |
+
</div>
|
| 572 |
+
</section>
|
| 573 |
+
|
| 574 |
+
{/* Sticky Bottom Nav / Quick Jump */}
|
| 575 |
+
<div className="sticky bottom-8 left-0 right-0 flex justify-center z-50 pointer-events-none">
|
| 576 |
+
<div className="bg-white/10 backdrop-blur-2xl px-1.5 py-1.5 rounded-[24px] border border-white/10 shadow-[0_20px_50px_rgba(0,0,0,0.5)] flex items-center gap-1.5 pointer-events-auto">
|
| 577 |
+
<button
|
| 578 |
+
onClick={() => {
|
| 579 |
+
const container = document.getElementById('main-content-area');
|
| 580 |
+
if (container) container.scrollTo({ top: 0, behavior: 'smooth' });
|
| 581 |
+
}}
|
| 582 |
+
className="px-6 py-3 rounded-[18px] bg-white text-black text-[10px] font-black transition-all shadow-xl hover:scale-105 active:scale-95 uppercase tracking-widest"
|
| 583 |
+
>
|
| 584 |
+
TOP
|
| 585 |
+
</button>
|
| 586 |
+
</div>
|
| 587 |
+
</div>
|
| 588 |
+
|
| 589 |
+
<style dangerouslySetInnerHTML={{ __html: `
|
| 590 |
+
.no-scrollbar::-webkit-scrollbar { display: none; }
|
| 591 |
+
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
| 592 |
+
`}} />
|
| 593 |
+
</div>
|
| 594 |
+
);
|
| 595 |
+
}
|
client/src/pages/Dashboard.tsx
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useAppStore } from '../store/useAppStore';
|
| 2 |
+
import { useDashboardStats } from '../features/dashboard/useDashboardStats';
|
| 3 |
+
import { NetWorthWidget } from '../features/dashboard/components/NetWorthWidget';
|
| 4 |
+
import { CashFlowWidget } from '../features/dashboard/components/CashFlowWidget';
|
| 5 |
+
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/card';
|
| 6 |
+
import { Select } from '../components/ui/select';
|
| 7 |
+
import { WalletIcon, TrendingUp, PieChart } from 'lucide-react';
|
| 8 |
+
import { useTransactions } from '../hooks/queries';
|
| 9 |
+
import { format } from 'date-fns';
|
| 10 |
+
import { cn } from '../lib/utils';
|
| 11 |
+
|
| 12 |
+
export default function Dashboard() {
|
| 13 |
+
const setMainCurrency = useAppStore(s => s.setMainCurrency);
|
| 14 |
+
const { data: transactions = [] } = useTransactions();
|
| 15 |
+
|
| 16 |
+
const {
|
| 17 |
+
isLoaded, mainCurrency, totalNetWorth, cashAssetsTotal, loansOwedToYouTotal, debtsYouOweTotal,
|
| 18 |
+
currencyBalances, rawWalletBalances, periodIncome, periodExpense, spendingByCategory, topExpenseCategory,
|
| 19 |
+
exchangeRates
|
| 20 |
+
} = useDashboardStats();
|
| 21 |
+
|
| 22 |
+
if (!isLoaded) {
|
| 23 |
+
return (
|
| 24 |
+
<div className="p-4 md:p-8 h-full flex items-center justify-center">
|
| 25 |
+
<p className="text-neutral-400 animate-pulse">Calculating net worth...</p>
|
| 26 |
+
</div>
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="p-4 md:p-8 h-full overflow-y-auto flex flex-col">
|
| 32 |
+
<div className="flex justify-between items-center mb-6 md:mb-8 shrink-0">
|
| 33 |
+
<div>
|
| 34 |
+
<h1 className="text-xl md:text-2xl font-bold">Dashboard</h1>
|
| 35 |
+
<p className="text-sm md:text-base text-neutral-400">Overview of your true personal net worth.</p>
|
| 36 |
+
</div>
|
| 37 |
+
<Select value={mainCurrency} onChange={e => setMainCurrency(e.target.value)} className="w-auto border-white/20 bg-black/60 shadow-lg text-white font-medium">
|
| 38 |
+
<option value="USD">View in USD</option>
|
| 39 |
+
<option value="IQD">View in IQD</option>
|
| 40 |
+
<option value="RMB">View in RMB</option>
|
| 41 |
+
</Select>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div className="glass-panel items-center justify-center p-4 mb-6 md:mb-8 flex bg-gradient-to-r from-blue-500/10 via-purple-500/10 to-blue-500/10 border-white/5 shadow-lg relative overflow-hidden group shrink-0">
|
| 45 |
+
<div className="absolute inset-0 bg-[linear-gradient(45deg,transparent_25%,rgba(68,107,254,0.1)_50%,transparent_75%)] bg-[length:250%_250%] opacity-50 group-hover:animate-[gradient_3s_linear_infinite]" style={{ animation: "gradient 3s linear infinite" }}></div>
|
| 46 |
+
<p className="text-lg md:text-2xl font-medium flex items-center gap-3 sm:gap-6 relative z-10 flex-wrap justify-center drop-shadow-md">
|
| 47 |
+
<span className="text-rose-400 flex items-center gap-1.5 font-semibold">
|
| 48 |
+
{(exchangeRates['RMB'] * 100)?.toLocaleString(undefined, { maximumFractionDigits: 2 })} <span className="text-sm md:text-base text-rose-400/80">RMB</span>
|
| 49 |
+
</span>
|
| 50 |
+
<span className="text-neutral-500/50 font-light">=</span>
|
| 51 |
+
<span className="text-emerald-400 font-bold flex items-center gap-1.5 shadow-emerald-500/50">
|
| 52 |
+
100 <span className="text-sm md:text-base text-emerald-400/80">$</span>
|
| 53 |
+
</span>
|
| 54 |
+
<span className="text-neutral-500/50 font-light">=</span>
|
| 55 |
+
<span className="text-indigo-400 flex items-center gap-1.5 font-semibold">
|
| 56 |
+
{(exchangeRates['IQD'] * 100)?.toLocaleString(undefined, { maximumFractionDigits: 0 })} <span className="text-sm md:text-base text-indigo-400/80">IQD</span>
|
| 57 |
+
</span>
|
| 58 |
+
</p>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<NetWorthWidget
|
| 62 |
+
totalNetWorth={totalNetWorth}
|
| 63 |
+
cashAssetsTotal={cashAssetsTotal}
|
| 64 |
+
loansOwedToYouTotal={loansOwedToYouTotal}
|
| 65 |
+
debtsYouOweTotal={debtsYouOweTotal}
|
| 66 |
+
mainCurrency={mainCurrency}
|
| 67 |
+
/>
|
| 68 |
+
|
| 69 |
+
<Card className="mb-6 md:mb-8 shrink-0">
|
| 70 |
+
<CardHeader className="pb-2">
|
| 71 |
+
<CardTitle className="text-base md:text-lg text-neutral-300">Available Liquid Cash Balances</CardTitle>
|
| 72 |
+
</CardHeader>
|
| 73 |
+
<CardContent>
|
| 74 |
+
<div className="flex flex-col sm:flex-row gap-4 md:gap-6">
|
| 75 |
+
{Object.entries(currencyBalances).map(([curr, amt]) => (
|
| 76 |
+
<div key={curr} className="bg-white/5 rounded-xl px-4 py-3 border border-white/5 flex-1 text-center">
|
| 77 |
+
<p className="text-sm text-neutral-400 mb-1">{curr}</p>
|
| 78 |
+
<p className="text-xl font-semibold">{amt.toLocaleString()} {curr}</p>
|
| 79 |
+
</div>
|
| 80 |
+
))}
|
| 81 |
+
</div>
|
| 82 |
+
</CardContent>
|
| 83 |
+
</Card>
|
| 84 |
+
|
| 85 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 shrink-0 pb-12">
|
| 86 |
+
|
| 87 |
+
<CashFlowWidget periodIncome={periodIncome} periodExpense={periodExpense} mainCurrency={mainCurrency} />
|
| 88 |
+
|
| 89 |
+
<Card className="flex flex-col min-h-[250px] md:min-h-[300px]">
|
| 90 |
+
<CardHeader className="flex flex-row justify-between items-strat pb-2">
|
| 91 |
+
<CardTitle className="flex items-center gap-2 text-neutral-200">
|
| 92 |
+
<PieChart className="w-5 h-5 text-orange-400" /> Spending by Category
|
| 93 |
+
</CardTitle>
|
| 94 |
+
{topExpenseCategory.amount > 0 && (
|
| 95 |
+
<div className="text-xs bg-orange-500/20 text-orange-300 border border-orange-500/20 px-3 py-1.5 rounded-full flex flex-col items-end">
|
| 96 |
+
<span className="text-[10px] text-orange-400/80 uppercase tracking-widest font-semibold">Top Expense</span>
|
| 97 |
+
<span>{topExpenseCategory.name} ({topExpenseCategory.amount.toLocaleString(undefined, { maximumFractionDigits: 0 })} {mainCurrency})</span>
|
| 98 |
+
</div>
|
| 99 |
+
)}
|
| 100 |
+
</CardHeader>
|
| 101 |
+
<CardContent className="flex-1 min-h-0 space-y-4">
|
| 102 |
+
{Object.entries(spendingByCategory).sort((a, b) => b[1] - a[1]).map(([cat, amount]) => {
|
| 103 |
+
const percentage = periodExpense > 0 ? (amount / periodExpense) * 100 : 0;
|
| 104 |
+
return (
|
| 105 |
+
<div key={cat} className="mb-2">
|
| 106 |
+
<div className="flex justify-between items-end mb-1">
|
| 107 |
+
<span className="font-medium text-sm">{cat}</span>
|
| 108 |
+
<span className="text-sm text-neutral-400">{amount.toLocaleString(undefined, { maximumFractionDigits: 0 })} {mainCurrency}</span>
|
| 109 |
+
</div>
|
| 110 |
+
<div className="h-2 w-full bg-black/40 rounded-full overflow-hidden flex">
|
| 111 |
+
<div style={{ width: `${percentage}%` }} className="bg-orange-500 h-full rounded-full" />
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
);
|
| 115 |
+
})
|
| 116 |
+
}
|
| 117 |
+
{Object.keys(spendingByCategory).length === 0 && <p className="text-neutral-500 text-center py-4">No expenses recorded in this period.</p>}
|
| 118 |
+
</CardContent>
|
| 119 |
+
</Card>
|
| 120 |
+
|
| 121 |
+
<Card className="flex flex-col min-h-[250px] md:min-h-[300px]">
|
| 122 |
+
<CardHeader className="pb-2">
|
| 123 |
+
<CardTitle className="flex items-center gap-2">
|
| 124 |
+
<WalletIcon className="w-5 h-5 text-indigo-400" /> Wallet Balances
|
| 125 |
+
</CardTitle>
|
| 126 |
+
</CardHeader>
|
| 127 |
+
<CardContent className="flex-1 min-h-0 space-y-3">
|
| 128 |
+
{rawWalletBalances.map((w: any) => (
|
| 129 |
+
<div key={w.id} className="flex justify-between items-center bg-white/5 p-3 md:p-4 rounded-xl border border-white/5 hover:bg-white/10 transition-colors">
|
| 130 |
+
<div className="flex items-center gap-2 md:gap-3">
|
| 131 |
+
<div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-indigo-500/20 text-indigo-400 flex items-center justify-center font-bold text-sm md:text-base">
|
| 132 |
+
{w.name[0]}
|
| 133 |
+
</div>
|
| 134 |
+
<div>
|
| 135 |
+
<p className="font-medium text-sm md:text-base text-left">{w.name}</p>
|
| 136 |
+
<p className="text-[10px] md:text-xs text-neutral-500 capitalize text-left">{w.type}</p>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
<div className="text-right">
|
| 140 |
+
<p className="font-semibold text-sm md:text-lg">{w.balance?.toLocaleString() || 0} {w.currency}</p>
|
| 141 |
+
<p className="text-[10px] md:text-xs text-neutral-500">
|
| 142 |
+
≈ {((w.balance || 0) / (exchangeRates[w.currency] || 1) * (exchangeRates[mainCurrency] || 1)).toLocaleString(undefined, { maximumFractionDigits: 0 })} {mainCurrency}
|
| 143 |
+
</p>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
))}
|
| 147 |
+
</CardContent>
|
| 148 |
+
</Card>
|
| 149 |
+
|
| 150 |
+
<Card className="flex flex-col min-h-[250px] md:min-h-[300px]">
|
| 151 |
+
<CardHeader className="pb-2">
|
| 152 |
+
<CardTitle className="flex items-center gap-2">
|
| 153 |
+
<TrendingUp className="w-5 h-5 text-emerald-400" /> Recent Activity
|
| 154 |
+
</CardTitle>
|
| 155 |
+
</CardHeader>
|
| 156 |
+
<CardContent className="flex-1 min-h-0 space-y-3">
|
| 157 |
+
{transactions.slice(0, 5).map((tx: any) => (
|
| 158 |
+
<div key={tx.id} className="flex justify-between items-center bg-white/5 p-4 rounded-xl border border-white/5 text-left">
|
| 159 |
+
<div>
|
| 160 |
+
<p className="font-medium text-neutral-200">{tx.note || <span className="capitalize">{tx.type}</span>}</p>
|
| 161 |
+
<div className="text-xs text-neutral-500 mt-1 flex gap-2">
|
| 162 |
+
<span>{format(new Date(tx.date), 'MMM d, yyyy')}</span>
|
| 163 |
+
{tx.category && (
|
| 164 |
+
<>
|
| 165 |
+
<span>•</span>
|
| 166 |
+
<span className="text-blue-400/80">{tx.category}</span>
|
| 167 |
+
</>
|
| 168 |
+
)}
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
<div className={cn(
|
| 172 |
+
"font-semibold text-right",
|
| 173 |
+
tx.type === 'income' ? "text-emerald-400" : tx.type === 'expense' ? "text-rose-400" : "text-blue-400"
|
| 174 |
+
)}>
|
| 175 |
+
{tx.type === 'expense' ? '-' : tx.type === 'income' ? '+' : ''}
|
| 176 |
+
{tx.amount.toLocaleString()} {tx.currency}
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
))}
|
| 180 |
+
{transactions.length === 0 && <p className="text-neutral-500 text-center py-4">No recent activity</p>}
|
| 181 |
+
</CardContent>
|
| 182 |
+
</Card>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
);
|
| 186 |
+
}
|
client/src/pages/Loans.tsx
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { useLoans, useCreateLoan, usePayLoan, useUpdateLoan, useDeleteLoan, useRates } from '../hooks/queries';
|
| 3 |
+
import { HandCoins, Plus, ArrowDown, ArrowUp, Pencil, Trash2, X } from 'lucide-react';
|
| 4 |
+
import { format } from 'date-fns';
|
| 5 |
+
import { cn } from '../lib/utils';
|
| 6 |
+
import { Button } from '../components/ui/button';
|
| 7 |
+
import { Input } from '../components/ui/input';
|
| 8 |
+
import { AmountInput } from '../components/ui/AmountInput';
|
| 9 |
+
import { Label } from '../components/ui/label';
|
| 10 |
+
|
| 11 |
+
export default function Loans() {
|
| 12 |
+
const { data: loans = [] } = useLoans();
|
| 13 |
+
const { data: rates } = useRates();
|
| 14 |
+
const { mutateAsync: createLoan } = useCreateLoan();
|
| 15 |
+
|
| 16 |
+
const [showForm, setShowForm] = useState(false);
|
| 17 |
+
const [editingLoan, setEditingLoan] = useState<any>(null);
|
| 18 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 19 |
+
|
| 20 |
+
const [person, setPerson] = useState('');
|
| 21 |
+
const [type, setType] = useState<'borrowed_from_me' | 'owed_by_me'>('borrowed_from_me');
|
| 22 |
+
const [amount, setAmount] = useState('');
|
| 23 |
+
const [currency, setCurrency] = useState('USD');
|
| 24 |
+
const [note, setNote] = useState('');
|
| 25 |
+
|
| 26 |
+
const { mutateAsync: updateLoan } = useUpdateLoan();
|
| 27 |
+
const { mutateAsync: deleteLoan } = useDeleteLoan();
|
| 28 |
+
|
| 29 |
+
const { mutateAsync: payLoan } = usePayLoan();
|
| 30 |
+
const [paymentId, setPaymentId] = useState<number | null>(null);
|
| 31 |
+
const [paymentAmount, setPaymentAmount] = useState('');
|
| 32 |
+
|
| 33 |
+
const exchangeRates: Record<string, number> = rates || { USD: 1, IQD: 1539.5, RMB: 6.86 };
|
| 34 |
+
|
| 35 |
+
const startEditing = (loan: any) => {
|
| 36 |
+
setEditingLoan(loan);
|
| 37 |
+
setPerson(loan.person);
|
| 38 |
+
setType(loan.type);
|
| 39 |
+
setAmount(loan.amount.toString());
|
| 40 |
+
setCurrency(loan.currency);
|
| 41 |
+
setNote(loan.note || '');
|
| 42 |
+
setShowForm(true);
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const cancelEditing = () => {
|
| 46 |
+
setEditingLoan(null);
|
| 47 |
+
setPerson('');
|
| 48 |
+
setType('borrowed_from_me');
|
| 49 |
+
setAmount('');
|
| 50 |
+
setCurrency('USD');
|
| 51 |
+
setNote('');
|
| 52 |
+
setShowForm(false);
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const handlePaymentSubmit = async (loanId: number, e: React.FormEvent) => {
|
| 56 |
+
e.preventDefault();
|
| 57 |
+
setIsSubmitting(true);
|
| 58 |
+
try {
|
| 59 |
+
await payLoan({ id: loanId, amount: parseFloat(paymentAmount) });
|
| 60 |
+
setPaymentId(null);
|
| 61 |
+
setPaymentAmount('');
|
| 62 |
+
} finally {
|
| 63 |
+
setIsSubmitting(false);
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 68 |
+
e.preventDefault();
|
| 69 |
+
setIsSubmitting(true);
|
| 70 |
+
try {
|
| 71 |
+
const loanData: any = {
|
| 72 |
+
person,
|
| 73 |
+
type,
|
| 74 |
+
amount: parseFloat(amount),
|
| 75 |
+
currency,
|
| 76 |
+
note,
|
| 77 |
+
date: editingLoan ? editingLoan.date : new Date().toISOString()
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
if (editingLoan) {
|
| 81 |
+
await updateLoan({
|
| 82 |
+
id: editingLoan.id,
|
| 83 |
+
data: { ...loanData, paid: editingLoan.paid }
|
| 84 |
+
});
|
| 85 |
+
} else {
|
| 86 |
+
await createLoan({
|
| 87 |
+
...loanData,
|
| 88 |
+
paid: 0
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
cancelEditing();
|
| 92 |
+
} finally {
|
| 93 |
+
setIsSubmitting(false);
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
const [showStats, setShowStats] = useState(false);
|
| 98 |
+
|
| 99 |
+
const stats = {
|
| 100 |
+
lent: loans.filter((l: any) => l.type === 'borrowed_from_me').reduce((acc: number, l: any) => {
|
| 101 |
+
const remaining = l.amount - (l.paid || 0);
|
| 102 |
+
const rate = exchangeRates[l.currency] || 1;
|
| 103 |
+
return acc + (remaining / rate);
|
| 104 |
+
}, 0),
|
| 105 |
+
borrowed: loans.filter((l: any) => l.type === 'owed_by_me').reduce((acc: number, l: any) => {
|
| 106 |
+
const remaining = l.amount - (l.paid || 0);
|
| 107 |
+
const rate = exchangeRates[l.currency] || 1;
|
| 108 |
+
return acc + (remaining / rate);
|
| 109 |
+
}, 0),
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div className="p-4 md:p-8 h-full flex flex-col max-w-7xl mx-auto w-full">
|
| 114 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8 animate-fade-in-up">
|
| 115 |
+
<div className="flex justify-between items-center w-full sm:w-auto">
|
| 116 |
+
<div>
|
| 117 |
+
<h1 className="text-2xl md:text-3xl font-bold flex items-center gap-3 text-white">
|
| 118 |
+
<HandCoins className="w-6 h-6 md:w-8 md:h-8 text-purple-400" /> Loan Tracking
|
| 119 |
+
</h1>
|
| 120 |
+
<p className="text-neutral-400 text-sm mt-1">Manage your debts and receivables</p>
|
| 121 |
+
</div>
|
| 122 |
+
<Button
|
| 123 |
+
variant="ghost"
|
| 124 |
+
size="sm"
|
| 125 |
+
onClick={() => setShowStats(!showStats)}
|
| 126 |
+
className="sm:hidden text-purple-400 hover:text-purple-300 hover:bg-purple-500/10"
|
| 127 |
+
>
|
| 128 |
+
{showStats ? 'Hide Summary' : 'Show Summary'}
|
| 129 |
+
</Button>
|
| 130 |
+
</div>
|
| 131 |
+
<Button onClick={() => {
|
| 132 |
+
if (showForm) cancelEditing();
|
| 133 |
+
else setShowForm(true);
|
| 134 |
+
}} className="w-full sm:w-auto bg-purple-600 hover:bg-purple-500 shadow-lg shadow-purple-500/20 py-6 px-6 text-lg rounded-xl transition-all hover:scale-[1.02]">
|
| 135 |
+
{showForm ? <X className="w-5 h-5 mr-2" /> : <Plus className="w-5 h-5 mr-2" />}
|
| 136 |
+
{showForm ? 'Cancel' : 'New Loan'}
|
| 137 |
+
</Button>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
{/* Summary Cards */}
|
| 141 |
+
<div className={cn(
|
| 142 |
+
"grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8 animate-fade-in-up delay-100 transition-all duration-300",
|
| 143 |
+
!showStats && "hidden sm:grid"
|
| 144 |
+
)}>
|
| 145 |
+
<div className="glass-panel p-5 border-emerald-500/20 bg-emerald-500/5">
|
| 146 |
+
<div className="flex items-center gap-3 mb-2">
|
| 147 |
+
<div className="p-2 bg-emerald-500/20 rounded-lg text-emerald-400">
|
| 148 |
+
<ArrowUp className="w-5 h-5" />
|
| 149 |
+
</div>
|
| 150 |
+
<span className="text-neutral-400 font-medium">Total Lent</span>
|
| 151 |
+
</div>
|
| 152 |
+
<div className="text-2xl font-bold text-white">
|
| 153 |
+
{stats.lent.toLocaleString()} <span className="text-sm font-normal text-neutral-500">USD</span>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
<div className="glass-panel p-5 border-rose-500/20 bg-rose-500/5">
|
| 157 |
+
<div className="flex items-center gap-3 mb-2">
|
| 158 |
+
<div className="p-2 bg-rose-500/20 rounded-lg text-rose-400">
|
| 159 |
+
<ArrowDown className="w-5 h-5" />
|
| 160 |
+
</div>
|
| 161 |
+
<span className="text-neutral-400 font-medium">Total Borrowed</span>
|
| 162 |
+
</div>
|
| 163 |
+
<div className="text-2xl font-bold text-white">
|
| 164 |
+
{stats.borrowed.toLocaleString()} <span className="text-sm font-normal text-neutral-500">USD</span>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
<div className="glass-panel p-5 border-purple-500/20 bg-purple-500/5">
|
| 168 |
+
<div className="flex items-center gap-3 mb-2">
|
| 169 |
+
<div className="p-2 bg-purple-500/20 rounded-lg text-purple-400">
|
| 170 |
+
<HandCoins className="w-5 h-5" />
|
| 171 |
+
</div>
|
| 172 |
+
<span className="text-neutral-400 font-medium">Net Position</span>
|
| 173 |
+
</div>
|
| 174 |
+
<div className={cn("text-2xl font-bold", (stats.lent - stats.borrowed) >= 0 ? "text-emerald-400" : "text-rose-400")}>
|
| 175 |
+
{(stats.lent - stats.borrowed).toLocaleString()} <span className="text-sm font-normal text-neutral-500">USD</span>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
|
| 180 |
+
{showForm && (
|
| 181 |
+
<div className="glass-panel p-6 mb-8 border-purple-500/40 animate-fade-in-up">
|
| 182 |
+
<h2 className="text-xl font-semibold mb-6 text-white flex items-center gap-2">
|
| 183 |
+
{editingLoan ? <Pencil className="w-5 h-5 text-purple-400" /> : <Plus className="w-5 h-5 text-purple-400" />}
|
| 184 |
+
{editingLoan ? 'Edit Loan Entry' : 'Create New Loan Entry'}
|
| 185 |
+
</h2>
|
| 186 |
+
<form onSubmit={handleSubmit} className="space-y-6">
|
| 187 |
+
<div className="flex flex-col sm:flex-row gap-4">
|
| 188 |
+
<button type="button" onClick={() => setType('borrowed_from_me')}
|
| 189 |
+
className={cn("flex-1 py-3 rounded-xl font-semibold capitalize border transition-all flex items-center justify-center gap-2",
|
| 190 |
+
type === 'borrowed_from_me' ? "bg-purple-500/20 border-purple-500/50 text-purple-400 shadow-inner" : "bg-white/5 border-white/10 text-neutral-400 hover:bg-white/10"
|
| 191 |
+
)}>
|
| 192 |
+
<ArrowUp className="w-5 h-5" /> They borrowed from me
|
| 193 |
+
</button>
|
| 194 |
+
<button type="button" onClick={() => setType('owed_by_me')}
|
| 195 |
+
className={cn("flex-1 py-3 rounded-xl font-semibold capitalize border transition-all flex items-center justify-center gap-2",
|
| 196 |
+
type === 'owed_by_me' ? "bg-purple-500/20 border-purple-500/50 text-purple-400 shadow-inner" : "bg-white/5 border-white/10 text-neutral-400 hover:bg-white/10"
|
| 197 |
+
)}>
|
| 198 |
+
<ArrowDown className="w-5 h-5" /> I borrowed from them
|
| 199 |
+
</button>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 203 |
+
<div className="space-y-2">
|
| 204 |
+
<Label className="text-neutral-300 ml-1">Person Name</Label>
|
| 205 |
+
<Input type="text" required value={person} onChange={e => setPerson(e.target.value)} placeholder="Who is it?" className="bg-black/40 border-white/10 h-12 rounded-xl focus:border-purple-500/50" />
|
| 206 |
+
</div>
|
| 207 |
+
<div className="space-y-2">
|
| 208 |
+
<Label className="text-neutral-300 ml-1">Amount & Currency</Label>
|
| 209 |
+
<div className="flex bg-black/40 border border-white/10 rounded-xl overflow-hidden focus-within:border-purple-500/50 h-12">
|
| 210 |
+
<AmountInput required value={amount} onChange={e => setAmount(e.target.value)}
|
| 211 |
+
className="flex-1 bg-transparent border-none px-4 py-2 h-full rounded-none focus-visible:ring-0" placeholder="0.00" />
|
| 212 |
+
<select value={currency} onChange={e => setCurrency(e.target.value)}
|
| 213 |
+
className="bg-white/5 px-4 py-2 border-l border-white/10 text-white focus:outline-none cursor-pointer">
|
| 214 |
+
<option value="USD">USD</option>
|
| 215 |
+
<option value="IQD">IQD</option>
|
| 216 |
+
<option value="RMB">RMB</option>
|
| 217 |
+
</select>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
<div className="col-span-1 md:col-span-2 space-y-2">
|
| 221 |
+
<Label className="text-neutral-300 ml-1">Note (Optional)</Label>
|
| 222 |
+
<Input type="text" value={note} onChange={e => setNote(e.target.value)} placeholder="Add some context..." className="bg-black/40 border-white/10 h-12 rounded-xl focus:border-purple-500/50" />
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<div className="flex justify-end gap-3 pt-4">
|
| 227 |
+
<Button type="button" variant="ghost" onClick={cancelEditing} disabled={isSubmitting} className="rounded-xl h-12 px-6">Cancel</Button>
|
| 228 |
+
<Button type="submit" disabled={isSubmitting} className="w-full md:w-auto bg-purple-600 hover:bg-purple-500 rounded-xl h-12 px-10 font-bold shadow-lg shadow-purple-500/20">
|
| 229 |
+
{editingLoan ? 'Update Loan' : 'Save Loan'}
|
| 230 |
+
</Button>
|
| 231 |
+
</div>
|
| 232 |
+
</form>
|
| 233 |
+
</div>
|
| 234 |
+
)}
|
| 235 |
+
|
| 236 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6 flex-1 content-start overflow-y-auto pb-32 md:pb-8 pr-2 custom-scrollbar">
|
| 237 |
+
{[...loans].sort((a, b) => {
|
| 238 |
+
const remainingA = a.amount - (a.paid || 0);
|
| 239 |
+
const remainingB = b.amount - (b.paid || 0);
|
| 240 |
+
const isSettledA = remainingA <= 0;
|
| 241 |
+
const isSettledB = remainingB <= 0;
|
| 242 |
+
|
| 243 |
+
// Settled loans always go to the bottom
|
| 244 |
+
if (isSettledA !== isSettledB) {
|
| 245 |
+
return isSettledA ? 1 : -1;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// Otherwise sort by original total amount in USD (Highest to Lowest)
|
| 249 |
+
const rateA = exchangeRates[a.currency] || 1;
|
| 250 |
+
const rateB = exchangeRates[b.currency] || 1;
|
| 251 |
+
const usdAmountA = a.amount / rateA;
|
| 252 |
+
const usdAmountB = b.amount / rateB;
|
| 253 |
+
|
| 254 |
+
return usdAmountB - usdAmountA;
|
| 255 |
+
}).map((loan: any, index: number) => {
|
| 256 |
+
const isLent = loan.type === 'borrowed_from_me';
|
| 257 |
+
const remaining = loan.amount - (loan.paid || 0);
|
| 258 |
+
const isSettled = remaining <= 0;
|
| 259 |
+
const paidPercentage = Math.min(100, Math.max(0, ((loan.paid || 0) / loan.amount) * 100));
|
| 260 |
+
|
| 261 |
+
const getColors = () => {
|
| 262 |
+
if (!isLent) { // I borrowed
|
| 263 |
+
return isSettled
|
| 264 |
+
? { border: "border-emerald-500/20", bg: "bg-emerald-500/5", text: "text-emerald-400", progress: "bg-emerald-500" }
|
| 265 |
+
: { border: "border-rose-500/20", bg: "bg-rose-500/5", text: "text-rose-400", progress: "bg-rose-500" };
|
| 266 |
+
} else { // Someone borrowed from me
|
| 267 |
+
return isSettled
|
| 268 |
+
? { border: "border-blue-500/20", bg: "bg-blue-500/5", text: "text-blue-400", progress: "bg-blue-500" }
|
| 269 |
+
: { border: "border-amber-500/20", bg: "bg-amber-500/5", text: "text-amber-400", progress: "bg-amber-500" };
|
| 270 |
+
}
|
| 271 |
+
};
|
| 272 |
+
|
| 273 |
+
const colors = getColors();
|
| 274 |
+
const delayClass = `delay-${Math.min((index + 1) * 100, 500)}`;
|
| 275 |
+
|
| 276 |
+
return (
|
| 277 |
+
<div key={loan.id} className={cn(
|
| 278 |
+
"glass-panel p-6 border flex flex-col relative group animate-fade-in-up",
|
| 279 |
+
colors.border, colors.bg, delayClass,
|
| 280 |
+
isSettled && "opacity-60 grayscale-[0.2]"
|
| 281 |
+
)}>
|
| 282 |
+
{/* Header Section */}
|
| 283 |
+
<div className="flex justify-between items-start mb-6 relative z-10">
|
| 284 |
+
<div className="flex items-center gap-4">
|
| 285 |
+
<div className={cn(
|
| 286 |
+
"w-12 h-12 rounded-2xl flex items-center justify-center font-bold text-xl bg-black/30 shadow-inner",
|
| 287 |
+
colors.text
|
| 288 |
+
)}>
|
| 289 |
+
{loan.person[0].toUpperCase()}
|
| 290 |
+
</div>
|
| 291 |
+
<div>
|
| 292 |
+
<h3 className={cn("font-bold text-lg text-white leading-none", isSettled && "line-through text-neutral-400")}>{loan.person}</h3>
|
| 293 |
+
<p className="text-xs text-neutral-500 mt-1.5">{format(new Date(loan.date), 'MMMM d, yyyy')}</p>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
<div className="flex items-center gap-2">
|
| 297 |
+
<div className={cn(
|
| 298 |
+
"px-3 py-1 rounded-lg text-[10px] uppercase tracking-wider font-bold border bg-black/40",
|
| 299 |
+
colors.border, colors.text
|
| 300 |
+
)}>
|
| 301 |
+
{isSettled ? 'Settled' : isLent ? 'Lent' : 'Borrowed'}
|
| 302 |
+
</div>
|
| 303 |
+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
| 304 |
+
<button onClick={(e) => { e.stopPropagation(); startEditing(loan); }} className="p-2 hover:bg-white/10 rounded-lg transition-colors text-neutral-400 hover:text-white" title="Edit">
|
| 305 |
+
<Pencil className="w-4 h-4" />
|
| 306 |
+
</button>
|
| 307 |
+
<button onClick={async (e) => { e.stopPropagation(); if (confirm('Delete this loan?')) await deleteLoan(loan.id); }} className="p-2 hover:bg-rose-500/20 rounded-lg transition-colors text-neutral-400 hover:text-rose-400" title="Delete">
|
| 308 |
+
<Trash2 className="w-4 h-4" />
|
| 309 |
+
</button>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
+
{/* Main Amount Section */}
|
| 315 |
+
<div className="mb-6 relative z-10">
|
| 316 |
+
<span className="text-[10px] font-bold text-neutral-500 uppercase tracking-widest block mb-1">REMAINING BALANCE</span>
|
| 317 |
+
<div className={cn("text-3xl font-black tracking-tight flex items-baseline gap-2", colors.text)}>
|
| 318 |
+
{remaining > 0 ? remaining.toLocaleString() : "0.00"}
|
| 319 |
+
<span className="text-sm font-bold opacity-60">{loan.currency}</span>
|
| 320 |
+
</div>
|
| 321 |
+
</div>
|
| 322 |
+
|
| 323 |
+
{/* Progress Section */}
|
| 324 |
+
<div className="space-y-3 mb-6 relative z-10 bg-black/20 p-3 rounded-xl border border-white/5">
|
| 325 |
+
<div className="flex justify-between text-[10px] font-bold uppercase tracking-tighter">
|
| 326 |
+
<span className="text-neutral-500">Repayment Progress</span>
|
| 327 |
+
<span className={colors.text}>{Math.round(paidPercentage)}%</span>
|
| 328 |
+
</div>
|
| 329 |
+
<div className="h-2 w-full bg-black/40 rounded-full overflow-hidden">
|
| 330 |
+
<div
|
| 331 |
+
className={cn("h-full transition-all duration-700 ease-out", colors.progress)}
|
| 332 |
+
style={{ width: `${paidPercentage}%` }}
|
| 333 |
+
/>
|
| 334 |
+
</div>
|
| 335 |
+
<div className="flex justify-between text-xs">
|
| 336 |
+
<div className="flex flex-col">
|
| 337 |
+
<span className="text-[10px] text-neutral-500 uppercase">Paid</span>
|
| 338 |
+
<span className="text-white font-bold">{(loan.paid || 0).toLocaleString()}</span>
|
| 339 |
+
</div>
|
| 340 |
+
<div className="flex flex-col items-end">
|
| 341 |
+
<span className="text-[10px] text-neutral-500 uppercase">Total</span>
|
| 342 |
+
<span className="text-neutral-300 font-bold">{loan.amount.toLocaleString()} {loan.currency}</span>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
</div>
|
| 346 |
+
|
| 347 |
+
{/* Action Buttons */}
|
| 348 |
+
<div className="mt-auto pt-4 relative z-10 border-t border-white/5">
|
| 349 |
+
{remaining > 0 && paymentId !== loan.id && (
|
| 350 |
+
<div className="grid grid-cols-2 gap-3">
|
| 351 |
+
<Button
|
| 352 |
+
variant="outline"
|
| 353 |
+
onClick={(e) => { e.stopPropagation(); setPaymentId(loan.id); }}
|
| 354 |
+
className="bg-white/5 hover:bg-white/10 text-white border-white/10 h-12 text-xs font-bold rounded-xl transition-all active:scale-95"
|
| 355 |
+
>
|
| 356 |
+
Partial Paid
|
| 357 |
+
</Button>
|
| 358 |
+
<Button
|
| 359 |
+
disabled={isSubmitting}
|
| 360 |
+
onClick={async (e) => {
|
| 361 |
+
e.stopPropagation();
|
| 362 |
+
setIsSubmitting(true);
|
| 363 |
+
try {
|
| 364 |
+
await payLoan({ id: loan.id, amount: remaining });
|
| 365 |
+
} finally {
|
| 366 |
+
setIsSubmitting(false);
|
| 367 |
+
}
|
| 368 |
+
}}
|
| 369 |
+
className={cn("text-white h-12 text-xs font-bold rounded-xl shadow-lg transition-all active:scale-95", colors.progress.replace('bg-', 'bg-').replace('-500', '-600') + " hover:opacity-90")}
|
| 370 |
+
>
|
| 371 |
+
Full Paid
|
| 372 |
+
</Button>
|
| 373 |
+
</div>
|
| 374 |
+
)}
|
| 375 |
+
|
| 376 |
+
{paymentId === loan.id && (
|
| 377 |
+
<form onSubmit={(e) => handlePaymentSubmit(loan.id, e)} className="flex gap-2 animate-fade-in-up">
|
| 378 |
+
<AmountInput autoFocus placeholder={`Amount`} value={paymentAmount} onChange={e => setPaymentAmount(e.target.value)} required className="flex-1 bg-black/40 h-11 rounded-xl border-white/10" />
|
| 379 |
+
<Button type="submit" disabled={isSubmitting} className="h-11 shrink-0 px-5 bg-emerald-600 hover:bg-emerald-500 font-bold rounded-xl">Pay</Button>
|
| 380 |
+
<Button type="button" variant="ghost" onClick={(e) => { e.stopPropagation(); setPaymentId(null); }} className="h-11 shrink-0 px-4 rounded-xl">
|
| 381 |
+
<X className="w-5 h-5" />
|
| 382 |
+
</Button>
|
| 383 |
+
</form>
|
| 384 |
+
)}
|
| 385 |
+
</div>
|
| 386 |
+
|
| 387 |
+
{/* Background Decoration */}
|
| 388 |
+
<div className={cn(
|
| 389 |
+
"absolute -bottom-8 -right-8 opacity-[0.03] group-hover:opacity-[0.07] transition-all duration-500 group-hover:scale-110",
|
| 390 |
+
colors.text
|
| 391 |
+
)}>
|
| 392 |
+
<HandCoins className="w-40 h-40 rotate-12" />
|
| 393 |
+
</div>
|
| 394 |
+
</div>
|
| 395 |
+
);
|
| 396 |
+
})}
|
| 397 |
+
{loans.length === 0 && (
|
| 398 |
+
<div className="col-span-full text-center py-20 glass-panel border-dashed border-white/10">
|
| 399 |
+
<HandCoins className="w-16 h-16 text-neutral-700 mx-auto mb-4" />
|
| 400 |
+
<h3 className="text-xl font-bold text-neutral-400">No Loans Found</h3>
|
| 401 |
+
<p className="text-neutral-600 mt-2">Start tracking your loans by clicking the "New Loan" button.</p>
|
| 402 |
+
</div>
|
| 403 |
+
)}
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
+
);
|
| 407 |
+
}
|
client/src/pages/Login.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { useAuthStore } from '../store/useAuthStore';
|
| 3 |
+
import { useNavigate } from 'react-router-dom';
|
| 4 |
+
import axios from 'axios';
|
| 5 |
+
import { Wallet, Lock, User, AlertCircle, ArrowRight, Loader2 } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
const Login: React.FC = () => {
|
| 8 |
+
const [username, setUsername] = useState('');
|
| 9 |
+
const [password, setPassword] = useState('');
|
| 10 |
+
const [error, setError] = useState('');
|
| 11 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 12 |
+
const [rememberMe, setRememberMe] = useState(true);
|
| 13 |
+
|
| 14 |
+
const { login, deviceId, username: storedUsername } = useAuthStore();
|
| 15 |
+
const navigate = useNavigate();
|
| 16 |
+
|
| 17 |
+
React.useEffect(() => {
|
| 18 |
+
const attemptAutoLogin = async () => {
|
| 19 |
+
if (deviceId && storedUsername) {
|
| 20 |
+
setIsLoading(true);
|
| 21 |
+
try {
|
| 22 |
+
const response = await axios.post('http://localhost:3001/api/login-device', {
|
| 23 |
+
username: storedUsername,
|
| 24 |
+
deviceId,
|
| 25 |
+
});
|
| 26 |
+
login(response.data.token, response.data.username);
|
| 27 |
+
navigate('/');
|
| 28 |
+
} catch (err) {
|
| 29 |
+
console.error('Auto-login failed:', err);
|
| 30 |
+
} finally {
|
| 31 |
+
setIsLoading(false);
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
attemptAutoLogin();
|
| 36 |
+
}, [deviceId, storedUsername, login, navigate]);
|
| 37 |
+
|
| 38 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 39 |
+
e.preventDefault();
|
| 40 |
+
setError('');
|
| 41 |
+
setIsLoading(true);
|
| 42 |
+
|
| 43 |
+
try {
|
| 44 |
+
const deviceName = `${navigator.platform} - ${navigator.userAgent.split(') ')[0].split(' (')[1] || 'Web Browser'}`;
|
| 45 |
+
|
| 46 |
+
const response = await axios.post('http://localhost:3001/api/login', {
|
| 47 |
+
username,
|
| 48 |
+
password,
|
| 49 |
+
deviceName,
|
| 50 |
+
rememberMe
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
login(response.data.token, response.data.username, response.data.deviceId);
|
| 54 |
+
navigate('/');
|
| 55 |
+
} catch (err: any) {
|
| 56 |
+
setError(err.response?.data?.error || 'Invalid username or password');
|
| 57 |
+
} finally {
|
| 58 |
+
setIsLoading(false);
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="min-h-screen bg-[#0f172a] flex items-center justify-center p-4 selection:bg-indigo-500/30">
|
| 64 |
+
{/* Background decoration */}
|
| 65 |
+
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
| 66 |
+
<div className="absolute top-1/4 -left-20 w-80 h-80 bg-indigo-600/20 rounded-full blur-[100px]" />
|
| 67 |
+
<div className="absolute bottom-1/4 -right-20 w-80 h-80 bg-blue-600/20 rounded-full blur-[100px]" />
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div className="w-full max-w-md relative">
|
| 71 |
+
{/* Card */}
|
| 72 |
+
<div className="bg-[#1e293b]/50 backdrop-blur-xl border border-white/10 rounded-3xl p-8 shadow-2xl">
|
| 73 |
+
<div className="flex flex-col items-center mb-10">
|
| 74 |
+
<div className="w-16 h-16 bg-gradient-to-tr from-indigo-500 to-blue-500 rounded-2xl flex items-center justify-center shadow-lg shadow-indigo-500/20 mb-4 animate-in fade-in zoom-in duration-700">
|
| 75 |
+
<Wallet className="w-8 h-8 text-white" />
|
| 76 |
+
</div>
|
| 77 |
+
<h1 className="text-3xl font-bold text-white tracking-tight mb-2">Welcome Back</h1>
|
| 78 |
+
<p className="text-slate-400 text-center">Enter your credentials to manage your wallets</p>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<form onSubmit={handleSubmit} className="space-y-6">
|
| 82 |
+
<div className="space-y-2">
|
| 83 |
+
<label className="text-sm font-medium text-slate-300 ml-1">Username</label>
|
| 84 |
+
<div className="relative group">
|
| 85 |
+
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-500 group-focus-within:text-indigo-400 transition-colors">
|
| 86 |
+
<User size={18} />
|
| 87 |
+
</div>
|
| 88 |
+
<input
|
| 89 |
+
type="text"
|
| 90 |
+
value={username}
|
| 91 |
+
onChange={(e) => setUsername(e.target.value)}
|
| 92 |
+
className="w-full bg-[#0f172a]/50 border border-white/5 rounded-2xl py-3.5 pl-11 pr-4 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500/50 transition-all"
|
| 93 |
+
placeholder="e.g. amez"
|
| 94 |
+
required
|
| 95 |
+
/>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div className="space-y-2">
|
| 100 |
+
<label className="text-sm font-medium text-slate-300 ml-1">Password</label>
|
| 101 |
+
<div className="relative group">
|
| 102 |
+
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-500 group-focus-within:text-indigo-400 transition-colors">
|
| 103 |
+
<Lock size={18} />
|
| 104 |
+
</div>
|
| 105 |
+
<input
|
| 106 |
+
type="password"
|
| 107 |
+
value={password}
|
| 108 |
+
onChange={(e) => setPassword(e.target.value)}
|
| 109 |
+
className="w-full bg-[#0f172a]/50 border border-white/5 rounded-2xl py-3.5 pl-11 pr-4 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500/50 transition-all"
|
| 110 |
+
placeholder="••••••••"
|
| 111 |
+
required
|
| 112 |
+
/>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div className="flex items-center justify-between px-1">
|
| 117 |
+
<label className="flex items-center gap-2 cursor-pointer group">
|
| 118 |
+
<div className="relative flex items-center">
|
| 119 |
+
<input
|
| 120 |
+
type="checkbox"
|
| 121 |
+
checked={rememberMe}
|
| 122 |
+
onChange={(e) => setRememberMe(e.target.checked)}
|
| 123 |
+
className="peer sr-only"
|
| 124 |
+
/>
|
| 125 |
+
<div className="w-5 h-5 border-2 border-slate-700 rounded-md peer-checked:bg-indigo-500 peer-checked:border-indigo-500 transition-all" />
|
| 126 |
+
<svg
|
| 127 |
+
className="absolute w-3.5 h-3.5 text-white opacity-0 peer-checked:opacity-100 transition-opacity left-[3px] pointer-events-none"
|
| 128 |
+
fill="none"
|
| 129 |
+
viewBox="0 0 24 24"
|
| 130 |
+
stroke="currentColor"
|
| 131 |
+
strokeWidth="3"
|
| 132 |
+
>
|
| 133 |
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
| 134 |
+
</svg>
|
| 135 |
+
</div>
|
| 136 |
+
<span className="text-sm text-slate-400 group-hover:text-slate-300 transition-colors">Remember this device</span>
|
| 137 |
+
</label>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
{error && (
|
| 141 |
+
<div className="flex items-center gap-2 text-rose-400 bg-rose-400/10 border border-rose-400/20 p-4 rounded-2xl text-sm animate-in fade-in slide-in-from-top-2">
|
| 142 |
+
<AlertCircle size={16} className="shrink-0" />
|
| 143 |
+
<p>{error}</p>
|
| 144 |
+
</div>
|
| 145 |
+
)}
|
| 146 |
+
|
| 147 |
+
<button
|
| 148 |
+
type="submit"
|
| 149 |
+
disabled={isLoading}
|
| 150 |
+
className="w-full bg-gradient-to-r from-indigo-500 to-blue-600 hover:from-indigo-600 hover:to-blue-700 text-white font-semibold py-4 rounded-2xl shadow-lg shadow-indigo-500/25 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100 flex items-center justify-center gap-2 group mt-8"
|
| 151 |
+
>
|
| 152 |
+
{isLoading ? (
|
| 153 |
+
<Loader2 className="w-5 h-5 animate-spin" />
|
| 154 |
+
) : (
|
| 155 |
+
<>
|
| 156 |
+
Connect Wallet
|
| 157 |
+
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
| 158 |
+
</>
|
| 159 |
+
)}
|
| 160 |
+
</button>
|
| 161 |
+
</form>
|
| 162 |
+
|
| 163 |
+
<div className="mt-8 text-center">
|
| 164 |
+
<p className="text-xs text-slate-500 font-medium uppercase tracking-widest">Wallets Secure Access</p>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
);
|
| 170 |
+
};
|
| 171 |
+
|
| 172 |
+
export default Login;
|
client/src/pages/Settings.tsx
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { Settings as SettingsIcon, Download, Upload, Trash2, AlertCircle, CheckCircle2, ShieldAlert, Smartphone, X } from 'lucide-react';
|
| 3 |
+
import { Button } from '../components/ui/button';
|
| 4 |
+
import { useAuthStore } from '../store/useAuthStore';
|
| 5 |
+
import axios from 'axios';
|
| 6 |
+
|
| 7 |
+
export default function Settings() {
|
| 8 |
+
const [exporting, setExporting] = useState(false);
|
| 9 |
+
const [importing, setImporting] = useState(false);
|
| 10 |
+
const [clearing, setClearing] = useState(false);
|
| 11 |
+
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
| 12 |
+
const [message, setMessage] = useState('');
|
| 13 |
+
const [replaceData, setReplaceData] = useState(false);
|
| 14 |
+
const [devices, setDevices] = useState<{id: number, device_name: string, last_used: string}[]>([]);
|
| 15 |
+
const [loadingDevices, setLoadingDevices] = useState(false);
|
| 16 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 17 |
+
const { token } = useAuthStore();
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
fetchDevices();
|
| 21 |
+
}, []);
|
| 22 |
+
|
| 23 |
+
const fetchDevices = async () => {
|
| 24 |
+
if (!token) return;
|
| 25 |
+
setLoadingDevices(true);
|
| 26 |
+
try {
|
| 27 |
+
const response = await axios.get('http://localhost:3001/api/devices', {
|
| 28 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 29 |
+
});
|
| 30 |
+
setDevices(response.data);
|
| 31 |
+
} catch (error) {
|
| 32 |
+
console.error('Failed to fetch devices:', error);
|
| 33 |
+
} finally {
|
| 34 |
+
setLoadingDevices(false);
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const handleDeleteDevice = async (id: number) => {
|
| 39 |
+
if (!window.confirm('Are you sure you want to remove this device? You will be logged out on that device.')) return;
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
await axios.delete(`http://localhost:3001/api/devices/${id}`, {
|
| 43 |
+
headers: { Authorization: `Bearer ${token}` }
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
fetchDevices();
|
| 47 |
+
} catch (error) {
|
| 48 |
+
console.error('Failed to delete device:', error);
|
| 49 |
+
alert('Failed to remove device');
|
| 50 |
+
}
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
const handleExport = async () => {
|
| 54 |
+
setExporting(true);
|
| 55 |
+
setStatus('idle');
|
| 56 |
+
try {
|
| 57 |
+
const response = await fetch('http://localhost:3001/api/export');
|
| 58 |
+
if (!response.ok) throw new Error('Export failed');
|
| 59 |
+
|
| 60 |
+
const blob = await response.blob();
|
| 61 |
+
const url = window.URL.createObjectURL(blob);
|
| 62 |
+
const a = document.createElement('a');
|
| 63 |
+
a.href = url;
|
| 64 |
+
a.download = `money_manager_backup_${new Date().toISOString().split('T')[0]}.xlsx`;
|
| 65 |
+
document.body.appendChild(a);
|
| 66 |
+
a.click();
|
| 67 |
+
window.URL.revokeObjectURL(url);
|
| 68 |
+
a.remove();
|
| 69 |
+
setStatus('success');
|
| 70 |
+
setMessage('Backup successfully generated and downloaded!');
|
| 71 |
+
} catch (error) {
|
| 72 |
+
console.error(error);
|
| 73 |
+
setStatus('error');
|
| 74 |
+
setMessage('Failed to generate backup. Please try again.');
|
| 75 |
+
} finally {
|
| 76 |
+
setExporting(false);
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 81 |
+
const file = e.target.files?.[0];
|
| 82 |
+
if (!file) return;
|
| 83 |
+
|
| 84 |
+
if (replaceData && !window.confirm('Are you sure? This will PERMANENTLY DELETE all current transactions, exchanges, and loans and replace them with the imported data.')) {
|
| 85 |
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
| 86 |
+
return;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
setImporting(true);
|
| 90 |
+
setStatus('idle');
|
| 91 |
+
|
| 92 |
+
try {
|
| 93 |
+
const reader = new FileReader();
|
| 94 |
+
reader.onload = async (event) => {
|
| 95 |
+
const base64 = (event.target?.result as string).split(',')[1];
|
| 96 |
+
try {
|
| 97 |
+
const response = await fetch('http://localhost:3001/api/import', {
|
| 98 |
+
method: 'POST',
|
| 99 |
+
headers: { 'Content-Type': 'application/json' },
|
| 100 |
+
body: JSON.stringify({ file: base64, replace: replaceData })
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
if (!response.ok) {
|
| 104 |
+
const err = await response.json();
|
| 105 |
+
throw new Error(err.error || 'Import failed');
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
setStatus('success');
|
| 109 |
+
setMessage('Data imported successfully!');
|
| 110 |
+
} catch (error: any) {
|
| 111 |
+
console.error(error);
|
| 112 |
+
setStatus('error');
|
| 113 |
+
setMessage(error.message || 'Failed to import data.');
|
| 114 |
+
} finally {
|
| 115 |
+
setImporting(false);
|
| 116 |
+
if (fileInputRef.current) fileInputRef.current.value = '';
|
| 117 |
+
}
|
| 118 |
+
};
|
| 119 |
+
reader.readAsDataURL(file);
|
| 120 |
+
} catch (error) {
|
| 121 |
+
console.error(error);
|
| 122 |
+
setStatus('error');
|
| 123 |
+
setMessage('Failed to read file.');
|
| 124 |
+
setImporting(false);
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const handleClearData = async () => {
|
| 129 |
+
if (!window.confirm('EXTREME WARNING: This will PERMANENTLY DELETE ALL your transactions, exchanges, and loans. This action cannot be undone. Do you really want to continue?')) {
|
| 130 |
+
return;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
setClearing(true);
|
| 134 |
+
setStatus('idle');
|
| 135 |
+
try {
|
| 136 |
+
const response = await fetch('http://localhost:3001/api/data', { method: 'DELETE' });
|
| 137 |
+
if (!response.ok) throw new Error('Clear data failed');
|
| 138 |
+
setStatus('success');
|
| 139 |
+
setMessage('All data cleared successfully.');
|
| 140 |
+
} catch (error) {
|
| 141 |
+
console.error(error);
|
| 142 |
+
setStatus('error');
|
| 143 |
+
setMessage('Failed to clear data.');
|
| 144 |
+
} finally {
|
| 145 |
+
setClearing(false);
|
| 146 |
+
}
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
return (
|
| 150 |
+
<div className="p-4 md:p-8 h-full flex flex-col pt-8 md:pt-8 w-full overflow-y-auto">
|
| 151 |
+
<div className="mb-6 md:mb-8 shrink-0">
|
| 152 |
+
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-3">
|
| 153 |
+
<SettingsIcon className="w-5 h-5 md:w-6 md:h-6 text-neutral-400" /> Settings & Backup
|
| 154 |
+
</h1>
|
| 155 |
+
<p className="text-sm md:text-base text-neutral-400 mt-1 md:mt-2">Manage your Data and App Preferences.</p>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div className="max-w-3xl space-y-6 pb-24 md:pb-8">
|
| 159 |
+
{/* Data Backup Section */}
|
| 160 |
+
<div className="glass-panel p-4 md:p-6 border border-white/5 space-y-6">
|
| 161 |
+
<div>
|
| 162 |
+
<h2 className="text-base md:text-lg font-semibold mb-2 flex items-center gap-2">
|
| 163 |
+
<Download className="w-4 h-4 text-emerald-400" /> Export Data
|
| 164 |
+
</h2>
|
| 165 |
+
<p className="text-sm text-neutral-400 mb-4">
|
| 166 |
+
Export all your transactions, wallets, exchanges, and loans into an Excel file.
|
| 167 |
+
</p>
|
| 168 |
+
<Button onClick={handleExport} disabled={exporting} variant="secondary" className="border border-white/10 w-full md:w-auto">
|
| 169 |
+
<Download className="w-4 h-4" />
|
| 170 |
+
{exporting ? 'Generating Excel...' : 'Export to Excel'}
|
| 171 |
+
</Button>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
<div className="pt-6 border-t border-white/5">
|
| 175 |
+
<h2 className="text-base md:text-lg font-semibold mb-2 flex items-center gap-2">
|
| 176 |
+
<Upload className="w-4 h-4 text-blue-400" /> Import Data
|
| 177 |
+
</h2>
|
| 178 |
+
<p className="text-sm text-neutral-400 mb-4">
|
| 179 |
+
Restore your data from a previously exported Excel backup.
|
| 180 |
+
</p>
|
| 181 |
+
|
| 182 |
+
<div className="flex flex-col gap-4">
|
| 183 |
+
<div className="flex items-center gap-3 bg-white/5 p-3 rounded-lg border border-white/10">
|
| 184 |
+
<input
|
| 185 |
+
type="checkbox"
|
| 186 |
+
id="replaceData"
|
| 187 |
+
checked={replaceData}
|
| 188 |
+
onChange={(e) => setReplaceData(e.target.checked)}
|
| 189 |
+
className="w-4 h-4 rounded border-white/20 bg-neutral-900 text-blue-500 focus:ring-blue-500"
|
| 190 |
+
/>
|
| 191 |
+
<label htmlFor="replaceData" className="text-sm font-medium cursor-pointer">
|
| 192 |
+
Replace all current data with imported data
|
| 193 |
+
</label>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<div className="flex flex-wrap gap-3">
|
| 197 |
+
<Button
|
| 198 |
+
onClick={() => fileInputRef.current?.click()}
|
| 199 |
+
disabled={importing}
|
| 200 |
+
variant="outline"
|
| 201 |
+
className="border border-blue-500/30 hover:bg-blue-500/10 text-blue-400"
|
| 202 |
+
>
|
| 203 |
+
<Upload className="w-4 h-4" />
|
| 204 |
+
{importing ? 'Importing...' : 'Select Excel File'}
|
| 205 |
+
</Button>
|
| 206 |
+
<input
|
| 207 |
+
type="file"
|
| 208 |
+
ref={fileInputRef}
|
| 209 |
+
onChange={handleImport}
|
| 210 |
+
className="hidden"
|
| 211 |
+
accept=".xlsx"
|
| 212 |
+
/>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
{status === 'success' && (
|
| 218 |
+
<div className="flex items-center gap-2 text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 p-3 rounded-lg animate-in fade-in duration-300">
|
| 219 |
+
<CheckCircle2 className="w-4 h-4" /> {message}
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
|
| 223 |
+
{status === 'error' && (
|
| 224 |
+
<div className="flex items-center gap-2 text-rose-400 text-sm bg-rose-500/10 border border-rose-500/20 p-3 rounded-lg animate-in fade-in duration-300">
|
| 225 |
+
<AlertCircle className="w-4 h-4" /> {message}
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
{/* Trusted Devices Section */}
|
| 231 |
+
<div className="glass-panel p-4 md:p-6 border border-white/5 space-y-4">
|
| 232 |
+
<h2 className="text-base md:text-lg font-semibold flex items-center gap-2">
|
| 233 |
+
<Smartphone className="w-4 h-4 text-indigo-400" /> Trusted Devices
|
| 234 |
+
</h2>
|
| 235 |
+
<p className="text-sm text-neutral-400">
|
| 236 |
+
These devices can log in automatically without a password.
|
| 237 |
+
</p>
|
| 238 |
+
|
| 239 |
+
<div className="space-y-3">
|
| 240 |
+
{loadingDevices ? (
|
| 241 |
+
<p className="text-sm text-neutral-500 italic">Loading devices...</p>
|
| 242 |
+
) : devices.length === 0 ? (
|
| 243 |
+
<p className="text-sm text-neutral-500 italic">No trusted devices found.</p>
|
| 244 |
+
) : (
|
| 245 |
+
devices.map((device) => (
|
| 246 |
+
<div key={device.id} className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/10 group hover:border-indigo-500/30 transition-all">
|
| 247 |
+
<div className="flex items-center gap-3">
|
| 248 |
+
<div className="w-10 h-10 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-400">
|
| 249 |
+
<Smartphone size={18} />
|
| 250 |
+
</div>
|
| 251 |
+
<div>
|
| 252 |
+
<p className="text-sm font-medium">{device.device_name}</p>
|
| 253 |
+
<p className="text-xs text-neutral-500">Last used: {new Date(device.last_used).toLocaleString()}</p>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
<button
|
| 257 |
+
onClick={() => handleDeleteDevice(device.id)}
|
| 258 |
+
className="p-2 text-neutral-500 hover:text-rose-400 hover:bg-rose-400/10 rounded-lg transition-all"
|
| 259 |
+
title="Remove device"
|
| 260 |
+
>
|
| 261 |
+
<X size={18} />
|
| 262 |
+
</button>
|
| 263 |
+
</div>
|
| 264 |
+
))
|
| 265 |
+
)}
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
|
| 269 |
+
{/* Danger Zone Section */}
|
| 270 |
+
<div className="glass-panel p-4 md:p-6 border border-rose-500/20 bg-rose-500/5">
|
| 271 |
+
<h2 className="text-base md:text-lg font-semibold mb-2 flex items-center gap-2 text-rose-400">
|
| 272 |
+
<ShieldAlert className="w-4 h-4" /> Danger Zone
|
| 273 |
+
</h2>
|
| 274 |
+
<p className="text-sm text-neutral-400 mb-6">
|
| 275 |
+
Irreversible actions that affect your data. Please be careful.
|
| 276 |
+
</p>
|
| 277 |
+
|
| 278 |
+
<Button
|
| 279 |
+
onClick={handleClearData}
|
| 280 |
+
disabled={clearing}
|
| 281 |
+
variant="destructive"
|
| 282 |
+
className="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 w-full md:w-auto"
|
| 283 |
+
>
|
| 284 |
+
<Trash2 className="w-4 h-4" />
|
| 285 |
+
{clearing ? 'Clearing...' : 'Clear All Current Data'}
|
| 286 |
+
</Button>
|
| 287 |
+
</div>
|
| 288 |
+
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
);
|
| 292 |
+
}
|
client/src/pages/Transactions.tsx
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useMemo } from 'react';
|
| 2 |
+
import { useTransactions, useWallets } from '../hooks/queries';
|
| 3 |
+
import { Search, Filter, X, ArrowDownRight, ArrowUpRight, ArrowRightLeft, Calendar } from 'lucide-react';
|
| 4 |
+
import { format, isWithinInterval, startOfDay, endOfDay, parseISO } from 'date-fns';
|
| 5 |
+
import { cn } from '../lib/utils';
|
| 6 |
+
import { Input } from '../components/ui/input';
|
| 7 |
+
|
| 8 |
+
type TransactionType = 'all' | 'income' | 'expense' | 'exchange';
|
| 9 |
+
|
| 10 |
+
export default function Transactions() {
|
| 11 |
+
const { data: transactions = [] } = useTransactions();
|
| 12 |
+
const { data: wallets = [] } = useWallets();
|
| 13 |
+
|
| 14 |
+
const [search, setSearch] = useState('');
|
| 15 |
+
const [typeFilter, setTypeFilter] = useState<TransactionType>('all');
|
| 16 |
+
const [walletFilter, setWalletFilter] = useState<string>('all');
|
| 17 |
+
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
| 18 |
+
const [startDate, setStartDate] = useState<string>('');
|
| 19 |
+
const [endDate, setEndDate] = useState<string>('');
|
| 20 |
+
|
| 21 |
+
const categories = useMemo(() => {
|
| 22 |
+
const cats = new Set<string>();
|
| 23 |
+
transactions.forEach((tx: any) => {
|
| 24 |
+
if (tx.category) cats.add(tx.category);
|
| 25 |
+
});
|
| 26 |
+
return Array.from(cats).sort();
|
| 27 |
+
}, [transactions]);
|
| 28 |
+
|
| 29 |
+
const filteredTransactions = useMemo(() => {
|
| 30 |
+
return transactions.filter((tx: any) => {
|
| 31 |
+
// Search filter (note only now)
|
| 32 |
+
const matchesSearch = !search ||
|
| 33 |
+
(tx.note?.toLowerCase().includes(search.toLowerCase()));
|
| 34 |
+
|
| 35 |
+
// Type filter - special handling for 'exchange'
|
| 36 |
+
let matchesType = true;
|
| 37 |
+
if (typeFilter !== 'all') {
|
| 38 |
+
if (typeFilter === 'exchange') {
|
| 39 |
+
matchesType = tx.type === 'transfer' || tx.category === 'Exchange';
|
| 40 |
+
} else if (typeFilter === 'income') {
|
| 41 |
+
matchesType = tx.type === 'income' && tx.category !== 'Exchange';
|
| 42 |
+
} else if (typeFilter === 'expense') {
|
| 43 |
+
matchesType = tx.type === 'expense' && tx.category !== 'Exchange';
|
| 44 |
+
} else {
|
| 45 |
+
matchesType = tx.type === typeFilter;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Wallet filter
|
| 50 |
+
const matchesWallet = walletFilter === 'all' ||
|
| 51 |
+
tx.wallet_id === parseInt(walletFilter) ||
|
| 52 |
+
tx.to_wallet_id === parseInt(walletFilter);
|
| 53 |
+
|
| 54 |
+
// Category filter
|
| 55 |
+
const matchesCategory = categoryFilter === 'all' || tx.category === categoryFilter;
|
| 56 |
+
|
| 57 |
+
// Date filter
|
| 58 |
+
let matchesDate = true;
|
| 59 |
+
if (startDate || endDate) {
|
| 60 |
+
try {
|
| 61 |
+
const txDate = parseISO(tx.date);
|
| 62 |
+
const interval = {
|
| 63 |
+
start: startDate ? startOfDay(parseISO(startDate)) : new Date(0),
|
| 64 |
+
end: endDate ? endOfDay(parseISO(endDate)) : new Date(8640000000000000)
|
| 65 |
+
};
|
| 66 |
+
matchesDate = isWithinInterval(txDate, interval);
|
| 67 |
+
} catch (e) {
|
| 68 |
+
matchesDate = true;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return matchesSearch && matchesType && matchesWallet && matchesCategory && matchesDate;
|
| 73 |
+
});
|
| 74 |
+
}, [transactions, search, typeFilter, walletFilter, categoryFilter, startDate, endDate]);
|
| 75 |
+
|
| 76 |
+
const getWalletName = (id: number) => wallets.find(w => w.id === id)?.name || 'Unknown';
|
| 77 |
+
|
| 78 |
+
const clearFilters = () => {
|
| 79 |
+
setSearch('');
|
| 80 |
+
setTypeFilter('all');
|
| 81 |
+
setWalletFilter('all');
|
| 82 |
+
setCategoryFilter('all');
|
| 83 |
+
setStartDate('');
|
| 84 |
+
setEndDate('');
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const hasActiveFilters = search !== '' ||
|
| 88 |
+
typeFilter !== 'all' ||
|
| 89 |
+
walletFilter !== 'all' ||
|
| 90 |
+
categoryFilter !== 'all' ||
|
| 91 |
+
startDate !== '' ||
|
| 92 |
+
endDate !== '';
|
| 93 |
+
|
| 94 |
+
return (
|
| 95 |
+
<div className="p-4 md:p-8 h-full flex flex-col">
|
| 96 |
+
<div className="flex flex-col mb-6 md:mb-8 shrink-0">
|
| 97 |
+
<h1 className="text-xl md:text-2xl font-bold">Transactions</h1>
|
| 98 |
+
<p className="text-sm md:text-base text-neutral-400">View and track your financial history.</p>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div className="glass-panel p-4 mb-6 shrink-0">
|
| 102 |
+
<div className="flex flex-wrap items-center gap-3">
|
| 103 |
+
{/* Selects: Type, Wallet, Category */}
|
| 104 |
+
<div className="flex flex-wrap gap-2 items-center w-full lg:w-auto">
|
| 105 |
+
<select
|
| 106 |
+
className="bg-neutral-900 border border-neutral-800 rounded-md px-3 py-2 h-10 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 flex-1 md:flex-none md:w-[120px]"
|
| 107 |
+
value={typeFilter}
|
| 108 |
+
onChange={(e) => setTypeFilter(e.target.value as TransactionType)}
|
| 109 |
+
>
|
| 110 |
+
<option value="all">All Types</option>
|
| 111 |
+
<option value="income">Income</option>
|
| 112 |
+
<option value="expense">Expense</option>
|
| 113 |
+
<option value="exchange">Exchange</option>
|
| 114 |
+
</select>
|
| 115 |
+
|
| 116 |
+
<select
|
| 117 |
+
className="bg-neutral-900 border border-neutral-800 rounded-md px-3 py-2 h-10 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 flex-1 md:flex-none md:w-[140px]"
|
| 118 |
+
value={walletFilter}
|
| 119 |
+
onChange={(e) => setWalletFilter(e.target.value)}
|
| 120 |
+
>
|
| 121 |
+
<option value="all">All Wallets</option>
|
| 122 |
+
{wallets.map(w => (
|
| 123 |
+
<option key={w.id} value={w.id.toString()}>{w.name}</option>
|
| 124 |
+
))}
|
| 125 |
+
</select>
|
| 126 |
+
|
| 127 |
+
<select
|
| 128 |
+
className="bg-neutral-900 border border-neutral-800 rounded-md px-3 py-2 h-10 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 flex-1 md:flex-none md:w-[140px]"
|
| 129 |
+
value={categoryFilter}
|
| 130 |
+
onChange={(e) => setCategoryFilter(e.target.value)}
|
| 131 |
+
>
|
| 132 |
+
<option value="all">All Categories</option>
|
| 133 |
+
{categories.map(cat => (
|
| 134 |
+
<option key={cat} value={cat}>{cat}</option>
|
| 135 |
+
))}
|
| 136 |
+
</select>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
{/* Date Range - Flexible width */}
|
| 140 |
+
<div className="flex items-center gap-2 bg-neutral-900 border border-neutral-800 rounded-md p-1 h-10 lg:w-auto w-full flex-shrink-0">
|
| 141 |
+
<div className="relative flex-1 md:flex-none">
|
| 142 |
+
<Calendar className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-500 pointer-events-none" />
|
| 143 |
+
<input
|
| 144 |
+
type="date"
|
| 145 |
+
className="bg-transparent border-none pl-7 pr-1 py-1 text-[11px] focus:outline-none w-full md:w-[110px] [color-scheme:dark]"
|
| 146 |
+
value={startDate}
|
| 147 |
+
onChange={(e) => setStartDate(e.target.value)}
|
| 148 |
+
/>
|
| 149 |
+
</div>
|
| 150 |
+
<span className="text-neutral-600 text-[10px] uppercase font-bold px-1">to</span>
|
| 151 |
+
<div className="relative flex-1 md:flex-none">
|
| 152 |
+
<Calendar className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-500 pointer-events-none" />
|
| 153 |
+
<input
|
| 154 |
+
type="date"
|
| 155 |
+
className="bg-transparent border-none pl-7 pr-1 py-1 text-[11px] focus:outline-none w-full md:w-[110px] [color-scheme:dark]"
|
| 156 |
+
value={endDate}
|
| 157 |
+
onChange={(e) => setEndDate(e.target.value)}
|
| 158 |
+
/>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
{/* Search - Compact width */}
|
| 163 |
+
<div className="relative w-full md:w-64">
|
| 164 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
| 165 |
+
<Input
|
| 166 |
+
placeholder="Search note..."
|
| 167 |
+
className="pl-10 h-10 w-full"
|
| 168 |
+
value={search}
|
| 169 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 170 |
+
/>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
{hasActiveFilters && (
|
| 174 |
+
<button
|
| 175 |
+
onClick={clearFilters}
|
| 176 |
+
className="flex items-center gap-1.5 px-3 py-2 text-xs text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 rounded-md border border-rose-500/20 h-10 w-full md:w-auto md:ml-auto"
|
| 177 |
+
>
|
| 178 |
+
<X className="w-3.5 h-3.5" /> Clear
|
| 179 |
+
</button>
|
| 180 |
+
)}
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
<div className="flex-1 overflow-y-auto w-full space-y-3 pb-24 md:pb-0 pr-2 pb-10">
|
| 186 |
+
{filteredTransactions.length === 0 ? (
|
| 187 |
+
<div className="flex flex-col items-center justify-center py-20 text-neutral-500">
|
| 188 |
+
<Filter className="w-12 h-12 mb-3 opacity-20" />
|
| 189 |
+
<p>No transactions found matching your filters</p>
|
| 190 |
+
</div>
|
| 191 |
+
) : (
|
| 192 |
+
filteredTransactions.map((tx: any) => (
|
| 193 |
+
<div key={tx.id} className="glass-panel p-3 md:p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between hover:bg-white/5 transition-colors gap-3 sm:gap-0">
|
| 194 |
+
<div className="flex items-center gap-3 md:gap-4 w-full sm:w-auto">
|
| 195 |
+
<div className={cn(
|
| 196 |
+
"w-10 h-10 rounded-full flex items-center justify-center border shrink-0",
|
| 197 |
+
tx.type === 'income' ? "bg-emerald-500/20 border-emerald-500/30 text-emerald-400" :
|
| 198 |
+
tx.type === 'expense' ? "bg-rose-500/20 border-rose-500/30 text-rose-400" :
|
| 199 |
+
"bg-blue-500/20 border-blue-500/30 text-blue-400"
|
| 200 |
+
)}>
|
| 201 |
+
{tx.type === 'income' ? <ArrowDownRight className="w-5 h-5" /> :
|
| 202 |
+
tx.type === 'expense' ? <ArrowUpRight className="w-5 h-5" /> :
|
| 203 |
+
<ArrowRightLeft className="w-5 h-5" />}
|
| 204 |
+
</div>
|
| 205 |
+
<div className="flex-1 flex flex-col justify-center min-w-0">
|
| 206 |
+
<div className="font-medium text-neutral-200 truncate">{tx.note || <span className="opacity-50 capitalize">{tx.type}</span>}</div>
|
| 207 |
+
<div className="text-[10px] md:text-xs text-neutral-500 mt-0.5 md:mt-1 flex flex-wrap gap-1.5 md:gap-2 items-center">
|
| 208 |
+
<span className="shrink-0">{format(new Date(tx.date), 'MMM d, yy HH:mm')}</span>
|
| 209 |
+
<span className="hidden sm:inline">•</span>
|
| 210 |
+
<span className="text-neutral-400 shrink-0">{getWalletName(tx.wallet_id)}{tx.to_wallet_id ? ` → ${getWalletName(tx.to_wallet_id)}` : ''}</span>
|
| 211 |
+
{tx.category && (
|
| 212 |
+
<>
|
| 213 |
+
<span className="hidden sm:inline">•</span>
|
| 214 |
+
<span className="text-blue-400/80 bg-blue-500/10 px-1.5 py-0.5 rounded shrink-0">{tx.category}</span>
|
| 215 |
+
</>
|
| 216 |
+
)}
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
<div className={cn(
|
| 221 |
+
"font-semibold text-base md:text-lg self-end sm:self-auto",
|
| 222 |
+
tx.type === 'income' ? "text-emerald-400" : tx.type === 'expense' ? "text-rose-400" : "text-blue-400"
|
| 223 |
+
)}>
|
| 224 |
+
{tx.type === 'expense' ? '-' : tx.type === 'income' ? '+' : ''}
|
| 225 |
+
{tx.amount.toLocaleString()} <span className="text-xs md:text-sm text-neutral-500">{tx.currency}</span>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
))
|
| 229 |
+
)}
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
);
|
| 233 |
+
}
|
| 234 |
+
|
client/src/pages/WalletsView.tsx
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useMemo } from 'react';
|
| 2 |
+
import { useWallets, useTransactions, useRates, useCreateTransaction, useExchanges, useCreateExchange } from '../hooks/queries';
|
| 3 |
+
import { Wallet as WalletIcon, DollarSign, Coins, Bitcoin, MessageCircle, ShieldCheck, Zap, CreditCard, PlusCircle, RefreshCw, MinusCircle } from 'lucide-react';
|
| 4 |
+
import { Button } from '../components/ui/button';
|
| 5 |
+
import { Input } from '../components/ui/input';
|
| 6 |
+
import { AmountInput } from '../components/ui/AmountInput';
|
| 7 |
+
import { Select } from '../components/ui/select';
|
| 8 |
+
import { Label } from '../components/ui/label';
|
| 9 |
+
|
| 10 |
+
export default function WalletsView() {
|
| 11 |
+
const getWalletStyling = (name: string) => {
|
| 12 |
+
const n = name.toLowerCase();
|
| 13 |
+
if (n.includes('dollar')) return { icon: DollarSign, color: 'text-emerald-400', bg: 'bg-emerald-500/20', border: 'border-emerald-500/30' };
|
| 14 |
+
if (n.includes('dinnar') || n.includes('dinar')) return { icon: Coins, color: 'text-indigo-400', bg: 'bg-indigo-500/20', border: 'border-indigo-500/30' };
|
| 15 |
+
if (n.includes('crypto')) return { icon: Bitcoin, color: 'text-orange-400', bg: 'bg-orange-500/20', border: 'border-orange-500/30' };
|
| 16 |
+
if (n.includes('wechat')) return { icon: MessageCircle, color: 'text-green-400', bg: 'bg-green-500/20', border: 'border-green-500/30' };
|
| 17 |
+
if (n.includes('alipay')) return { icon: ShieldCheck, color: 'text-blue-400', bg: 'bg-blue-500/20', border: 'border-blue-500/30' };
|
| 18 |
+
if (n.includes('fib')) return { icon: Zap, color: 'text-yellow-400', bg: 'bg-yellow-500/20', border: 'border-yellow-500/30' };
|
| 19 |
+
if (n.includes('fastpay')) return { icon: Zap, color: 'text-red-400', bg: 'bg-red-500/20', border: 'border-red-500/30' };
|
| 20 |
+
if (n.includes('super qi')) return { icon: CreditCard, color: 'text-blue-400', bg: 'bg-blue-500/20', border: 'border-blue-500/30' };
|
| 21 |
+
if (n.includes('kj wallets')) return { icon: WalletIcon, color: 'text-purple-400', bg: 'bg-purple-500/20', border: 'border-purple-500/30' };
|
| 22 |
+
return { icon: WalletIcon, color: 'text-indigo-400', bg: 'bg-indigo-500/20', border: 'border-indigo-500/30' };
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const getAllowedDestinations = (sourceName: string) => {
|
| 26 |
+
const name = sourceName.toLowerCase();
|
| 27 |
+
if (name.includes('usd') && !name.includes('usdt')) return ['USDT', 'Cash Dinar', 'Alipay', 'WeChat'];
|
| 28 |
+
if (name.includes('kj wallets')) return ['Cash USD', 'FIB'];
|
| 29 |
+
if (name.includes('dinar')) return ['FIB', 'FastPay', 'Super Qi', 'Cash USD'];
|
| 30 |
+
if (name.includes('usdt')) return ['Cash USD'];
|
| 31 |
+
if (name.includes('fib')) return ['FastPay', 'Super Qi', 'Cash Dinar'];
|
| 32 |
+
if (name.includes('fastpay')) return ['FIB', 'Super Qi', 'Cash Dinar'];
|
| 33 |
+
if (name.includes('qi')) return ['FastPay', 'FIB', 'Cash Dinar'];
|
| 34 |
+
if (name.includes('wechat')) return ['Alipay', 'Cash USD'];
|
| 35 |
+
if (name.includes('alipay')) return ['WeChat', 'Cash USD'];
|
| 36 |
+
return [];
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const { data: rawWallets = [] } = useWallets();
|
| 40 |
+
const wallets = useMemo(() => {
|
| 41 |
+
const order = ['cash usd', 'cash dinar', 'super qi', 'alipay', 'wechat', 'fib', 'fastpay', 'usdt', 'kj wallets'];
|
| 42 |
+
return [...rawWallets].sort((a: any, b: any) => {
|
| 43 |
+
const idxA = order.indexOf(a.name.toLowerCase());
|
| 44 |
+
const idxB = order.indexOf(b.name.toLowerCase());
|
| 45 |
+
return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB);
|
| 46 |
+
});
|
| 47 |
+
}, [rawWallets]);
|
| 48 |
+
|
| 49 |
+
const { data: transactions = [] } = useTransactions();
|
| 50 |
+
const { data: exchanges = [] } = useExchanges();
|
| 51 |
+
const { data: rates } = useRates();
|
| 52 |
+
const { mutateAsync: createTransaction } = useCreateTransaction();
|
| 53 |
+
const { mutateAsync: createExchange } = useCreateExchange();
|
| 54 |
+
|
| 55 |
+
const [actionState, setActionState] = useState<{type: 'transfer' | 'income' | 'expense' | 'exchange', walletId?: number} | null>(null);
|
| 56 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 57 |
+
|
| 58 |
+
// Form States
|
| 59 |
+
const [amount, setAmount] = useState('');
|
| 60 |
+
const [toAmount, setToAmount] = useState('');
|
| 61 |
+
const [toWallet, setToWallet] = useState('');
|
| 62 |
+
const [note, setNote] = useState('');
|
| 63 |
+
const [category, setCategory] = useState('other');
|
| 64 |
+
|
| 65 |
+
const exchangeRates = rates || { USD: 1, IQD: 1539.5, RMB: 6.86 };
|
| 66 |
+
|
| 67 |
+
// Calculate Balances dynamically factoring in currency differences
|
| 68 |
+
const getBalance = (w: any) => {
|
| 69 |
+
const txBal = transactions.reduce((acc: number, tx: any) => {
|
| 70 |
+
let effectiveAmount = tx.amount;
|
| 71 |
+
if (tx.currency !== w.currency) {
|
| 72 |
+
const txRate = exchangeRates[tx.currency] || 1;
|
| 73 |
+
const walletRate = exchangeRates[w.currency] || 1;
|
| 74 |
+
effectiveAmount = (tx.amount / txRate) * walletRate;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if (tx.type === 'income' && tx.wallet_id === w.id) return acc + effectiveAmount;
|
| 78 |
+
if (tx.type === 'expense' && tx.wallet_id === w.id) return acc - effectiveAmount;
|
| 79 |
+
if (tx.type === 'transfer') {
|
| 80 |
+
if (tx.wallet_id === w.id) return acc - effectiveAmount;
|
| 81 |
+
if (tx.to_wallet_id === w.id) return acc + effectiveAmount;
|
| 82 |
+
}
|
| 83 |
+
return acc;
|
| 84 |
+
}, 0);
|
| 85 |
+
|
| 86 |
+
const exBal = exchanges.reduce((acc: number, ex: any) => {
|
| 87 |
+
let bal = 0;
|
| 88 |
+
if (ex.from_wallet_id === w.id) bal -= ex.from_amount;
|
| 89 |
+
if (ex.to_wallet_id === w.id) bal += ex.to_amount;
|
| 90 |
+
return acc + bal;
|
| 91 |
+
}, 0);
|
| 92 |
+
|
| 93 |
+
return txBal + exBal;
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
const activeWallet = actionState?.walletId ? wallets.find((w: any) => w.id === actionState.walletId) : null;
|
| 97 |
+
const currentBalance = activeWallet ? getBalance(activeWallet) : 0;
|
| 98 |
+
const enteredAmount = parseFloat(amount) || 0;
|
| 99 |
+
const isInsufficient = (actionState?.type === 'expense' || actionState?.type === 'exchange') && enteredAmount > currentBalance;
|
| 100 |
+
|
| 101 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 102 |
+
e.preventDefault();
|
| 103 |
+
if (isInsufficient) return;
|
| 104 |
+
setIsSubmitting(true);
|
| 105 |
+
try {
|
| 106 |
+
if (actionState?.type === 'income' || actionState?.type === 'expense') {
|
| 107 |
+
const targetWallet = wallets.find((w: any) => w.id === actionState.walletId);
|
| 108 |
+
if (!targetWallet) return;
|
| 109 |
+
|
| 110 |
+
let finalCategory = category;
|
| 111 |
+
if (actionState.type === 'income' && category === 'other') {
|
| 112 |
+
// If it was the default 'other' but from a previous expense, and we are in income now,
|
| 113 |
+
// we might want to ensure it's handled correctly if not changed.
|
| 114 |
+
// But actually, the state is shared. So we just use 'category' state.
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
await createTransaction({
|
| 118 |
+
type: actionState.type,
|
| 119 |
+
amount: parseFloat(amount),
|
| 120 |
+
currency: targetWallet.currency,
|
| 121 |
+
wallet_id: targetWallet.id,
|
| 122 |
+
category: finalCategory,
|
| 123 |
+
note: note || (actionState.type === 'income' ? 'Added Income' : 'Recorded Expense'),
|
| 124 |
+
date: new Date().toISOString()
|
| 125 |
+
} as any);
|
| 126 |
+
} else if (actionState?.type === 'exchange') {
|
| 127 |
+
const sourceW = wallets.find((w: any) => w.id === actionState.walletId);
|
| 128 |
+
const destW = wallets.find((w: any) => w.id.toString() === toWallet);
|
| 129 |
+
if (!sourceW || !destW) return;
|
| 130 |
+
|
| 131 |
+
const fAmt = parseFloat(amount);
|
| 132 |
+
const tAmt = parseFloat(toAmount);
|
| 133 |
+
|
| 134 |
+
await createExchange({
|
| 135 |
+
from_amount: fAmt,
|
| 136 |
+
from_currency: sourceW.currency,
|
| 137 |
+
from_wallet_id: sourceW.id,
|
| 138 |
+
to_amount: tAmt,
|
| 139 |
+
to_currency: destW.currency,
|
| 140 |
+
to_wallet_id: destW.id,
|
| 141 |
+
rate: tAmt / fAmt,
|
| 142 |
+
note: note || `Exchanged to ${destW.name}`,
|
| 143 |
+
date: new Date().toISOString()
|
| 144 |
+
} as any);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
setActionState(null);
|
| 148 |
+
setAmount(''); setToAmount(''); setNote(''); setToWallet(''); setCategory('other');
|
| 149 |
+
} finally {
|
| 150 |
+
setIsSubmitting(false);
|
| 151 |
+
}
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
return (
|
| 155 |
+
<div className="p-4 md:p-8 h-full flex flex-col">
|
| 156 |
+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6 md:mb-8 shrink-0">
|
| 157 |
+
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-3">
|
| 158 |
+
<WalletIcon className="w-5 h-5 md:w-6 md:h-6 text-indigo-400" /> Wallets
|
| 159 |
+
</h1>
|
| 160 |
+
<Button onClick={() => setActionState(null)} className="opacity-0 cursor-default pointer-events-none w-full sm:w-auto">
|
| 161 |
+
Placeholder
|
| 162 |
+
</Button>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
{actionState && (
|
| 166 |
+
<div className={`glass-panel p-4 md:p-6 mb-6 md:mb-8 border ${
|
| 167 |
+
actionState.type === 'income' ? 'border-emerald-500/30' :
|
| 168 |
+
actionState.type === 'expense' ? 'border-rose-500/30' :
|
| 169 |
+
'border-blue-500/30'
|
| 170 |
+
}`}>
|
| 171 |
+
<div className="flex justify-between items-center mb-4">
|
| 172 |
+
<h2 className={`text-lg font-semibold ${
|
| 173 |
+
actionState.type === 'income' ? 'text-emerald-400' :
|
| 174 |
+
actionState.type === 'expense' ? 'text-rose-400' :
|
| 175 |
+
'text-blue-400'
|
| 176 |
+
}`}>
|
| 177 |
+
{actionState.type === 'income' ? `Add Salary / Income to ${wallets.find((w: any) => w.id === actionState.walletId)?.name}` :
|
| 178 |
+
actionState.type === 'expense' ? `Add Expense from ${wallets.find((w: any) => w.id === actionState.walletId)?.name}` :
|
| 179 |
+
`Exchange from ${wallets.find((w: any) => w.id === actionState.walletId)?.name}`}
|
| 180 |
+
</h2>
|
| 181 |
+
<Button variant="ghost" size="sm" onClick={() => setActionState(null)}>Cancel</Button>
|
| 182 |
+
</div>
|
| 183 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 184 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 185 |
+
{actionState.type === 'exchange' && (() => {
|
| 186 |
+
const sourceName = wallets.find((w: any) => w.id === actionState.walletId)?.name || '';
|
| 187 |
+
const allowedNames = getAllowedDestinations(sourceName);
|
| 188 |
+
const availableDestinations = wallets.filter((w: any) => w.id !== actionState.walletId && allowedNames.includes(w.name));
|
| 189 |
+
|
| 190 |
+
return (
|
| 191 |
+
<div>
|
| 192 |
+
<Label>To Wallet</Label>
|
| 193 |
+
<Select required value={toWallet} onChange={e => setToWallet(e.target.value)} className="mt-1">
|
| 194 |
+
<option value="">Select Destination</option>
|
| 195 |
+
{availableDestinations.map((w: any) => (
|
| 196 |
+
<option key={w.id} value={w.id}>{w.name} ({w.currency})</option>
|
| 197 |
+
))}
|
| 198 |
+
</Select>
|
| 199 |
+
{availableDestinations.length === 0 && (
|
| 200 |
+
<p className="text-xs text-red-400 mt-2">No allowed conversions for this wallet type.</p>
|
| 201 |
+
)}
|
| 202 |
+
</div>
|
| 203 |
+
);
|
| 204 |
+
})()}
|
| 205 |
+
<div>
|
| 206 |
+
<Label>{
|
| 207 |
+
actionState.type === 'exchange' ? 'Amount to Exchange' :
|
| 208 |
+
actionState.type === 'income' ? 'Income Amount' :
|
| 209 |
+
'Expense Amount'
|
| 210 |
+
}</Label>
|
| 211 |
+
<div className="relative mt-1">
|
| 212 |
+
<AmountInput required value={amount} onChange={e => setAmount(e.target.value)} className="pr-12" />
|
| 213 |
+
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-neutral-500 font-medium text-sm">
|
| 214 |
+
{wallets.find((w: any) => w.id === actionState.walletId)?.currency}
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
{actionState.type === 'exchange' && (
|
| 219 |
+
<div>
|
| 220 |
+
<Label>Exact Amount Received</Label>
|
| 221 |
+
<div className="relative mt-1">
|
| 222 |
+
<AmountInput required value={toAmount} onChange={e => setToAmount(e.target.value)} className="pr-12" />
|
| 223 |
+
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none text-neutral-500 font-medium text-sm">
|
| 224 |
+
{wallets.find((w: any) => w.id.toString() === toWallet)?.currency}
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
)}
|
| 229 |
+
{actionState.type === 'expense' && (
|
| 230 |
+
<div>
|
| 231 |
+
<Label>Category</Label>
|
| 232 |
+
<Select
|
| 233 |
+
value={category}
|
| 234 |
+
onChange={e => setCategory(e.target.value)}
|
| 235 |
+
className="mt-1"
|
| 236 |
+
>
|
| 237 |
+
<option value="food">Food</option>
|
| 238 |
+
<option value="market">Market</option>
|
| 239 |
+
<option value="transport">Transport</option>
|
| 240 |
+
<option value="cafe">Cafe</option>
|
| 241 |
+
<option value="barber">Barber</option>
|
| 242 |
+
<option value="mobile-balance">Mobile Balance</option>
|
| 243 |
+
<option value="electricity">Electricity</option>
|
| 244 |
+
<option value="health">Health</option>
|
| 245 |
+
<option value="gift">Gift</option>
|
| 246 |
+
<option value="other">Other</option>
|
| 247 |
+
</Select>
|
| 248 |
+
</div>
|
| 249 |
+
)}
|
| 250 |
+
{actionState.type === 'income' && (
|
| 251 |
+
<div>
|
| 252 |
+
<Label>Category</Label>
|
| 253 |
+
<Select
|
| 254 |
+
value={category}
|
| 255 |
+
onChange={e => setCategory(e.target.value)}
|
| 256 |
+
className="mt-1"
|
| 257 |
+
>
|
| 258 |
+
<option value="salary">Salary</option>
|
| 259 |
+
<option value="gift">Gift</option>
|
| 260 |
+
<option value="other">Other</option>
|
| 261 |
+
</Select>
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
<div className={actionState.type === 'exchange' ? "col-span-1 md:col-span-2" : ""}>
|
| 265 |
+
<Label>Note (Optional)</Label>
|
| 266 |
+
<Input type="text" value={note} onChange={e => setNote(e.target.value)} placeholder={
|
| 267 |
+
actionState.type === 'income' ? 'e.g. Salary' :
|
| 268 |
+
actionState.type === 'expense' ? 'e.g. Food, Transport' :
|
| 269 |
+
''
|
| 270 |
+
} className="mt-1" />
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
{isInsufficient && (
|
| 274 |
+
<div className="bg-rose-500/10 border border-rose-500/20 text-rose-400 p-3 rounded-xl text-sm mb-4">
|
| 275 |
+
cantbe expense balnce than your balance
|
| 276 |
+
</div>
|
| 277 |
+
)}
|
| 278 |
+
<div className="flex justify-end pt-2 md:pt-4">
|
| 279 |
+
<Button
|
| 280 |
+
type="submit"
|
| 281 |
+
disabled={isSubmitting || isInsufficient}
|
| 282 |
+
className={`w-full md:w-auto ${
|
| 283 |
+
isInsufficient ? 'bg-neutral-800 text-neutral-500 cursor-not-allowed' :
|
| 284 |
+
actionState.type === 'income' ? 'bg-emerald-600 hover:bg-emerald-500' :
|
| 285 |
+
actionState.type === 'expense' ? 'bg-rose-600 hover:bg-rose-500' :
|
| 286 |
+
'bg-blue-600 hover:bg-blue-500'
|
| 287 |
+
} text-white`}
|
| 288 |
+
>
|
| 289 |
+
{actionState.type === 'income' ? 'Add Funds' :
|
| 290 |
+
actionState.type === 'expense' ? 'Record Expense' :
|
| 291 |
+
'Execute Exchange'}
|
| 292 |
+
</Button>
|
| 293 |
+
</div>
|
| 294 |
+
</form>
|
| 295 |
+
</div>
|
| 296 |
+
)}
|
| 297 |
+
|
| 298 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 overflow-y-auto pb-24 md:pb-0 pr-2">
|
| 299 |
+
{wallets.map((w: any) => {
|
| 300 |
+
const balance = getBalance(w);
|
| 301 |
+
const { icon: Icon, color, bg, border } = getWalletStyling(w.name);
|
| 302 |
+
|
| 303 |
+
return (
|
| 304 |
+
<div key={w.id} className={`glass-panel p-4 md:p-6 border border-white/5 hover:border-indigo-500/30 transition-all flex flex-col justify-between group`}>
|
| 305 |
+
<div>
|
| 306 |
+
<div className={`w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center mb-3 md:mb-4 border ${bg} ${color} ${border}`}>
|
| 307 |
+
<Icon className="w-5 h-5 md:w-6 md:h-6" />
|
| 308 |
+
</div>
|
| 309 |
+
<h3 className="text-base md:text-lg font-bold">{w.name}</h3>
|
| 310 |
+
<p className="text-xs md:text-sm text-neutral-400 capitalize">{w.type}</p>
|
| 311 |
+
</div>
|
| 312 |
+
<div className="mt-6 md:mt-8 pt-4 border-t border-white/10">
|
| 313 |
+
<p className="text-xs md:text-sm text-neutral-500 mb-1">Current Balance</p>
|
| 314 |
+
<p className="text-xl md:text-2xl font-bold tracking-tight mb-4">
|
| 315 |
+
{balance.toLocaleString(undefined, { maximumFractionDigits: 2 })} <span className="text-sm md:text-lg text-neutral-500">{w.currency}</span>
|
| 316 |
+
</p>
|
| 317 |
+
<div className="grid grid-cols-3 gap-1 md:gap-2 mt-auto">
|
| 318 |
+
<button onClick={() => { setActionState({ type: 'income', walletId: w.id }); setCategory('salary'); }} className="flex items-center justify-center gap-1 md:gap-1.5 py-2 rounded shadow-sm bg-white/5 hover:bg-emerald-500/20 text-emerald-400 hover:text-emerald-300 font-medium text-[10px] md:text-sm transition-colors border border-emerald-500/10 hover:border-emerald-500/30">
|
| 319 |
+
<PlusCircle className="w-3 h-3 md:w-4 md:h-4" /> Add
|
| 320 |
+
</button>
|
| 321 |
+
<button onClick={() => { setActionState({ type: 'expense', walletId: w.id }); setCategory('other'); }} className="flex items-center justify-center gap-1 md:gap-1.5 py-2 rounded shadow-sm bg-white/5 hover:bg-rose-500/20 text-rose-400 hover:text-rose-300 font-medium text-[10px] md:text-sm transition-colors border border-rose-500/10 hover:border-rose-500/30">
|
| 322 |
+
<MinusCircle className="w-3 h-3 md:w-4 md:h-4" /> Expense
|
| 323 |
+
</button>
|
| 324 |
+
<button onClick={() => { setActionState({ type: 'exchange', walletId: w.id }); setCategory('other'); }} className="flex items-center justify-center gap-1 md:gap-1.5 py-2 rounded shadow-sm bg-white/5 hover:bg-blue-500/20 text-blue-400 hover:text-blue-300 font-medium text-[10px] md:text-sm transition-colors border border-blue-500/10 hover:border-blue-500/30">
|
| 325 |
+
<RefreshCw className="w-3 h-3 md:w-3.5 md:h-3.5" /> Exch
|
| 326 |
+
</button>
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
);
|
| 331 |
+
})}
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
);
|
| 335 |
+
}
|
client/src/store/useAppStore.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { create } from 'zustand';
|
| 2 |
+
|
| 3 |
+
interface AppState {
|
| 4 |
+
mainCurrency: string;
|
| 5 |
+
setMainCurrency: (currency: string) => void;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export const useAppStore = create<AppState>((set) => ({
|
| 9 |
+
mainCurrency: 'USD',
|
| 10 |
+
setMainCurrency: (currency) => set({ mainCurrency: currency }),
|
| 11 |
+
}));
|
client/src/store/useAuthStore.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { create } from 'zustand';
|
| 2 |
+
|
| 3 |
+
interface AuthState {
|
| 4 |
+
token: string | null;
|
| 5 |
+
username: string | null;
|
| 6 |
+
deviceId: string | null;
|
| 7 |
+
isAuthenticated: boolean;
|
| 8 |
+
login: (token: string, username: string, deviceId?: string | null) => void;
|
| 9 |
+
setDeviceId: (deviceId: string | null) => void;
|
| 10 |
+
logout: () => void;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export const useAuthStore = create<AuthState>((set) => {
|
| 14 |
+
const storedToken = localStorage.getItem('auth_token');
|
| 15 |
+
const storedUsername = localStorage.getItem('auth_username');
|
| 16 |
+
const storedDeviceId = localStorage.getItem('auth_device_id');
|
| 17 |
+
|
| 18 |
+
return {
|
| 19 |
+
token: storedToken,
|
| 20 |
+
username: storedUsername,
|
| 21 |
+
deviceId: storedDeviceId,
|
| 22 |
+
isAuthenticated: !!storedToken,
|
| 23 |
+
login: (token, username, deviceId = null) => {
|
| 24 |
+
localStorage.setItem('auth_token', token);
|
| 25 |
+
localStorage.setItem('auth_username', username);
|
| 26 |
+
if (deviceId) {
|
| 27 |
+
localStorage.setItem('auth_device_id', deviceId);
|
| 28 |
+
}
|
| 29 |
+
set({ token, username, deviceId: deviceId || storedDeviceId, isAuthenticated: true });
|
| 30 |
+
},
|
| 31 |
+
setDeviceId: (deviceId) => {
|
| 32 |
+
if (deviceId) {
|
| 33 |
+
localStorage.setItem('auth_device_id', deviceId);
|
| 34 |
+
} else {
|
| 35 |
+
localStorage.removeItem('auth_device_id');
|
| 36 |
+
}
|
| 37 |
+
set({ deviceId });
|
| 38 |
+
},
|
| 39 |
+
logout: () => {
|
| 40 |
+
localStorage.removeItem('auth_token');
|
| 41 |
+
localStorage.removeItem('auth_username');
|
| 42 |
+
// Note: we don't remove auth_device_id here because it's used for auto-login
|
| 43 |
+
set({ token: null, username: null, isAuthenticated: false });
|
| 44 |
+
},
|
| 45 |
+
};
|
| 46 |
+
});
|
client/src/types.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface Wallet {
|
| 2 |
+
id: number;
|
| 3 |
+
name: string;
|
| 4 |
+
type: string;
|
| 5 |
+
currency: string;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export interface Transaction {
|
| 9 |
+
id: number;
|
| 10 |
+
type: 'income' | 'expense' | 'transfer';
|
| 11 |
+
amount: number;
|
| 12 |
+
currency: string;
|
| 13 |
+
wallet_id: number;
|
| 14 |
+
to_wallet_id?: number | null;
|
| 15 |
+
category?: string;
|
| 16 |
+
note?: string;
|
| 17 |
+
date: string;
|
| 18 |
+
country_id?: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export interface Exchange {
|
| 22 |
+
id: number;
|
| 23 |
+
from_amount: number;
|
| 24 |
+
from_currency: string;
|
| 25 |
+
from_wallet_id: number;
|
| 26 |
+
to_amount: number;
|
| 27 |
+
to_currency: string;
|
| 28 |
+
to_wallet_id: number;
|
| 29 |
+
rate: number;
|
| 30 |
+
date: string;
|
| 31 |
+
note?: string;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export interface Loan {
|
| 35 |
+
id: number;
|
| 36 |
+
person: string;
|
| 37 |
+
type: 'borrowed_from_me' | 'owed_by_me';
|
| 38 |
+
amount: number;
|
| 39 |
+
currency: string;
|
| 40 |
+
paid: number;
|
| 41 |
+
date: string;
|
| 42 |
+
note?: string;
|
| 43 |
+
}
|
client/tsconfig.app.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
| 4 |
+
"target": "ES2022",
|
| 5 |
+
"useDefineForClassFields": true,
|
| 6 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 7 |
+
"module": "ESNext",
|
| 8 |
+
"types": ["vite/client"],
|
| 9 |
+
"skipLibCheck": true,
|
| 10 |
+
|
| 11 |
+
/* Bundler mode */
|
| 12 |
+
"moduleResolution": "bundler",
|
| 13 |
+
"allowImportingTsExtensions": true,
|
| 14 |
+
"verbatimModuleSyntax": true,
|
| 15 |
+
"moduleDetection": "force",
|
| 16 |
+
"noEmit": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
|
| 19 |
+
/* Linting */
|
| 20 |
+
"strict": true,
|
| 21 |
+
"noUnusedLocals": true,
|
| 22 |
+
"noUnusedParameters": true,
|
| 23 |
+
"erasableSyntaxOnly": true,
|
| 24 |
+
"noFallthroughCasesInSwitch": true,
|
| 25 |
+
"noUncheckedSideEffectImports": true
|
| 26 |
+
},
|
| 27 |
+
"include": ["src"]
|
| 28 |
+
}
|
client/tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"files": [],
|
| 3 |
+
"references": [
|
| 4 |
+
{ "path": "./tsconfig.app.json" },
|
| 5 |
+
{ "path": "./tsconfig.node.json" }
|
| 6 |
+
]
|
| 7 |
+
}
|
client/tsconfig.node.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
| 4 |
+
"target": "ES2023",
|
| 5 |
+
"lib": ["ES2023"],
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"types": ["node"],
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
|
| 10 |
+
/* Bundler mode */
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"allowImportingTsExtensions": true,
|
| 13 |
+
"verbatimModuleSyntax": true,
|
| 14 |
+
"moduleDetection": "force",
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
|
| 17 |
+
/* Linting */
|
| 18 |
+
"strict": true,
|
| 19 |
+
"noUnusedLocals": true,
|
| 20 |
+
"noUnusedParameters": true,
|
| 21 |
+
"erasableSyntaxOnly": true,
|
| 22 |
+
"noFallthroughCasesInSwitch": true,
|
| 23 |
+
"noUncheckedSideEffectImports": true
|
| 24 |
+
},
|
| 25 |
+
"include": ["vite.config.ts"]
|
| 26 |
+
}
|
client/vite.config.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
import tailwindcss from '@tailwindcss/vite'
|
| 4 |
+
|
| 5 |
+
// https://vite.dev/config/
|
| 6 |
+
export default defineConfig({
|
| 7 |
+
plugins: [
|
| 8 |
+
tailwindcss(),
|
| 9 |
+
react()
|
| 10 |
+
],
|
| 11 |
+
})
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "wallets-workspace",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"workspaces": [
|
| 6 |
+
"client",
|
| 7 |
+
"server"
|
| 8 |
+
],
|
| 9 |
+
"scripts": {
|
| 10 |
+
"dev": "concurrently \"npm run dev -w server\" \"npm run dev -w client\"",
|
| 11 |
+
"build": "npm run build -w client",
|
| 12 |
+
"install:all": "npm install"
|
| 13 |
+
},
|
| 14 |
+
"devDependencies": {
|
| 15 |
+
"concurrently": "^9.1.2",
|
| 16 |
+
"puppeteer": "^24.39.0"
|
| 17 |
+
}
|
| 18 |
+
}
|