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:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 7project/backend/**
|
||||
- ../../7project/src/backend/**
|
||||
- 7project/frontend/**
|
||||
- 7project/charts/myapp-chart/**
|
||||
- ../../7project/src/charts/myapp-chart/**
|
||||
- .github/workflows/deploy-prod.yaml
|
||||
- .github/workflows/build-image.yaml
|
||||
- .github/workflows/frontend-pages.yml
|
||||
|
||||
16
7project/.gitignore
vendored
16
7project/.gitignore
vendored
@@ -1,8 +1,8 @@
|
||||
/tofu/controlplane.yaml
|
||||
/tofu/kubeconfig
|
||||
/tofu/talosconfig
|
||||
/tofu/terraform.tfstate
|
||||
/tofu/terraform.tfstate.backup
|
||||
/tofu/worker.yaml
|
||||
/tofu/.terraform.lock.hcl
|
||||
/tofu/.terraform/
|
||||
/src/tofu/controlplane.yaml
|
||||
/src/tofu/kubeconfig
|
||||
/src/tofu/talosconfig
|
||||
/src/tofu/terraform.tfstate
|
||||
/src/tofu/terraform.tfstate.backup
|
||||
/src/tofu/worker.yaml
|
||||
/src/tofu/.terraform.lock.hcl
|
||||
/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
|
||||
git clone https://github.com/dat515-2025/Group-8.git
|
||||
cd Group-8/7project
|
||||
cd Group-8/7project/src
|
||||
```
|
||||
|
||||
### 2) Install dependencies
|
||||
@@ -423,8 +423,8 @@ The tests are located in 7project/backend/tests directory. All tests are run by
|
||||
push to main.
|
||||
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)
|
||||
that will start a test DB container with [docker compose](backend/docker-compose.test.yml) and remove it afterwards.
|
||||
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](src/backend/docker-compose.test.yml) and remove it afterwards.
|
||||
```bash
|
||||
cd 7project/backend
|
||||
bash test_locally.sh
|
||||
@@ -432,7 +432,7 @@ bash test_locally.sh
|
||||
|
||||
### 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 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 | |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
@@ -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` |
|
||||
| 12.11 | Frontend | 3 | Enabled multiple transaction edits at once, CSAS button state | `PR #28`, `frontend basics` |
|
||||
| 13.11 | Video | 3 | Video | |
|
||||
| **Total** | | **80** | | |
|
||||
| **Total** | | **81** | | |
|
||||
|
||||
### 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