Add slots based per class, admin courses mgmt, admin roles, horizontal nav/landing, glove favicon
This commit is contained in:
@@ -5,13 +5,14 @@ import { usePathname } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
|
||||
const adminLinks = [
|
||||
{ href: "/admin", label: "Vue d'ensemble", icon: "🏠" },
|
||||
{ href: "/admin", label: "Accueil", icon: "🏠" },
|
||||
{ href: "/admin/users", label: "Membres", icon: "👥" },
|
||||
{ href: "/admin/messages", label: "Messages", icon: "💬" },
|
||||
{ href: "/admin/media", label: "Photos & vidéos", icon: "🎬" },
|
||||
{ href: "/admin/media", label: "Médias", icon: "🎬" },
|
||||
{ href: "/admin/classes", label: "Cours", icon: "📅" },
|
||||
{ href: "/admin/homework", label: "Exercices", icon: "🏋️" },
|
||||
{ href: "/admin/certificates", label: "Certificats", icon: "📄" },
|
||||
{ href: "/admin/yoga", label: "Gestion Yoga", icon: "🧘" },
|
||||
{ href: "/admin/yoga", label: "Yoga", icon: "🧘" },
|
||||
];
|
||||
|
||||
export default function AdminLayout({
|
||||
@@ -32,7 +33,7 @@ export default function AdminLayout({
|
||||
Gère ton club JuanitoSport
|
||||
</p>
|
||||
|
||||
<nav className="mb-6 flex gap-2 overflow-x-auto pb-2 -mx-4 px-4">
|
||||
<nav className="mb-6 grid grid-cols-4 gap-1.5 sm:gap-2">
|
||||
{adminLinks.map((link) => {
|
||||
const active =
|
||||
pathname === link.href ||
|
||||
@@ -41,14 +42,14 @@ export default function AdminLayout({
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
className={`flex flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-2.5 text-center text-[11px] font-semibold transition-colors sm:flex-row sm:gap-1.5 sm:px-3 sm:py-2 sm:text-xs ${
|
||||
active
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-white/5 text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
<span>{link.label}</span>
|
||||
<span className="text-base leading-none sm:text-lg">{link.icon}</span>
|
||||
<span className="leading-tight">{link.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const SPORT_LABEL: Record<string, { icon: string; name: string }> = {
|
||||
"muay-thai": { icon: "🥊", name: "Muay Thai" },
|
||||
yoga: { icon: "🧘", name: "Yoga" },
|
||||
};
|
||||
|
||||
export default function AdminClasses({ slots }: { slots: any[] }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold">📅 Cours & places disponibles</h2>
|
||||
<p className="text-sm text-white/60">
|
||||
Renseigne le nombre de places pour chaque cours. Quand les places sont
|
||||
épuisées, le cours s'affiche « COMPLET » sur le site.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{slots.map((slot) => (
|
||||
<SlotCard key={slot.id} slot={slot} onUpdate={() => router.refresh()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotCard({ slot, onUpdate }: { slot: any; onUpdate: () => void }) {
|
||||
const sport = SPORT_LABEL[slot.sport] || { icon: "🏅", name: slot.sport };
|
||||
const [capacity, setCapacity] = useState(String(slot.capacity));
|
||||
const [booked, setBooked] = useState(String(slot.booked));
|
||||
const [isFull, setIsFull] = useState(slot.isFull);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const capacityNum = Math.max(0, parseInt(capacity) || 0);
|
||||
const bookedNum = Math.max(0, parseInt(booked) || 0);
|
||||
const remaining = Math.max(0, capacityNum - bookedNum);
|
||||
const full = isFull || remaining === 0;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
const res = await fetch("/api/admin/classes", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: slot.id, capacity, booked, isFull }),
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.ok) onUpdate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{sport.icon}</span>
|
||||
<div>
|
||||
<p className="font-bold">
|
||||
{sport.name} — {slot.label}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">{slot.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-xs font-bold ${
|
||||
full
|
||||
? "bg-red-500/20 text-red-300"
|
||||
: remaining <= 3
|
||||
? "bg-amber-500/20 text-amber-300"
|
||||
: "bg-emerald-500/20 text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
{full ? "❌ COMPLET" : `${remaining} places libres`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Capacité totale</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input"
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Places occupées</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
className="input"
|
||||
value={booked}
|
||||
onChange={(e) => setBooked(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2 rounded-xl bg-white/5 px-4 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isFull}
|
||||
onChange={(e) => setIsFull(e.target.checked)}
|
||||
className="h-4 w-4 accent-red-600"
|
||||
/>
|
||||
<span className="font-semibold text-red-300">Marquer ce cours comme COMPLET</span>
|
||||
</label>
|
||||
|
||||
<button onClick={save} className="btn-primary mt-3 w-full" disabled={saving}>
|
||||
{saving ? "Enregistrement..." : "💾 Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AdminClasses from "./classes-admin";
|
||||
|
||||
export default async function ClassesPage() {
|
||||
const slots = await prisma.classSlot.findMany({
|
||||
orderBy: { label: "asc" },
|
||||
});
|
||||
|
||||
return <AdminClasses slots={slots} />;
|
||||
}
|
||||
+125
-30
@@ -28,7 +28,7 @@ export default function AdminUsers({ users }: { users: any[] }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
["ALL", "Tous"],
|
||||
@@ -70,6 +70,7 @@ function CreateUserForm({ onDone }: { onDone: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState("MEMBER");
|
||||
const [isMuayThai, setIsMuayThai] = useState(true);
|
||||
const [isYoga, setIsYoga] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -82,7 +83,14 @@ function CreateUserForm({ onDone }: { onDone: () => void }) {
|
||||
const res = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password, isMuayThai, isYoga }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
role,
|
||||
isMuayThai: role === "MEMBER" ? isMuayThai : false,
|
||||
isYoga: role === "MEMBER" ? isYoga : false,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setSaving(false);
|
||||
@@ -95,7 +103,7 @@ function CreateUserForm({ onDone }: { onDone: () => void }) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="card space-y-4 border-primary-500/30">
|
||||
<h3 className="font-semibold">✨ Créer un compte membre</h3>
|
||||
<h3 className="font-semibold">✨ Créer un compte</h3>
|
||||
<div>
|
||||
<label className="label">Nom complet</label>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Jean Dupont" required />
|
||||
@@ -108,10 +116,39 @@ function CreateUserForm({ onDone }: { onDone: () => void }) {
|
||||
<label className="label">Mot de passe initial</label>
|
||||
<input className="input" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Mot de passe temporaire" required />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Checkbox label="🥊 Muay Thai" checked={isMuayThai} onChange={setIsMuayThai} />
|
||||
<Checkbox label="🧘 Yoga" checked={isYoga} onChange={setIsYoga} />
|
||||
<div>
|
||||
<label className="label">Rôle</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("MEMBER")}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
role === "MEMBER"
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
👤 Membre
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole("ADMIN")}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
role === "ADMIN"
|
||||
? "bg-amber-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
⭐ Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{role === "MEMBER" && (
|
||||
<div className="flex gap-3">
|
||||
<Checkbox label="🥊 Muay Thai" checked={isMuayThai} onChange={setIsMuayThai} />
|
||||
<Checkbox label="🧘 Yoga" checked={isYoga} onChange={setIsYoga} />
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<button type="submit" className="btn-primary w-full" disabled={saving}>
|
||||
{saving ? "Création..." : "✅ Créer le compte"}
|
||||
@@ -133,19 +170,27 @@ function MemberCard({ user, onUpdate }: { user: any; onUpdate: () => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [editName, setEditName] = useState(user.name);
|
||||
const [editPassword, setEditPassword] = useState("");
|
||||
const router = useRouter();
|
||||
const [editRole, setEditRole] = useState(user.role);
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const certStatus = user.certificates?.find((c: any) => c.status !== "REJECTED")?.status;
|
||||
|
||||
async function updateUser(data: any) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
setSaving(false);
|
||||
if (res.ok) {
|
||||
setEditPassword("");
|
||||
onUpdate();
|
||||
setExpanded(false);
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setError(d.error || "Erreur lors de la mise à jour");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,17 +204,31 @@ function MemberCard({ user, onUpdate }: { user: any; onUpdate: () => void }) {
|
||||
if (res.ok) onUpdate();
|
||||
}
|
||||
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
const canDelete = !isAdmin;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary-600/20 text-lg font-bold text-primary-300">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold ${
|
||||
isAdmin
|
||||
? "bg-amber-500/20 text-amber-300"
|
||||
: "bg-primary-600/20 text-primary-300"
|
||||
}`}
|
||||
>
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{user.name}</p>
|
||||
<p className="text-xs text-white/50">{user.email}</p>
|
||||
<div className="mt-1 flex gap-1.5">
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{isAdmin && (
|
||||
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 text-xs text-amber-300">
|
||||
⭐ Admin
|
||||
</span>
|
||||
)}
|
||||
{user.isMuayThai && (
|
||||
<span className="rounded-full bg-primary-600/20 px-2 py-0.5 text-xs text-primary-300">
|
||||
🥊 Muay Thai
|
||||
@@ -180,11 +239,6 @@ function MemberCard({ user, onUpdate }: { user: any; onUpdate: () => void }) {
|
||||
🧘 Yoga
|
||||
</span>
|
||||
)}
|
||||
{user.role === "ADMIN" && (
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70">
|
||||
⭐ Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,33 +266,74 @@ function MemberCard({ user, onUpdate }: { user: any; onUpdate: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && user.role !== "ADMIN" && (
|
||||
{expanded && (
|
||||
<div className="mt-4 space-y-3 border-t border-white/10 pt-4">
|
||||
<h4 className="text-sm font-semibold">Modifier</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label className="label">Nom</label>
|
||||
<input className="input" value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Nouveau mot de passe (optionnel)</label>
|
||||
<input className="input" value={editPassword} onChange={(e) => setEditPassword(e.target.value)} placeholder="Laisser vide pour ne pas changer" />
|
||||
<h4 className="text-sm font-semibold">
|
||||
⚙️ Configuration — {isAdmin ? "compte admin" : "compte membre"}
|
||||
</h4>
|
||||
|
||||
<div>
|
||||
<label className="label">Nom</label>
|
||||
<input className="input" value={editName} onChange={(e) => setEditName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Rôle</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditRole("MEMBER")}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
editRole === "MEMBER"
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
👤 Membre
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditRole("ADMIN")}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
editRole === "ADMIN"
|
||||
? "bg-amber-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
⭐ Admin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Réinitialiser le mot de passe</label>
|
||||
<input
|
||||
className="input"
|
||||
value={editPassword}
|
||||
onChange={(e) => setEditPassword(e.target.value)}
|
||||
placeholder="Nouveau mot de passe (laisser vide pour ne pas changer)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const data: any = { name: editName };
|
||||
const data: any = { name: editName, role: editRole };
|
||||
if (editPassword) data.password = editPassword;
|
||||
updateUser(data);
|
||||
}}
|
||||
className="btn-primary flex-1 !py-2 text-sm"
|
||||
disabled={saving}
|
||||
>
|
||||
💾 Enregistrer
|
||||
</button>
|
||||
<button onClick={deleteUser} className="btn-danger">
|
||||
🗑️
|
||||
{saving ? "Enregistrement..." : "💾 Enregistrer"}
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button onClick={deleteUser} className="btn-danger" title="Supprimer">
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user