278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Plus, Pencil, Trash2, Clock } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
adminListServices, adminCreateService, adminUpdateService, adminDeleteService,
|
|
AdminServiceApi, ServicePayload, formatDuration,
|
|
} from "@/lib/api/services";
|
|
import { ApiError } from "@/lib/api";
|
|
import { useLanguage } from "@/contexts/LanguageContext";
|
|
|
|
const EMPTY: ServicePayload = {
|
|
name: "",
|
|
description: "",
|
|
duration_minutes: 60,
|
|
price: 0,
|
|
is_active: true,
|
|
};
|
|
|
|
export default function AdminServices() {
|
|
const { t } = useLanguage();
|
|
const [services, setServices] = useState<AdminServiceApi[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editing, setEditing] = useState<AdminServiceApi | null>(null);
|
|
const [form, setForm] = useState<ServicePayload>(EMPTY);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
setServices(await adminListServices());
|
|
} catch (err) {
|
|
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
setForm(EMPTY);
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const openEdit = (s: AdminServiceApi) => {
|
|
setEditing(s);
|
|
setForm({
|
|
name: s.name,
|
|
description: s.description ?? "",
|
|
duration_minutes: s.duration_minutes,
|
|
price: s.price,
|
|
is_active: s.is_active,
|
|
});
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.name.trim()) { toast.error(t("admin.services.name_req")); return; }
|
|
setSaving(true);
|
|
try {
|
|
if (editing) {
|
|
const updated = await adminUpdateService(editing.id, form);
|
|
setServices((prev) => prev.map((s) => (s.id === editing.id ? updated : s)));
|
|
toast.success(t("admin.services.updated"));
|
|
} else {
|
|
const created = await adminCreateService(form);
|
|
setServices((prev) => [...prev, created]);
|
|
toast.success(t("admin.services.created"));
|
|
}
|
|
setDialogOpen(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteId) return;
|
|
try {
|
|
await adminDeleteService(deleteId);
|
|
setServices((prev) => prev.filter((s) => s.id !== deleteId));
|
|
toast.success(t("admin.services.deleted"));
|
|
} catch (err) {
|
|
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
|
} finally {
|
|
setDeleteId(null);
|
|
}
|
|
};
|
|
|
|
const set = (k: keyof ServicePayload, v: ServicePayload[keyof ServicePayload]) =>
|
|
setForm((prev) => ({ ...prev, [k]: v }));
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="font-serif text-2xl font-semibold">{t("admin.services.title")}</h2>
|
|
<p className="text-sm text-muted-foreground mt-1">{t("admin.services.subtitle")}</p>
|
|
</div>
|
|
<Button onClick={openCreate}>
|
|
<Plus className="h-4 w-4 mr-2" /> {t("admin.services.new_btn")}
|
|
</Button>
|
|
</div>
|
|
|
|
<Card>
|
|
{loading ? (
|
|
<div className="p-8 space-y-3">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t("admin.services.col_name")}</TableHead>
|
|
<TableHead>{t("admin.services.col_desc")}</TableHead>
|
|
<TableHead>{t("admin.services.col_duration")}</TableHead>
|
|
<TableHead>{t("admin.services.col_price")}</TableHead>
|
|
<TableHead>{t("admin.status")}</TableHead>
|
|
<TableHead className="text-right">{t("admin.actions")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{services.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-10">
|
|
{t("admin.services.none")}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
services.map((s) => (
|
|
<TableRow key={s.id}>
|
|
<TableCell className="font-medium">{s.name}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
|
|
{s.description ?? "—"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
{formatDuration(s.duration_minutes)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="font-semibold">
|
|
{s.price === 0 ? t("admin.services.free") : `${s.price} €`}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={s.is_active ? "default" : "secondary"}>
|
|
{s.is_active ? t("admin.status.active") : t("admin.status.inactive")}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right space-x-1">
|
|
<Button variant="ghost" size="icon" onClick={() => openEdit(s)}>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={() => setDeleteId(s.id)}>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</Card>
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{editing ? t("admin.services.edit_title") : t("admin.services.create_title")}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<div>
|
|
<Label htmlFor="s-name">{t("admin.services.col_name")} *</Label>
|
|
<Input
|
|
id="s-name"
|
|
value={form.name}
|
|
onChange={(e) => set("name", e.target.value)}
|
|
className="mt-1"
|
|
placeholder={t("admin.services.name_ph")}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="s-desc">{t("admin.services.col_desc")}</Label>
|
|
<Input
|
|
id="s-desc"
|
|
value={form.description ?? ""}
|
|
onChange={(e) => set("description", e.target.value)}
|
|
className="mt-1"
|
|
placeholder={t("admin.services.desc_ph")}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="s-dur">{t("admin.services.duration")}</Label>
|
|
<Input
|
|
id="s-dur"
|
|
type="number"
|
|
min={5}
|
|
max={480}
|
|
value={form.duration_minutes}
|
|
onChange={(e) => set("duration_minutes", parseInt(e.target.value) || 60)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="s-price">{t("admin.services.price")}</Label>
|
|
<Input
|
|
id="s-price"
|
|
type="number"
|
|
min={0}
|
|
step={0.01}
|
|
value={form.price}
|
|
onChange={(e) => set("price", parseFloat(e.target.value) || 0)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="s-active"
|
|
type="checkbox"
|
|
checked={form.is_active}
|
|
onChange={(e) => set("is_active", e.target.checked)}
|
|
className="h-4 w-4"
|
|
/>
|
|
<Label htmlFor="s-active" className="font-normal cursor-pointer">
|
|
{t("admin.services.active_label")}
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>{t("admin.cancel")}</Button>
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? t("admin.saving") : t("admin.save")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("admin.services.delete_title")}</AlertDialogTitle>
|
|
<AlertDialogDescription>{t("admin.irreversible")}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t("admin.cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleDelete}>{t("admin.delete")}</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|