mirror of
https://github.com/dat515-2025/Group-8.git
synced 2026-03-22 15:12:08 +01:00
feat(frontend): added CNB API and moved management into a new tab
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { type Category, type Transaction, type BalancePoint, createTransaction, getCategories, getTransactions, createCategory, updateTransaction, getBalanceSeries } from '../api';
|
import { type Category, type Transaction, type BalancePoint, getCategories, getTransactions, createTransaction, updateTransaction, getBalanceSeries } from '../api';
|
||||||
import AccountPage from './AccountPage';
|
import AccountPage from './AccountPage';
|
||||||
import AppearancePage from './AppearancePage';
|
import AppearancePage from './AppearancePage';
|
||||||
import BalanceChart from './BalanceChart';
|
import BalanceChart from './BalanceChart';
|
||||||
|
import ManualManagement from './ManualManagement';
|
||||||
import CategoryPieChart from './CategoryPieChart';
|
import CategoryPieChart from './CategoryPieChart';
|
||||||
import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
|
import MockBankModal, { type MockGenerationOptions } from './MockBankModal';
|
||||||
import { BACKEND_URL } from '../config';
|
import { BACKEND_URL } from '../config';
|
||||||
@@ -11,8 +12,108 @@ function formatAmount(n: number) {
|
|||||||
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add this new component to your Dashboard.tsx file, above the Dashboard component
|
||||||
|
|
||||||
|
// Define the structure for the rate data we care about
|
||||||
|
type CnbRate = {
|
||||||
|
currencyCode: string;
|
||||||
|
rate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The part of the API response structure we need
|
||||||
|
type CnbApiResponse = {
|
||||||
|
rates: Array<{
|
||||||
|
amount: number;
|
||||||
|
currencyCode: string;
|
||||||
|
rate: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The currencies you want to display
|
||||||
|
const TARGET_CURRENCIES = ['EUR', 'USD', 'NOK'];
|
||||||
|
|
||||||
|
function CurrencyRates() {
|
||||||
|
const [rates, setRates] = useState<CnbRate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchRates() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Get today's date in YYYY-MM-DD format for the API
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const CNB_API_URL = `/api-cnb/cnbapi/exrates/daily?date=${today}&lang=EN`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(CNB_API_URL);
|
||||||
|
if (!res.ok) {
|
||||||
|
// This can happen on weekends/holidays or if rates aren't posted yet
|
||||||
|
throw new Error(`Rates unavailable (Status: ${res.status})`);
|
||||||
|
}
|
||||||
|
const data: CnbApiResponse = await res.json();
|
||||||
|
|
||||||
|
if (!data.rates) {
|
||||||
|
throw new Error("Invalid API response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRates = data.rates
|
||||||
|
.filter(rate => TARGET_CURRENCIES.includes(rate.currencyCode))
|
||||||
|
.map(rate => ({
|
||||||
|
currencyCode: rate.currencyCode,
|
||||||
|
// Handle 'amount' field (e.g., JPY is per 100)
|
||||||
|
rate: rate.rate / rate.amount
|
||||||
|
}));
|
||||||
|
|
||||||
|
setRates(filteredRates);
|
||||||
|
} 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||||
const [current, setCurrent] = useState<'home' | 'account' | 'appearance'>('home');
|
const [current, setCurrent] = useState<'home' | 'manual' | 'account' | 'appearance'>('home');
|
||||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -41,11 +142,6 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New transaction form state
|
|
||||||
const [amount, setAmount] = useState<string>('');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>('');
|
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [minAmount, setMinAmount] = useState<string>('');
|
const [minAmount, setMinAmount] = useState<string>('');
|
||||||
const [maxAmount, setMaxAmount] = useState<string>('');
|
const [maxAmount, setMaxAmount] = useState<string>('');
|
||||||
@@ -63,12 +159,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
// Balance chart series for current date filter
|
// Balance chart series for current date filter
|
||||||
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
|
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
|
||||||
|
|
||||||
// Category creation form
|
// Manual forms moved to ManualManagement page
|
||||||
const [newCatName, setNewCatName] = useState('');
|
|
||||||
const [newCatDesc, setNewCatDesc] = useState('');
|
|
||||||
|
|
||||||
// New transaction date
|
|
||||||
const [txDate, setTxDate] = useState<string>('');
|
|
||||||
|
|
||||||
// Inline edit state for transaction categories
|
// Inline edit state for transaction categories
|
||||||
const [editingTxId, setEditingTxId] = useState<number | null>(null);
|
const [editingTxId, setEditingTxId] = useState<number | null>(null);
|
||||||
@@ -167,23 +258,6 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
|
|
||||||
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
|
function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
|
||||||
|
|
||||||
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);
|
|
||||||
setTransactions(prev => [created, ...prev]);
|
|
||||||
setAmount(''); setDescription(''); setSelectedCategoryId(''); setTxDate('');
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err?.message || 'Failed to create transaction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginEditCategories(t: Transaction) {
|
function beginEditCategories(t: Transaction) {
|
||||||
setEditingTxId(t.id);
|
setEditingTxId(t.id);
|
||||||
@@ -206,17 +280,23 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div>
|
||||||
<div className="logo">7Project</div>
|
<div className="logo">7Project</div>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
|
<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 === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
|
||||||
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
|
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CurrencyRates />
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
<div className="content">
|
<div className="content">
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'account' ? 'Account' : 'Appearance'}</h2>
|
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'manual' ? 'Manual management' : current === 'account' ? 'Account' : 'Appearance'}</h2>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<span className="user muted">Signed in</span>
|
<span className="user muted">Signed in</span>
|
||||||
<button className="btn" onClick={onLogout}>Logout</button>
|
<button className="btn" onClick={onLogout}>Logout</button>
|
||||||
@@ -237,28 +317,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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={async (e) => { e.preventDefault(); if (!newCatName.trim()) return; try { const cat = await createCategory({ name: newCatName.trim(), description: newCatDesc || undefined }); setCategories(prev => [...prev, cat]); setNewCatName(''); setNewCatDesc(''); } catch (err: any) { alert(err?.message || 'Failed to create category'); } }}>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3>Filters</h3>
|
<h3>Filters</h3>
|
||||||
@@ -368,6 +427,14 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
<AccountPage onDeleted={onLogout} />
|
<AccountPage onDeleted={onLogout} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{current === 'manual' && (
|
||||||
|
<ManualManagement
|
||||||
|
categories={categories}
|
||||||
|
onTransactionAdded={(t) => setTransactions(prev => [t, ...prev])}
|
||||||
|
onCategoryCreated={(c) => setCategories(prev => [...prev, c])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{current === 'appearance' && (
|
{current === 'appearance' && (
|
||||||
<AppearancePage />
|
<AppearancePage />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ body[data-theme="dark"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Layout */
|
/* Layout */
|
||||||
.app-layout { display: grid; grid-template-columns: 260px 1fr; height: 100vh; }
|
.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 { 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; }
|
.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 { 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, .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; }
|
.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; }
|
.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 { 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); }
|
.topbar .user { color: var(--muted); }
|
||||||
.page { padding: 24px; }
|
.page { padding: 24px; }
|
||||||
|
|||||||
@@ -4,4 +4,21 @@ import react from '@vitejs/plugin-react'
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// We'll proxy any request that starts with '/api-cnb'
|
||||||
|
'/api-cnb': {
|
||||||
|
// This is the real API server we want to talk to
|
||||||
|
target: 'https://api.cnb.cz',
|
||||||
|
|
||||||
|
// This is crucial: it changes the 'Origin' header
|
||||||
|
// to match the target, so the CNB server is happy.
|
||||||
|
changeOrigin: true,
|
||||||
|
|
||||||
|
// This rewrites the request path. It removes the '/api-cnb' part
|
||||||
|
// so the CNB server gets the correct path ('/cnbapi/exrates/daily...').
|
||||||
|
rewrite: (path) => path.replace(/^\/api-cnb/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user