mirror of
http://88.130.71.182:3000/BlitTech/badoHair_fe.git
synced 2026-06-13 11:03:02 +00:00
Update May 12 by Elvis
This commit is contained in:
277
app/admin/services/page.tsx
Normal file
277
app/admin/services/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user