Initial commit: JuanitoSport - Muay Thai & Yog4Fight PWA

This commit is contained in:
Wycz
2026-09-08 08:25:33 +02:00
commit a69ca86330
70 changed files with 4301 additions and 0 deletions
+34
View File
@@ -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} />
);
}
+241
View File
@@ -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&apos;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>
);
}