Update May 12 by Elvis

This commit is contained in:
belviskhoremk
2026-05-12 00:28:37 +00:00
parent b32a70cd0e
commit c4450c993b
37 changed files with 3749 additions and 600 deletions

153
app/admin/clients/page.tsx Normal file
View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};
}

View 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
View 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>
);
}

View File

@@ -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>
);
};
}

View File

@@ -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
View 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>
);
}