feat: frontend core — auth, layout, transactions, categories
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user