Initial commit: JuanitoSport - Muay Thai & Yog4Fight PWA
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
|
||||
const adminLinks = [
|
||||
{ href: "/admin", label: "Vue d'ensemble", icon: "🏠" },
|
||||
{ href: "/admin/users", label: "Membres", icon: "👥" },
|
||||
{ href: "/admin/messages", label: "Messages", icon: "💬" },
|
||||
{ href: "/admin/media", label: "Photos & vidéos", icon: "🎬" },
|
||||
{ href: "/admin/homework", label: "Exercices", icon: "🏋️" },
|
||||
{ href: "/admin/certificates", label: "Certificats", icon: "📄" },
|
||||
{ href: "/admin/yoga", label: "Gestion Yoga", icon: "🧘" },
|
||||
];
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-20">
|
||||
<Header />
|
||||
<div className="mx-auto max-w-3xl px-4 py-6">
|
||||
<h1 className="mb-1 text-2xl font-extrabold">
|
||||
⚙️ Tableau de bord Juanito
|
||||
</h1>
|
||||
<p className="mb-5 text-sm text-white/60">
|
||||
Gère ton club JuanitoSport
|
||||
</p>
|
||||
|
||||
<nav className="mb-6 flex gap-2 overflow-x-auto pb-2 -mx-4 px-4">
|
||||
{adminLinks.map((link) => {
|
||||
const active =
|
||||
pathname === link.href ||
|
||||
(link.href !== "/admin" && pathname.startsWith(link.href));
|
||||
return (
|
||||
<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 ${
|
||||
active
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-white/5 text-white/70 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span>{link.icon}</span>
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AdminCertificates({ certificates }: any) {
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState("PENDING");
|
||||
const [viewing, setViewing] = useState<any>(null);
|
||||
|
||||
const filtered = certificates.filter((c: any) =>
|
||||
filter === "ALL" ? true : c.status === filter
|
||||
);
|
||||
|
||||
async function updateStatus(id: string, status: string) {
|
||||
const res = await fetch("/api/certificates", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setViewing(null);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCount = certificates.filter((c: any) => c.status === "PENDING").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold">
|
||||
📄 Certificats médical
|
||||
{pendingCount > 0 && (
|
||||
<span className="ml-2 rounded-full bg-amber-500 px-2 py-0.5 text-xs text-black">
|
||||
{pendingCount} en attente
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(["PENDING", "VALIDATED", "REJECTED", "ALL"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded-xl px-3 py-2 text-sm ${
|
||||
filter === f ? "bg-primary-600 text-white" : "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
{f === "PENDING" ? "⏳ En attente" : f === "VALIDATED" ? "✓ Validés" : f === "REJECTED" ? "✗ Rejetés" : "Tous"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 && (
|
||||
<p className="card text-center text-sm text-white/50">Aucun certificat ici.</p>
|
||||
)}
|
||||
{filtered.map((c: any) => (
|
||||
<div key={c.id} className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">{c.user.name}</p>
|
||||
<p className="text-xs text-white/50">{c.user.email}</p>
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
Reçu le {new Date(c.createdAt).toLocaleDateString("fr-FR")}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={c.status} />
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<a
|
||||
href={c.fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary flex-1 !py-2 text-sm"
|
||||
>
|
||||
👁️ Voir le fichier
|
||||
</a>
|
||||
{c.status === "PENDING" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateStatus(c.id, "VALIDATED")}
|
||||
className="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
|
||||
>
|
||||
✓ Valider
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateStatus(c.id, "REJECTED")}
|
||||
className="btn-danger"
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case "VALIDATED":
|
||||
return <span className="rounded-full bg-emerald-500/20 px-3 py-1 text-xs font-semibold text-emerald-300">✓ Validé</span>;
|
||||
case "REJECTED":
|
||||
return <span className="rounded-full bg-red-500/20 px-3 py-1 text-xs font-semibold text-red-300">✗ Rejeté</span>;
|
||||
default:
|
||||
return <span className="rounded-full bg-amber-500/20 px-3 py-1 text-xs font-semibold text-amber-300">⏳ En attente</span>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AdminCertificates from "./certificates-admin";
|
||||
|
||||
export default async function CertificatesPage() {
|
||||
const certificates = await prisma.certificate.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return <AdminCertificates certificates={certificates} />;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AdminHomework({ homework, members }: any) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoUrl, setVideoUrl] = useState("");
|
||||
const [target, setTarget] = useState("all");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title || !description) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
let finalVideoUrl = videoUrl;
|
||||
if (videoFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", videoFile);
|
||||
formData.append("subdir", "homework");
|
||||
const up = await fetch("/api/upload", { method: "POST", body: formData });
|
||||
if (!up.ok) throw new Error("Upload vidéo échoué");
|
||||
const { url } = await up.json();
|
||||
finalVideoUrl = url;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/homework", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
videoUrl: finalVideoUrl,
|
||||
userId: target === "all" ? null : target,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Enregistrement échoué");
|
||||
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setVideoFile(null);
|
||||
setVideoUrl("");
|
||||
setTarget("all");
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Erreur");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHomework(id: string) {
|
||||
if (!confirm("Supprimer cet exercice ?")) return;
|
||||
await fetch("/api/homework", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold">🏋️ Exercices à travailler</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="card space-y-3 border-primary-500/30">
|
||||
<h3 className="font-semibold">📝 Nouvel exercice</h3>
|
||||
<div>
|
||||
<label className="label">Titre</label>
|
||||
<input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ex: 10 rounds de shadow box" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Description</label>
|
||||
<textarea className="input min-h-[100px]" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Décris l'exercice..." required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">À destination de</label>
|
||||
<select className="input" value={target} onChange={(e) => setTarget(e.target.value)}>
|
||||
<option value="all">Tous les membres</option>
|
||||
{members.map((m: any) => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Vidéo démonstrative (optionnel)</label>
|
||||
{videoFile ? (
|
||||
<p className="mb-2 text-sm text-primary-300">{videoFile.name}</p>
|
||||
) : (
|
||||
<label className="btn-secondary mb-2 w-full cursor-pointer">
|
||||
📹 Téléverser une vidéo
|
||||
<input type="file" accept="video/*" className="hidden" onChange={(e) => setVideoFile(e.target.files?.[0] || null)} />
|
||||
</label>
|
||||
)}
|
||||
<input className="input" value={videoUrl} onChange={(e) => setVideoUrl(e.target.value)} placeholder="...ou coller une URL de vidéo" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<button type="submit" className="btn-primary w-full" disabled={saving}>
|
||||
{saving ? "Enregistrement..." : "✅ Publier"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-3">
|
||||
{homework.length === 0 && (
|
||||
<p className="card text-center text-sm text-white/50">Aucun exercice publié.</p>
|
||||
)}
|
||||
{homework.map((h: any) => (
|
||||
<div key={h.id} className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{h.title}</h3>
|
||||
<p className="mt-1 whitespace-pre-line text-sm text-white/75">{h.description}</p>
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
{h.user ? `Pour ${h.user.name}` : "Pour tous les membres"} ·{" "}
|
||||
{new Date(h.createdAt).toLocaleDateString("fr-FR")}
|
||||
</p>
|
||||
{h.videoUrl && (
|
||||
<video controls playsInline preload="metadata" className="mt-2 aspect-video w-full rounded-xl max-w-sm" src={h.videoUrl} />
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => deleteHomework(h.id)} className="ml-3 shrink-0 text-sm text-red-400">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AdminHomework from "./homework-admin";
|
||||
|
||||
export default async function HomeworkPage() {
|
||||
const [homework, members] = await Promise.all([
|
||||
prisma.homework.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
prisma.user.findMany({
|
||||
where: { role: "MEMBER" },
|
||||
select: { id: true, name: true, isMuayThai: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return <AdminHomework homework={homework} members={members} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import AdminLayout from "./admin-layout";
|
||||
|
||||
export default async function AdminLayoutWrapper({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role !== "ADMIN") redirect("/member");
|
||||
|
||||
return <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: "MEMBERS_ONLY", label: "👥 Tous les adhérents" },
|
||||
{ value: "MUAY_THAI_ONLY", label: "🥊 Muay Thai uniquement" },
|
||||
{ value: "YOGA_ONLY", label: "🧘 Yoga uniquement" },
|
||||
{ value: "PUBLIC", label: "🌍 Public" },
|
||||
];
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "PHOTO", label: "📷 Photo" },
|
||||
{ value: "VIDEO", label: "🎥 Vidéo" },
|
||||
];
|
||||
|
||||
export default function AdminMedia({ media }: { media: any[] }) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState("PHOTO");
|
||||
const [visibility, setVisibility] = useState("MEMBERS_ONLY");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const dir = type === "VIDEO" ? "media/videos" : "media/photos";
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("subdir", dir);
|
||||
|
||||
const up = await fetch("/api/upload", { method: "POST", body: formData });
|
||||
if (!up.ok) throw new Error("Upload échoué");
|
||||
const { url } = await up.json();
|
||||
|
||||
const res = await fetch("/api/media", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, description, url, type, visibility }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Enregistrement échoué");
|
||||
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setFile(null);
|
||||
setType("PHOTO");
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Une erreur est survenue");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMedia(id: string) {
|
||||
if (!confirm("Supprimer ce média ?")) return;
|
||||
const res = await fetch("/api/media", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (res.ok) router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold">🎬 Photos & vidéos</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="card space-y-3 border-primary-500/30">
|
||||
<h3 className="font-semibold">📤 Ajouter un média</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{TYPE_OPTIONS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setType(t.value)}
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm ${type === t.value ? "bg-primary-600 text-white" : "bg-white/5 text-white/60"}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="label">❌ Visibilité</label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{VISIBILITY_OPTIONS.map((v) => (
|
||||
<label key={v.value} className="flex cursor-pointer items-center gap-2 rounded-xl bg-white/5 px-3 py-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="visibility"
|
||||
checked={visibility === v.value}
|
||||
onChange={() => setVisibility(v.value)}
|
||||
className="accent-primary-600"
|
||||
/>
|
||||
{v.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Titre</label>
|
||||
<input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ex: Entraînement du mardi" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Description (optionnel)</label>
|
||||
<input className="input" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-white/20 bg-white/5 py-6">
|
||||
<span className="mb-1 text-3xl">{type === "VIDEO" ? "🎥" : "📷"}</span>
|
||||
{file ? (
|
||||
<p className="text-sm text-primary-300">{file.name}</p>
|
||||
) : (
|
||||
<p className="text-sm text-white/50">
|
||||
{type === "VIDEO" ? "Choisir une vidéo" : "Choisir une photo"}
|
||||
</p>
|
||||
)}
|
||||
<label className="btn-secondary mt-3 cursor-pointer">
|
||||
Choisir un fichier
|
||||
<input
|
||||
type="file"
|
||||
accept={type === "VIDEO" ? "video/*" : "image/*"}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
setFile(e.target.files?.[0] || null);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<button type="submit" className="btn-primary w-full" disabled={uploading || !file}>
|
||||
{uploading ? "Upload..." : "🚀 Publier"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{media.length === 0 && (
|
||||
<p className="card col-span-2 text-center text-sm text-white/50">Aucun média.</p>
|
||||
)}
|
||||
{media.map((m) => (
|
||||
<div key={m.id} className="card overflow-hidden !p-0">
|
||||
{m.type === "PHOTO" ? (
|
||||
<img src={m.url} alt={m.title} className="aspect-square w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<video controls playsInline preload="metadata" className="aspect-video w-full" src={m.url} />
|
||||
)}
|
||||
<div className="p-3">
|
||||
<p className="truncate text-sm font-semibold">{m.title}</p>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-xs text-white/40">
|
||||
{m.visibility === "PUBLIC" ? "🌍 Public" : m.visibility === "MUAY_THAI_ONLY" ? "🥊 Muay Thai" : m.visibility === "YOGA_ONLY" ? "🧘 Yoga" : "👥 Adhérents"} ·{" "}
|
||||
{new Date(m.createdAt).toLocaleDateString("fr-FR")}
|
||||
</span>
|
||||
<button onClick={() => deleteMedia(m.id)} className="text-xs text-red-400">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AdminMedia from "./media-admin";
|
||||
|
||||
export default async function MediaPage() {
|
||||
const media = await prisma.media.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
uploadedBy: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return <AdminMedia media={media} />;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AdminMessages({ messages, readMap, totalMembers }: any) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [category, setCategory] = useState("general");
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
async function sendMessage(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSending(true);
|
||||
const res = await fetch("/api/admin/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, content, category }),
|
||||
});
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
setTitle("");
|
||||
setContent("");
|
||||
setCategory("general");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold">💬 Messages / Tips</h2>
|
||||
|
||||
<form onSubmit={sendMessage} className="card space-y-3 border-primary-500/30">
|
||||
<h3 className="font-semibold">✍️ Nouveau message</h3>
|
||||
<div>
|
||||
<label className="label">Titre</label>
|
||||
<input className="input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Ex: Conseil de la semaine" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Message</label>
|
||||
<textarea className="input min-h-[120px]" value={content} onChange={(e) => setContent(e.target.value)} placeholder="Écris ton message... les adhérents le verront dans leur espace." required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Audience</label>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
["general", "🌍 Tout le monde"],
|
||||
["muay-thai", "🥊 Muay Thai"],
|
||||
["yoga", "🧘 Yoga"],
|
||||
].map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => setCategory(val)}
|
||||
className={`rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
category === val
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={sending || !title || !content}>
|
||||
{sending ? "Envoi..." : "📨 Publier le message"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<p className="card text-center text-sm text-white/50">
|
||||
Aucun message publié.
|
||||
</p>
|
||||
)}
|
||||
{messages.map((m: any) => (
|
||||
<div key={m.id} className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{m.title}</h3>
|
||||
<p className="mt-1 whitespace-pre-line text-sm text-white/75">{m.content}</p>
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
{new Date(m.createdAt).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })} ·{" "}
|
||||
{m.category === "muay-thai" ? "🥊" : m.category === "yoga" ? "🧘" : "🌍"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-white/5 px-2 py-1 text-xs text-white/50">
|
||||
{readMap[m.id] || 0} lu(s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AdminMessages from "./messages-admin";
|
||||
|
||||
export default async function MessagesPage() {
|
||||
const messages = await prisma.message.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const readCount = await prisma.messageRead.groupBy({
|
||||
by: ["messageId"],
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const readMap: Record<string, number> = {};
|
||||
readCount.forEach((r) => {
|
||||
readMap[r.messageId] = r._count._all;
|
||||
});
|
||||
|
||||
return <AdminMessages messages={messages} readMap={readMap} totalMembers={0} />;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminOverview({ stats }: { stats: any }) {
|
||||
const cards = [
|
||||
{
|
||||
label: "Adhérents",
|
||||
value: stats.totalMembers,
|
||||
icon: "👥",
|
||||
href: "/admin/users",
|
||||
sub: `${stats.muayThaiMembers} Muay Thai · ${stats.yogaMembers} Yoga`,
|
||||
},
|
||||
{
|
||||
label: "Certificats en attente",
|
||||
value: stats.pendingCertificates,
|
||||
icon: "📄",
|
||||
href: "/admin/certificates",
|
||||
sub: stats.pendingCertificates > 0 ? "à valider" : "tout est à jour",
|
||||
alert: stats.pendingCertificates > 0,
|
||||
},
|
||||
{
|
||||
label: "Photos & vidéos",
|
||||
value: stats.totalMedia,
|
||||
icon: "🎬",
|
||||
href: "/admin/media",
|
||||
sub: "médias publiés",
|
||||
},
|
||||
{
|
||||
label: "Messages publiés",
|
||||
value: stats.messages,
|
||||
icon: "💬",
|
||||
href: "/admin/messages",
|
||||
sub: "messages envoyés",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{cards.map((c) => (
|
||||
<Link
|
||||
key={c.label}
|
||||
href={c.href}
|
||||
className={`card transition-colors hover:bg-white/10 ${c.alert ? "border-amber-500/50 bg-amber-500/5" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xl">{c.icon}</span>
|
||||
{c.alert && (
|
||||
<span className="h-3 w-3 rounded-full bg-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-extrabold">{c.value}</p>
|
||||
<p className="text-sm font-semibold text-white/80">{c.label}</p>
|
||||
<p className="mt-1 text-xs text-white/50">{c.sub}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="mb-2 font-bold">Accès rapides</h2>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<Link href="/admin/users" className="rounded-xl bg-white/5 px-3 py-2 text-center hover:bg-white/10">
|
||||
👥 Gérer les membres
|
||||
</Link>
|
||||
<Link href="/admin/messages" className="rounded-xl bg-white/5 px-3 py-2 text-center hover:bg-white/10">
|
||||
💬 Publier un message
|
||||
</Link>
|
||||
<Link href="/admin/media" className="rounded-xl bg-white/5 px-3 py-2 text-center hover:bg-white/10">
|
||||
🎬 Ajouter une photo/vidéo
|
||||
</Link>
|
||||
<Link href="/admin/yoga" className="rounded-xl bg-white/5 px-3 py-2 text-center hover:bg-white/10">
|
||||
🧘 Gérer les séances yoga
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/" className="btn-secondary w-full">
|
||||
← Voir le site public
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import AdminOverview from "./overview";
|
||||
|
||||
export default async function AdminIndex() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) redirect("/login");
|
||||
if (session.user.role !== "ADMIN") redirect("/member");
|
||||
|
||||
const [
|
||||
totalMembers,
|
||||
muayThaiMembers,
|
||||
yogaMembers,
|
||||
pendingCertificates,
|
||||
totalMedia,
|
||||
unreadMessagesCount,
|
||||
] = await Promise.all([
|
||||
prisma.user.count({ where: { role: "MEMBER" } }),
|
||||
prisma.user.count({ where: { isMuayThai: true } }),
|
||||
prisma.user.count({ where: { isYoga: true } }),
|
||||
prisma.certificate.count({ where: { status: "PENDING" } }),
|
||||
prisma.media.count(),
|
||||
prisma.message.count(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminOverview
|
||||
stats={{
|
||||
totalMembers,
|
||||
muayThaiMembers,
|
||||
yogaMembers,
|
||||
pendingCertificates,
|
||||
totalMedia,
|
||||
messages: unreadMessagesCount,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AdminUsers from "./users-admin";
|
||||
|
||||
export default async function UsersPage() {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
muayThai: true,
|
||||
yoga: true,
|
||||
certificates: { orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
|
||||
const safeUsers = users.map(({ password, ...u }) => u);
|
||||
|
||||
return <AdminUsers users={safeUsers} />;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AdminUsers({ users }: { users: any[] }) {
|
||||
const router = useRouter();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [filter, setFilter] = useState<"ALL" | "MUAY" | "YOGA">("ALL");
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
if (filter === "MUAY") return u.isMuayThai;
|
||||
if (filter === "YOGA") return u.isYoga;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold">
|
||||
👥 Adhérents ({filtered.length})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
className="btn-primary !px-4 !py-2 text-sm"
|
||||
>
|
||||
{showCreate ? "Fermer" : "+ Nouveau compte"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
["ALL", "Tous"],
|
||||
["MUAY", "🥊 Muay Thai"],
|
||||
["YOGA", "🧘 Yoga"],
|
||||
] as const
|
||||
).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setFilter(val)}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
filter === val
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-white/5 text-white/60"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showCreate && <CreateUserForm onDone={() => { setShowCreate(false); router.refresh(); }} />}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 && (
|
||||
<p className="card text-center text-sm text-white/50">
|
||||
Aucun adhérent.
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((user) => (
|
||||
<MemberCard key={user.id} user={user} onUpdate={() => router.refresh()} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserForm({ onDone }: { onDone: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isMuayThai, setIsMuayThai] = useState(true);
|
||||
const [isYoga, setIsYoga] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const res = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password, isMuayThai, isYoga }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setSaving(false);
|
||||
if (!res.ok) {
|
||||
setError(data.error || "Erreur");
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="card space-y-4 border-primary-500/30">
|
||||
<h3 className="font-semibold">✨ Créer un compte membre</h3>
|
||||
<div>
|
||||
<label className="label">Nom complet</label>
|
||||
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Jean Dupont" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input type="email" className="input" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="jean@gmail.com" required />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
{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"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-xl bg-white/5 px-4 py-2 text-sm">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-4 w-4 accent-primary-600" />
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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 certStatus = user.certificates?.find((c: any) => c.status !== "REJECTED")?.status;
|
||||
|
||||
async function updateUser(data: any) {
|
||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) {
|
||||
onUpdate();
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
if (!confirm(`Supprimer le compte de ${user.name} ?`)) return;
|
||||
const res = await fetch(`/api/admin/users/${user.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: user.id }),
|
||||
});
|
||||
if (res.ok) onUpdate();
|
||||
}
|
||||
|
||||
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">
|
||||
{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">
|
||||
{user.isMuayThai && (
|
||||
<span className="rounded-full bg-primary-600/20 px-2 py-0.5 text-xs text-primary-300">
|
||||
🥊 Muay Thai
|
||||
</span>
|
||||
)}
|
||||
{user.isYoga && (
|
||||
<span className="rounded-full bg-emerald-600/20 px-2 py-0.5 text-xs text-emerald-300">
|
||||
🧘 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>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="rounded-xl bg-white/5 px-3 py-2 text-sm text-white/60"
|
||||
>
|
||||
{expanded ? "✕" : "⚙️"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{certStatus && (
|
||||
<div className="mt-3 flex items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-xs">
|
||||
<span className="text-white/60">Certificat médical</span>
|
||||
<CertBadge status={certStatus} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.yoga && (
|
||||
<div className="mt-2 flex items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-xs">
|
||||
<span className="text-white/60">Séances yoga restantes</span>
|
||||
<span className="font-bold text-emerald-300">
|
||||
{user.yoga.sessionsRemaining}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && user.role !== "ADMIN" && (
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const data: any = { name: editName };
|
||||
if (editPassword) data.password = editPassword;
|
||||
updateUser(data);
|
||||
}}
|
||||
className="btn-primary flex-1 !py-2 text-sm"
|
||||
>
|
||||
💾 Enregistrer
|
||||
</button>
|
||||
<button onClick={deleteUser} className="btn-danger">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CertBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case "VALIDATED":
|
||||
return <span className="font-bold text-emerald-300">✓ Validé</span>;
|
||||
case "PENDING":
|
||||
return <span className="font-bold text-amber-300">⏳ En attente</span>;
|
||||
case "REJECTED":
|
||||
return <span className="font-bold text-red-300">✗ Rejeté</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AdminYoga from "./yoga-admin";
|
||||
|
||||
export default async function YogaAdminPage() {
|
||||
const [tiers, members, recentAttendances] = await Promise.all([
|
||||
prisma.yogaTier.findMany({ orderBy: { numberOfSessions: "asc" } }),
|
||||
prisma.user.findMany({
|
||||
where: { isYoga: true },
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
yoga: {
|
||||
include: {
|
||||
attendances: { orderBy: { date: "desc" }, take: 5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.yogaAttendance.findMany({
|
||||
orderBy: { date: "desc" },
|
||||
take: 10,
|
||||
include: {
|
||||
profile: {
|
||||
include: { user: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const safeMembers = members.map(({ password, ...m }) => m);
|
||||
|
||||
return (
|
||||
<AdminYoga tiers={tiers} members={safeMembers} recentAttendances={recentAttendances} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AdminYoga({ tiers, members, recentAttendances }: any) {
|
||||
const router = useRouter();
|
||||
|
||||
const [tierName, setTierName] = useState("");
|
||||
const [tierSessions, setTierSessions] = useState("");
|
||||
const [tierPrice, setTierPrice] = useState("");
|
||||
const [savingTier, setSavingTier] = useState(false);
|
||||
const [tierError, setTierError] = useState("");
|
||||
|
||||
async function createTier(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!tierName || !tierSessions || !tierPrice) return;
|
||||
setSavingTier(true);
|
||||
setTierError("");
|
||||
const res = await fetch("/api/admin/yoga-tiers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: tierName,
|
||||
numberOfSessions: Number(tierSessions),
|
||||
priceTotal: Number(tierPrice),
|
||||
}),
|
||||
});
|
||||
setSavingTier(false);
|
||||
if (res.ok) {
|
||||
setTierName("");
|
||||
setTierSessions("");
|
||||
setTierPrice("");
|
||||
router.refresh();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setTierError(data.error || "Erreur");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-bold">🧘 Gestion Yoga</h2>
|
||||
|
||||
{/* Tarifs dégressifs */}
|
||||
<section className="card">
|
||||
<h3 className="mb-3 font-bold">💰 Tarifs dégressifs</h3>
|
||||
<p className="mb-4 text-sm text-white/60">
|
||||
Configure les packs de séances que Juanito veut vendre. Les plus
|
||||
assidus paient moins cher par séance.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 space-y-2">
|
||||
{tiers.map((t: any) => (
|
||||
<div key={t.id} className="flex items-center justify-between rounded-xl bg-white/5 px-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold">{t.name}</p>
|
||||
<p className="text-xs text-white/50">
|
||||
{t.numberOfSessions} séance{t.numberOfSessions > 1 ? "s" : ""} ·{" "}
|
||||
<span className="font-bold text-emerald-300">
|
||||
{t.priceTotal} €
|
||||
</span>{" "}
|
||||
({(t.priceTotal / t.numberOfSessions).toFixed(2)} €/séance)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => toggleTier(t.id, !t.isActive)}
|
||||
className={`rounded-xl px-3 py-1.5 text-sm ${t.isActive ? "bg-white/5 text-white/60" : "bg-emerald-600/20 text-emerald-300"}`}
|
||||
>
|
||||
{t.isActive ? "Actif" : "Inactif"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTier(t.id)}
|
||||
className="rounded-xl bg-white/5 px-3 py-1.5 text-sm text-white/60"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{tiers.length === 0 && (
|
||||
<p className="text-sm text-white/50">Aucun tarif configuré.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={createTier} className="grid grid-cols-1 gap-2 sm:grid-cols-4">
|
||||
<input className="input sm:col-span-2" placeholder="Nom (ex: Pack 10)" value={tierName} onChange={(e) => setTierName(e.target.value)} />
|
||||
<input className="input" placeholder="Séances" type="number" value={tierSessions} onChange={(e) => setTierSessions(e.target.value)} />
|
||||
<input className="input" placeholder="Prix (€)" type="number" step="0.01" value={tierPrice} onChange={(e) => setTierPrice(e.target.value)} />
|
||||
<button type="submit" className="btn-primary sm:col-span-4" disabled={savingTier}>
|
||||
{savingTier ? "..." : "+ Ajouter ce tarif"}
|
||||
</button>
|
||||
</form>
|
||||
{tierError && <p className="mt-2 text-sm text-red-400">{tierError}</p>}
|
||||
<p className="mt-3 text-xs text-white/40">
|
||||
💡 Un pack de 10 à 70€ = 7€/séance au lieu de 9€. Les membres
|
||||
voient le tarif dégressif dans leur espace.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Compteur séances membres */}
|
||||
<section className="card">
|
||||
<h3 className="mb-3 font-bold">👥 Compteurs de séances des membres</h3>
|
||||
<div className="space-y-3">
|
||||
{members.length === 0 && (
|
||||
<p className="text-sm text-white/50">
|
||||
Aucun membre yoga. Crée-en un dans l'onglet Membres.
|
||||
</p>
|
||||
)}
|
||||
{members.map((m: any) => (
|
||||
<YogaMemberCard key={m.id} member={m} onUpdate={() => router.refresh()} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Dernières présences */}
|
||||
<section className="card">
|
||||
<h3 className="mb-3 font-bold">🕐 Dernières présences enregistrées</h3>
|
||||
{recentAttendances.length === 0 && (
|
||||
<p className="text-sm text-white/50">Aucune présence enregistrée.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{recentAttendances.map((a: any) => (
|
||||
<div key={a.id} className="flex items-center justify-between rounded-xl bg-white/5 px-3 py-2 text-sm">
|
||||
<span className="font-semibold">{a.profile.user.name}</span>
|
||||
<span className="text-xs text-white/50">
|
||||
{new Date(a.date).toLocaleDateString("fr-FR", { day: "numeric", month: "long" })}
|
||||
{a.pricePaid !== null && (
|
||||
<span className="ml-2 text-emerald-300">{a.pricePaid} €</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function toggleTier(id: string, isActive: boolean) {
|
||||
const res = await fetch(`/api/admin/yoga-tiers/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, isActive }),
|
||||
});
|
||||
if (res.ok) router.refresh();
|
||||
}
|
||||
|
||||
async function deleteTier(id: string) {
|
||||
if (!confirm("Supprimer ce tarif ?")) return;
|
||||
const res = await fetch(`/api/admin/yoga-tiers/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (res.ok) router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function YogaMemberCard({ member, onUpdate }: any) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [count, setCount] = useState("1");
|
||||
const [recordingSession, setRecordingSession] = useState(false);
|
||||
|
||||
const profile = member.yoga;
|
||||
const remaining = profile?.sessionsRemaining ?? 0;
|
||||
|
||||
async function addSessions(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await fetch("/api/admin/yoga-members", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: member.id, sessionsToAdd: Number(count) }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setAdding(false);
|
||||
setCount("1");
|
||||
onUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
async function recordSession() {
|
||||
if (!confirm(`Enregistrer une séance suivie pour ${member.name} ? (1 séance restante va être déduite)`)) return;
|
||||
setRecordingSession(true);
|
||||
const res = await fetch("/api/yoga-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: member.id }),
|
||||
});
|
||||
setRecordingSession(false);
|
||||
if (res.ok) onUpdate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-white/5 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">{member.name}</p>
|
||||
<p className="text-xs text-white/50">
|
||||
<span className="font-bold text-emerald-300">{remaining}</span> séance
|
||||
{remaining > 1 ? "s" : ""} restante{remaining > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setAdding(!adding)}
|
||||
className="rounded-xl bg-white/5 px-3 py-2 text-sm text-white/70 hover:bg-white/10"
|
||||
>
|
||||
{adding ? "✕" : "+ Séances"}
|
||||
</button>
|
||||
<button
|
||||
onClick={recordSession}
|
||||
disabled={recordingSession || remaining <= 0}
|
||||
className={`rounded-xl px-3 py-2 text-sm font-semibold ${
|
||||
remaining > 0
|
||||
? "bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
: "bg-white/5 text-white/30"
|
||||
}`}
|
||||
>
|
||||
{recordingSession ? "..." : "✓ Séance suivie"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<form onSubmit={addSessions} className="mt-3 flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
className="input !w-20"
|
||||
/>
|
||||
<button type="submit" className="btn-primary flex-1 !py-2 text-sm">
|
||||
Ajouter les séances
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user