mirror of
https://github.com/dat515-2025/Group-8.git
synced 2026-03-22 06:57:47 +01:00
feat(docs): codebase refactor - added src directory
This commit is contained in:
4
.github/workflows/deploy-prod.yaml
vendored
4
.github/workflows/deploy-prod.yaml
vendored
@@ -4,9 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
paths:
|
paths:
|
||||||
- 7project/backend/**
|
- ../../7project/src/backend/**
|
||||||
- 7project/frontend/**
|
- 7project/frontend/**
|
||||||
- 7project/charts/myapp-chart/**
|
- ../../7project/src/charts/myapp-chart/**
|
||||||
- .github/workflows/deploy-prod.yaml
|
- .github/workflows/deploy-prod.yaml
|
||||||
- .github/workflows/build-image.yaml
|
- .github/workflows/build-image.yaml
|
||||||
- .github/workflows/frontend-pages.yml
|
- .github/workflows/frontend-pages.yml
|
||||||
|
|||||||
16
7project/.gitignore
vendored
16
7project/.gitignore
vendored
@@ -1,8 +1,8 @@
|
|||||||
/tofu/controlplane.yaml
|
/src/tofu/controlplane.yaml
|
||||||
/tofu/kubeconfig
|
/src/tofu/kubeconfig
|
||||||
/tofu/talosconfig
|
/src/tofu/talosconfig
|
||||||
/tofu/terraform.tfstate
|
/src/tofu/terraform.tfstate
|
||||||
/tofu/terraform.tfstate.backup
|
/src/tofu/terraform.tfstate.backup
|
||||||
/tofu/worker.yaml
|
/src/tofu/worker.yaml
|
||||||
/tofu/.terraform.lock.hcl
|
/src/tofu/.terraform.lock.hcl
|
||||||
/tofu/.terraform/
|
/src/tofu/.terraform/
|
||||||
|
|||||||
24
7project/frontend/.gitignore
vendored
24
7project/frontend/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@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
|
|
||||||
- [@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
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs['recommended-latest'],
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>frontend</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
3820
7project/frontend/package-lock.json
generated
3820
7project/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^19.1.1",
|
|
||||||
"react-dom": "^19.1.1",
|
|
||||||
"recharts": "^3.3.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.36.0",
|
|
||||||
"@types/node": "^24.6.0",
|
|
||||||
"@types/react": "^19.1.16",
|
|
||||||
"@types/react-dom": "^19.1.9",
|
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
|
||||||
"eslint": "^9.36.0",
|
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.22",
|
|
||||||
"globals": "^16.4.0",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"typescript-eslint": "^8.45.0",
|
|
||||||
"vite": "^7.1.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,89 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import LoginRegisterPage from './pages/LoginRegisterPage';
|
|
||||||
import Dashboard from './pages/Dashboard';
|
|
||||||
import { logout } from './api';
|
|
||||||
import { BACKEND_URL } from './config';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [hasToken, setHasToken] = useState<boolean>(!!localStorage.getItem('token'));
|
|
||||||
const [processingCallback, setProcessingCallback] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
|
|
||||||
// Minimal handling for provider callbacks: /auth|/oauth/:provider/callback?code=...&state=...
|
|
||||||
const parts = path.split('/').filter(Boolean);
|
|
||||||
const isCallback = parts.length === 3 && (parts[0] === 'auth') && parts[2] === 'callback';
|
|
||||||
|
|
||||||
if (isCallback) {
|
|
||||||
// Guard against double invocation in React 18 StrictMode/dev
|
|
||||||
const w = window as any;
|
|
||||||
if (w.__oauthCallbackHandled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
w.__oauthCallbackHandled = true;
|
|
||||||
|
|
||||||
setProcessingCallback(true);
|
|
||||||
|
|
||||||
const provider = parts[1];
|
|
||||||
const qs = window.location.search || '';
|
|
||||||
const base = BACKEND_URL.replace(/\/$/, '');
|
|
||||||
const url = `${base}/auth/${encodeURIComponent(provider)}/callback${qs}`;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
||||||
});
|
|
||||||
let data: any = null;
|
|
||||||
try {
|
|
||||||
data = await res.json();
|
|
||||||
} catch {}
|
|
||||||
if (provider !== 'csas' && res.ok && data?.access_token) {
|
|
||||||
localStorage.setItem('token', data?.access_token);
|
|
||||||
setHasToken(true);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
// Clean URL and go home regardless of result
|
|
||||||
setProcessingCallback(false);
|
|
||||||
window.history.replaceState({}, '', '/');
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
const onStorage = (e: StorageEvent) => {
|
|
||||||
if (e.key === 'token') setHasToken(!!e.newValue);
|
|
||||||
};
|
|
||||||
window.addEventListener('storage', onStorage);
|
|
||||||
return () => window.removeEventListener('storage', onStorage);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (processingCallback) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'grid', placeItems: 'center', height: '100vh' }}>
|
|
||||||
<div className="card" style={{ width: 360, textAlign: 'center', padding: 24 }}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
|
|
||||||
<svg width="48" height="48" viewBox="0 0 50 50" aria-label="Loading">
|
|
||||||
<circle cx="25" cy="25" r="20" fill="none" stroke="#3b82f6" strokeWidth="5" strokeLinecap="round" strokeDasharray="31.4 31.4">
|
|
||||||
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.9s" repeatCount="indefinite" />
|
|
||||||
</circle>
|
|
||||||
</svg>
|
|
||||||
<div>Finishing sign-in…</div>
|
|
||||||
<div className="muted">Please wait</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasToken) {
|
|
||||||
return <LoginRegisterPage onLoggedIn={() => setHasToken(true)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dashboard onLogout={() => { logout(); setHasToken(false); }} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import { BACKEND_URL } from './config';
|
|
||||||
|
|
||||||
export type LoginResponse = {
|
|
||||||
access_token: string;
|
|
||||||
token_type: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Category = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Transaction = {
|
|
||||||
id: number;
|
|
||||||
amount: number;
|
|
||||||
description?: string | null;
|
|
||||||
category_ids: number[];
|
|
||||||
date?: string | null; // ISO date (YYYY-MM-DD)
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function deleteTransaction(id: number): Promise<void> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/transactions/${id}/delete`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders('none'),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Failed to delete transaction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBaseUrl() {
|
|
||||||
const base = BACKEND_URL?.replace(/\/$/, '') || '';
|
|
||||||
return base || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHeaders(contentType: 'json' | 'form' | 'none' = 'json'): Record<string, string> {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (contentType === 'json') {
|
|
||||||
headers['Content-Type'] = 'application/json';
|
|
||||||
} else if (contentType === 'form') {
|
|
||||||
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(email: string, password: string): Promise<void> {
|
|
||||||
const body = new URLSearchParams();
|
|
||||||
body.set('username', email);
|
|
||||||
body.set('password', password);
|
|
||||||
|
|
||||||
const res = await fetch(`${getBaseUrl()}/auth/jwt/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Login failed');
|
|
||||||
}
|
|
||||||
const data: LoginResponse = await res.json();
|
|
||||||
localStorage.setItem('token', data.access_token);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function register(email: string, password: string, first_name?: string, last_name?: string): Promise<void> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/auth/register`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email, password, first_name, last_name }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Registration failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCategories(): Promise<Category[]> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/categories/`, {
|
|
||||||
headers: getHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to load categories');
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateTransactionInput = {
|
|
||||||
amount: number;
|
|
||||||
description?: string;
|
|
||||||
category_ids?: number[];
|
|
||||||
date?: string; // YYYY-MM-DD
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createTransaction(input: CreateTransactionInput): Promise<Transaction> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/transactions/create`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Failed to create transaction');
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTransactions(start_date?: string, end_date?: string): Promise<Transaction[]> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (start_date) params.set('start_date', start_date);
|
|
||||||
if (end_date) params.set('end_date', end_date);
|
|
||||||
const qs = params.toString();
|
|
||||||
const url = `${getBaseUrl()}/transactions/${qs ? `?${qs}` : ''}`;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: getHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to load transactions');
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export type User = {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
first_name?: string | null;
|
|
||||||
last_name?: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
is_superuser: boolean;
|
|
||||||
is_verified: boolean;
|
|
||||||
// Optional JSON config object for user-level integrations and settings
|
|
||||||
// Example: { csas: "{\"expires_at\": 1761824615, ...}" } or { csas: { expires_at: 1761824615, ... } }
|
|
||||||
config?: Record<string, any> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getMe(): Promise<User> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/users/me`, {
|
|
||||||
headers: getHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to load user');
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateMeInput = Partial<Pick<User, 'first_name' | 'last_name'>> & { password?: string };
|
|
||||||
export async function updateMe(input: UpdateMeInput): Promise<User> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/users/me`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Failed to update user');
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteMe(): Promise<void> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/users/me`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: getHeaders(),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Failed to delete account');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logout() {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Categories
|
|
||||||
export type CreateCategoryInput = { name: string; description?: string };
|
|
||||||
export async function createCategory(input: CreateCategoryInput): Promise<Category> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/categories/create`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Failed to create category');
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UpdateCategoryInput = { name?: string; description?: string };
|
|
||||||
export async function updateCategory(category_id: number, input: UpdateCategoryInput): Promise<Category> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/categories/${category_id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Failed to update category');
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transactions update
|
|
||||||
export type UpdateTransactionInput = {
|
|
||||||
amount?: number;
|
|
||||||
description?: string;
|
|
||||||
date?: string;
|
|
||||||
category_ids?: number[];
|
|
||||||
};
|
|
||||||
export async function updateTransaction(id: number, input: UpdateTransactionInput): Promise<Transaction> {
|
|
||||||
const res = await fetch(`${getBaseUrl()}/transactions/${id}/edit`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: getHeaders(),
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Failed to update transaction');
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Balance series
|
|
||||||
export type BalancePoint = { date: string; balance: number };
|
|
||||||
export async function getBalanceSeries(start_date?: string, end_date?: string): Promise<BalancePoint[]> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (start_date) params.set('start_date', start_date);
|
|
||||||
if (end_date) params.set('end_date', end_date);
|
|
||||||
const qs = params.toString();
|
|
||||||
const url = `${getBaseUrl()}/transactions/balance_series${qs ? `?${qs}` : ''}`;
|
|
||||||
const res = await fetch(url, { headers: getHeaders() });
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || 'Failed to load balance series');
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
export type Theme = 'system' | 'light' | 'dark';
|
|
||||||
export type FontSize = 'small' | 'medium' | 'large';
|
|
||||||
|
|
||||||
const THEME_KEY = 'app_theme';
|
|
||||||
const FONT_KEY = 'app_font_size';
|
|
||||||
|
|
||||||
export function applyTheme(theme: Theme) {
|
|
||||||
const body = document.body;
|
|
||||||
const effective = theme === 'system' ? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme;
|
|
||||||
body.setAttribute('data-theme', effective);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyFontSize(size: FontSize) {
|
|
||||||
const root = document.documentElement;
|
|
||||||
const map: Record<FontSize, string> = {
|
|
||||||
small: '12px',
|
|
||||||
medium: '15px',
|
|
||||||
large: '21px',
|
|
||||||
};
|
|
||||||
root.style.fontSize = map[size];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveAppearance(theme: Theme, size: FontSize) {
|
|
||||||
localStorage.setItem(THEME_KEY, theme);
|
|
||||||
localStorage.setItem(FONT_KEY, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadAppearance(): { theme: Theme; size: FontSize } {
|
|
||||||
const theme = (localStorage.getItem(THEME_KEY) as Theme) || 'light';
|
|
||||||
const size = (localStorage.getItem(FONT_KEY) as FontSize) || 'medium';
|
|
||||||
return { theme, size };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyAppearanceFromStorage() {
|
|
||||||
const { theme, size } = loadAppearance();
|
|
||||||
applyTheme(theme);
|
|
||||||
applyFontSize(size);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,2 +0,0 @@
|
|||||||
export const BACKEND_URL: string =
|
|
||||||
import.meta.env.VITE_BACKEND_URL ?? 'http://127.0.0.1:8000';
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: rgba(255, 255, 255, 0.87);
|
|
||||||
background-color: #242424;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.2em;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
border-color: #646cff;
|
|
||||||
}
|
|
||||||
button:focus,
|
|
||||||
button:focus-visible {
|
|
||||||
outline: 4px auto -webkit-focus-ring-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
:root {
|
|
||||||
color: #213547;
|
|
||||||
background-color: #ffffff;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { StrictMode } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import './index.css'
|
|
||||||
import './ui.css'
|
|
||||||
import App from './App.tsx'
|
|
||||||
import { applyAppearanceFromStorage } from './appearance'
|
|
||||||
|
|
||||||
applyAppearanceFromStorage()
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { deleteMe, getMe, type UpdateMeInput, type User, updateMe } from '../api';
|
|
||||||
|
|
||||||
export default function AccountPage({ onDeleted }: { onDeleted: () => void }) {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [firstName, setFirstName] = useState('');
|
|
||||||
const [lastName, setLastName] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const u = await getMe();
|
|
||||||
setUser(u);
|
|
||||||
setFirstName(u.first_name || '');
|
|
||||||
setLastName(u.last_name || '');
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || 'Failed to load account');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleSave(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const payload: UpdateMeInput = { first_name: firstName || null as any, last_name: lastName || null as any };
|
|
||||||
const updated = await updateMe(payload);
|
|
||||||
setUser(updated);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || 'Failed to update');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (!confirm('Are you sure you want to delete your account? This cannot be undone.')) return;
|
|
||||||
try {
|
|
||||||
await deleteMe();
|
|
||||||
onDeleted();
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e?.message || 'Failed to delete account');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<h3>Account</h3>
|
|
||||||
{loading ? (
|
|
||||||
<div>Loading…</div>
|
|
||||||
) : error ? (
|
|
||||||
<div style={{ color: 'crimson' }}>{error}</div>
|
|
||||||
) : !user ? (
|
|
||||||
<div>Not signed in</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y">
|
|
||||||
<div className="muted">Email: <strong>{user.email}</strong></div>
|
|
||||||
<form onSubmit={handleSave} className="space-y">
|
|
||||||
<div className="form-row">
|
|
||||||
<div>
|
|
||||||
<label className="muted">First name</label>
|
|
||||||
<input className="input" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="muted">Last name</label>
|
|
||||||
<input className="input" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="actions" style={{ justifyContent: 'flex-end' }}>
|
|
||||||
<button className="btn primary" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save changes'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="actions" style={{ justifyContent: 'space-between' }}>
|
|
||||||
<div className="muted"></div>
|
|
||||||
<button className="btn" style={{ borderColor: 'crimson', color: 'crimson' }} onClick={handleDelete}>Delete account</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { applyFontSize, applyTheme, loadAppearance, saveAppearance, type FontSize, type Theme } from '../appearance';
|
|
||||||
|
|
||||||
export default function AppearancePage() {
|
|
||||||
const [theme, setTheme] = useState<Theme>('light');
|
|
||||||
const [size, setSize] = useState<FontSize>('medium');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { theme, size } = loadAppearance();
|
|
||||||
setTheme(theme);
|
|
||||||
setSize(size);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function onThemeChange(next: Theme) {
|
|
||||||
setTheme(next);
|
|
||||||
applyTheme(next);
|
|
||||||
saveAppearance(next, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSizeChange(next: FontSize) {
|
|
||||||
setSize(next);
|
|
||||||
applyFontSize(next);
|
|
||||||
saveAppearance(theme, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<h3>Appearance</h3>
|
|
||||||
<div className="space-y">
|
|
||||||
<div>
|
|
||||||
<div className="muted" style={{ marginBottom: 6 }}>Theme</div>
|
|
||||||
<div className="segmented">
|
|
||||||
<button className={theme === 'light' ? 'active' : ''} onClick={() => onThemeChange('light')}>Light</button>
|
|
||||||
<button className={theme === 'dark' ? 'active' : ''} onClick={() => onThemeChange('dark')}>Dark</button>
|
|
||||||
<button className={theme === 'system' ? 'active' : ''} onClick={() => onThemeChange('system')}>System</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="muted" style={{ marginBottom: 6 }}>Font size</div>
|
|
||||||
<div className="segmented">
|
|
||||||
<button className={size === 'small' ? 'active' : ''} onClick={() => onSizeChange('small')}>Small</button>
|
|
||||||
<button className={size === 'medium' ? 'active' : ''} onClick={() => onSizeChange('medium')}>Medium</button>
|
|
||||||
<button className={size === 'large' ? 'active' : ''} onClick={() => onSizeChange('large')}>Large</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
// src/BalanceChart.tsx
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
|
|
||||||
import { type BalancePoint } from '../api';
|
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
|
||||||
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
|
||||||
return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = { data: BalancePoint[]; pxPerPoint?: number };
|
|
||||||
|
|
||||||
export default function BalanceChart({ data, pxPerPoint = 40 }: Props) {
|
|
||||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [containerWidth, setContainerWidth] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function measure() {
|
|
||||||
if (!wrapRef.current) return;
|
|
||||||
setContainerWidth(wrapRef.current.clientWidth);
|
|
||||||
}
|
|
||||||
measure();
|
|
||||||
const obs = new ResizeObserver(measure);
|
|
||||||
if (wrapRef.current) obs.observe(wrapRef.current);
|
|
||||||
return () => obs.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (data.length === 0) {
|
|
||||||
return <div>No data to display</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const desiredWidth = Math.max(containerWidth, Math.max(600, data.length * pxPerPoint));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={wrapRef} className="chart-scroll">
|
|
||||||
<div className="chart-inner" style={{ minWidth: desiredWidth, paddingBottom: 8 }}>
|
|
||||||
<LineChart
|
|
||||||
width={desiredWidth}
|
|
||||||
height={300}
|
|
||||||
data={data}
|
|
||||||
margin={{ top: 5, right: 30, left: 50, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="date"
|
|
||||||
tickFormatter={formatDate}
|
|
||||||
label={{ value: 'Date', position: 'insideBottom', offset: -5 }}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tickFormatter={(value) => formatAmount(value as number)}
|
|
||||||
label={{ value: 'Balance', angle: -90, position: 'insideLeft', offset: -30 }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
labelFormatter={formatDate}
|
|
||||||
formatter={(value) => [formatAmount(value as number), 'Balance']}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Line type="monotone" dataKey="balance" stroke="#3b82f6" strokeWidth={2} activeDot={{ r: 8 }} />
|
|
||||||
</LineChart>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// src/CategoryPieCharts.tsx (renamed from CategoryPieChart.tsx)
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
|
||||||
import { type Transaction, type Category } from '../api';
|
|
||||||
|
|
||||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#AF19FF', '#FF4242', '#8884d8', '#82ca9d'];
|
|
||||||
|
|
||||||
// Helper component for a single pie chart
|
|
||||||
function SinglePieChart({ data, title }: { data: { name: string; value: number }[]; title: string }) {
|
|
||||||
if (data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div style={{ flex: 1, textAlign: 'center' }}>
|
|
||||||
<h4>{title}</h4>
|
|
||||||
<div>No data to display.</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<h4>{title}</h4>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={data}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
outerRadius={80}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
label={(props: any) => `${props.name} ${(props.percent * 100).toFixed(0)}%`}
|
|
||||||
>
|
|
||||||
{data.map((_entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip formatter={(value) => new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(value as number)} />
|
|
||||||
<Legend />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default function CategoryPieCharts({ transactions, categories }: { transactions: Transaction[], categories: Category[] }) {
|
|
||||||
|
|
||||||
// Calculate expenses data
|
|
||||||
const expensesData = useMemo(() => {
|
|
||||||
const spendingMap = new Map<number, number>();
|
|
||||||
|
|
||||||
transactions.forEach(tx => {
|
|
||||||
// Expenses are typically negative amounts in your system
|
|
||||||
if (tx.amount < 0 && tx.category_ids.length > 0) {
|
|
||||||
tx.category_ids.forEach(catId => {
|
|
||||||
// Use absolute value for display on chart
|
|
||||||
spendingMap.set(catId, (spendingMap.get(catId) || 0) + Math.abs(tx.amount));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(spendingMap.entries())
|
|
||||||
.map(([categoryId, total]) => ({
|
|
||||||
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
|
|
||||||
value: total,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.value - a.value); // Sort descending
|
|
||||||
}, [transactions, categories]);
|
|
||||||
|
|
||||||
// Calculate earnings data
|
|
||||||
const earningsData = useMemo(() => {
|
|
||||||
const incomeMap = new Map<number, number>();
|
|
||||||
|
|
||||||
transactions.forEach(tx => {
|
|
||||||
// Earnings are typically positive amounts in your system
|
|
||||||
if (tx.amount > 0 && tx.category_ids.length > 0) {
|
|
||||||
tx.category_ids.forEach(catId => {
|
|
||||||
incomeMap.set(catId, (incomeMap.get(catId) || 0) + tx.amount);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(incomeMap.entries())
|
|
||||||
.map(([categoryId, total]) => ({
|
|
||||||
name: categories.find(c => c.id === categoryId)?.name || `Category #${categoryId}`,
|
|
||||||
value: total,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.value - a.value); // Sort descending
|
|
||||||
}, [transactions, categories]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pie-grid" >
|
|
||||||
<div className="pie-card">
|
|
||||||
<SinglePieChart data={expensesData} title="Expenses by Category" />
|
|
||||||
</div>
|
|
||||||
<div className="pie-card">
|
|
||||||
<SinglePieChart data={earningsData} title="Earnings by Category" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,679 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
|
||||||
import { type Category, type Transaction, type BalancePoint, getMe, deleteTransaction, getCategories, getTransactions, createTransaction, updateTransaction, getBalanceSeries } from '../api';
|
|
||||||
import AccountPage from './AccountPage';
|
|
||||||
import AppearancePage from './AppearancePage';
|
|
||||||
import BalanceChart from './BalanceChart';
|
|
||||||
import ManualManagement from './ManualManagement';
|
|
||||||
import CategoryPieChart from './CategoryPieChart';
|
|
||||||
import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
|
|
||||||
import { BACKEND_URL } from '../config';
|
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
|
||||||
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
//https://unirateapi.com/
|
|
||||||
|
|
||||||
|
|
||||||
// Define the structure for the rate data we care about
|
|
||||||
type RateData = {
|
|
||||||
currencyCode: string;
|
|
||||||
rate: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// The currencies you want to display
|
|
||||||
const TARGET_CURRENCIES = ['EUR', 'USD', 'NOK'];
|
|
||||||
|
|
||||||
function CurrencyRates() {
|
|
||||||
const [rates, setRates] = useState<RateData[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchRates() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const base = BACKEND_URL.replace(/\/$/, '');
|
|
||||||
const url = `${base}/exchange-rates?symbols=${TARGET_CURRENCIES.join(',')}`;
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || `Failed to load rates (${res.status})`);
|
|
||||||
}
|
|
||||||
const data: RateData[] = await res.json();
|
|
||||||
setRates(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Could not load rates');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRates();
|
|
||||||
}, []); // Runs once on component mount
|
|
||||||
|
|
||||||
return (
|
|
||||||
// This component will push itself to the bottom of the sidebar
|
|
||||||
<div
|
|
||||||
className="currency-rates"
|
|
||||||
style={{
|
|
||||||
padding: '0 1.5rem',
|
|
||||||
marginTop: 'auto', // Pushes to bottom
|
|
||||||
paddingBottom: '1.5rem' // Adds some spacing at the end
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h4 style={{
|
|
||||||
margin: '1.5rem 0 0.75rem 0',
|
|
||||||
color: '#8a91b4', // Muted color to match dark sidebar
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: '0.9em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
Rates (vs CZK)
|
|
||||||
</h4>
|
|
||||||
{loading && <div style={{ fontSize: '0.9em', color: '#ccc' }}>Loading...</div>}
|
|
||||||
{error && <div style={{ fontSize: '0.9em', color: 'crimson' }}>{error}</div>}
|
|
||||||
{!loading && !error && (
|
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: '0.9em', color: '#fff' }}>
|
|
||||||
{rates.length > 0 ? rates.map(rate => (
|
|
||||||
<li key={rate.currencyCode} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
|
||||||
<strong>{rate.currencyCode}</strong>
|
|
||||||
<span>{rate.rate.toFixed(3)}</span>
|
|
||||||
</li>
|
|
||||||
)) : <li style={{color: '#8a91b4'}}>No rates found.</li>}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://unirateapi.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
marginTop: '1rem',
|
|
||||||
fontSize: '0.8em',
|
|
||||||
color: '#8a91b4', // Muted color
|
|
||||||
textDecoration: 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Exchange Rates By UniRateAPI
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|
||||||
const [current, setCurrent] = useState<'home' | 'manual' | 'account' | 'appearance'>('home');
|
|
||||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isMockModalOpen, setMockModalOpen] = useState(false);
|
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
|
||||||
|
|
||||||
// Current user and CSAS connection status
|
|
||||||
const [csasConnected, setCsasConnected] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const u = await getMe();
|
|
||||||
// Determine CSAS connection validity
|
|
||||||
const csas = (u as any)?.config?.csas;
|
|
||||||
let obj: any = null;
|
|
||||||
if (csas) {
|
|
||||||
if (typeof csas === 'string') {
|
|
||||||
try { obj = JSON.parse(csas); } catch {}
|
|
||||||
} else if (typeof csas === 'object') {
|
|
||||||
obj = csas;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let exp: number | null = null;
|
|
||||||
const raw = obj?.expires_at;
|
|
||||||
if (typeof raw === 'number') {
|
|
||||||
exp = raw;
|
|
||||||
} else if (typeof raw === 'string') {
|
|
||||||
const asNum = Number(raw);
|
|
||||||
if (!Number.isNaN(asNum)) {
|
|
||||||
exp = asNum;
|
|
||||||
} else {
|
|
||||||
const ms = Date.parse(raw);
|
|
||||||
if (!Number.isNaN(ms)) exp = Math.floor(ms / 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (exp && exp > Math.floor(Date.now() / 1000)) {
|
|
||||||
setCsasConnected(true);
|
|
||||||
} else {
|
|
||||||
setCsasConnected(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// ignore, user may not be loaded; keep button enabled
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Start CSAS (George) OAuth after login
|
|
||||||
async function startOauthCsas() {
|
|
||||||
const base = BACKEND_URL.replace(/\/$/, '');
|
|
||||||
const url = `${base}/auth/csas/authorize`;
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const res = await fetch(url, {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data && typeof data.authorization_url === 'string') {
|
|
||||||
window.location.assign(data.authorization_url);
|
|
||||||
} else {
|
|
||||||
alert('Cannot start CSAS OAuth.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('Cannot start CSAS OAuth.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
const [minAmount, setMinAmount] = useState<string>('');
|
|
||||||
const [maxAmount, setMaxAmount] = useState<string>('');
|
|
||||||
const [filterCategoryId, setFilterCategoryId] = useState<number | ''>('');
|
|
||||||
const [searchText, setSearchText] = useState('');
|
|
||||||
|
|
||||||
// Date-range filter
|
|
||||||
const [startDate, setStartDate] = useState<string>(''); // YYYY-MM-DD
|
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
|
||||||
|
|
||||||
// Pagination over filtered transactions (20 per page), 0 = latest (most recent)
|
|
||||||
const pageSize = 20;
|
|
||||||
const [page, setPage] = useState<number>(0);
|
|
||||||
|
|
||||||
// Balance chart series for current date filter
|
|
||||||
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
|
|
||||||
|
|
||||||
// Manual forms moved to ManualManagement page
|
|
||||||
|
|
||||||
// Inline edit state for transaction editing
|
|
||||||
const [editingTxId, setEditingTxId] = useState<number | null>(null);
|
|
||||||
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]);
|
|
||||||
const [editingAmount, setEditingAmount] = useState<string>('');
|
|
||||||
const [editingDescription, setEditingDescription] = useState<string>('');
|
|
||||||
const [editingDate, setEditingDate] = useState<string>(''); // YYYY-MM-DD
|
|
||||||
|
|
||||||
// Sidebar toggle for mobile
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
// Multi-select state for transactions and bulk category assignment
|
|
||||||
const [selectedTxIds, setSelectedTxIds] = useState<number[]>([]);
|
|
||||||
const [bulkCategoryIds, setBulkCategoryIds] = useState<number[]>([]);
|
|
||||||
const toggleSelectTx = useCallback((id: number) => {
|
|
||||||
setSelectedTxIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
|
||||||
}, []);
|
|
||||||
const clearSelection = useCallback(() => setSelectedTxIds([]), []);
|
|
||||||
const selectAllVisible = useCallback((ids: number[]) => setSelectedTxIds(ids), []);
|
|
||||||
|
|
||||||
async function loadAll() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const [txs, cats, series] = await Promise.all([
|
|
||||||
getTransactions(startDate || undefined, endDate || undefined),
|
|
||||||
getCategories(),
|
|
||||||
getBalanceSeries(startDate || undefined, endDate || undefined),
|
|
||||||
]);
|
|
||||||
setTransactions(txs);
|
|
||||||
setCategories(cats);
|
|
||||||
setBalanceSeries(series);
|
|
||||||
// reset paging to most recent
|
|
||||||
setPage(0);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message || 'Failed to load data');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGenerateMockTransactions(options: MockGenerationOptions) {
|
|
||||||
setIsGenerating(true);
|
|
||||||
setMockModalOpen(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const base = BACKEND_URL.replace(/\/$/, '');
|
|
||||||
const url = `${base}/mock-bank/generate`;
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(options),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || `Failed to generate mock transactions (${res.status})`);
|
|
||||||
}
|
|
||||||
const generated: Array<{ amount: number; date: string; category_ids: number[]; description?: string | null }>
|
|
||||||
= await res.json();
|
|
||||||
|
|
||||||
const newTransactions: Transaction[] = [];
|
|
||||||
for (const g of generated) {
|
|
||||||
try {
|
|
||||||
const created = await createTransaction({
|
|
||||||
amount: g.amount,
|
|
||||||
date: g.date,
|
|
||||||
category_ids: g.category_ids || [],
|
|
||||||
description: g.description || undefined,
|
|
||||||
});
|
|
||||||
newTransactions.push(created);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to create mock transaction:', err);
|
|
||||||
// continue creating others
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(`${newTransactions.length} mock transactions were successfully generated!`);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
alert(err?.message || 'Failed to generate mock transactions');
|
|
||||||
} finally {
|
|
||||||
setIsGenerating(false);
|
|
||||||
await loadAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { loadAll(); clearSelection(); }, [startDate, endDate]);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
let arr = [...transactions];
|
|
||||||
const min = minAmount !== '' ? Number(minAmount) : undefined;
|
|
||||||
const max = maxAmount !== '' ? Number(maxAmount) : undefined;
|
|
||||||
if (min !== undefined) arr = arr.filter(t => t.amount >= min);
|
|
||||||
if (max !== undefined) arr = arr.filter(t => t.amount <= max);
|
|
||||||
if (filterCategoryId !== '') arr = arr.filter(t => t.category_ids.includes(filterCategoryId as number));
|
|
||||||
if (searchText.trim()) arr = arr.filter(t => (t.description || '').toLowerCase().includes(searchText.toLowerCase()));
|
|
||||||
return arr;
|
|
||||||
}, [transactions, minAmount, maxAmount, filterCategoryId, searchText]);
|
|
||||||
|
|
||||||
const sortedDesc = useMemo(() => {
|
|
||||||
return [...filtered].sort((a, b) => {
|
|
||||||
const ad = (a.date || '') > (b.date || '') ? 1 : (a.date || '') < (b.date || '') ? -1 : 0;
|
|
||||||
if (ad !== 0) return -ad; // date desc
|
|
||||||
return b.id - a.id; // fallback id desc
|
|
||||||
});
|
|
||||||
}, [filtered]);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(sortedDesc.length / pageSize);
|
|
||||||
const pageStart = page * pageSize;
|
|
||||||
const pageEnd = pageStart + pageSize;
|
|
||||||
const visible = sortedDesc.slice(pageStart, pageEnd);
|
|
||||||
|
|
||||||
// Reset selection when page or filters impacting visible set change
|
|
||||||
useEffect(() => { clearSelection(); }, [page, minAmount, maxAmount, filterCategoryId, searchText]);
|
|
||||||
|
|
||||||
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
|
|
||||||
|
|
||||||
|
|
||||||
function beginEditTransaction(t: Transaction) {
|
|
||||||
setEditingTxId(t.id);
|
|
||||||
setEditingCategoryIds([...(t.category_ids || [])]);
|
|
||||||
setEditingAmount(String(t.amount));
|
|
||||||
setEditingDescription(t.description || '');
|
|
||||||
setEditingDate(t.date || '');
|
|
||||||
}
|
|
||||||
function cancelEditTransaction() {
|
|
||||||
setEditingTxId(null);
|
|
||||||
setEditingCategoryIds([]);
|
|
||||||
setEditingAmount('');
|
|
||||||
setEditingDescription('');
|
|
||||||
setEditingDate('');
|
|
||||||
}
|
|
||||||
async function saveEditTransaction() {
|
|
||||||
if (editingTxId == null) return;
|
|
||||||
const amountNum = Number(editingAmount);
|
|
||||||
if (Number.isNaN(amountNum)) {
|
|
||||||
alert('Amount must be a number.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const updated = await updateTransaction(editingTxId, {
|
|
||||||
amount: amountNum,
|
|
||||||
description: editingDescription,
|
|
||||||
date: editingDate || undefined,
|
|
||||||
category_ids: editingCategoryIds,
|
|
||||||
});
|
|
||||||
setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p)));
|
|
||||||
// Optionally refresh balance series to reflect changes immediately
|
|
||||||
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
|
|
||||||
cancelEditTransaction();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.message || 'Failed to update transaction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function handleDeleteTransaction(id: number) {
|
|
||||||
if (!confirm('Delete this transaction? This cannot be undone.')) return;
|
|
||||||
try {
|
|
||||||
await deleteTransaction(id);
|
|
||||||
setTransactions(prev => prev.filter(t => t.id !== id));
|
|
||||||
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.message || 'Failed to delete transaction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`app-layout ${sidebarOpen ? 'sidebar-open' : ''}`}>
|
|
||||||
<aside className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<div>
|
|
||||||
<div className="logo">Finance Tracker</div>
|
|
||||||
<nav className="nav" onClick={() => setSidebarOpen(false)}>
|
|
||||||
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
|
|
||||||
<button className={current === 'manual' ? 'active' : ''} onClick={() => setCurrent('manual')}>Manual management</button>
|
|
||||||
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
|
|
||||||
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CurrencyRates />
|
|
||||||
|
|
||||||
</aside>
|
|
||||||
<div className="content">
|
|
||||||
<div className="topbar">
|
|
||||||
<button
|
|
||||||
className="icon-btn hamburger"
|
|
||||||
aria-label="Open menu"
|
|
||||||
aria-expanded={sidebarOpen}
|
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
>☰</button>
|
|
||||||
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'manual' ? 'Manual management' : current === 'account' ? 'Account' : 'Appearance'}</h2>
|
|
||||||
<div className="actions">
|
|
||||||
<span className="user muted">Signed in</span>
|
|
||||||
<button className="btn" onClick={onLogout}>Logout</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<main className="page space-y">
|
|
||||||
{current === 'home' && (
|
|
||||||
<>
|
|
||||||
<section className="card space-y">
|
|
||||||
<h3>Bank connections</h3>
|
|
||||||
<div className="connection-row">
|
|
||||||
<p className="muted" style={{ margin: 0 }}>Connect your CSAS (George) account.</p>
|
|
||||||
<button className="btn primary" onClick={startOauthCsas} disabled={csasConnected}>{csasConnected ? 'Successfully connected to CSAS' : 'Connect CSAS (George)'}</button>
|
|
||||||
</div>
|
|
||||||
<div className="connection-row">
|
|
||||||
<p className="muted" style={{ margin: 0 }}>Generate data from a mock bank.</p>
|
|
||||||
<button className="btn primary" onClick={() => setMockModalOpen(true)}>Connect Mock Bank</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<section className="card">
|
|
||||||
<h3>Filters</h3>
|
|
||||||
<div className="form-row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
|
||||||
<input className="input" type="date" placeholder="Start date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
|
||||||
<input className="input" type="date" placeholder="End date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
|
||||||
<input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} />
|
|
||||||
<input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} />
|
|
||||||
<select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}>
|
|
||||||
<option value="">All categories</option>
|
|
||||||
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
|
|
||||||
</select>
|
|
||||||
<input className="input" type="text" placeholder="Search in description" value={searchText} onChange={(e) => setSearchText(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card">
|
|
||||||
<h3>Balance over time</h3>
|
|
||||||
{loading ? (
|
|
||||||
<div>Loading…</div>
|
|
||||||
) : error ? (
|
|
||||||
<div style={{ color: 'crimson' }}>{error}</div>
|
|
||||||
) : (
|
|
||||||
<BalanceChart data={balanceSeries} />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 3. Add the new section for the Category Pie Chart */}
|
|
||||||
<section className="card">
|
|
||||||
{loading ? (
|
|
||||||
<div>Loading…</div>
|
|
||||||
) : error ? (
|
|
||||||
<div style={{ color: 'crimson' }}>{error}</div>
|
|
||||||
) : (
|
|
||||||
// Pass the filtered transactions to see the breakdown for the current view
|
|
||||||
<CategoryPieChart transactions={filtered} categories={categories} />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card">
|
|
||||||
<h3>Transactions</h3>
|
|
||||||
{loading ? (
|
|
||||||
<div>Loading…</div>
|
|
||||||
) : error ? (
|
|
||||||
<div style={{ color: 'crimson' }}>{error}</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div>No transactions</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="table-controls">
|
|
||||||
<div className="muted">
|
|
||||||
Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
|
|
||||||
</div>
|
|
||||||
<div className="actions" style={{ gap: 8, alignItems: 'center' }}>
|
|
||||||
{selectedTxIds.length > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="muted">Selected: {selectedTxIds.length}</span>
|
|
||||||
<select
|
|
||||||
className="input"
|
|
||||||
multiple
|
|
||||||
value={bulkCategoryIds.map(String)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const ids = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value));
|
|
||||||
setBulkCategoryIds(ids);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{categories.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
className="btn primary"
|
|
||||||
onClick={async () => {
|
|
||||||
if (bulkCategoryIds.length === 0) {
|
|
||||||
alert('Pick at least one category to assign.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Apply selected categories to each selected transaction, replacing their categories
|
|
||||||
const updates = await Promise.allSettled(
|
|
||||||
selectedTxIds.map(id => updateTransaction(id, { category_ids: bulkCategoryIds }))
|
|
||||||
);
|
|
||||||
const fulfilled = updates.filter(u => u.status === 'fulfilled') as PromiseFulfilledResult<Transaction>[];
|
|
||||||
const updatedById = new Map<number, Transaction>(fulfilled.map(f => [f.value.id, f.value]));
|
|
||||||
setTransactions(prev => prev.map(t => updatedById.get(t.id) || t));
|
|
||||||
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
|
|
||||||
if (fulfilled.length !== selectedTxIds.length) {
|
|
||||||
alert(`Assigned categories to ${fulfilled.length} of ${selectedTxIds.length} selected transactions. Some updates failed.`);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e?.message || 'Failed to assign categories');
|
|
||||||
} finally {
|
|
||||||
clearSelection();
|
|
||||||
setBulkCategoryIds([]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply categories to selected
|
|
||||||
</button>
|
|
||||||
<button className="btn" onClick={clearSelection}>Clear selection</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button className="btn primary" disabled={page <= 0} onClick={() => setPage(p => Math.max(0, p - 1))}>Previous</button>
|
|
||||||
<button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table className="table responsive">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ width: 36 }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label="Select all on page"
|
|
||||||
checked={visible.length > 0 && visible.every(v => selectedTxIds.includes(v.id))}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.currentTarget.checked) {
|
|
||||||
selectAllVisible(visible.map(v => v.id));
|
|
||||||
} else {
|
|
||||||
// remove only currently visible from selection
|
|
||||||
setSelectedTxIds(prev => prev.filter(id => !visible.some(v => v.id === id)));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Categories</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{visible.map(t => (
|
|
||||||
<tr key={t.id} style={{ backgroundColor: selectedTxIds.includes(t.id) ? 'rgba(88, 136, 255, 0.1)' : undefined }}>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`Select transaction ${t.id}`}
|
|
||||||
checked={selectedTxIds.includes(t.id)}
|
|
||||||
onChange={() => toggleSelectTx(t.id)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{/* Date cell */}
|
|
||||||
<td data-label="Date">
|
|
||||||
{editingTxId === t.id ? (
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="date"
|
|
||||||
value={editingDate}
|
|
||||||
onChange={(e) => setEditingDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t.date || ''
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Amount cell */}
|
|
||||||
<td data-label="Amount" className="amount" style={{ textAlign: 'right' }}>
|
|
||||||
{editingTxId === t.id ? (
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={editingAmount}
|
|
||||||
onChange={(e) => setEditingAmount(e.target.value)}
|
|
||||||
style={{ textAlign: 'right' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
formatAmount(t.amount)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Description cell */}
|
|
||||||
<td data-label="Description">
|
|
||||||
{editingTxId === t.id ? (
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="text"
|
|
||||||
value={editingDescription}
|
|
||||||
onChange={(e) => setEditingDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t.description || ''
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Categories cell */}
|
|
||||||
<td data-label="Categories">
|
|
||||||
{editingTxId === t.id ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<select
|
|
||||||
multiple
|
|
||||||
className="input"
|
|
||||||
value={editingCategoryIds.map(String)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value));
|
|
||||||
setEditingCategoryIds(opts);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{categories.map(c => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Actions cell */}
|
|
||||||
<td data-label="Actions">
|
|
||||||
{editingTxId === t.id ? (
|
|
||||||
<div className="actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
||||||
<button className="btn small" onClick={saveEditTransaction}>Save</button>
|
|
||||||
<button className="btn small" onClick={cancelEditTransaction}>Cancel</button>
|
|
||||||
<button className="btn small" onClick={() => handleDeleteTransaction(t.id)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
||||||
<button className="btn small" onClick={() => beginEditTransaction(t)}>Edit</button>
|
|
||||||
<button className="btn small" onClick={() => handleDeleteTransaction(t.id)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{current === 'account' && (
|
|
||||||
// lazy import avoided for simplicity
|
|
||||||
<AccountPage onDeleted={onLogout} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{current === 'manual' && (
|
|
||||||
<ManualManagement
|
|
||||||
categories={categories}
|
|
||||||
onTransactionAdded={(t) => setTransactions(prev => [t, ...prev])}
|
|
||||||
onCategoryCreated={(c) => setCategories(prev => [...prev, c])}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{current === 'appearance' && (
|
|
||||||
<AppearancePage />
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<MockBankModal
|
|
||||||
isOpen={isMockModalOpen}
|
|
||||||
isGenerating={isGenerating}
|
|
||||||
categories={categories}
|
|
||||||
onClose={() => setMockModalOpen(false)}
|
|
||||||
onGenerate={handleGenerateMockTransactions}
|
|
||||||
/>
|
|
||||||
{sidebarOpen && <div className="backdrop" onClick={() => setSidebarOpen(false)} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { login, register } from '../api';
|
|
||||||
import { BACKEND_URL } from '../config';
|
|
||||||
|
|
||||||
// Minimal helper to start OAuth: fetch authorization_url and redirect
|
|
||||||
async function startOauth(provider: 'mojeid' | 'bankid') {
|
|
||||||
const base = BACKEND_URL.replace(/\/$/, '');
|
|
||||||
const url = `${base}/auth/${provider}/authorize`;
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { credentials: 'include' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data && typeof data.authorization_url === 'string') {
|
|
||||||
window.location.assign(data.authorization_url);
|
|
||||||
} else {
|
|
||||||
alert('Cannot start OAuth.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('Cannot start OAuth.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginRegisterPage({ onLoggedIn }: { onLoggedIn: () => void }) {
|
|
||||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [firstName, setFirstName] = useState('');
|
|
||||||
const [lastName, setLastName] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
if (mode === 'login') {
|
|
||||||
await login(email, password);
|
|
||||||
onLoggedIn();
|
|
||||||
} else {
|
|
||||||
await register(email, password, firstName || undefined, lastName || undefined);
|
|
||||||
// After register, prompt login automatically
|
|
||||||
await login(email, password);
|
|
||||||
onLoggedIn();
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message || 'Operation failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add this useEffect hook
|
|
||||||
useEffect(() => {
|
|
||||||
// When the component mounts, add a class to the body
|
|
||||||
document.body.classList.add('auth-page');
|
|
||||||
|
|
||||||
// When the component unmounts, remove the class
|
|
||||||
return () => {
|
|
||||||
document.body.classList.remove('auth-page');
|
|
||||||
};
|
|
||||||
}, []); // The empty array ensures this runs only once
|
|
||||||
|
|
||||||
// The JSX no longer needs the wrapper div
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ width: 420 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
||||||
<h2 style={{ margin: 0 }}>{mode === 'login' ? 'Welcome back' : 'Create your account'}</h2>
|
|
||||||
<div className="segmented">
|
|
||||||
<button className={mode === 'login' ? 'active' : ''} type="button" onClick={() => setMode('login')}>Login</button>
|
|
||||||
<button className={mode === 'register' ? 'active' : ''} type="button" onClick={() => setMode('register')}>Register</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y">
|
|
||||||
<div>
|
|
||||||
<label className="muted">Email</label>
|
|
||||||
<input className="input" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="muted">Password</label>
|
|
||||||
<input className="input" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
{mode === 'register' && (
|
|
||||||
<div className="space-y">
|
|
||||||
<div>
|
|
||||||
<label className="muted">First name (optional)</label>
|
|
||||||
<input className="input" type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="muted">Last name (optional)</label>
|
|
||||||
<input className="input" type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <div style={{ color: 'crimson' }}>{error}</div>}
|
|
||||||
<div className="actions" style={{ justifyContent: 'space-between' }}>
|
|
||||||
<div className="muted">Or continue with</div>
|
|
||||||
<div className="actions">
|
|
||||||
<button type="button" className="btn" onClick={() => startOauth('mojeid')}>MojeID</button>
|
|
||||||
<button type="button" className="btn" onClick={() => startOauth('bankid')}>BankID</button>
|
|
||||||
<button className="btn primary" type="submit" disabled={loading}>{loading ? 'Please wait…' : (mode === 'login' ? 'Login' : 'Register')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { type Category, type Transaction, createTransaction, createCategory } from '../api';
|
|
||||||
|
|
||||||
export default function ManualManagement({
|
|
||||||
categories,
|
|
||||||
onTransactionAdded,
|
|
||||||
onCategoryCreated,
|
|
||||||
}: {
|
|
||||||
categories: Category[];
|
|
||||||
onTransactionAdded: (t: Transaction) => void;
|
|
||||||
onCategoryCreated: (c: Category) => void;
|
|
||||||
}) {
|
|
||||||
// New transaction form state
|
|
||||||
const [amount, setAmount] = useState<string>('');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>('');
|
|
||||||
const [txDate, setTxDate] = useState<string>('');
|
|
||||||
|
|
||||||
// Category creation form
|
|
||||||
const [newCatName, setNewCatName] = useState('');
|
|
||||||
const [newCatDesc, setNewCatDesc] = useState('');
|
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!amount) return;
|
|
||||||
const payload = {
|
|
||||||
amount: Number(amount),
|
|
||||||
description: description || undefined,
|
|
||||||
category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined,
|
|
||||||
date: txDate || undefined,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const created = await createTransaction(payload);
|
|
||||||
onTransactionAdded(created);
|
|
||||||
setAmount(''); setDescription(''); setSelectedCategoryId(''); setTxDate('');
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.message || 'Failed to create transaction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateCategory(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newCatName.trim()) return;
|
|
||||||
try {
|
|
||||||
const cat = await createCategory({ name: newCatName.trim(), description: newCatDesc || undefined });
|
|
||||||
onCategoryCreated(cat);
|
|
||||||
setNewCatName(''); setNewCatDesc('');
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.message || 'Failed to create category');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className="card">
|
|
||||||
<h3>Add Transaction</h3>
|
|
||||||
<form onSubmit={handleCreate} className="form-row">
|
|
||||||
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
|
|
||||||
<input className="input" type="date" placeholder="Date (optional)" value={txDate} onChange={(e) => setTxDate(e.target.value)} />
|
|
||||||
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
||||||
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
|
|
||||||
<option value="">No category</option>
|
|
||||||
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
|
|
||||||
</select>
|
|
||||||
<button className="btn primary" type="submit">Add</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card">
|
|
||||||
<h3>Categories</h3>
|
|
||||||
<form className="form-row" onSubmit={handleCreateCategory}>
|
|
||||||
<input className="input" type="text" placeholder="New category name" value={newCatName} onChange={(e) => setNewCatName(e.target.value)} />
|
|
||||||
<input className="input" type="text" placeholder="Description (optional)" value={newCatDesc} onChange={(e) => setNewCatDesc(e.target.value)} />
|
|
||||||
<button className="btn primary" type="submit">Create category</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
// src/MockBankModal.tsx
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { type Category } from '../api';
|
|
||||||
|
|
||||||
// Define the shape of the generation options
|
|
||||||
export interface MockGenerationOptions {
|
|
||||||
count: number;
|
|
||||||
minAmount: number;
|
|
||||||
maxAmount: number;
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
categoryIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MockBankModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
isGenerating: boolean;
|
|
||||||
categories: Category[]; // Pass in available categories
|
|
||||||
onClose: () => void;
|
|
||||||
onGenerate: (options: MockGenerationOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MockBankModal({ isOpen, isGenerating, categories, onClose, onGenerate }: MockBankModalProps) {
|
|
||||||
// State for all the new form fields
|
|
||||||
const [count, setCount] = useState('10');
|
|
||||||
const [minAmount, setMinAmount] = useState('-200');
|
|
||||||
const [maxAmount, setMaxAmount] = useState('200');
|
|
||||||
const [startDate, setStartDate] = useState(() => new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]); // Default to one year ago
|
|
||||||
const [endDate, setEndDate] = useState(() => new Date().toISOString().split('T')[0]); // Default to today
|
|
||||||
const [selectedCategoryIds, setSelectedCategoryIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
function handleGenerateClick() {
|
|
||||||
const parsedCount = parseInt(count, 10);
|
|
||||||
const parsedMinAmount = parseFloat(minAmount);
|
|
||||||
const parsedMaxAmount = parseFloat(maxAmount);
|
|
||||||
const parsedStartDate = new Date(startDate);
|
|
||||||
const parsedEndDate = new Date(endDate);
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (
|
|
||||||
isNaN(parsedCount) || parsedCount <= 0 ||
|
|
||||||
isNaN(parsedMinAmount) || isNaN(parsedMaxAmount) ||
|
|
||||||
parsedMaxAmount < parsedMinAmount ||
|
|
||||||
isNaN(parsedStartDate.getTime()) || isNaN(parsedEndDate.getTime()) ||
|
|
||||||
parsedEndDate < parsedStartDate
|
|
||||||
) {
|
|
||||||
alert(
|
|
||||||
"Please ensure:\n" +
|
|
||||||
"- Count is a positive number\n" +
|
|
||||||
"- Min and Max Amount are valid numbers, and Max >= Min\n" +
|
|
||||||
"- Start and End Date are valid, and End Date >= Start Date"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: MockGenerationOptions = {
|
|
||||||
count: parsedCount,
|
|
||||||
minAmount: parsedMinAmount,
|
|
||||||
maxAmount: parsedMaxAmount,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
categoryIds: selectedCategoryIds.map(Number),
|
|
||||||
};
|
|
||||||
|
|
||||||
onGenerate(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h3>Generate Mock Transactions</h3>
|
|
||||||
<p className="muted">
|
|
||||||
Customize the random transactions you'd like to import.
|
|
||||||
</p>
|
|
||||||
<div className="space-y">
|
|
||||||
<input className="input" type="number" value={count} onChange={(e) => setCount(e.target.value)} placeholder="Number of transactions" />
|
|
||||||
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
|
||||||
<input className="input" type="number" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} placeholder="Min amount" />
|
|
||||||
<input className="input" type="number" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="Max amount" />
|
|
||||||
</div>
|
|
||||||
<div className="form-row" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
|
||||||
<input className="input" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} placeholder="Earliest date" />
|
|
||||||
<input className="input" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} placeholder="Latest date" />
|
|
||||||
</div>
|
|
||||||
<select multiple className="input" style={{ height: '120px' }} value={selectedCategoryIds} onChange={(e) => setSelectedCategoryIds(Array.from(e.target.selectedOptions, option => option.value))}>
|
|
||||||
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="actions" style={{ justifyContent: 'flex-end', marginTop: '16px' }}>
|
|
||||||
<button className="btn" onClick={onClose} disabled={isGenerating}>Cancel</button>
|
|
||||||
<button className="btn primary" onClick={handleGenerateClick} disabled={isGenerating}>
|
|
||||||
{isGenerating ? 'Generating...' : `Generate Transactions`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg: #f7f7fb;
|
|
||||||
--panel: #ffffff;
|
|
||||||
--text: #9aa3b2;
|
|
||||||
--muted: #6b7280;
|
|
||||||
--primary: #6f49fe;
|
|
||||||
--primary-600: #5a37fb;
|
|
||||||
--border: #e5e7eb;
|
|
||||||
--radius: 12px;
|
|
||||||
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.08);
|
|
||||||
|
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
|
||||||
html, body, #root { height: 100%; }
|
|
||||||
|
|
||||||
body { background: var(--bg); margin: 0; display: block; }
|
|
||||||
|
|
||||||
/* Dark theme variables */
|
|
||||||
body[data-theme="dark"] {
|
|
||||||
--bg: #161a2b;
|
|
||||||
--panel: #283046;
|
|
||||||
--text: #283046;
|
|
||||||
--muted: #cbd5e1;
|
|
||||||
--primary: #8b7bff;
|
|
||||||
--primary-600: #7b69ff;
|
|
||||||
--border: #283046;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layout */
|
|
||||||
.app-layout { display: grid; grid-template-columns: 260px minmax(0,1fr); height: 100vh; }
|
|
||||||
.sidebar { background: #15172a; color: #e5e7eb; display: flex; flex-direction: column; padding: 20px 12px; }
|
|
||||||
.sidebar .logo { color: #fff; font-weight: 700; font-size: 18px; padding: 12px 14px; display: flex; align-items: center; gap: 10px; }
|
|
||||||
.nav { margin-top: 12px; display: grid; gap: 4px; }
|
|
||||||
.nav a, .nav button { color: #cbd5e1; text-align: left; background: transparent; border: 0; padding: 10px 12px; border-radius: 8px; cursor: pointer; }
|
|
||||||
.nav a.active, .nav a:hover, .nav button:hover { background: rgba(255,255,255,0.08); color: #fff; }
|
|
||||||
|
|
||||||
.content { display: flex; flex-direction: column; overflow-y: auto; min-width: 0; width: 100%; }
|
|
||||||
.topbar { height: 64px; display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--border); }
|
|
||||||
.topbar .user { color: var(--muted); }
|
|
||||||
.page { padding: 24px; }
|
|
||||||
|
|
||||||
/* Cards */
|
|
||||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; }
|
|
||||||
.card h3 { margin: 0 0 12px; }
|
|
||||||
|
|
||||||
/* Forms */
|
|
||||||
/* Common field styles (no custom arrow here) */
|
|
||||||
.input, textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background-color: var(--panel);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Select-only: show custom dropdown arrow */
|
|
||||||
select.input {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
|
|
||||||
padding-right: 32px; /* room for the arrow */
|
|
||||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
|
||||||
background-position: right 0.5rem center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 1.5em 1.5em;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pie-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.pie-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Make charts scale nicely within the cards */
|
|
||||||
.pie-card canvas, .pie-card svg {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input:focus, select:focus, textarea:focus {
|
|
||||||
outline: 2px solid var(--primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
.form-row { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0,1fr)); }
|
|
||||||
.form-row > * { min-width: 140px; }
|
|
||||||
.form-row > .btn {
|
|
||||||
justify-self: start;
|
|
||||||
}
|
|
||||||
.actions { display: flex; align-items: center; gap: 8px; }
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn { border: 1px solid var(--border); background: #fff; color: var(--text); padding: 10px 14px; border-radius: 10px; cursor: pointer; }
|
|
||||||
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
|
|
||||||
.btn.primary:hover { background: var(--primary-600); }
|
|
||||||
.btn.ghost { background: transparent; color: var(--muted); }
|
|
||||||
.btn, .input, select, textarea, .nav a, .nav button, .segmented button {
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
.btn.small {
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 0.875rem; /* 14px */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tables */
|
|
||||||
.table { width: 100%; border-collapse: collapse; }
|
|
||||||
.table th, .table td { padding: 10px; border-bottom: 1px solid var(--border); }
|
|
||||||
.table th { text-align: left; color: var(--muted); font-weight: 600; }
|
|
||||||
.table td.amount { text-align: right; font-variant-numeric: tabular-nums; }
|
|
||||||
.table-controls {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px; /* Adds some space above the table */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Segmented control */
|
|
||||||
.segmented { display: inline-flex; background: #f1f5f9; border-radius: 10px; padding: 4px; border: 1px solid var(--border); }
|
|
||||||
.segmented button { border: 0; background: transparent; padding: 8px 12px; border-radius: 8px; color: var(--muted); cursor: pointer; }
|
|
||||||
.segmented button.active { background: #fff; color: var(--text); box-shadow: var(--shadow); }
|
|
||||||
|
|
||||||
/* Auth layout */
|
|
||||||
body.auth-page #root {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility */
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.space-y > * + * { margin-top: 12px; }
|
|
||||||
|
|
||||||
/* Modal mock bank */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: var(--panel);
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Responsive enhancements */
|
|
||||||
|
|
||||||
/* Off-canvas sidebar + hamburger for mobile */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.app-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
min-height: 100dvh;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0 auto 0 0;
|
|
||||||
width: 80vw;
|
|
||||||
max-width: 320px;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 200ms ease;
|
|
||||||
z-index: 1000;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.app-layout.sidebar-open .sidebar {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
.hamburger {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
.topbar { position: sticky; top: 0; z-index: 500; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 901px) {
|
|
||||||
.hamburger { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Backdrop when sidebar is open */
|
|
||||||
.backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0,0,0,0.45);
|
|
||||||
z-index: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive table: convert to card list on small screens */
|
|
||||||
.table.responsive { width: 100%; }
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.table.responsive thead { display: none; }
|
|
||||||
.table.responsive tbody tr {
|
|
||||||
display: block;
|
|
||||||
border: 1px solid var(--border, #2a2f45);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
.table.responsive tbody td {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
text-align: left !important; /* override any right align */
|
|
||||||
}
|
|
||||||
.table.responsive tbody td:last-child { border-bottom: 0; }
|
|
||||||
.table.responsive tbody td::before {
|
|
||||||
content: attr(data-label);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.table.responsive .actions { width: 100%; justify-content: flex-end; }
|
|
||||||
.table.responsive .amount { font-weight: 600; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filters and controls wrapping */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.form-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.form-row { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-controls { gap: 12px; }
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.table-controls { flex-direction: column; align-items: stretch; }
|
|
||||||
.table-controls .actions { width: 100%; }
|
|
||||||
.table-controls .actions .btn { flex: 1 0 auto; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Touch-friendly sizes */
|
|
||||||
.btn, .input, select.input { min-height: 40px; }
|
|
||||||
.btn.small { min-height: 36px; }
|
|
||||||
|
|
||||||
/* Connection rows on mobile */
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.connection-row { flex-direction: column; align-items: stretch; gap: 8px; }
|
|
||||||
.connection-row .btn { width: 100%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Charts should scale to container */
|
|
||||||
.card canvas, .card svg { max-width: 100%; height: auto; display: block; }
|
|
||||||
|
|
||||||
|
|
||||||
/* Horizontal scroll container for wide charts */
|
|
||||||
.chart-scroll {
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
-webkit-overflow-scrolling: touch; /* momentum scroll on iOS */
|
|
||||||
}
|
|
||||||
.chart-inner { min-width: 900px; }
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["node"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
})
|
|
||||||
@@ -166,7 +166,7 @@ You can run the project with Docker Compose and Python virtual environment for t
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dat515-2025/Group-8.git
|
git clone https://github.com/dat515-2025/Group-8.git
|
||||||
cd Group-8/7project
|
cd Group-8/7project/src
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2) Install dependencies
|
### 2) Install dependencies
|
||||||
@@ -423,8 +423,8 @@ The tests are located in 7project/backend/tests directory. All tests are run by
|
|||||||
push to main.
|
push to main.
|
||||||
See the workflow [here](../.github/workflows/run-tests.yml).
|
See the workflow [here](../.github/workflows/run-tests.yml).
|
||||||
|
|
||||||
If you want to run the tests locally, the preferred way is to use a [bash script](backend/test_locally.sh)
|
If you want to run the tests locally, the preferred way is to use a [bash script](src/backend/test_locally.sh)
|
||||||
that will start a test DB container with [docker compose](backend/docker-compose.test.yml) and remove it afterwards.
|
that will start a test DB container with [docker compose](src/backend/docker-compose.test.yml) and remove it afterwards.
|
||||||
```bash
|
```bash
|
||||||
cd 7project/backend
|
cd 7project/backend
|
||||||
bash test_locally.sh
|
bash test_locally.sh
|
||||||
@@ -432,7 +432,7 @@ bash test_locally.sh
|
|||||||
|
|
||||||
### Unit Tests
|
### Unit Tests
|
||||||
|
|
||||||
There are only 5 basic unit tests, since our services logic is very simple
|
There are 5 basic unit tests, since our services logic is very simple
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash test_locally.sh --only-unit
|
bash test_locally.sh --only-unit
|
||||||
@@ -584,7 +584,7 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
|
|||||||
| 25.9. | Design | 2 | 6design | |
|
| 25.9. | Design | 2 | 6design | |
|
||||||
| 9.10 to 11.10. | Backend APIs | 14 | Implemented Backend APIs | `PR #26`, `20-create-a-controller-layer-on-backend-side` |
|
| 9.10 to 11.10. | Backend APIs | 14 | Implemented Backend APIs | `PR #26`, `20-create-a-controller-layer-on-backend-side` |
|
||||||
| 13.10 to 15.10. | Frontend Development | 8 | Created user interface mockups | `PR #28`, `frontend basics` |
|
| 13.10 to 15.10. | Frontend Development | 8 | Created user interface mockups | `PR #28`, `frontend basics` |
|
||||||
| Continually | Documentation | 7 | Documenting the dev process | |
|
| Continually | Documentation | 8 | Documenting the dev process | |
|
||||||
| 21.10 to 23.10 | Tests, frontend | 10 | Test basics, balance charts, and frontend improvement | `PR #31`, `30 create tests and set up a GitHub pipeline` |
|
| 21.10 to 23.10 | Tests, frontend | 10 | Test basics, balance charts, and frontend improvement | `PR #31`, `30 create tests and set up a GitHub pipeline` |
|
||||||
| 28.10 to 30.10 | CI | 6 | Integrated tests with test database setup on github workflows | `PR #28`, `frontend basics` |
|
| 28.10 to 30.10 | CI | 6 | Integrated tests with test database setup on github workflows | `PR #28`, `frontend basics` |
|
||||||
| 28.10 to 30.10 | Frontend | 8 | UI improvements and exchange rate API integration | `PR #28`, `frontend basics` |
|
| 28.10 to 30.10 | Frontend | 8 | UI improvements and exchange rate API integration | `PR #28`, `frontend basics` |
|
||||||
@@ -594,7 +594,7 @@ curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/authenticated-route
|
|||||||
| 11.11 to 12.11 | Tests | 3 | Local testing DB container, few fixes | `PR #28`, `frontend basics` |
|
| 11.11 to 12.11 | Tests | 3 | Local testing DB container, few fixes | `PR #28`, `frontend basics` |
|
||||||
| 12.11 | Frontend | 3 | Enabled multiple transaction edits at once, CSAS button state | `PR #28`, `frontend basics` |
|
| 12.11 | Frontend | 3 | Enabled multiple transaction edits at once, CSAS button state | `PR #28`, `frontend basics` |
|
||||||
| 13.11 | Video | 3 | Video | |
|
| 13.11 | Video | 3 | Video | |
|
||||||
| **Total** | | **80** | | |
|
| **Total** | | **81** | | |
|
||||||
|
|
||||||
### Group Total: [XXX.X] hours
|
### Group Total: [XXX.X] hours
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user