257 lines
7.8 KiB
TypeScript
257 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|