mirror of
http://88.130.71.182:3000/BlitTech/badoHair_fe.git
synced 2026-06-13 10:41:11 +00:00
Update May 12 by Elvis
This commit is contained in:
153
app/admin/clients/page.tsx
Normal file
153
app/admin/clients/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Search, Ban, CheckCircle, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { adminListCustomers, adminBlockCustomer, CustomerApi } from "@/lib/api/customers";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
export default function AdminClients() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [customers, setCustomers] = useState<CustomerApi[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminListCustomers(query || undefined);
|
||||
setCustomers(res.data);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setQuery(search);
|
||||
};
|
||||
|
||||
const toggleBlock = async (c: CustomerApi) => {
|
||||
setUpdating(c.id);
|
||||
try {
|
||||
const updated = await adminBlockCustomer(c.id, !c.is_blocked);
|
||||
setCustomers((prev) => prev.map((x) => (x.id === c.id ? { ...x, is_blocked: updated.is_blocked } : x)));
|
||||
toast.success(updated.is_blocked ? t("admin.customers.blocked") : t("admin.customers.unblocked"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold">{t("admin.customers.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{customers.length} {t("admin.customers.subtitle")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
{t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="flex gap-2 max-w-sm">
|
||||
<Input
|
||||
placeholder={t("admin.customers.search_ph")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="icon">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div className="p-8 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.customers.col_name")}</TableHead>
|
||||
<TableHead>{t("admin.customers.col_email")}</TableHead>
|
||||
<TableHead>{t("admin.customers.col_phone")}</TableHead>
|
||||
<TableHead className="text-center">{t("admin.customers.col_orders")}</TableHead>
|
||||
<TableHead className="text-center">{t("admin.customers.col_bookings")}</TableHead>
|
||||
<TableHead className="text-right">{t("admin.customers.col_spent")}</TableHead>
|
||||
<TableHead>{t("admin.customers.col_joined")}</TableHead>
|
||||
<TableHead>{t("admin.status")}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
|
||||
{t("admin.customers.none")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
customers.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.full_name ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{c.email}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
|
||||
<TableCell className="text-center text-sm">{c.orders_count}</TableCell>
|
||||
<TableCell className="text-center text-sm">{c.bookings_count}</TableCell>
|
||||
<TableCell className="text-right font-semibold">{Number(c.total_spent).toFixed(2)} €</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(c.created_at).toLocaleDateString(locale, { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{c.is_blocked ? (
|
||||
<Badge variant="destructive">{t("admin.status.blocked")}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{t("admin.status.active")}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={updating === c.id}
|
||||
onClick={() => toggleBlock(c)}
|
||||
title={c.is_blocked ? t("admin.status.active") : t("admin.status.blocked")}
|
||||
>
|
||||
{c.is_blocked ? (
|
||||
<CheckCircle className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Ban className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
app/admin/commandes/page.tsx
Normal file
174
app/admin/commandes/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDown, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { adminListOrders, adminUpdateOrderStatus, AdminOrderApi } from "@/lib/api/orders";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
type Status = "all" | "pending" | "paid" | "shipped" | "delivered" | "cancelled" | "refunded";
|
||||
|
||||
const NEXT_STATUSES: Record<string, string[]> = {
|
||||
pending: ["paid", "cancelled"],
|
||||
paid: ["shipped", "cancelled"],
|
||||
shipped: ["delivered"],
|
||||
delivered: [],
|
||||
cancelled: [],
|
||||
refunded: [],
|
||||
};
|
||||
|
||||
export default function AdminCommandes() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [orders, setOrders] = useState<AdminOrderApi[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<Status>("all");
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
pending: { label: t("admin.status.pending"), variant: "secondary" },
|
||||
paid: { label: t("admin.status.paid"), variant: "default" },
|
||||
shipped: { label: t("admin.status.shipped"), variant: "outline" },
|
||||
delivered: { label: t("admin.status.delivered"), variant: "default" },
|
||||
cancelled: { label: t("admin.status.cancelled"), variant: "destructive" },
|
||||
refunded: { label: t("admin.status.refunded"), variant: "destructive" },
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminListOrders(filter === "all" ? undefined : filter);
|
||||
setOrders(res.data);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleStatus = async (id: string, status: string) => {
|
||||
setUpdating(id);
|
||||
try {
|
||||
await adminUpdateOrderStatus(id, status);
|
||||
setOrders((prev) => prev.map((o) => (o.id === id ? { ...o, status } : o)));
|
||||
toast.success(t("admin.settings.saved"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold">{t("admin.orders.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{orders.length} {t("admin.orders.subtitle")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
{t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={filter} onValueChange={(v) => setFilter(v as Status)}>
|
||||
<TabsList className="flex-wrap">
|
||||
<TabsTrigger value="all">{t("admin.orders.tab_all")}</TabsTrigger>
|
||||
<TabsTrigger value="pending">{t("admin.orders.tab_pending")}</TabsTrigger>
|
||||
<TabsTrigger value="paid">{t("admin.orders.tab_paid")}</TabsTrigger>
|
||||
<TabsTrigger value="shipped">{t("admin.orders.tab_shipped")}</TabsTrigger>
|
||||
<TabsTrigger value="delivered">{t("admin.orders.tab_delivered")}</TabsTrigger>
|
||||
<TabsTrigger value="cancelled">{t("admin.orders.tab_cancelled")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div className="p-8 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.orders.col_ref")}</TableHead>
|
||||
<TableHead>{t("admin.orders.col_customer")}</TableHead>
|
||||
<TableHead>{t("admin.orders.col_date")}</TableHead>
|
||||
<TableHead>{t("admin.orders.col_total")}</TableHead>
|
||||
<TableHead>{t("admin.status")}</TableHead>
|
||||
<TableHead className="text-right">{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
{t("admin.orders.none")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
orders.map((o) => {
|
||||
const cfg = STATUS_CONFIG[o.status] ?? { label: o.status, variant: "outline" as const };
|
||||
const nextStatuses = NEXT_STATUSES[o.status] ?? [];
|
||||
return (
|
||||
<TableRow key={o.id}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
#{o.id.slice(0, 8).toUpperCase()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium text-sm">{o.client_name ?? "—"}</div>
|
||||
<div className="text-xs text-muted-foreground">{o.client_email ?? ""}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{new Date(o.created_at).toLocaleDateString(locale, { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">{o.total_amount.toFixed(2)} €</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={cfg.variant}>{cfg.label}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{nextStatuses.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={updating === o.id}>
|
||||
{t("admin.orders.update_btn")} <ChevronDown className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{nextStatuses.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => handleStatus(o.id, s)}>
|
||||
{STATUS_CONFIG[s]?.label ?? s}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,29 +2,26 @@
|
||||
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AdminSidebar } from "@/components/admin/AdminSidebar";
|
||||
import { useAdmin } from "@/contexts/AdminContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { getToken } from "@/lib/api";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const { isAdmin } = useAdmin();
|
||||
const { isAdmin, isLoading } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const isLoginRoute = pathname === "/admin/login";
|
||||
const hasToken = typeof window !== "undefined" && !!getToken();
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!isAdmin && !isLoginRoute) {
|
||||
// router.push("/admin/login");
|
||||
// }
|
||||
// }, [isAdmin, isLoginRoute, router]);
|
||||
|
||||
// if (!isAdmin && !isLoginRoute) {
|
||||
// return (
|
||||
// <div className="min-h-screen flex items-center justify-center bg-muted/30">
|
||||
// <div className="text-center">Chargement...</div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAdmin && !isLoginRoute) {
|
||||
router.push("/admin/login");
|
||||
}
|
||||
}, [isAdmin, isLoading, isLoginRoute, router]);
|
||||
|
||||
if (isLoginRoute) {
|
||||
return (
|
||||
@@ -36,17 +33,33 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
);
|
||||
}
|
||||
|
||||
// Still loading auth state, or token exists but isAdmin not yet committed (post-login race)
|
||||
if (isLoading || (!isAdmin && hasToken)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted/30">
|
||||
<div className="text-sm text-muted-foreground">{t("admin.loading")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Layout pour l'admin connecté (avec la sidebar)
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen flex w-full bg-muted/30">
|
||||
<AdminSidebar />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header className="h-14 flex items-center border-b border-border bg-background px-4 sticky top-0 z-10">
|
||||
<SidebarTrigger />
|
||||
<h1 className="ml-4 text-sm font-medium text-muted-foreground">
|
||||
Tableau de bord
|
||||
</h1>
|
||||
<header className="h-14 flex items-center justify-between border-b border-border bg-background px-4 sticky top-0 z-10">
|
||||
<div className="flex items-center">
|
||||
<SidebarTrigger />
|
||||
<h1 className="ml-4 text-sm font-medium text-muted-foreground">
|
||||
{t("admin.header")}
|
||||
</h1>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</header>
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
|
||||
@@ -2,33 +2,39 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Lock } from "lucide-react";
|
||||
import { useAdmin } from "@/contexts/AdminContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AdminLogin() {
|
||||
const { login, isAdmin } = useAdmin();
|
||||
const route = useRouter();
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
const ok = login(password);
|
||||
setLoading(false);
|
||||
if (ok) {
|
||||
toast.success("Connexion réussie");
|
||||
route.push("/admin");
|
||||
} else {
|
||||
toast.error("Mot de passe incorrect");
|
||||
try {
|
||||
const profile = await login(email, password);
|
||||
if (profile.role !== "admin") {
|
||||
toast.error("Accès refusé : rôle administrateur requis");
|
||||
return;
|
||||
}
|
||||
}, 300);
|
||||
toast.success("Connexion réussie");
|
||||
router.push("/admin");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : "Identifiants incorrects";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -43,6 +49,17 @@ export default function AdminLogin() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Input
|
||||
@@ -52,9 +69,7 @@ export default function AdminLogin() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Démo : <span className="font-mono">admin123</span></p>
|
||||
</div>
|
||||
<Button type="submit" className="w-full p-6" disabled={loading}>
|
||||
{loading ? "Connexion..." : "Se connecter"}
|
||||
@@ -64,4 +79,4 @@ export default function AdminLogin() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,59 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { Package, CalendarCheck, Clock, TrendingUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Package, CalendarCheck, Clock, TrendingUp, ShoppingBag, Users, AlertTriangle, Euro } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAdmin } from "@/contexts/AdminContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { getDashboardStats, DashboardStats } from "@/lib/api/admin";
|
||||
|
||||
export default function AdminOverview() {
|
||||
const { products, reservations } = useAdmin();
|
||||
const { reservations } = useAdmin();
|
||||
const { t, locale } = useLanguage();
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
|
||||
const pending = reservations.filter((r) => r.status === "pending").length;
|
||||
const confirmed = reservations.filter((r) => r.status === "confirmed").length;
|
||||
const totalValue = products.reduce((sum, p) => sum + p.price, 0);
|
||||
|
||||
const stats = [
|
||||
{ label: "Produits", value: products.length, icon: Package, color: "text-blue-600", bg: "bg-blue-100" },
|
||||
{ label: "RDV en attente", value: pending, icon: Clock, color: "text-amber-600", bg: "bg-amber-100" },
|
||||
{ label: "RDV confirmés", value: confirmed, icon: CalendarCheck, color: "text-green-600", bg: "bg-green-100" },
|
||||
{ label: "Valeur catalogue", value: `${totalValue} €`, icon: TrendingUp, color: "text-primary", bg: "bg-primary/10" },
|
||||
];
|
||||
useEffect(() => {
|
||||
getDashboardStats().then(setStats).catch((e) => console.error("[admin] getDashboardStats failed:", e));
|
||||
}, []);
|
||||
|
||||
const upcoming = [...reservations]
|
||||
.filter((r) => r.status !== "cancelled")
|
||||
.sort((a, b) => `${a.date}${a.time}`.localeCompare(`${b.date}${b.time}`))
|
||||
.slice(0, 5);
|
||||
|
||||
const mainCards = stats
|
||||
? [
|
||||
{ label: t("admin.overview.orders_pending"), value: stats.orders_pending, icon: ShoppingBag, color: "text-orange-600", bg: "bg-orange-100" },
|
||||
{ label: t("admin.overview.bookings_pending"), value: stats.bookings_pending, icon: Clock, color: "text-amber-600", bg: "bg-amber-100" },
|
||||
{ label: t("admin.overview.bookings_confirmed"),value: stats.bookings_confirmed,icon: CalendarCheck,color: "text-green-600", bg: "bg-green-100" },
|
||||
{ label: t("admin.overview.products_count"), value: stats.products_count, icon: Package, color: "text-blue-600", bg: "bg-blue-100" },
|
||||
]
|
||||
: [];
|
||||
|
||||
const revenueCards = stats
|
||||
? [
|
||||
{ label: t("admin.overview.revenue_today"), value: `${stats.revenue_today.toFixed(2)} €`, icon: Euro, color: "text-emerald-600", bg: "bg-emerald-100" },
|
||||
{ label: t("admin.overview.revenue_week"), value: `${stats.revenue_week.toFixed(2)} €`, icon: TrendingUp, color: "text-emerald-600", bg: "bg-emerald-100" },
|
||||
{ label: t("admin.overview.revenue_month"), value: `${stats.revenue_month.toFixed(2)} €`, icon: TrendingUp, color: "text-primary", bg: "bg-primary/10" },
|
||||
{ label: t("admin.overview.new_customers"), value: stats.new_customers_month, icon: Users, color: "text-purple-600", bg: "bg-purple-100" },
|
||||
]
|
||||
: [];
|
||||
|
||||
const Skeleton = () => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-5">
|
||||
<div className="h-4 bg-muted animate-pulse rounded w-2/3 mb-3" />
|
||||
<div className="h-7 bg-muted animate-pulse rounded w-1/3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const StatGrid = ({ cards }: { cards: typeof mainCards }) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{cards.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold mt-1">{s.value}</p>
|
||||
</div>
|
||||
<div className={`h-10 w-10 rounded-full ${s.bg} flex items-center justify-center`}>
|
||||
<s.icon className={`h-5 w-5 ${s.color}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold">Vue d'ensemble</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">Aperçu de votre activité</p>
|
||||
<h2 className="font-serif text-2xl font-semibold">{t("admin.overview.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("admin.overview.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||
<p className="text-2xl font-semibold mt-1">{s.value}</p>
|
||||
</div>
|
||||
<div className={`h-10 w-10 rounded-full ${s.bg} flex items-center justify-center`}>
|
||||
<s.icon className={`h-5 w-5 ${s.color}`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">{t("admin.overview.activity_section")}</h3>
|
||||
{stats === null ? <Skeleton /> : <StatGrid cards={mainCards} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">{t("admin.overview.revenue_section")}</h3>
|
||||
{stats === null ? <Skeleton /> : <StatGrid cards={revenueCards} />}
|
||||
</div>
|
||||
|
||||
{stats && stats.low_stock_count > 0 && (
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 flex-shrink-0" />
|
||||
<p className="text-sm text-amber-800">
|
||||
{t("admin.overview.low_stock", { n: stats.low_stock_count })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Prochains rendez-vous</CardTitle>
|
||||
<CardTitle className="text-base">{t("admin.overview.upcoming_title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucun rendez-vous à venir.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("admin.overview.no_upcoming")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcoming.map((r) => (
|
||||
@@ -63,9 +115,11 @@ export default function AdminOverview() {
|
||||
<p className="text-xs text-muted-foreground">{r.service}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{new Date(r.date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short" })} à {r.time}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(r.date).toLocaleDateString(locale, { day: "2-digit", month: "short" })} à {r.time}
|
||||
</p>
|
||||
<Badge variant={r.status === "confirmed" ? "default" : "secondary"} className="mt-1 text-xs">
|
||||
{r.status === "confirmed" ? "Confirmé" : "En attente"}
|
||||
{r.status === "confirmed" ? t("admin.status.confirmed") : t("admin.status.pending")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,4 +130,4 @@ export default function AdminOverview() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
143
app/admin/parametres/page.tsx
Normal file
143
app/admin/parametres/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { adminGetSettings, adminUpdateSetting, StoreSetting } from "@/lib/api/settings";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
export default function AdminParametres() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [settings, setSettings] = useState<StoreSetting[]>([]);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const SETTING_META: Record<string, { label: string; description: string; type: "number" | "text" }> = {
|
||||
default_booking_price: {
|
||||
label: t("booking.free") === "Free" ? "Default booking price (€)" : t("booking.free") === "Kostenlos" ? "Standardpreis für Reservierungen (€)" : "Prix de réservation par défaut (€)",
|
||||
description: t("booking.free") === "Free" ? "Price applied to each appointment (0 = free)" : t("booking.free") === "Kostenlos" ? "Preis pro Termin (0 = kostenlos)" : "Prix appliqué à chaque rendez-vous (0 = gratuit)",
|
||||
type: "number",
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
adminGetSettings()
|
||||
.then((rows) => {
|
||||
setSettings(rows);
|
||||
const init: Record<string, string> = {};
|
||||
rows.forEach((r) => {
|
||||
init[r.key] = r.value !== null && r.value !== undefined ? String(r.value) : "";
|
||||
});
|
||||
setValues(init);
|
||||
})
|
||||
.catch((e) => toast.error(e instanceof ApiError ? e.message : t("admin.error")))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (key: string) => {
|
||||
setSaving(key);
|
||||
try {
|
||||
const meta = SETTING_META[key];
|
||||
const raw = values[key] ?? "";
|
||||
const value = meta?.type === "number" ? parseFloat(raw) : raw;
|
||||
await adminUpdateSetting(key, value);
|
||||
toast.success(t("admin.settings.saved"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const knownKeys = settings.filter((s) => SETTING_META[s.key]);
|
||||
const unknownKeys = settings.filter((s) => !SETTING_META[s.key]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold">{t("admin.settings.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("admin.settings.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-20 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("admin.settings.bookings")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{knownKeys.map((s) => {
|
||||
const meta = SETTING_META[s.key];
|
||||
return (
|
||||
<div key={s.key}>
|
||||
<Label htmlFor={s.key}>{meta.label}</Label>
|
||||
<p className="text-xs text-muted-foreground mb-2">{meta.description}</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={s.key}
|
||||
type={meta.type}
|
||||
value={values[s.key] ?? ""}
|
||||
onChange={(e) => setValues((prev) => ({ ...prev, [s.key]: e.target.value }))}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleSave(s.key)} disabled={saving === s.key}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving === s.key ? t("admin.saving") : t("admin.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{s.updated_at && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.settings.last_updated")} {new Date(s.updated_at).toLocaleDateString(locale)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{knownKeys.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("admin.settings.no_settings")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{unknownKeys.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("admin.settings.other")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{unknownKeys.map((s) => (
|
||||
<div key={s.key}>
|
||||
<Label htmlFor={`u-${s.key}`}>{s.key}</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
id={`u-${s.key}`}
|
||||
value={values[s.key] ?? ""}
|
||||
onChange={(e) => setValues((prev) => ({ ...prev, [s.key]: e.target.value }))}
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleSave(s.key)} disabled={saving === s.key}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{saving === s.key ? t("admin.saving") : t("admin.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
574
app/admin/planning/page.tsx
Normal file
574
app/admin/planning/page.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } 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 { ChevronLeft, ChevronRight, Plus, Trash2, RefreshCw, Ban, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
adminGetSchedule, adminCreateSchedule, adminDeleteSchedule,
|
||||
adminListSlots, adminGenerateSlots, adminUpdateSlot, adminDeleteSlot,
|
||||
adminGetBlockedDates, adminAddBlockedDate, adminRemoveBlockedDate,
|
||||
WeeklySchedule, BlockedDate, TimeSlotApi,
|
||||
} from "@/lib/api/bookings";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
|
||||
const DURATIONS = [30, 45, 60, 90, 120];
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function monthStart(year: number, month: number) {
|
||||
return new Date(year, month, 1);
|
||||
}
|
||||
|
||||
function daysInMonth(year: number, month: number) {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
// Monday-first weekday index (0=Mon, 6=Sun)
|
||||
function weekdayMon(date: Date) {
|
||||
return (date.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
// ── Schedule Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ScheduleTab() {
|
||||
const { t } = useLanguage();
|
||||
const DAY_NAMES = Array.from({ length: 7 }, (_, i) => t(`admin.day.${i}`));
|
||||
|
||||
const [schedule, setSchedule] = useState<WeeklySchedule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [form, setForm] = useState({
|
||||
day_of_week: 0,
|
||||
start_time: "09:00",
|
||||
end_time: "18:00",
|
||||
slot_duration_minutes: 60,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [genFrom, setGenFrom] = useState(toDateStr(new Date()));
|
||||
const [genTo, setGenTo] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30);
|
||||
return toDateStr(d);
|
||||
});
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminGetSchedule()
|
||||
.then(setSchedule)
|
||||
.catch(() => toast.error(t("admin.error")))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const addEntry = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const entry = await adminCreateSchedule({
|
||||
day_of_week: Number(form.day_of_week),
|
||||
start_time: form.start_time + ":00",
|
||||
end_time: form.end_time + ":00",
|
||||
slot_duration_minutes: Number(form.slot_duration_minutes),
|
||||
});
|
||||
setSchedule((prev) => [...prev, entry].sort((a, b) => a.day_of_week - b.day_of_week));
|
||||
toast.success(t("admin.planning.schedule_added"));
|
||||
} catch {
|
||||
toast.error(t("admin.error"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeEntry = async (id: string) => {
|
||||
try {
|
||||
await adminDeleteSchedule(id);
|
||||
setSchedule((prev) => prev.filter((e) => e.id !== id));
|
||||
toast.success(t("admin.planning.schedule_deleted"));
|
||||
} catch {
|
||||
toast.error(t("admin.error"));
|
||||
}
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const res = await adminGenerateSlots(genFrom, genTo);
|
||||
toast.success(t("admin.planning.generated", { n: res.created }));
|
||||
} catch {
|
||||
toast.error(t("admin.error"));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const grouped = DAY_NAMES.map((name, idx) => ({
|
||||
name,
|
||||
entries: schedule.filter((e) => e.day_of_week === idx),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("admin.planning.weekly_title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{grouped.map(({ name, entries }) => (
|
||||
<div key={name} className="flex items-start gap-3">
|
||||
<span className="w-24 text-sm font-medium pt-1 shrink-0">{name}</span>
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
{entries.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground pt-1">{t("admin.planning.not_available")}</span>
|
||||
) : (
|
||||
entries.map((e) => (
|
||||
<div key={e.id} className="flex items-center gap-1.5 bg-muted rounded px-2 py-1 text-xs">
|
||||
<span>{e.start_time.slice(0, 5)} – {e.end_time.slice(0, 5)}</span>
|
||||
<span className="text-muted-foreground">({e.slot_duration_minutes} min)</span>
|
||||
<button
|
||||
onClick={() => removeEntry(e.id)}
|
||||
className="text-muted-foreground hover:text-destructive ml-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("admin.planning.add_title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">{t("admin.planning.day")}</Label>
|
||||
<select
|
||||
className="w-full mt-1 text-sm border border-input rounded-md px-3 py-2 bg-background"
|
||||
value={form.day_of_week}
|
||||
onChange={(e) => setForm((f) => ({ ...f, day_of_week: Number(e.target.value) }))}
|
||||
>
|
||||
{DAY_NAMES.map((d, i) => (
|
||||
<option key={i} value={i}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{t("admin.planning.start")}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
className="mt-1"
|
||||
value={form.start_time}
|
||||
onChange={(e) => setForm((f) => ({ ...f, start_time: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{t("admin.planning.end")}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
className="mt-1"
|
||||
value={form.end_time}
|
||||
onChange={(e) => setForm((f) => ({ ...f, end_time: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{t("admin.planning.duration_min")}</Label>
|
||||
<select
|
||||
className="w-full mt-1 text-sm border border-input rounded-md px-3 py-2 bg-background"
|
||||
value={form.slot_duration_minutes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, slot_duration_minutes: Number(e.target.value) }))}
|
||||
>
|
||||
{DURATIONS.map((d) => (
|
||||
<option key={d} value={d}>{d} min</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={addEntry} disabled={saving} className="mt-3" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
{saving ? t("admin.planning.adding") : t("admin.add")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("admin.planning.generate_title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-3">{t("admin.planning.generate_desc")}</p>
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div>
|
||||
<Label className="text-xs">{t("admin.planning.from")}</Label>
|
||||
<Input type="date" className="mt-1 w-40" value={genFrom}
|
||||
onChange={(e) => setGenFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{t("admin.planning.to")}</Label>
|
||||
<Input type="date" className="mt-1 w-40" value={genTo}
|
||||
onChange={(e) => setGenTo(e.target.value)} />
|
||||
</div>
|
||||
<Button onClick={generate} disabled={generating} size="sm">
|
||||
<RefreshCw className={`h-4 w-4 mr-1.5 ${generating ? "animate-spin" : ""}`} />
|
||||
{generating ? t("admin.planning.generating") : t("admin.planning.generate_btn")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Calendar Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function CalendarTab() {
|
||||
const { t, locale } = useLanguage();
|
||||
const DAY_NAMES = Array.from({ length: 7 }, (_, i) => t(`admin.day.${i}`));
|
||||
const MONTH_NAMES = Array.from({ length: 12 }, (_, i) => t(`admin.month.${i}`));
|
||||
|
||||
const today = new Date();
|
||||
const [year, setYear] = useState(today.getFullYear());
|
||||
const [month, setMonth] = useState(today.getMonth());
|
||||
const [slots, setSlots] = useState<TimeSlotApi[]>([]);
|
||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||
const [selectedDay, setSelectedDay] = useState<string | null>(null);
|
||||
|
||||
const loadSlots = useCallback(() => {
|
||||
setLoadingSlots(true);
|
||||
const from = toDateStr(monthStart(year, month));
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const to = toDateStr(last);
|
||||
adminListSlots(from, to)
|
||||
.then(setSlots)
|
||||
.catch(() => toast.error(t("admin.error")))
|
||||
.finally(() => setLoadingSlots(false));
|
||||
}, [year, month]);
|
||||
|
||||
useEffect(() => { loadSlots(); }, [loadSlots]);
|
||||
|
||||
const prevMonth = () => {
|
||||
if (month === 0) { setYear((y) => y - 1); setMonth(11); }
|
||||
else setMonth((m) => m - 1);
|
||||
setSelectedDay(null);
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (month === 11) { setYear((y) => y + 1); setMonth(0); }
|
||||
else setMonth((m) => m + 1);
|
||||
setSelectedDay(null);
|
||||
};
|
||||
|
||||
const blockSlot = async (slot: TimeSlotApi) => {
|
||||
try {
|
||||
const updated = await adminUpdateSlot(slot.id, !slot.is_blocked);
|
||||
setSlots((prev) => prev.map((s) => s.id === slot.id ? updated : s));
|
||||
toast.success(slot.is_blocked ? t("admin.planning.slot_unblocked") : t("admin.planning.slot_blocked"));
|
||||
} catch {
|
||||
toast.error(t("admin.error"));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSlot = async (id: string) => {
|
||||
try {
|
||||
await adminDeleteSlot(id);
|
||||
setSlots((prev) => prev.filter((s) => s.id !== id));
|
||||
toast.success(t("admin.planning.slot_deleted"));
|
||||
} catch {
|
||||
toast.error(t("admin.error"));
|
||||
}
|
||||
};
|
||||
|
||||
const firstDay = monthStart(year, month);
|
||||
const totalDays = daysInMonth(year, month);
|
||||
const startOffset = weekdayMon(firstDay);
|
||||
const cells: (number | null)[] = [
|
||||
...Array(startOffset).fill(null),
|
||||
...Array.from({ length: totalDays }, (_, i) => i + 1),
|
||||
];
|
||||
while (cells.length % 7 !== 0) cells.push(null);
|
||||
|
||||
const slotsByDay: Record<string, TimeSlotApi[]> = {};
|
||||
slots.forEach((s) => {
|
||||
(slotsByDay[s.date] ||= []).push(s);
|
||||
});
|
||||
|
||||
const selectedSlots = selectedDay ? (slotsByDay[selectedDay] ?? []) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" size="icon" onClick={prevMonth}><ChevronLeft className="h-4 w-4" /></Button>
|
||||
<h3 className="font-medium">{MONTH_NAMES[month]} {year}</h3>
|
||||
<Button variant="outline" size="icon" onClick={nextMonth}><ChevronRight className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="grid grid-cols-7 gap-px">
|
||||
{DAY_NAMES.map((d) => (
|
||||
<div key={d} className="text-center text-xs font-medium text-muted-foreground py-2">
|
||||
{d.slice(0, 3)}
|
||||
</div>
|
||||
))}
|
||||
{cells.map((day, i) => {
|
||||
if (!day) return <div key={i} />;
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
const daySlots = slotsByDay[dateStr] ?? [];
|
||||
const available = daySlots.filter((s) => !s.is_blocked && !s.is_booked).length;
|
||||
const booked = daySlots.filter((s) => s.is_booked).length;
|
||||
const blocked = daySlots.filter((s) => s.is_blocked).length;
|
||||
const isToday = dateStr === toDateStr(today);
|
||||
const isSelected = dateStr === selectedDay;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedDay(isSelected ? null : dateStr)}
|
||||
className={`rounded-lg p-1.5 text-left transition-colors min-h-[60px] ${
|
||||
isSelected ? "bg-primary/10 ring-1 ring-primary" :
|
||||
isToday ? "bg-muted ring-1 ring-muted-foreground/30" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className={`text-xs font-medium mb-1 ${isToday ? "text-primary" : ""}`}>{day}</div>
|
||||
{daySlots.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{available > 0 && <div className="text-[10px] text-green-600">{available} {t("admin.status.free").toLowerCase()}</div>}
|
||||
{booked > 0 && <div className="text-[10px] text-blue-600">{booked} {t("admin.status.booked").toLowerCase()}</div>}
|
||||
{blocked > 0 && <div className="text-[10px] text-red-500">{blocked} {t("admin.status.blocked").toLowerCase()}</div>}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedDay && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
{t("admin.planning.slots_for")} {new Date(selectedDay + "T00:00:00").toLocaleDateString(locale, {
|
||||
weekday: "long", day: "numeric", month: "long"
|
||||
})}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingSlots ? (
|
||||
<div className="text-sm text-muted-foreground">{t("admin.loading")}</div>
|
||||
) : selectedSlots.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("admin.planning.no_slots")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedSlots
|
||||
.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
.map((slot) => (
|
||||
<div key={slot.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{slot.start_time.slice(0, 5)} – {slot.end_time.slice(0, 5)}</span>
|
||||
{slot.is_booked && <Badge variant="secondary" className="text-xs">{t("admin.status.booked")}</Badge>}
|
||||
{slot.is_blocked && <Badge variant="destructive" className="text-xs">{t("admin.status.blocked")}</Badge>}
|
||||
{!slot.is_booked && !slot.is_blocked && <Badge variant="outline" className="text-xs text-green-600 border-green-300">{t("admin.status.free")}</Badge>}
|
||||
</div>
|
||||
{!slot.is_booked && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-7 w-7"
|
||||
title={slot.is_blocked ? t("admin.planning.unblock_title") : t("admin.planning.block_btn")}
|
||||
onClick={() => blockSlot(slot)}
|
||||
>
|
||||
{slot.is_blocked
|
||||
? <Check className="h-3.5 w-3.5 text-green-600" />
|
||||
: <Ban className="h-3.5 w-3.5 text-amber-500" />
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-7 w-7"
|
||||
title={t("admin.delete")}
|
||||
onClick={() => deleteSlot(slot.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Blocked Dates Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
function BlockedDatesTab() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [dates, setDates] = useState<BlockedDate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newDate, setNewDate] = useState(toDateStr(new Date()));
|
||||
const [newReason, setNewReason] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminGetBlockedDates()
|
||||
.then(setDates)
|
||||
.catch(() => toast.error(t("admin.error")))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const add = async () => {
|
||||
setAdding(true);
|
||||
try {
|
||||
const entry = await adminAddBlockedDate(newDate, newReason || undefined);
|
||||
setDates((prev) => [...prev, entry].sort((a, b) => a.date.localeCompare(b.date)));
|
||||
setNewReason("");
|
||||
toast.success(t("admin.planning.date_blocked"));
|
||||
} catch {
|
||||
toast.error(t("admin.error"));
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await adminRemoveBlockedDate(id);
|
||||
setDates((prev) => prev.filter((d) => d.id !== id));
|
||||
toast.success(t("admin.planning.date_unblocked"));
|
||||
} catch {
|
||||
toast.error(t("admin.error"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("admin.planning.block_title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-3">{t("admin.planning.block_desc")}</p>
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div>
|
||||
<Label className="text-xs">{t("admin.planning.date_lbl")}</Label>
|
||||
<Input type="date" className="mt-1 w-44" value={newDate}
|
||||
onChange={(e) => setNewDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{t("admin.planning.reason")}</Label>
|
||||
<Input className="mt-1 w-56" placeholder={t("admin.planning.reason_ph")} value={newReason}
|
||||
onChange={(e) => setNewReason(e.target.value)} />
|
||||
</div>
|
||||
<Button onClick={add} disabled={adding} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
{adding ? t("admin.planning.blocking") : t("admin.planning.block_btn")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("admin.planning.blocked_list")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-10 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : dates.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("admin.planning.no_blocked")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{dates.map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
{new Date(d.date + "T00:00:00").toLocaleDateString(locale, {
|
||||
weekday: "long", day: "numeric", month: "long", year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
{d.reason && <span className="text-xs text-muted-foreground ml-2">— {d.reason}</span>}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => remove(d.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = "schedule" | "calendar" | "blocked";
|
||||
|
||||
export default function PlanningPage() {
|
||||
const { t } = useLanguage();
|
||||
const [tab, setTab] = useState<Tab>("schedule");
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "schedule", label: t("admin.planning.tab_schedule") },
|
||||
{ id: "calendar", label: t("admin.planning.tab_calendar") },
|
||||
{ id: "blocked", label: t("admin.planning.tab_blocked") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold">{t("admin.planning.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("admin.planning.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-lg w-fit">
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setTab(item.id)}
|
||||
className={`px-4 py-1.5 text-sm rounded-md transition-colors ${
|
||||
tab === item.id
|
||||
? "bg-background shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "schedule" && <ScheduleTab />}
|
||||
{tab === "calendar" && <CalendarTab />}
|
||||
{tab === "blocked" && <BlockedDatesTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { Plus, Pencil, Trash2, Upload, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -9,39 +9,22 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAdmin } from "@/contexts/AdminContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { adminUploadProductImage } from "@/lib/api/products";
|
||||
import { Product } from "@/data/products";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -49,7 +32,7 @@ type FormState = {
|
||||
name: string;
|
||||
category: Product["category"];
|
||||
price: string;
|
||||
image: string;
|
||||
original_price: string;
|
||||
description: string;
|
||||
colors: string;
|
||||
lengths: string;
|
||||
@@ -61,7 +44,7 @@ const emptyForm: FormState = {
|
||||
name: "",
|
||||
category: "clip-in",
|
||||
price: "",
|
||||
image: "",
|
||||
original_price: "",
|
||||
description: "",
|
||||
colors: "",
|
||||
lengths: "",
|
||||
@@ -70,15 +53,29 @@ const emptyForm: FormState = {
|
||||
};
|
||||
|
||||
export default function AdminProducts() {
|
||||
const { products, addProduct, updateProduct, deleteProduct } = useAdmin();
|
||||
const { products, productsLoading, addProduct, updateProduct, deleteProduct, refreshProducts } = useAdmin();
|
||||
const { t } = useLanguage();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const categoryLabels: Record<Product["category"], string> = {
|
||||
"clip-in": "Clip-In",
|
||||
"tape-in": "Tape-In",
|
||||
"ponytail": "Ponytail",
|
||||
"keratin": t("admin.products.category") === "Kategorie" ? "Keratin" : t("admin.products.category") === "Category" ? "Keratin" : "Kératine",
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -88,21 +85,36 @@ export default function AdminProducts() {
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
price: String(p.price),
|
||||
image: p.image,
|
||||
original_price: p.originalPrice ? String(p.originalPrice) : "",
|
||||
description: p.description,
|
||||
colors: p.colors.join(", "),
|
||||
lengths: p.lengths.join(", "),
|
||||
isNew: !!p.isNew,
|
||||
isBestseller: !!p.isBestseller,
|
||||
});
|
||||
setImageFile(null);
|
||||
setImagePreview(p.image || null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImageFile(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const clearImage = () => {
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const price = parseFloat(form.price);
|
||||
if (!form.name || isNaN(price)) {
|
||||
toast.error("Nom et prix valides requis");
|
||||
toast.error(t("admin.products.valid_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,109 +122,131 @@ export default function AdminProducts() {
|
||||
name: form.name,
|
||||
category: form.category,
|
||||
price,
|
||||
image: form.image || "https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop",
|
||||
images: [form.image || "https://images.unsplash.com/photo-1522337360788-8b13dee7a37e?w=600&h=800&fit=crop"],
|
||||
original_price: form.original_price ? parseFloat(form.original_price) : undefined,
|
||||
colors: form.colors.split(",").map((c) => c.trim()).filter(Boolean),
|
||||
lengths: form.lengths.split(",").map((l) => l.trim()).filter(Boolean),
|
||||
description: form.description,
|
||||
features: [],
|
||||
isNew: form.isNew,
|
||||
isBestseller: form.isBestseller,
|
||||
rating: 5,
|
||||
reviewCount: 0,
|
||||
is_new: form.isNew,
|
||||
is_bestseller: form.isBestseller,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
updateProduct(editingId, payload);
|
||||
toast.success("Produit modifié");
|
||||
} else {
|
||||
addProduct(payload);
|
||||
toast.success("Produit ajouté");
|
||||
setSaving(true);
|
||||
try {
|
||||
let savedId = editingId;
|
||||
if (editingId) {
|
||||
await updateProduct(editingId, payload);
|
||||
} else {
|
||||
const created = await addProduct(payload);
|
||||
savedId = created.id;
|
||||
}
|
||||
if (imageFile && savedId) {
|
||||
await adminUploadProductImage(savedId, imageFile);
|
||||
await refreshProducts();
|
||||
}
|
||||
toast.success(editingId ? t("admin.products.saved") : t("admin.products.added"));
|
||||
setDialogOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.products.save_error"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteId) {
|
||||
deleteProduct(deleteId);
|
||||
toast.success("Produit supprimé");
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
try {
|
||||
await deleteProduct(deleteId);
|
||||
toast.success(t("admin.products.deleted"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.products.delete_error"));
|
||||
} finally {
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const categoryLabels: Record<Product["category"], string> = {
|
||||
"clip-in": "Clip-In",
|
||||
"tape-in": "Tape-In",
|
||||
"ponytail": "Ponytail",
|
||||
"keratin": "Kératine",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold">Produits</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{products.length} produit{products.length > 1 ? "s" : ""} au catalogue</p>
|
||||
<h2 className="font-serif text-2xl font-semibold">{t("admin.products.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{products.length} {t("admin.products.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Ajouter
|
||||
{t("admin.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Image</TableHead>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Catégorie</TableHead>
|
||||
<TableHead>Prix</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>
|
||||
<img src={p.image} alt={p.name} className="h-12 w-12 rounded object-cover" />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{categoryLabels[p.category]}</TableCell>
|
||||
<TableCell>{p.price} €</TableCell>
|
||||
<TableCell className="space-x-1">
|
||||
{p.isNew && <Badge variant="secondary">Nouveau</Badge>}
|
||||
{p.isBestseller && <Badge>Bestseller</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(p)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteId(p.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{productsLoading ? (
|
||||
<div className="p-8 space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">{t("admin.products.col_image")}</TableHead>
|
||||
<TableHead>{t("admin.products.col_name")}</TableHead>
|
||||
<TableHead>{t("admin.products.col_category")}</TableHead>
|
||||
<TableHead>{t("admin.products.col_price")}</TableHead>
|
||||
<TableHead>{t("admin.products.col_status")}</TableHead>
|
||||
<TableHead className="text-right">{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>
|
||||
{p.image ? (
|
||||
<img src={p.image} alt={p.name} className="h-12 w-12 rounded object-cover" />
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded bg-muted flex items-center justify-center">
|
||||
<Upload className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{categoryLabels[p.category]}</TableCell>
|
||||
<TableCell>{p.price} €</TableCell>
|
||||
<TableCell className="space-x-1">
|
||||
{p.isNew && <Badge variant="secondary">{t("admin.products.badge_new")}</Badge>}
|
||||
{p.isBestseller && <Badge>Bestseller</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEdit(p)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteId(p.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="lg:min-w-2xl max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "Modifier le produit" : "Nouveau produit"}</DialogTitle>
|
||||
<DialogDescription>Renseignez les informations du produit</DialogDescription>
|
||||
<DialogTitle>{editingId ? t("admin.products.edit_title") : t("admin.products.create_title")}</DialogTitle>
|
||||
<DialogDescription>{t("admin.products.form_desc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="name">Nom</Label>
|
||||
<Label htmlFor="name">{t("admin.products.name")}</Label>
|
||||
<Input id="name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Catégorie</Label>
|
||||
<Label>{t("admin.products.category")}</Label>
|
||||
<Select value={form.category} onValueChange={(v) => setForm({ ...form, category: v as Product["category"] })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -224,37 +258,60 @@ export default function AdminProducts() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">Prix (€)</Label>
|
||||
<Label htmlFor="price">{t("admin.products.price")}</Label>
|
||||
<Input id="price" type="number" step="0.01" value={form.price} onChange={(e) => setForm({ ...form, price: e.target.value })} required />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="image">URL de l'image</Label>
|
||||
<Input id="image" value={form.image} onChange={(e) => setForm({ ...form, image: e.target.value })} placeholder="https://..." />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="original_price">{t("admin.products.original_price")}</Label>
|
||||
<Input id="original_price" type="number" step="0.01" value={form.original_price} onChange={(e) => setForm({ ...form, original_price: e.target.value })} placeholder={t("admin.products.optional")} />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Label>{t("admin.products.image")}</Label>
|
||||
<div className="flex items-start gap-4">
|
||||
{imagePreview && (
|
||||
<div className="relative shrink-0">
|
||||
<img src={imagePreview} alt="preview" className="h-24 w-24 rounded object-cover border border-border" />
|
||||
{imageFile && (
|
||||
<button type="button" onClick={clearImage} className="absolute -top-2 -right-2 bg-destructive text-destructive-foreground rounded-full p-0.5">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
{imagePreview ? t("admin.products.change_image") : t("admin.products.choose_image")}
|
||||
</Button>
|
||||
{imageFile && <p className="text-xs text-muted-foreground mt-1.5">{imageFile.name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="description">{t("admin.products.description")}</Label>
|
||||
<Textarea id="description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={3} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="colors">Couleurs (séparées par des virgules)</Label>
|
||||
<Label htmlFor="colors">{t("admin.products.colors")}</Label>
|
||||
<Input id="colors" value={form.colors} onChange={(e) => setForm({ ...form, colors: e.target.value })} placeholder="Brun, Blond, Noir" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lengths">Longueurs (séparées par des virgules)</Label>
|
||||
<Label htmlFor="lengths">{t("admin.products.lengths")}</Label>
|
||||
<Input id="lengths" value={form.lengths} onChange={(e) => setForm({ ...form, lengths: e.target.value })} placeholder="40 cm, 50 cm" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.isNew} onChange={(e) => setForm({ ...form, isNew: e.target.checked })} />
|
||||
Marquer comme Nouveau
|
||||
{t("admin.products.mark_new")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={form.isBestseller} onChange={(e) => setForm({ ...form, isBestseller: e.target.checked })} />
|
||||
Marquer comme Bestseller
|
||||
{t("admin.products.mark_bestseller")}
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>Annuler</Button>
|
||||
<Button type="submit">{editingId ? "Enregistrer" : "Créer"}</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>{t("admin.cancel")}</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? t("admin.saving") : editingId ? t("admin.save") : t("admin.create")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
@@ -263,15 +320,15 @@ export default function AdminProducts() {
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Supprimer ce produit ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Cette action est irréversible.</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t("admin.products.delete_title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("admin.irreversible")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Supprimer</AlertDialogAction>
|
||||
<AlertDialogCancel>{t("admin.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>{t("admin.delete")}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,60 +6,60 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useAdmin, Reservation } from "@/contexts/AdminContext";
|
||||
import { useLanguage } from "@/contexts/LanguageContext";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const statusConfig: Record<Reservation["status"], { label: string; variant: "default" | "secondary" | "destructive" }> = {
|
||||
pending: { label: "En attente", variant: "secondary" },
|
||||
confirmed: { label: "Confirmé", variant: "default" },
|
||||
cancelled: { label: "Annulé", variant: "destructive" },
|
||||
};
|
||||
|
||||
export default function AdminReservations() {
|
||||
const { reservations, updateReservationStatus, deleteReservation } = useAdmin();
|
||||
const { reservations, reservationsLoading, updateReservationStatus, deleteReservation } = useAdmin();
|
||||
const { t, locale } = useLanguage();
|
||||
const [filter, setFilter] = useState<"all" | Reservation["status"]>("all");
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const statusConfig: Record<Reservation["status"], { label: string; variant: "default" | "secondary" | "destructive" }> = {
|
||||
pending: { label: t("admin.status.pending"), variant: "secondary" },
|
||||
confirmed: { label: t("admin.status.confirmed"), variant: "default" },
|
||||
cancelled: { label: t("admin.status.cancelled"), variant: "destructive" },
|
||||
};
|
||||
|
||||
const filtered = reservations
|
||||
.filter((r) => filter === "all" || r.status === filter)
|
||||
.sort((a, b) => `${b.date}${b.time}`.localeCompare(`${a.date}${a.time}`));
|
||||
|
||||
const handleConfirm = (id: string) => {
|
||||
updateReservationStatus(id, "confirmed");
|
||||
toast.success("Réservation confirmée");
|
||||
const handleConfirm = async (id: string) => {
|
||||
try {
|
||||
await updateReservationStatus(id, "confirmed");
|
||||
toast.success(t("admin.bookings.confirmed_toast"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = (id: string) => {
|
||||
updateReservationStatus(id, "cancelled");
|
||||
toast.success("Réservation annulée");
|
||||
const handleCancel = async (id: string) => {
|
||||
try {
|
||||
await updateReservationStatus(id, "cancelled");
|
||||
toast.success(t("admin.bookings.cancelled_toast"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteId) {
|
||||
deleteReservation(deleteId);
|
||||
toast.success("Réservation supprimée");
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
try {
|
||||
await deleteReservation(deleteId);
|
||||
toast.success(t("admin.bookings.deleted_toast"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : t("admin.error"));
|
||||
} finally {
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
@@ -67,96 +67,108 @@ export default function AdminReservations() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-serif text-2xl font-semibold">Réservations</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{reservations.length} réservation{reservations.length > 1 ? "s" : ""} au total</p>
|
||||
<h2 className="font-serif text-2xl font-semibold">{t("admin.bookings.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{reservations.length} {t("admin.bookings.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={filter} onValueChange={(v) => setFilter(v as typeof filter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Toutes</TabsTrigger>
|
||||
<TabsTrigger value="pending">En attente</TabsTrigger>
|
||||
<TabsTrigger value="confirmed">Confirmées</TabsTrigger>
|
||||
<TabsTrigger value="cancelled">Annulées</TabsTrigger>
|
||||
<TabsTrigger value="all">{t("admin.bookings.tab_all")}</TabsTrigger>
|
||||
<TabsTrigger value="pending">{t("admin.bookings.tab_pending")}</TabsTrigger>
|
||||
<TabsTrigger value="confirmed">{t("admin.bookings.tab_confirmed")}</TabsTrigger>
|
||||
<TabsTrigger value="cancelled">{t("admin.bookings.tab_cancelled")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Cliente</TableHead>
|
||||
<TableHead>Contact</TableHead>
|
||||
<TableHead>Service</TableHead>
|
||||
<TableHead>Date & Heure</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
{reservationsLoading ? (
|
||||
<div className="p-8 space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
Aucune réservation
|
||||
</TableCell>
|
||||
<TableHead>{t("admin.bookings.col_client")}</TableHead>
|
||||
<TableHead>{t("admin.bookings.col_contact")}</TableHead>
|
||||
<TableHead>{t("admin.bookings.col_service")}</TableHead>
|
||||
<TableHead>{t("admin.bookings.col_datetime")}</TableHead>
|
||||
<TableHead>{t("admin.status")}</TableHead>
|
||||
<TableHead className="text-right">{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.clientName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Mail className="h-3 w-3" /> {r.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Phone className="h-3 w-3" /> {r.phone}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{r.service}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
{new Date(r.date).toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{r.time}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusConfig[r.status].variant}>{statusConfig[r.status].label}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-1">
|
||||
{r.status !== "confirmed" && (
|
||||
<Button variant="ghost" size="icon" onClick={() => handleConfirm(r.id)} title="Confirmer">
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
</Button>
|
||||
)}
|
||||
{r.status !== "cancelled" && (
|
||||
<Button variant="ghost" size="icon" onClick={() => handleCancel(r.id)} title="Annuler">
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteId(r.id)} title="Supprimer">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
{t("admin.bookings.none")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-medium">{r.clientName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Mail className="h-3 w-3" /> {r.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Phone className="h-3 w-3" /> {r.phone}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{r.service}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
{new Date(r.date).toLocaleDateString(locale, { day: "2-digit", month: "short", year: "numeric" })}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{r.time}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusConfig[r.status].variant}>
|
||||
{statusConfig[r.status].label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-1">
|
||||
{r.status !== "confirmed" && (
|
||||
<Button variant="ghost" size="icon" onClick={() => handleConfirm(r.id)} title={t("admin.status.confirmed")}>
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
</Button>
|
||||
)}
|
||||
{r.status !== "cancelled" && (
|
||||
<Button variant="ghost" size="icon" onClick={() => handleCancel(r.id)} title={t("admin.status.cancelled")}>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteId(r.id)} title={t("admin.delete")}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Supprimer cette réservation ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Cette action est irréversible.</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t("admin.bookings.delete_title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("admin.irreversible")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>Supprimer</AlertDialogAction>
|
||||
<AlertDialogCancel>{t("admin.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>{t("admin.delete")}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
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