feat: frontend core — auth, layout, transactions, categories
This commit is contained in:
@@ -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,
|
||||
|
||||
+225
-5
@@ -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<string>((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<AuthTokens> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("username", email);
|
||||
params.append("password", password);
|
||||
const { data } = await apiClient.post<AuthTokens>("/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<User> {
|
||||
const { data } = await apiClient.post<User>("/auth/register", {
|
||||
email,
|
||||
password,
|
||||
full_name,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<User> {
|
||||
const { data } = await apiClient.get<User>("/auth/me");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function logoutUser(refreshToken: string): Promise<void> {
|
||||
await apiClient.post("/auth/logout", { refresh_token: refreshToken });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transactions API
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function getTransactions(
|
||||
filters: TransactionFilters,
|
||||
): Promise<PaginatedResponse<Transaction>> {
|
||||
const { data } = await apiClient.get<PaginatedResponse<Transaction>>(
|
||||
"/transactions",
|
||||
{ params: filters },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createTransaction(
|
||||
payload: CreateTransactionPayload,
|
||||
): Promise<Transaction> {
|
||||
const { data } = await apiClient.post<Transaction>("/transactions", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTransaction(
|
||||
id: string,
|
||||
payload: UpdateTransactionPayload,
|
||||
): Promise<Transaction> {
|
||||
const { data } = await apiClient.put<Transaction>(
|
||||
`/transactions/${id}`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteTransaction(id: string): Promise<void> {
|
||||
await apiClient.delete(`/transactions/${id}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categories API
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function getCategories(
|
||||
type?: string,
|
||||
): Promise<Category[]> {
|
||||
const { data } = await apiClient.get<Category[]>("/categories", {
|
||||
params: type ? { type } : undefined,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createCategory(
|
||||
payload: CreateCategoryPayload,
|
||||
): Promise<Category> {
|
||||
const { data } = await apiClient.post<Category>("/categories", payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
payload: UpdateCategoryPayload,
|
||||
): Promise<Category> {
|
||||
const { data } = await apiClient.put<Category>(
|
||||
`/categories/${id}`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: string): Promise<void> {
|
||||
await apiClient.delete(`/categories/${id}`);
|
||||
}
|
||||
|
||||
@@ -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<T> {
|
||||
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;
|
||||
}
|
||||
@@ -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<FormState>(DEFAULT_FORM);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
|
||||
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<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined }));
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const errs: Partial<Record<keyof FormState, string>> = {};
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl bg-white shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-5 py-4">
|
||||
<h2 className="text-base font-semibold text-slate-800">
|
||||
{isEditing ? "Modifier la catégorie" : "Nouvelle catégorie"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-700"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-5">
|
||||
{isDefault && (
|
||||
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
Les catégories par défaut ne peuvent pas être modifiées en type.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cat-name"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Nom
|
||||
</label>
|
||||
<input
|
||||
id="cat-name"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cat-type"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="cat-type"
|
||||
value={form.type}
|
||||
onChange={(e) => set("type", e.target.value as CategoryType)}
|
||||
disabled={isDefault || isEditing}
|
||||
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 disabled:bg-slate-50 disabled:text-slate-400"
|
||||
>
|
||||
{CATEGORY_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Color */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cat-color"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Couleur
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="cat-color"
|
||||
type="color"
|
||||
value={form.color}
|
||||
onChange={(e) => set("color", e.target.value)}
|
||||
className="h-9 w-14 cursor-pointer rounded border border-slate-200 p-0.5"
|
||||
/>
|
||||
<span className="text-sm text-slate-500">{form.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cat-icon"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Icône{" "}
|
||||
<span className="font-normal text-slate-400">(optionnel)</span>
|
||||
</label>
|
||||
<input
|
||||
id="cat-icon"
|
||||
type="text"
|
||||
value={form.icon}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isPending
|
||||
? "Enregistrement…"
|
||||
: isEditing
|
||||
? "Modifier"
|
||||
: "Créer"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100">
|
||||
<AlertTriangle size={20} className="text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-slate-800">
|
||||
Confirmer la suppression
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mb-6 text-sm text-slate-600">{message}</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "Suppression…" : "Supprimer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center border-b border-slate-700 px-4">
|
||||
<span className="text-lg font-bold text-white">Budget Tracker</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<ul className="space-y-1">
|
||||
{NAV_ITEMS.map(({ to, icon: Icon, label, end }) => (
|
||||
<li key={to}>
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={navLinkClass}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
<ChevronRight size={14} className="ml-auto opacity-40" />
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* User / Logout */}
|
||||
<div className="border-t border-slate-700 p-4">
|
||||
<div className="mb-2 truncate text-xs text-slate-400">
|
||||
{user?.full_name ?? user?.email ?? "—"}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-700 hover:text-white"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden w-60 shrink-0 bg-slate-800 lg:flex lg:flex-col">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-60 transform bg-slate-800 transition-transform duration-200 lg:hidden ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="absolute right-3 top-4 text-slate-400 hover:text-white"
|
||||
aria-label="Fermer le menu"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Mobile header */}
|
||||
<header className="flex h-14 items-center border-b bg-white px-4 lg:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="text-slate-600 hover:text-slate-900"
|
||||
aria-label="Ouvrir le menu"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<span className="ml-3 font-semibold text-slate-800">
|
||||
Budget Tracker
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50 p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <Navigate to="/login" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -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<FormState>(DEFAULT_FORM);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
|
||||
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<K extends keyof FormState>(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<Record<keyof FormState, string>> = {};
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl bg-white shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-5 py-4">
|
||||
<h2 className="text-base font-semibold text-slate-800">
|
||||
{isEditing ? "Modifier la transaction" : "Nouvelle transaction"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-700"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-5">
|
||||
{/* Type toggle */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
Type
|
||||
</label>
|
||||
<div className="flex rounded-lg border border-slate-200 p-1">
|
||||
{(["expense", "income"] as TransactionType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => set("type", t)}
|
||||
className={`flex-1 rounded-md py-1.5 text-sm font-medium transition-colors ${
|
||||
form.type === t
|
||||
? t === "expense"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-green-100 text-green-700"
|
||||
: "text-slate-500 hover:text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{t === "expense" ? "Dépense" : "Revenu"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tx-amount"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Montant (€)
|
||||
</label>
|
||||
<input
|
||||
id="tx-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={form.amount}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.amount}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tx-date"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
id="tx-date"
|
||||
type="date"
|
||||
value={form.transaction_date}
|
||||
onChange={(e) => 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 && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.transaction_date}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tx-category"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Catégorie
|
||||
</label>
|
||||
<select
|
||||
id="tx-category"
|
||||
value={form.category_id}
|
||||
onChange={(e) => set("category_id", 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.category_id ? "border-red-400" : "border-slate-200"
|
||||
}`}
|
||||
>
|
||||
<option value="">— Choisir une catégorie —</option>
|
||||
{filteredCategories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.category_id && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.category_id}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tx-description"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Description{" "}
|
||||
<span className="font-normal text-slate-400">(optionnel)</span>
|
||||
</label>
|
||||
<input
|
||||
id="tx-description"
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isPending
|
||||
? "Enregistrement…"
|
||||
: isEditing
|
||||
? "Modifier"
|
||||
: "Ajouter"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Toast, "id">) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
const ToastContext = createContext<ToastContextValue | null>(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<ToastType, string> = {
|
||||
success: "bg-green-600",
|
||||
error: "bg-red-600",
|
||||
info: "bg-blue-600",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-lg px-4 py-3 text-sm text-white shadow-lg ${colors[toast.type]}`}
|
||||
>
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="shrink-0 opacity-80 hover:opacity-100"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Toast, "id">) => {
|
||||
dispatch({ type: "ADD", toast: { ...toast, id: ++nextId } });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} toast={t} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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"] }),
|
||||
});
|
||||
}
|
||||
@@ -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"] }),
|
||||
});
|
||||
}
|
||||
@@ -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<FormState>({ email: "", password: "" });
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Budget Tracker</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Connectez-vous à votre compte
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
|
||||
{errors.global && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{errors.global}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={form.email}
|
||||
onChange={(e) =>
|
||||
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 && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
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 && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "Connexion…" : "Se connecter"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-slate-500">
|
||||
Pas encore de compte ?{" "}
|
||||
<Link to="/register" className="font-medium text-primary hover:underline">
|
||||
S'inscrire
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<FormState>({
|
||||
full_name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Budget Tracker</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Créez votre compte</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
|
||||
{errors.global && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{errors.global}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="full_name"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Nom complet
|
||||
</label>
|
||||
<input
|
||||
id="full_name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={form.full_name}
|
||||
onChange={(e) =>
|
||||
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 && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.full_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={form.email}
|
||||
onChange={(e) =>
|
||||
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 && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Mot de passe
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.password}
|
||||
onChange={(e) =>
|
||||
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 && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "Inscription…" : "Créer mon compte"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-slate-500">
|
||||
Déjà un compte ?{" "}
|
||||
<Link to="/login" className="font-medium text-primary hover:underline">
|
||||
Se connecter
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<tr>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<div className="h-4 animate-pulse rounded bg-slate-100" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const { addToast } = useToast();
|
||||
const { data: categoriesData } = useCategories();
|
||||
|
||||
const [month, setMonth] = useState(currentMonth());
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<TransactionType | "">("");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingTx, setEditingTx] = useState<Transaction | undefined>();
|
||||
const [deleteTarget, setDeleteTarget] = useState<Transaction | undefined>();
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-slate-800">Transactions</h1>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Nouvelle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => {
|
||||
setCategoryFilter(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"
|
||||
>
|
||||
<option value="">Toutes les catégories</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex rounded-lg border border-slate-200">
|
||||
{(
|
||||
[
|
||||
{ value: "", label: "Tout" },
|
||||
{ value: "income", label: "Revenus" },
|
||||
{ value: "expense", label: "Dépenses" },
|
||||
] as { value: TransactionType | ""; label: string }[]
|
||||
).map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setTypeFilter(value);
|
||||
handleFilterChange();
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
|
||||
typeFilter === value
|
||||
? "bg-primary text-white"
|
||||
: "text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50">
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-slate-500">
|
||||
Catégorie
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-slate-500">
|
||||
Montant
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-slate-500">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-slate-500">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} />)
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-4 py-12 text-center text-slate-400"
|
||||
>
|
||||
<p className="font-medium">Aucune transaction</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Ajoutez votre première transaction avec le bouton ci-dessus.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.items.map((tx) => (
|
||||
<tr
|
||||
key={tx.id}
|
||||
className="group transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
|
||||
{formatDate(tx.transaction_date)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-800">
|
||||
{tx.description ?? (
|
||||
<span className="text-slate-400 italic">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: tx.category.color
|
||||
? `${tx.category.color}20`
|
||||
: "#e2e8f0",
|
||||
color: tx.category.color ?? "#64748b",
|
||||
}}
|
||||
>
|
||||
{tx.category.name}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className={`whitespace-nowrap px-4 py-3 text-right font-medium tabular-nums ${
|
||||
tx.type === "income"
|
||||
? "text-green-600"
|
||||
: "text-slate-800"
|
||||
}`}
|
||||
>
|
||||
{tx.type === "income" ? "+" : "-"}
|
||||
{formatCurrency(tx.amount_cents)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{tx.type === "income" ? (
|
||||
<TrendingUp
|
||||
size={16}
|
||||
className="mx-auto text-green-500"
|
||||
/>
|
||||
) : (
|
||||
<TrendingDown
|
||||
size={16}
|
||||
className="mx-auto text-red-400"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={() => openEdit(tx)}
|
||||
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
aria-label="Modifier"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(tx)}
|
||||
className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600"
|
||||
aria-label="Supprimer"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-slate-100 px-4 py-3">
|
||||
<span className="text-xs text-slate-500">
|
||||
{data.total} transaction{data.total > 1 ? "s" : ""} au total
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
|
||||
(p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={`min-w-[2rem] rounded px-2 py-1 text-xs font-medium ${
|
||||
p === page
|
||||
? "bg-primary text-white"
|
||||
: "text-slate-500 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<TransactionForm
|
||||
isOpen={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
transaction={editingTx}
|
||||
/>
|
||||
|
||||
<DeleteConfirmModal
|
||||
isOpen={!!deleteTarget}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Supprimer la transaction "${deleteTarget.description ?? formatCurrency(deleteTarget.amount_cents)}" ?`
|
||||
: ""
|
||||
}
|
||||
isLoading={deleteMutation.isPending}
|
||||
onConfirm={handleDelete}
|
||||
onClose={() => setDeleteTarget(undefined)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<AuthState & AuthActions>()(
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user