diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py index 7a4b247..0642dab 100644 --- a/backend/app/auth/router.py +++ b/backend/app/auth/router.py @@ -127,6 +127,11 @@ async def refresh_token( return TokenRefresh(access_token=new_access_token, token_type="bearer") +@router.get("/me", response_model=UserResponse) +async def get_me(current_user: User = Depends(get_current_user)) -> UserResponse: + return UserResponse.model_validate(current_user) + + @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) async def logout( data: TokenRefreshRequest, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 906efcf..807d204 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,8 +1,228 @@ -import axios from "axios"; +import axios, { + type InternalAxiosRequestConfig, + type AxiosResponse, +} from "axios"; +import type { + AuthTokens, + User, + Transaction, + Category, + PaginatedResponse, + TransactionFilters, + CreateTransactionPayload, + UpdateTransactionPayload, + CreateCategoryPayload, + UpdateCategoryPayload, +} from "./types"; +// --------------------------------------------------------------------------- +// Module-level token state (no circular dependency with auth store) +// --------------------------------------------------------------------------- +let _accessToken: string | null = null; +let _refreshToken: string | null = null; +let _onAuthFailed: (() => void) | null = null; + +export function setClientTokens( + access: string | null, + refresh: string | null, +): void { + _accessToken = access; + _refreshToken = refresh; +} + +export function setOnAuthFailed(cb: () => void): void { + _onAuthFailed = cb; +} + +// --------------------------------------------------------------------------- +// Axios instance +// --------------------------------------------------------------------------- export const apiClient = axios.create({ - baseURL: "/api", - headers: { - "Content-Type": "application/json", - }, + baseURL: "/api/v1", + headers: { "Content-Type": "application/json" }, }); + +// Request interceptor — attach Bearer token +apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { + if (_accessToken) { + config.headers.Authorization = `Bearer ${_accessToken}`; + } + return config; +}); + +// --------------------------------------------------------------------------- +// Response interceptor — silent token refresh on 401 +// --------------------------------------------------------------------------- +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (token: string) => void; + reject: (err: unknown) => void; +}> = []; + +function processQueue(error: unknown, token: string | null): void { + failedQueue.forEach((p) => { + if (error) p.reject(error); + else if (token) p.resolve(token); + }); + failedQueue = []; +} + +apiClient.interceptors.response.use( + (response: AxiosResponse) => response, + async (error: unknown) => { + if (!axios.isAxiosError(error)) return Promise.reject(error); + + const config = error.config; + const status = error.response?.status; + + if (status !== 401 || !config) return Promise.reject(error); + + // Don't retry auth endpoints + if ( + config.url?.includes("/auth/refresh") || + config.url?.includes("/auth/login") + ) { + return Promise.reject(error); + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + config.headers.Authorization = `Bearer ${token}`; + return apiClient(config); + }); + } + + isRefreshing = true; + try { + const rt = _refreshToken; + if (!rt) throw new Error("No refresh token"); + + const { data } = await axios.post<{ access_token: string }>( + "/api/v1/auth/refresh", + { refresh_token: rt }, + ); + + _accessToken = data.access_token; + processQueue(null, data.access_token); + config.headers.Authorization = `Bearer ${data.access_token}`; + return apiClient(config); + } catch (refreshError) { + processQueue(refreshError, null); + _accessToken = null; + _refreshToken = null; + _onAuthFailed?.(); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + }, +); + +// --------------------------------------------------------------------------- +// Auth API +// --------------------------------------------------------------------------- +export async function loginUser( + email: string, + password: string, +): Promise { + const params = new URLSearchParams(); + params.append("username", email); + params.append("password", password); + const { data } = await apiClient.post("/auth/login", params, { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + return data; +} + +export async function registerUser( + email: string, + password: string, + full_name: string, +): Promise { + const { data } = await apiClient.post("/auth/register", { + email, + password, + full_name, + }); + return data; +} + +export async function getMe(): Promise { + const { data } = await apiClient.get("/auth/me"); + return data; +} + +export async function logoutUser(refreshToken: string): Promise { + await apiClient.post("/auth/logout", { refresh_token: refreshToken }); +} + +// --------------------------------------------------------------------------- +// Transactions API +// --------------------------------------------------------------------------- +export async function getTransactions( + filters: TransactionFilters, +): Promise> { + const { data } = await apiClient.get>( + "/transactions", + { params: filters }, + ); + return data; +} + +export async function createTransaction( + payload: CreateTransactionPayload, +): Promise { + const { data } = await apiClient.post("/transactions", payload); + return data; +} + +export async function updateTransaction( + id: string, + payload: UpdateTransactionPayload, +): Promise { + const { data } = await apiClient.put( + `/transactions/${id}`, + payload, + ); + return data; +} + +export async function deleteTransaction(id: string): Promise { + await apiClient.delete(`/transactions/${id}`); +} + +// --------------------------------------------------------------------------- +// Categories API +// --------------------------------------------------------------------------- +export async function getCategories( + type?: string, +): Promise { + const { data } = await apiClient.get("/categories", { + params: type ? { type } : undefined, + }); + return data; +} + +export async function createCategory( + payload: CreateCategoryPayload, +): Promise { + const { data } = await apiClient.post("/categories", payload); + return data; +} + +export async function updateCategory( + id: string, + payload: UpdateCategoryPayload, +): Promise { + const { data } = await apiClient.put( + `/categories/${id}`, + payload, + ); + return data; +} + +export async function deleteCategory(id: string): Promise { + await apiClient.delete(`/categories/${id}`); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..cb715b7 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,84 @@ +export type TransactionType = "income" | "expense"; +export type CategoryType = "income" | "expense" | "both"; + +export interface User { + id: string; + email: string; + full_name: string; +} + +export interface AuthTokens { + access_token: string; + refresh_token: string; + token_type: string; +} + +export interface CategoryBrief { + id: string; + name: string; + color: string | null; +} + +export interface Transaction { + id: string; + amount_cents: number; + type: TransactionType; + description: string | null; + category: CategoryBrief; + transaction_date: string; + created_at: string; +} + +export interface Category { + id: string; + name: string; + type: CategoryType; + color: string | null; + icon: string | null; + is_default: boolean; + created_at: string; +} + +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + per_page: number; +} + +export interface TransactionFilters { + month?: string; + category_id?: string; + type?: TransactionType; + page?: number; + per_page?: number; +} + +export interface CreateTransactionPayload { + amount_cents: number; + type: TransactionType; + category_id: string; + description?: string; + transaction_date: string; +} + +export interface UpdateTransactionPayload { + amount_cents?: number; + type?: TransactionType; + category_id?: string | null; + description?: string | null; + transaction_date?: string; +} + +export interface CreateCategoryPayload { + name: string; + type: CategoryType; + color?: string; + icon?: string; +} + +export interface UpdateCategoryPayload { + name?: string; + color?: string; + icon?: string; +} diff --git a/frontend/src/components/CategoryModal.tsx b/frontend/src/components/CategoryModal.tsx new file mode 100644 index 0000000..3a2d84b --- /dev/null +++ b/frontend/src/components/CategoryModal.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from "react"; +import { X } from "lucide-react"; +import { useCreateCategory, useUpdateCategory } from "../hooks/useCategories"; +import { useToast } from "../context/ToastContext"; +import type { Category, CategoryType } from "../api/types"; + +interface Props { + isOpen: boolean; + onClose: () => void; + category?: Category; +} + +interface FormState { + name: string; + type: CategoryType; + color: string; + icon: string; +} + +const DEFAULT_FORM: FormState = { + name: "", + type: "expense", + color: "#6366f1", + icon: "", +}; + +export default function CategoryModal({ isOpen, onClose, category }: Props) { + const createMutation = useCreateCategory(); + const updateMutation = useUpdateCategory(); + const { addToast } = useToast(); + + const [form, setForm] = useState(DEFAULT_FORM); + const [errors, setErrors] = useState>>({}); + + const isEditing = !!category; + const isDefault = category?.is_default ?? false; + const isPending = createMutation.isPending || updateMutation.isPending; + + useEffect(() => { + if (category) { + setForm({ + name: category.name, + type: category.type, + color: category.color ?? "#6366f1", + icon: category.icon ?? "", + }); + } else { + setForm(DEFAULT_FORM); + } + setErrors({}); + }, [category, isOpen]); + + function set(key: K, value: FormState[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined })); + } + + function validate(): boolean { + const errs: Partial> = {}; + if (!form.name.trim()) errs.name = "Nom requis"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validate()) return; + + if (isEditing && category) { + updateMutation.mutate( + { + id: category.id, + payload: { + name: form.name.trim(), + color: form.color, + icon: form.icon || undefined, + }, + }, + { + onSuccess: () => { + addToast({ type: "success", message: "Catégorie modifiée" }); + onClose(); + }, + onError: () => + addToast({ type: "error", message: "Erreur lors de la modification" }), + }, + ); + } else { + createMutation.mutate( + { + name: form.name.trim(), + type: form.type, + color: form.color, + icon: form.icon || undefined, + }, + { + onSuccess: () => { + addToast({ type: "success", message: "Catégorie créée" }); + onClose(); + }, + onError: () => + addToast({ type: "error", message: "Erreur lors de la création" }), + }, + ); + } + } + + if (!isOpen) return null; + + const CATEGORY_TYPES: { value: CategoryType; label: string }[] = [ + { value: "expense", label: "Dépense" }, + { value: "income", label: "Revenu" }, + { value: "both", label: "Les deux" }, + ]; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ {isEditing ? "Modifier la catégorie" : "Nouvelle catégorie"} +

+ +
+ +
+ {isDefault && ( +

+ Les catégories par défaut ne peuvent pas être modifiées en type. +

+ )} + + {/* Name */} +
+ + set("name", e.target.value)} + placeholder="Ex. : Alimentation" + className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${ + errors.name ? "border-red-400" : "border-slate-200" + }`} + /> + {errors.name && ( +

{errors.name}

+ )} +
+ + {/* Type */} +
+ + +
+ + {/* Color */} +
+ +
+ set("color", e.target.value)} + className="h-9 w-14 cursor-pointer rounded border border-slate-200 p-0.5" + /> + {form.color} +
+
+ + {/* Icon */} +
+ + set("icon", e.target.value)} + placeholder="Ex. : shopping-cart" + className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30" + /> +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/DeleteConfirmModal.tsx b/frontend/src/components/DeleteConfirmModal.tsx new file mode 100644 index 0000000..c17524a --- /dev/null +++ b/frontend/src/components/DeleteConfirmModal.tsx @@ -0,0 +1,57 @@ +import { AlertTriangle } from "lucide-react"; + +interface Props { + isOpen: boolean; + message: string; + isLoading?: boolean; + onConfirm: () => void; + onClose: () => void; +} + +export default function DeleteConfirmModal({ + isOpen, + message, + isLoading, + onConfirm, + onClose, +}: Props) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > +
+
+ +
+

+ Confirmer la suppression +

+
+

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..0602778 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { + LayoutDashboard, + ArrowLeftRight, + Tag, + Wallet, + History, + Menu, + X, + LogOut, + ChevronRight, +} from "lucide-react"; +import { useAuthStore } from "../stores/auth"; +import { logoutUser } from "../api/client"; +import { useToast } from "../context/ToastContext"; + +const NAV_ITEMS = [ + { to: "/", icon: LayoutDashboard, label: "Dashboard", end: true }, + { to: "/transactions", icon: ArrowLeftRight, label: "Transactions", end: false }, + { to: "/categories", icon: Tag, label: "Catégories", end: false }, + { to: "/budgets", icon: Wallet, label: "Budgets", end: false }, + { to: "/history", icon: History, label: "Historique", end: false }, +]; + +export default function Layout() { + const [sidebarOpen, setSidebarOpen] = useState(false); + const { user, refreshToken, logout } = useAuthStore(); + const navigate = useNavigate(); + const { addToast } = useToast(); + + const handleLogout = async () => { + try { + if (refreshToken) await logoutUser(refreshToken); + } catch { + // best-effort logout + } finally { + logout(); + navigate("/login"); + addToast({ type: "info", message: "Déconnecté avec succès" }); + } + }; + + const navLinkClass = ({ isActive }: { isActive: boolean }) => + `flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${ + isActive + ? "bg-primary text-white" + : "text-slate-300 hover:bg-slate-700 hover:text-white" + }`; + + const SidebarContent = () => ( +
+ {/* Logo */} +
+ Budget Tracker +
+ + {/* Navigation */} + + + {/* User / Logout */} +
+
+ {user?.full_name ?? user?.email ?? "—"} +
+ +
+
+ ); + + return ( +
+ {/* Desktop sidebar */} + + + {/* Mobile overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Mobile sidebar */} + + + {/* Main content */} +
+ {/* Mobile header */} +
+ + + Budget Tracker + +
+ + {/* Page content */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..82b7893 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,8 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useAuthStore } from "../stores/auth"; + +export default function ProtectedRoute() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + if (!isAuthenticated) return ; + return ; +} diff --git a/frontend/src/components/TransactionForm.tsx b/frontend/src/components/TransactionForm.tsx new file mode 100644 index 0000000..7c07825 --- /dev/null +++ b/frontend/src/components/TransactionForm.tsx @@ -0,0 +1,302 @@ +import { useEffect, useState } from "react"; +import { X } from "lucide-react"; +import { useCategories } from "../hooks/useCategories"; +import { + useCreateTransaction, + useUpdateTransaction, +} from "../hooks/useTransactions"; +import { eurosToCents, centsToEuros } from "../utils/format"; +import { useToast } from "../context/ToastContext"; +import type { Transaction, TransactionType } from "../api/types"; + +interface Props { + isOpen: boolean; + onClose: () => void; + transaction?: Transaction; +} + +interface FormState { + amount: string; + type: TransactionType; + category_id: string; + description: string; + transaction_date: string; +} + +function todayISO(): string { + return new Date().toISOString().slice(0, 10); +} + +const DEFAULT_FORM: FormState = { + amount: "", + type: "expense", + category_id: "", + description: "", + transaction_date: todayISO(), +}; + +export default function TransactionForm({ isOpen, onClose, transaction }: Props) { + const { data: categories = [] } = useCategories(); + const createMutation = useCreateTransaction(); + const updateMutation = useUpdateTransaction(); + const { addToast } = useToast(); + + const [form, setForm] = useState(DEFAULT_FORM); + const [errors, setErrors] = useState>>({}); + + const isEditing = !!transaction; + const isPending = createMutation.isPending || updateMutation.isPending; + + // Populate form when editing + useEffect(() => { + if (transaction) { + setForm({ + amount: String(centsToEuros(transaction.amount_cents)), + type: transaction.type, + category_id: transaction.category.id, + description: transaction.description ?? "", + transaction_date: transaction.transaction_date, + }); + } else { + setForm(DEFAULT_FORM); + } + setErrors({}); + }, [transaction, isOpen]); + + const filteredCategories = categories.filter( + (c) => c.type === form.type || c.type === "both", + ); + + function set(key: K, value: FormState[K]) { + setForm((prev) => { + const next = { ...prev, [key]: value }; + // Reset category when type changes and current category is incompatible + if (key === "type") { + const cat = categories.find((c) => c.id === prev.category_id); + if (cat && cat.type !== "both" && cat.type !== value) { + next.category_id = ""; + } + } + return next; + }); + if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined })); + } + + function validate(): boolean { + const errs: Partial> = {}; + const amountNum = parseFloat(form.amount); + if (!form.amount || isNaN(amountNum) || amountNum <= 0) + errs.amount = "Montant invalide (doit être > 0)"; + if (!form.category_id) errs.category_id = "Catégorie requise"; + if (!form.transaction_date) errs.transaction_date = "Date requise"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validate()) return; + + const payload = { + amount_cents: eurosToCents(parseFloat(form.amount)), + type: form.type, + category_id: form.category_id, + description: form.description || undefined, + transaction_date: form.transaction_date, + }; + + if (isEditing && transaction) { + updateMutation.mutate( + { id: transaction.id, payload }, + { + onSuccess: () => { + addToast({ type: "success", message: "Transaction modifiée" }); + onClose(); + }, + onError: () => + addToast({ type: "error", message: "Erreur lors de la modification" }), + }, + ); + } else { + createMutation.mutate(payload, { + onSuccess: () => { + addToast({ type: "success", message: "Transaction ajoutée" }); + onClose(); + }, + onError: () => + addToast({ type: "error", message: "Erreur lors de l'ajout" }), + }); + } + } + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ {isEditing ? "Modifier la transaction" : "Nouvelle transaction"} +

+ +
+ +
+ {/* Type toggle */} +
+ +
+ {(["expense", "income"] as TransactionType[]).map((t) => ( + + ))} +
+
+ + {/* Amount */} +
+ + set("amount", e.target.value)} + placeholder="0,00" + className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${ + errors.amount ? "border-red-400" : "border-slate-200" + }`} + /> + {errors.amount && ( +

{errors.amount}

+ )} +
+ + {/* Date */} +
+ + set("transaction_date", e.target.value)} + className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${ + errors.transaction_date ? "border-red-400" : "border-slate-200" + }`} + /> + {errors.transaction_date && ( +

{errors.transaction_date}

+ )} +
+ + {/* Category */} +
+ + + {errors.category_id && ( +

{errors.category_id}

+ )} +
+ + {/* Description */} +
+ + set("description", e.target.value)} + placeholder="Ex. : Courses Carrefour" + className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30" + /> +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx new file mode 100644 index 0000000..120af41 --- /dev/null +++ b/frontend/src/context/ToastContext.tsx @@ -0,0 +1,112 @@ +import { + createContext, + useCallback, + useContext, + useReducer, + useEffect, +} from "react"; +import { X } from "lucide-react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +type ToastType = "success" | "error" | "info"; + +interface Toast { + id: number; + type: ToastType; + message: string; +} + +interface ToastContextValue { + addToast: (toast: Omit) => void; +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- +const ToastContext = createContext(null); + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error("useToast must be used within ToastProvider"); + return ctx; +} + +// --------------------------------------------------------------------------- +// Reducer +// --------------------------------------------------------------------------- +type Action = + | { type: "ADD"; toast: Toast } + | { type: "REMOVE"; id: number }; + +function reducer(state: Toast[], action: Action): Toast[] { + switch (action.type) { + case "ADD": + return [...state, action.toast]; + case "REMOVE": + return state.filter((t) => t.id !== action.id); + } +} + +// --------------------------------------------------------------------------- +// Provider + UI +// --------------------------------------------------------------------------- +let nextId = 0; + +function ToastItem({ + toast, + onRemove, +}: { + toast: Toast; + onRemove: (id: number) => void; +}) { + useEffect(() => { + const timer = setTimeout(() => onRemove(toast.id), 4000); + return () => clearTimeout(timer); + }, [toast.id, onRemove]); + + const colors: Record = { + success: "bg-green-600", + error: "bg-red-600", + info: "bg-blue-600", + }; + + return ( +
+ {toast.message} + +
+ ); +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, dispatch] = useReducer(reducer, []); + + const removeToast = useCallback((id: number) => { + dispatch({ type: "REMOVE", id }); + }, []); + + const addToast = useCallback((toast: Omit) => { + dispatch({ type: "ADD", toast: { ...toast, id: ++nextId } }); + }, []); + + return ( + + {children} +
+ {toasts.map((t) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/hooks/useCategories.ts b/frontend/src/hooks/useCategories.ts new file mode 100644 index 0000000..f32288b --- /dev/null +++ b/frontend/src/hooks/useCategories.ts @@ -0,0 +1,43 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createCategory, + deleteCategory, + getCategories, + updateCategory, +} from "../api/client"; +import type { + CreateCategoryPayload, + UpdateCategoryPayload, +} from "../api/types"; + +export function useCategories() { + return useQuery({ + queryKey: ["categories"], + queryFn: () => getCategories(), + }); +} + +export function useCreateCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: CreateCategoryPayload) => createCategory(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }), + }); +} + +export function useUpdateCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: UpdateCategoryPayload }) => + updateCategory(id, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }), + }); +} + +export function useDeleteCategory() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteCategory(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }), + }); +} diff --git a/frontend/src/hooks/useTransactions.ts b/frontend/src/hooks/useTransactions.ts new file mode 100644 index 0000000..cebaa15 --- /dev/null +++ b/frontend/src/hooks/useTransactions.ts @@ -0,0 +1,50 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createTransaction, + deleteTransaction, + getTransactions, + updateTransaction, +} from "../api/client"; +import type { + CreateTransactionPayload, + TransactionFilters, + UpdateTransactionPayload, +} from "../api/types"; + +export function useTransactions(filters: TransactionFilters) { + return useQuery({ + queryKey: ["transactions", filters], + queryFn: () => getTransactions(filters), + }); +} + +export function useCreateTransaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: CreateTransactionPayload) => + createTransaction(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }), + }); +} + +export function useUpdateTransaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + payload, + }: { + id: string; + payload: UpdateTransactionPayload; + }) => updateTransaction(id, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }), + }); +} + +export function useDeleteTransaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteTransaction(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }), + }); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..ca9e733 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,152 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { loginUser, getMe, setClientTokens } from "../api/client"; +import { useAuthStore } from "../stores/auth"; + +interface FormState { + email: string; + password: string; +} + +interface Errors { + email?: string; + password?: string; + global?: string; +} + +export default function LoginPage() { + const navigate = useNavigate(); + const login = useAuthStore((s) => s.login); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + const [form, setForm] = useState({ email: "", password: "" }); + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + if (isAuthenticated) { + navigate("/", { replace: true }); + return null; + } + + function validate(): boolean { + const errs: Errors = {}; + if (!form.email) errs.email = "Email requis"; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) + errs.email = "Email invalide"; + if (!form.password) errs.password = "Mot de passe requis"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validate()) return; + + setIsLoading(true); + setErrors({}); + try { + const tokens = await loginUser(form.email, form.password); + // Set tokens on the client so getMe() succeeds + setClientTokens(tokens.access_token, tokens.refresh_token); + const user = await getMe(); + login(user, tokens.access_token, tokens.refresh_token); + navigate("/", { replace: true }); + } catch (err) { + const msg = + err instanceof Error && err.message.includes("401") + ? "Email ou mot de passe incorrect" + : "Erreur de connexion, veuillez réessayer"; + setErrors({ global: msg }); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+

Budget Tracker

+

+ Connectez-vous à votre compte +

+
+ +
+ {errors.global && ( +
+ {errors.global} +
+ )} + +
+
+ + + setForm((f) => ({ ...f, email: e.target.value })) + } + className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${ + errors.email ? "border-red-400" : "border-slate-200" + }`} + placeholder="vous@exemple.com" + /> + {errors.email && ( +

{errors.email}

+ )} +
+ +
+ + + setForm((f) => ({ ...f, password: e.target.value })) + } + className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${ + errors.password ? "border-red-400" : "border-slate-200" + }`} + placeholder="••••••••" + /> + {errors.password && ( +

{errors.password}

+ )} +
+ + +
+
+ +

+ Pas encore de compte ?{" "} + + S'inscrire + +

+
+
+ ); +} diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..c098975 --- /dev/null +++ b/frontend/src/pages/RegisterPage.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { registerUser, loginUser, getMe, setClientTokens } from "../api/client"; +import { useAuthStore } from "../stores/auth"; + +interface FormState { + full_name: string; + email: string; + password: string; +} + +interface Errors { + full_name?: string; + email?: string; + password?: string; + global?: string; +} + +export default function RegisterPage() { + const navigate = useNavigate(); + const login = useAuthStore((s) => s.login); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + const [form, setForm] = useState({ + full_name: "", + email: "", + password: "", + }); + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + if (isAuthenticated) { + navigate("/", { replace: true }); + return null; + } + + function validate(): boolean { + const errs: Errors = {}; + if (!form.full_name.trim() || form.full_name.trim().length < 2) + errs.full_name = "Nom complet requis (2 caractères minimum)"; + if (!form.email) errs.email = "Email requis"; + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) + errs.email = "Email invalide"; + if (!form.password || form.password.length < 8) + errs.password = "Mot de passe requis (8 caractères minimum)"; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!validate()) return; + + setIsLoading(true); + setErrors({}); + try { + await registerUser(form.email, form.password, form.full_name.trim()); + // Auto-login after registration + const tokens = await loginUser(form.email, form.password); + setClientTokens(tokens.access_token, tokens.refresh_token); + const user = await getMe(); + login(user, tokens.access_token, tokens.refresh_token); + navigate("/", { replace: true }); + } catch (err) { + const isConflict = + err instanceof Error && err.message.toLowerCase().includes("409"); + setErrors({ + global: isConflict + ? "Cet email est déjà utilisé" + : "Erreur lors de l'inscription, veuillez réessayer", + }); + } finally { + setIsLoading(false); + } + } + + return ( +
+
+
+

Budget Tracker

+

Créez votre compte

+
+ +
+ {errors.global && ( +
+ {errors.global} +
+ )} + +
+
+ + + setForm((f) => ({ ...f, full_name: e.target.value })) + } + className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${ + errors.full_name ? "border-red-400" : "border-slate-200" + }`} + placeholder="Jean Dupont" + /> + {errors.full_name && ( +

{errors.full_name}

+ )} +
+ +
+ + + setForm((f) => ({ ...f, email: e.target.value })) + } + className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${ + errors.email ? "border-red-400" : "border-slate-200" + }`} + placeholder="vous@exemple.com" + /> + {errors.email && ( +

{errors.email}

+ )} +
+ +
+ + + setForm((f) => ({ ...f, password: e.target.value })) + } + className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${ + errors.password ? "border-red-400" : "border-slate-200" + }`} + placeholder="8 caractères minimum" + /> + {errors.password && ( +

{errors.password}

+ )} +
+ + +
+
+ +

