z1amez commited on
Commit
2dddd1f
·
0 Parent(s):
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +33 -0
  2. DEPLOYMENT.md +48 -0
  3. client/.gitignore +24 -0
  4. client/README.md +73 -0
  5. client/eslint.config.js +23 -0
  6. client/index.html +13 -0
  7. client/package-lock.json +0 -0
  8. client/package.json +47 -0
  9. client/public/vite.svg +1 -0
  10. client/src/App.css +42 -0
  11. client/src/App.tsx +44 -0
  12. client/src/api.ts +58 -0
  13. client/src/assets/react.svg +1 -0
  14. client/src/components/ui/AmountInput.tsx +83 -0
  15. client/src/components/ui/badge.tsx +31 -0
  16. client/src/components/ui/button.tsx +50 -0
  17. client/src/components/ui/card.tsx +50 -0
  18. client/src/components/ui/input.tsx +23 -0
  19. client/src/components/ui/label.tsx +18 -0
  20. client/src/components/ui/modal.tsx +31 -0
  21. client/src/components/ui/select.tsx +24 -0
  22. client/src/features/analytics/useAnalyticsStats.ts +347 -0
  23. client/src/features/dashboard/components/CashFlowWidget.tsx +47 -0
  24. client/src/features/dashboard/components/NetWorthWidget.tsx +52 -0
  25. client/src/features/dashboard/useDashboardStats.ts +152 -0
  26. client/src/features/transactions/components/TransactionForm.tsx +161 -0
  27. client/src/hooks/queries.ts +110 -0
  28. client/src/index.css +48 -0
  29. client/src/layouts/MainLayout.tsx +19 -0
  30. client/src/layouts/MobileNav.tsx +44 -0
  31. client/src/layouts/Sidebar.tsx +63 -0
  32. client/src/lib/react-query.ts +11 -0
  33. client/src/lib/utils.ts +6 -0
  34. client/src/main.tsx +14 -0
  35. client/src/pages/Analytics.tsx +595 -0
  36. client/src/pages/Dashboard.tsx +186 -0
  37. client/src/pages/Loans.tsx +407 -0
  38. client/src/pages/Login.tsx +172 -0
  39. client/src/pages/Settings.tsx +292 -0
  40. client/src/pages/Transactions.tsx +234 -0
  41. client/src/pages/WalletsView.tsx +335 -0
  42. client/src/store/useAppStore.ts +11 -0
  43. client/src/store/useAuthStore.ts +46 -0
  44. client/src/types.ts +43 -0
  45. client/tsconfig.app.json +28 -0
  46. client/tsconfig.json +7 -0
  47. client/tsconfig.node.json +26 -0
  48. client/vite.config.ts +11 -0
  49. package-lock.json +0 -0
  50. 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
+ }