Grapevine_Disease_Detection/vineye-admin/app/(admin)/users/[id]/user-detail-client.tsx
Yanis fe70005a86 add vineye-admin dashboard (Next.js)
Admin panel for VinEye with dashboard, users, diseases, guides, alerts management.
Stack: Next.js App Router + Prisma + PostgreSQL + better-auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:22:01 +02:00

208 lines
7.7 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { ArrowLeft, ScanLine, Trophy, Zap, Calendar } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { formatDate } from "@/lib/utils";
interface UserDetailProps {
user: {
id: string;
name: string;
email: string;
role: string;
xp: number;
level: number;
banned: boolean;
bannedReason: string | null;
createdAt: Date;
scans: {
id: string;
confidence: number;
createdAt: Date;
disease: { name: string; severity: string } | null;
}[];
_count: { scans: number };
};
}
const SEVERITY_STYLES: Record<string, string> = {
HIGH: "bg-wine/10 text-[#FB7185] border-wine/20",
MEDIUM: "bg-gold/10 text-gold border-gold/20",
LOW: "bg-[#60A5FA]/10 text-[#60A5FA] border-[#60A5FA]/20",
};
export default function UserDetailClient({ user }: UserDetailProps) {
const router = useRouter();
async function handleUpdate(data: Record<string, unknown>) {
try {
const res = await fetch(`/api/users/${user.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error();
toast.success("Utilisateur mis a jour");
router.refresh();
} catch {
toast.error("Erreur lors de la mise a jour");
}
}
const STAT_ITEMS = [
{ label: "Scans", value: user._count.scans, icon: ScanLine, color: "text-vine" },
{ label: "XP", value: user.xp, icon: Zap, color: "text-gold" },
{ label: "Niveau", value: user.level, icon: Trophy, color: "text-[#A78BFA]" },
{ label: "Inscrit", value: formatDate(user.createdAt, "MMM yyyy"), icon: Calendar, color: "text-[#60A5FA]" },
];
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-stone-600 hover:text-cream"
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="font-display text-2xl font-semibold tracking-tight text-cream">
Utilisateur
</h1>
</div>
{/* Profile card */}
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card p-6">
<div className="flex items-start gap-4">
<Avatar className="h-14 w-14 ring-2 ring-[oklch(0.25_0.006_60)]">
<AvatarFallback className="bg-vine/10 text-vine text-lg font-semibold">
{user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="flex items-center gap-2.5 mb-1">
<h2 className="text-lg font-semibold text-cream">{user.name}</h2>
<Badge
variant="secondary"
className={`text-[11px] font-medium border ${
user.role === "ADMIN"
? "bg-[#A78BFA]/10 text-[#A78BFA] border-[#A78BFA]/20"
: "bg-[oklch(0.18_0.005_60)] text-stone-400 border-[oklch(0.25_0.006_60)]"
}`}
>
{user.role}
</Badge>
{user.banned && (
<Badge variant="secondary" className="text-[11px] font-medium bg-wine/10 text-[#FB7185] border border-wine/20">
Banni
</Badge>
)}
</div>
<p className="text-sm text-stone-400">{user.email}</p>
<p className="text-[11px] text-stone-600 mt-1">
Inscrit le {formatDate(user.createdAt)}
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-6">
{STAT_ITEMS.map((stat) => (
<div
key={stat.label}
className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-[oklch(0.12_0.005_60)] p-3.5 text-center"
>
<stat.icon className={`h-4 w-4 mx-auto mb-1.5 ${stat.color}`} strokeWidth={1.5} />
<p className="text-sm font-semibold text-cream font-mono">{stat.value}</p>
<p className="text-[10px] text-stone-600 uppercase tracking-wider mt-0.5">{stat.label}</p>
</div>
))}
</div>
</div>
{/* Admin actions */}
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card p-6 space-y-5">
<p className="text-[11px] font-semibold text-gold/70 uppercase tracking-[0.1em]">
Actions admin
</p>
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium text-cream">Role</p>
<p className="text-[11px] text-stone-600">Modifier le role de l&apos;utilisateur</p>
</div>
<Select value={user.role} onValueChange={(role) => handleUpdate({ role })}>
<SelectTrigger className="w-32 rounded-xl bg-[oklch(0.12_0.005_60)] border-[oklch(0.22_0.005_60)] text-cream">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USER">USER</SelectItem>
<SelectItem value="ADMIN">ADMIN</SelectItem>
</SelectContent>
</Select>
</div>
<div className="h-px bg-[oklch(0.20_0.006_60)]" />
<div className="flex items-center justify-between">
<div>
<p className="text-[13px] font-medium text-cream">Bannir</p>
<p className="text-[11px] text-stone-600">Empecher l&apos;acces a l&apos;application</p>
</div>
<Switch
checked={user.banned}
onCheckedChange={(banned) => handleUpdate({ banned })}
/>
</div>
</div>
{/* Scan history */}
<div className="rounded-xl border border-[oklch(0.22_0.006_60)] bg-card overflow-hidden">
<div className="px-6 py-4 border-b border-[oklch(0.20_0.006_60)]">
<h3 className="text-sm font-semibold text-cream">Historique des scans</h3>
</div>
<div className="divide-y divide-[oklch(0.20_0.006_60)]">
{user.scans.length === 0 ? (
<p className="text-sm text-stone-600 text-center py-8">Aucun scan</p>
) : (
user.scans.map((scan) => (
<div
key={scan.id}
className="flex items-center justify-between px-6 py-3 hover:bg-[oklch(0.16_0.005_60)] transition-colors"
>
<div>
<p className="text-[13px] font-medium text-cream">{scan.disease?.name || "Non identifie"}</p>
<p className="text-[11px] text-stone-600">{formatDate(scan.createdAt)}</p>
</div>
<div className="flex items-center gap-2.5">
{scan.disease?.severity && (
<Badge
variant="secondary"
className={`text-[11px] font-medium border ${SEVERITY_STYLES[scan.disease.severity]}`}
>
{scan.disease.severity}
</Badge>
)}
<span className="text-[12px] font-mono font-medium text-vine">
{Math.round(scan.confidence * 100)}%
</span>
</div>
</div>
))
)}
</div>
</div>
</div>
);
}