+ Déjà un compte ?{" "} + + Se connecter + +

+
+
+ ); +} diff --git a/frontend/src/pages/TransactionsPage.tsx b/frontend/src/pages/TransactionsPage.tsx new file mode 100644 index 0000000..1768619 --- /dev/null +++ b/frontend/src/pages/TransactionsPage.tsx @@ -0,0 +1,327 @@ +import { useState } from "react"; +import { Plus, Pencil, Trash2, TrendingUp, TrendingDown, ChevronLeft, ChevronRight } from "lucide-react"; +import { + useTransactions, + useDeleteTransaction, +} from "../hooks/useTransactions"; +import { useCategories } from "../hooks/useCategories"; +import TransactionForm from "../components/TransactionForm"; +import DeleteConfirmModal from "../components/DeleteConfirmModal"; +import { useToast } from "../context/ToastContext"; +import { formatCurrency, formatDate, currentMonth } from "../utils/format"; +import type { Transaction, TransactionType } from "../api/types"; + +const PER_PAGE = 20; + +// Skeleton row +function SkeletonRow() { + return ( + + {Array.from({ length: 6 }).map((_, i) => ( + +
+ + ))} + + ); +} + +export default function TransactionsPage() { + const { addToast } = useToast(); + const { data: categoriesData } = useCategories(); + + const [month, setMonth] = useState(currentMonth()); + const [categoryFilter, setCategoryFilter] = useState(""); + const [typeFilter, setTypeFilter] = useState(""); + const [page, setPage] = useState(1); + + const [formOpen, setFormOpen] = useState(false); + const [editingTx, setEditingTx] = useState(); + const [deleteTarget, setDeleteTarget] = useState(); + + const filters = { + month: month || undefined, + category_id: categoryFilter || undefined, + type: typeFilter || undefined, + page, + per_page: PER_PAGE, + }; + + const { data, isLoading } = useTransactions(filters); + const deleteMutation = useDeleteTransaction(); + + const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 0; + + function openCreate() { + setEditingTx(undefined); + setFormOpen(true); + } + + function openEdit(tx: Transaction) { + setEditingTx(tx); + setFormOpen(true); + } + + function handleFilterChange() { + setPage(1); + } + + function handleDelete() { + if (!deleteTarget) return; + deleteMutation.mutate(deleteTarget.id, { + onSuccess: () => { + addToast({ type: "success", message: "Transaction supprimée" }); + setDeleteTarget(undefined); + }, + onError: () => + addToast({ type: "error", message: "Erreur lors de la suppression" }), + }); + } + + const categories = categoriesData ?? []; + + return ( +
+
+

Transactions

+ +
+ + {/* Filters */} +
+ { + setMonth(e.target.value); + handleFilterChange(); + }} + className="rounded-lg border border-slate-200 px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30" + /> + + + +
+ {( + [ + { value: "", label: "Tout" }, + { value: "income", label: "Revenus" }, + { value: "expense", label: "Dépenses" }, + ] as { value: TransactionType | ""; label: string }[] + ).map(({ value, label }) => ( + + ))} +
+
+ + {/* Table */} +
+
+ + + + + + + + + + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ) + ) : !data || data.items.length === 0 ? ( + + + + ) : ( + data.items.map((tx) => ( + + + + + + + + + )) + )} + +
+ Date + + Description + + Catégorie + + Montant + + Type + + Actions +
+

