diff --git a/7project/frontend/src/App.tsx b/7project/frontend/src/App.tsx index b6cce59..93626c2 100644 --- a/7project/frontend/src/App.tsx +++ b/7project/frontend/src/App.tsx @@ -1,39 +1,27 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' -import { BACKEND_URL } from './config' +import { useEffect, useState } from 'react'; +import './App.css'; +import LoginRegisterPage from './pages/LoginRegisterPage'; +import Dashboard from './pages/Dashboard'; +import { logout } from './api'; function App() { - const [count, setCount] = useState(0) + const [hasToken, setHasToken] = useState(!!localStorage.getItem('token')); + + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === 'token') setHasToken(!!e.newValue); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }, []); + + if (!hasToken) { + return setHasToken(true)} />; + } return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-

- Backend URL: {BACKEND_URL || '(not configured)'} -

-
-

- Click on the Vite and React logos to learn more -

- - ) + { logout(); setHasToken(false); }} /> + ); } -export default App +export default App; diff --git a/7project/frontend/src/api.ts b/7project/frontend/src/api.ts new file mode 100644 index 0000000..ff2d679 --- /dev/null +++ b/7project/frontend/src/api.ts @@ -0,0 +1,100 @@ +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[]; +}; + +function getBaseUrl() { + const base = BACKEND_URL?.replace(/\/$/, '') || ''; + return base || ''; +} + +function authHeaders() { + const token = localStorage.getItem('token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export async function login(email: string, password: string): Promise { + 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 { + 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 { + const res = await fetch(`${getBaseUrl()}/categories/`, { + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + }); + if (!res.ok) throw new Error('Failed to load categories'); + return res.json(); +} + +export type CreateTransactionInput = { + amount: number; + description?: string; + category_ids?: number[]; +}; + +export async function createTransaction(input: CreateTransactionInput): Promise { + const res = await fetch(`${getBaseUrl()}/transactions/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + 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(): Promise { + const res = await fetch(`${getBaseUrl()}/transactions/`, { + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + }); + if (!res.ok) throw new Error('Failed to load transactions'); + return res.json(); +} + +export function logout() { + localStorage.removeItem('token'); +} diff --git a/7project/frontend/src/pages/Dashboard.tsx b/7project/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..f606fdb --- /dev/null +++ b/7project/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useState } from 'react'; +import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api'; + +function formatAmount(n: number) { + return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); +} + +export default function Dashboard({ onLogout }: { onLogout: () => void }) { + const [transactions, setTransactions] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // New transaction form state + const [amount, setAmount] = useState(''); + const [description, setDescription] = useState(''); + const [selectedCategoryId, setSelectedCategoryId] = useState(''); + + // Filters + const [minAmount, setMinAmount] = useState(''); + const [maxAmount, setMaxAmount] = useState(''); + const [filterCategoryId, setFilterCategoryId] = useState(''); + const [searchText, setSearchText] = useState(''); + + async function loadAll() { + setLoading(true); + setError(null); + try { + const [txs, cats] = await Promise.all([getTransactions(), getCategories()]); + setTransactions(txs); + setCategories(cats); + } catch (err: any) { + setError(err?.message || 'Failed to load data'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadAll(); + }, []); + + const last10 = useMemo(() => { + const sorted = [...transactions].sort((a, b) => b.id - a.id); + return sorted.slice(0, 10); + }, [transactions]); + + const filtered = useMemo(() => { + let arr = last10; + 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; + }, [last10, minAmount, maxAmount, filterCategoryId, searchText]); + + 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, + }; + try { + const created = await createTransaction(payload); + setTransactions(prev => [created, ...prev]); + // reset form + setAmount(''); + setDescription(''); + setSelectedCategoryId(''); + } catch (err: any) { + alert(err?.message || 'Failed to create transaction'); + } + } + + return ( +
+
+

Dashboard

+ +
+ +
+

Add Transaction

+
+ setAmount(e.target.value)} required /> + setDescription(e.target.value)} /> + + +
+
+ +
+

Filters

+
+ setMinAmount(e.target.value)} /> + setMaxAmount(e.target.value)} /> + + setSearchText(e.target.value)} /> +
+
+ +
+

Latest Transactions (last 10)

+ {loading ? ( +
Loading…
+ ) : error ? ( +
{error}
+ ) : filtered.length === 0 ? ( +
No transactions
+ ) : ( + + + + + + + + + + + {filtered.map(t => ( + + + + + + + ))} + +
IDAmountDescriptionCategories
{t.id}{formatAmount(t.amount)}{t.description || ''} + {t.category_ids.map(id => categoryNameById(id)).join(', ')} +
+ )} +
+
+ ); +} diff --git a/7project/frontend/src/pages/LoginRegisterPage.tsx b/7project/frontend/src/pages/LoginRegisterPage.tsx new file mode 100644 index 0000000..e265167 --- /dev/null +++ b/7project/frontend/src/pages/LoginRegisterPage.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { login, register } from '../api'; + +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(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); + } + } + + return ( +
+

{mode === 'login' ? 'Login' : 'Register'}

+
+ + +
+
+
+ +
+
+ +
+ {mode === 'register' && ( + <> +
+ +
+
+ +
+ + )} + {error &&
{error}
} + +
+
+ ); +}