Aucune transaction

+

+ Ajoutez votre première transaction avec le bouton ci-dessus. +

+
+ {formatDate(tx.transaction_date)} + + {tx.description ?? ( + + )} + + + {tx.category.name} + + + {tx.type === "income" ? "+" : "-"} + {formatCurrency(tx.amount_cents)} + + {tx.type === "income" ? ( + + ) : ( + + )} + +
+ + +
+
+
+ + {/* Pagination */} + {data && totalPages > 1 && ( +
+ + {data.total} transaction{data.total > 1 ? "s" : ""} au total + +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1).map( + (p) => ( + + ), + )} + +
+
+ )} +
+ + {/* Modals */} + setFormOpen(false)} + transaction={editingTx} + /> + + setDeleteTarget(undefined)} + /> +
+ ); +} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..fa5c443 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,61 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { setClientTokens, setOnAuthFailed } from "../api/client"; +import type { User } from "../api/types"; + +interface AuthState { + user: User | null; + accessToken: string | null; + refreshToken: string | null; + isAuthenticated: boolean; +} + +interface AuthActions { + login: (user: User, accessToken: string, refreshToken: string) => void; + updateTokens: (accessToken: string) => void; + logout: () => void; + registerOnAuthFailed: (cb: () => void) => void; +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + + login(user, accessToken, refreshToken) { + setClientTokens(accessToken, refreshToken); + set({ user, accessToken, refreshToken, isAuthenticated: true }); + }, + + updateTokens(accessToken) { + setClientTokens(accessToken, get().refreshToken); + set({ accessToken }); + }, + + logout() { + setClientTokens(null, null); + set({ + user: null, + accessToken: null, + refreshToken: null, + isAuthenticated: false, + }); + }, + + registerOnAuthFailed(cb) { + setOnAuthFailed(cb); + }, + }), + { + name: "budget-tracker-auth", + onRehydrateStorage: () => (state) => { + if (state?.accessToken) { + setClientTokens(state.accessToken, state.refreshToken); + } + }, + }, + ), +); diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..3fb3ea0 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,25 @@ +export function centsToEuros(cents: number): number { + return cents / 100; +} + +export function eurosToCents(euros: number): number { + return Math.round(euros * 100); +} + +export function formatCurrency(cents: number): string { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(cents / 100); +} + +export function formatDate(dateStr: string): string { + return new Intl.DateTimeFormat("fr-FR").format(new Date(dateStr + "T00:00:00")); +} + +export function currentMonth(): string { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + return `${y}-${m}`; +}