diff --git a/vineye-admin/.gitignore b/vineye-admin/.gitignore new file mode 100644 index 0000000..45254b6 --- /dev/null +++ b/vineye-admin/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/app/generated/prisma diff --git a/vineye-admin/AGENTS.md b/vineye-admin/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/vineye-admin/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/vineye-admin/CLAUDE.md b/vineye-admin/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/vineye-admin/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/vineye-admin/README.md b/vineye-admin/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/vineye-admin/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/vineye-admin/app/(admin)/alerts/[id]/edit/page.tsx b/vineye-admin/app/(admin)/alerts/[id]/edit/page.tsx new file mode 100644 index 0000000..54445d3 --- /dev/null +++ b/vineye-admin/app/(admin)/alerts/[id]/edit/page.tsx @@ -0,0 +1,32 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import AlertForm from "@/components/admin/alert-form"; + +export default async function EditAlertPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const alert = await prisma.seasonAlert.findUnique({ where: { id } }); + if (!alert) notFound(); + + return ( + + ); +} diff --git a/vineye-admin/app/(admin)/alerts/alerts-client.tsx b/vineye-admin/app/(admin)/alerts/alerts-client.tsx new file mode 100644 index 0000000..95e34ba --- /dev/null +++ b/vineye-admin/app/(admin)/alerts/alerts-client.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Plus, Search, Pencil } from "lucide-react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import DeleteDialog from "@/components/admin/delete-dialog"; +import { toast } from "sonner"; +import { cn, formatDateShort } from "@/lib/utils"; + +type Alert = { + id: string; + title: string; + type: string; + region: string; + active: boolean; + activeFrom: Date; + activeTo: Date | null; + createdAt: Date; +}; + +const TYPE_STYLES: Record = { + WARNING: "bg-gold/10 text-gold border-gold/20", + INFO: "bg-[#60A5FA]/10 text-[#60A5FA] border-[#60A5FA]/20", + DANGER: "bg-wine/10 text-[#FB7185] border-wine/20", +}; + +export default function AlertsClient({ alerts }: { alerts: Alert[] }) { + const router = useRouter(); + const [search, setSearch] = useState(""); + + const filtered = alerts.filter( + (a) => + a.title.toLowerCase().includes(search.toLowerCase()) || + a.region.toLowerCase().includes(search.toLowerCase()) + ); + + async function handleToggleActive(id: string, active: boolean) { + try { + const alert = alerts.find((a) => a.id === id); + if (!alert) return; + const res = await fetch(`/api/alerts/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...alert, active }), + }); + if (!res.ok) throw new Error(); + toast.success(active ? "Alerte activee" : "Alerte desactivee"); + router.refresh(); + } catch { + toast.error("Erreur"); + } + } + + async function handleDelete(id: string) { + const res = await fetch(`/api/alerts/${id}`, { method: "DELETE" }); + if (!res.ok) { + toast.error("Erreur lors de la suppression"); + return; + } + toast.success("Alerte supprimee"); + router.refresh(); + } + + return ( +
+
+
+

+ Alertes saisonnieres +

+

{alerts.length} alertes

+
+ + + Ajouter + +
+ +
+ + setSearch(e.target.value)} + className="pl-9 rounded-xl bg-card border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40" + /> +
+ +
+ + + + Titre + Type + Region + Periode + Active + Actions + + + + {filtered.map((alert) => ( + + {alert.title} + + + {alert.type} + + + {alert.region} + + {formatDateShort(alert.activeFrom)} + {alert.activeTo && ` — ${formatDateShort(alert.activeTo)}`} + + + handleToggleActive(alert.id, checked)} + /> + + +
+ + + + handleDelete(alert.id)} + /> +
+
+
+ ))} +
+
+ {filtered.length === 0 && ( +

Aucune alerte trouvee

+ )} +
+ + {/* Mobile */} +
+ {filtered.map((alert) => ( +
+
+

{alert.title}

+ + + +
+
+ + {alert.type} + + {alert.region} + handleToggleActive(alert.id, checked)} + className="ml-auto" + /> +
+
+ ))} +
+
+ ); +} diff --git a/vineye-admin/app/(admin)/alerts/new/page.tsx b/vineye-admin/app/(admin)/alerts/new/page.tsx new file mode 100644 index 0000000..de599b6 --- /dev/null +++ b/vineye-admin/app/(admin)/alerts/new/page.tsx @@ -0,0 +1,5 @@ +import AlertForm from "@/components/admin/alert-form"; + +export default function NewAlertPage() { + return ; +} diff --git a/vineye-admin/app/(admin)/alerts/page.tsx b/vineye-admin/app/(admin)/alerts/page.tsx new file mode 100644 index 0000000..05962e9 --- /dev/null +++ b/vineye-admin/app/(admin)/alerts/page.tsx @@ -0,0 +1,12 @@ +import { prisma } from "@/lib/prisma"; +import AlertsClient from "./alerts-client"; + +export const dynamic = "force-dynamic"; + +export default async function AlertsPage() { + const alerts = await prisma.seasonAlert.findMany({ + orderBy: { createdAt: "desc" }, + }); + + return ; +} diff --git a/vineye-admin/app/(admin)/dashboard/dashboard-client.tsx b/vineye-admin/app/(admin)/dashboard/dashboard-client.tsx new file mode 100644 index 0000000..e6ae627 --- /dev/null +++ b/vineye-admin/app/(admin)/dashboard/dashboard-client.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { Users, ScanLine, Bug, AlertTriangle } from "lucide-react"; +import StatCard from "@/components/admin/stat-card"; +import { Badge } from "@/components/ui/badge"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; + +interface DashboardProps { + stats: { + totalUsers: number; + scansThisMonth: number; + totalDiseases: number; + activeAlerts: number; + }; + recentScans: { + id: string; + userName: string; + diseaseName: string; + confidence: number; + date: string; + }[]; + topDiseases: { name: string; count: number }[]; +} + +export default function DashboardClient({ stats, recentScans, topDiseases }: DashboardProps) { + const today = format(new Date(), "EEEE d MMMM yyyy", { locale: fr }); + const maxCount = topDiseases[0]?.count || 1; + + return ( +
+ {/* Header */} +
+

+ Tableau de bord +

+

{today}

+
+ + {/* Stats */} +
+ + + + +
+ +
+ {/* Recent scans */} +
+
+

Scans recents

+
+
+ {recentScans.length === 0 ? ( +

+ Aucun scan pour le moment +

+ ) : ( + recentScans.map((scan) => ( +
+
+

{scan.diseaseName}

+

{scan.userName}

+
+
+ + {scan.confidence}% + + {scan.date} +
+
+ )) + )} +
+
+ + {/* Top diseases */} +
+
+

Maladies les plus detectees

+
+
+ {topDiseases.length === 0 ? ( +

+ Pas assez de donnees +

+ ) : ( + topDiseases.map((disease, i) => ( +
+
+
+ + {String(i + 1).padStart(2, "0")} + + {disease.name} +
+ + {disease.count} + +
+
+
+
+
+ )) + )} +
+
+
+
+ ); +} diff --git a/vineye-admin/app/(admin)/dashboard/page.tsx b/vineye-admin/app/(admin)/dashboard/page.tsx new file mode 100644 index 0000000..8507c9a --- /dev/null +++ b/vineye-admin/app/(admin)/dashboard/page.tsx @@ -0,0 +1,74 @@ +import { prisma } from "@/lib/prisma"; +import { formatDate } from "@/lib/utils"; +import DashboardClient from "./dashboard-client"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const [totalUsers, totalDiseases, activeAlerts, scansThisMonth, recentScans, topDiseases] = + await Promise.all([ + prisma.user.count(), + prisma.disease.count(), + prisma.seasonAlert.count({ where: { active: true } }), + prisma.scan.count({ + where: { + createdAt: { + gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1), + }, + }, + }), + prisma.scan.findMany({ + select: { + id: true, + confidence: true, + createdAt: true, + user: { select: { name: true } }, + disease: { select: { name: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 8, + }), + prisma.scan + .groupBy({ + by: ["diseaseId"], + _count: { id: true }, + where: { diseaseId: { not: null } }, + orderBy: { _count: { id: "desc" } }, + take: 5, + }) + .then(async (stats) => { + const ids = stats + .map((s) => s.diseaseId) + .filter((id): id is string => id !== null); + const diseases = await prisma.disease.findMany({ + where: { id: { in: ids } }, + select: { id: true, name: true }, + }); + return stats.map((stat) => ({ + name: diseases.find((d) => d.id === stat.diseaseId)?.name || "Inconnu", + count: stat._count.id, + })); + }), + ]); + + const formattedScans = recentScans.map((scan) => ({ + id: scan.id, + userName: scan.user.name, + diseaseName: scan.disease?.name || "Non identifie", + confidence: Math.round(scan.confidence * 100), + date: formatDate(scan.createdAt), + })); + + return ( + + ); +} diff --git a/vineye-admin/app/(admin)/diseases/[id]/edit/page.tsx b/vineye-admin/app/(admin)/diseases/[id]/edit/page.tsx new file mode 100644 index 0000000..d07c6eb --- /dev/null +++ b/vineye-admin/app/(admin)/diseases/[id]/edit/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import DiseaseForm from "@/components/admin/disease-form"; + +export default async function EditDiseasePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const disease = await prisma.disease.findUnique({ where: { id } }); + if (!disease) notFound(); + + return ( + + ); +} diff --git a/vineye-admin/app/(admin)/diseases/diseases-client.tsx b/vineye-admin/app/(admin)/diseases/diseases-client.tsx new file mode 100644 index 0000000..70247ed --- /dev/null +++ b/vineye-admin/app/(admin)/diseases/diseases-client.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Plus, Search, Pencil } from "lucide-react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Switch } from "@/components/ui/switch"; +import DeleteDialog from "@/components/admin/delete-dialog"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +type Disease = { + id: string; + name: string; + scientificName: string; + slug: string; + type: string; + severity: string; + published: boolean; + createdAt: Date; + _count: { scans: number }; +}; + +const TYPE_LABELS: Record = { + FUNGAL: "Fongique", + BACTERIAL: "Bacterien", + PEST: "Ravageur", + ABIOTIC: "Carence", +}; + +const TYPE_STYLES: Record = { + FUNGAL: "bg-vine/10 text-vine border-vine/20", + BACTERIAL: "bg-[#A78BFA]/10 text-[#A78BFA] border-[#A78BFA]/20", + PEST: "bg-gold/10 text-gold border-gold/20", + ABIOTIC: "bg-[#60A5FA]/10 text-[#60A5FA] border-[#60A5FA]/20", +}; + +const SEVERITY_STYLES: Record = { + 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", +}; + +const SEVERITY_LABELS: Record = { + HIGH: "Critique", + MEDIUM: "Modere", + LOW: "Faible", +}; + +export default function DiseasesClient({ diseases }: { diseases: Disease[] }) { + const router = useRouter(); + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState("ALL"); + + const filtered = diseases.filter((d) => { + const matchSearch = + d.name.toLowerCase().includes(search.toLowerCase()) || + d.scientificName.toLowerCase().includes(search.toLowerCase()); + const matchType = typeFilter === "ALL" || d.type === typeFilter; + return matchSearch && matchType; + }); + + async function handleTogglePublish(id: string, published: boolean) { + try { + const disease = diseases.find((d) => d.id === id); + if (!disease) return; + + const res = await fetch(`/api/diseases/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...disease, + published, + symptoms: [], + description: "placeholder", + treatment: "placeholder", + season: "placeholder", + }), + }); + + if (!res.ok) throw new Error(); + toast.success(published ? "Maladie publiee" : "Maladie depubliee"); + router.refresh(); + } catch { + toast.error("Erreur lors de la mise a jour"); + } + } + + async function handleDelete(id: string) { + const res = await fetch(`/api/diseases/${id}`, { method: "DELETE" }); + if (!res.ok) { + toast.error("Erreur lors de la suppression"); + return; + } + toast.success("Maladie supprimee"); + router.refresh(); + } + + return ( +
+ {/* Header */} +
+
+

+ Maladies de la vigne +

+

{diseases.length} maladies repertoriees

+
+ + + Ajouter + +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-9 rounded-xl bg-card border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40" + /> +
+
+ {["ALL", "FUNGAL", "BACTERIAL", "PEST", "ABIOTIC"].map((type) => ( + + ))} +
+
+ + {/* Table (desktop) */} +
+ + + + Nom + Type + Severite + Scans + Publie + Actions + + + + {filtered.map((disease) => ( + + +
+

{disease.name}

+ {disease.scientificName && ( +

{disease.scientificName}

+ )} +
+
+ + + {TYPE_LABELS[disease.type]} + + + + + {SEVERITY_LABELS[disease.severity]} + + + + {disease._count.scans} + + + handleTogglePublish(disease.id, checked)} + /> + + +
+ + + + handleDelete(disease.id)} + /> +
+
+
+ ))} +
+
+ {filtered.length === 0 && ( +

+ Aucune maladie trouvee +

+ )} +
+ + {/* Cards (mobile) */} +
+ {filtered.map((disease) => ( +
+
+
+

{disease.name}

+ {disease.scientificName && ( +

{disease.scientificName}

+ )} +
+ + + +
+
+ + {TYPE_LABELS[disease.type]} + + + {SEVERITY_LABELS[disease.severity]} + + {disease._count.scans} scans +
+
+ ))} +
+
+ ); +} diff --git a/vineye-admin/app/(admin)/diseases/new/page.tsx b/vineye-admin/app/(admin)/diseases/new/page.tsx new file mode 100644 index 0000000..38200dd --- /dev/null +++ b/vineye-admin/app/(admin)/diseases/new/page.tsx @@ -0,0 +1,5 @@ +import DiseaseForm from "@/components/admin/disease-form"; + +export default function NewDiseasePage() { + return ; +} diff --git a/vineye-admin/app/(admin)/diseases/page.tsx b/vineye-admin/app/(admin)/diseases/page.tsx new file mode 100644 index 0000000..b71f530 --- /dev/null +++ b/vineye-admin/app/(admin)/diseases/page.tsx @@ -0,0 +1,23 @@ +import { prisma } from "@/lib/prisma"; +import DiseasesClient from "./diseases-client"; + +export const dynamic = "force-dynamic"; + +export default async function DiseasesPage() { + const diseases = await prisma.disease.findMany({ + orderBy: { createdAt: "desc" }, + select: { + id: true, + name: true, + scientificName: true, + slug: true, + type: true, + severity: true, + published: true, + createdAt: true, + _count: { select: { scans: true } }, + }, + }); + + return ; +} diff --git a/vineye-admin/app/(admin)/guides/[id]/edit/page.tsx b/vineye-admin/app/(admin)/guides/[id]/edit/page.tsx new file mode 100644 index 0000000..fa8bdd5 --- /dev/null +++ b/vineye-admin/app/(admin)/guides/[id]/edit/page.tsx @@ -0,0 +1,35 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import GuideForm from "@/components/admin/guide-form"; + +export default async function EditGuidePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const guide = await prisma.guide.findUnique({ where: { id } }); + if (!guide) notFound(); + + return ( + + ); +} diff --git a/vineye-admin/app/(admin)/guides/guides-client.tsx b/vineye-admin/app/(admin)/guides/guides-client.tsx new file mode 100644 index 0000000..a38ce62 --- /dev/null +++ b/vineye-admin/app/(admin)/guides/guides-client.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Plus, Search, Pencil } from "lucide-react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Switch } from "@/components/ui/switch"; +import DeleteDialog from "@/components/admin/delete-dialog"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; + +type Guide = { + id: string; + title: string; + slug: string; + subtitle: string; + category: string; + order: number; + published: boolean; + createdAt: Date; +}; + +const CATEGORY_STYLES: Record = { + diagnostic: "bg-vine/10 text-vine border-vine/20", + traitement: "bg-gold/10 text-gold border-gold/20", + cepages: "bg-[#A78BFA]/10 text-[#A78BFA] border-[#A78BFA]/20", + general: "bg-[#60A5FA]/10 text-[#60A5FA] border-[#60A5FA]/20", +}; + +export default function GuidesClient({ guides }: { guides: Guide[] }) { + const router = useRouter(); + const [search, setSearch] = useState(""); + + const filtered = guides.filter( + (g) => + g.title.toLowerCase().includes(search.toLowerCase()) || + g.category.toLowerCase().includes(search.toLowerCase()) + ); + + async function handleDelete(id: string) { + const res = await fetch(`/api/guides/${id}`, { method: "DELETE" }); + if (!res.ok) { + toast.error("Erreur lors de la suppression"); + return; + } + toast.success("Guide supprime"); + router.refresh(); + } + + return ( +
+
+
+

+ Guides +

+

{guides.length} guides

+
+ + + Ajouter + +
+ +
+ + setSearch(e.target.value)} + className="pl-9 rounded-xl bg-card border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40" + /> +
+ +
+ + + + Titre + Categorie + Ordre + Publie + Actions + + + + {filtered.map((guide) => ( + + +
+

{guide.title}

+

{guide.subtitle}

+
+
+ + + {guide.category} + + + + {guide.order} + + + + + +
+ + + + handleDelete(guide.id)} + /> +
+
+
+ ))} +
+
+ {filtered.length === 0 && ( +

Aucun guide trouve

+ )} +
+ + {/* Mobile cards */} +
+ {filtered.map((guide) => ( +
+
+
+

{guide.title}

+

{guide.subtitle}

+
+ + + +
+
+ + {guide.category} + + #{guide.order} +
+
+ ))} +
+
+ ); +} diff --git a/vineye-admin/app/(admin)/guides/new/page.tsx b/vineye-admin/app/(admin)/guides/new/page.tsx new file mode 100644 index 0000000..12bf199 --- /dev/null +++ b/vineye-admin/app/(admin)/guides/new/page.tsx @@ -0,0 +1,5 @@ +import GuideForm from "@/components/admin/guide-form"; + +export default function NewGuidePage() { + return ; +} diff --git a/vineye-admin/app/(admin)/guides/page.tsx b/vineye-admin/app/(admin)/guides/page.tsx new file mode 100644 index 0000000..c480763 --- /dev/null +++ b/vineye-admin/app/(admin)/guides/page.tsx @@ -0,0 +1,12 @@ +import { prisma } from "@/lib/prisma"; +import GuidesClient from "./guides-client"; + +export const dynamic = "force-dynamic"; + +export default async function GuidesPage() { + const guides = await prisma.guide.findMany({ + orderBy: { order: "asc" }, + }); + + return ; +} diff --git a/vineye-admin/app/(admin)/layout.tsx b/vineye-admin/app/(admin)/layout.tsx new file mode 100644 index 0000000..27f4b05 --- /dev/null +++ b/vineye-admin/app/(admin)/layout.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useState } from "react"; +import { useSession } from "@/lib/auth-client"; +import Sidebar from "@/components/admin/sidebar"; +import Header from "@/components/admin/header"; +import { Sheet, SheetContent } from "@/components/ui/sheet"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const [sidebarOpen, setSidebarOpen] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const { data: session } = useSession(); + + const userName = session?.user?.name || "Admin"; + const userEmail = session?.user?.email || ""; + + return ( +
+ {/* Desktop sidebar */} +
+ setCollapsed(!collapsed)} + userName={userName} + userEmail={userEmail} + /> +
+ + {/* Mobile sidebar */} + + + + + + + {/* Main content */} +
+
setSidebarOpen(true)} userName={userName} /> +
+
{children}
+
+
+
+ ); +} diff --git a/vineye-admin/app/(admin)/users/[id]/page.tsx b/vineye-admin/app/(admin)/users/[id]/page.tsx new file mode 100644 index 0000000..d83a83f --- /dev/null +++ b/vineye-admin/app/(admin)/users/[id]/page.tsx @@ -0,0 +1,43 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import UserDetailClient from "./user-detail-client"; + +export const dynamic = "force-dynamic"; + +export default async function UserDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + role: true, + xp: true, + level: true, + banned: true, + bannedReason: true, + createdAt: true, + scans: { + select: { + id: true, + confidence: true, + createdAt: true, + disease: { select: { name: true, severity: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 20, + }, + _count: { select: { scans: true } }, + }, + }); + + if (!user) notFound(); + + return ; +} diff --git a/vineye-admin/app/(admin)/users/[id]/user-detail-client.tsx b/vineye-admin/app/(admin)/users/[id]/user-detail-client.tsx new file mode 100644 index 0000000..827e3fd --- /dev/null +++ b/vineye-admin/app/(admin)/users/[id]/user-detail-client.tsx @@ -0,0 +1,207 @@ +"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 = { + 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) { + 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 ( +
+
+ +

+ Utilisateur +

+
+ + {/* Profile card */} +
+
+ + + {user.name.charAt(0).toUpperCase()} + + +
+
+

{user.name}

+ + {user.role} + + {user.banned && ( + + Banni + + )} +
+

{user.email}

+

+ Inscrit le {formatDate(user.createdAt)} +

+
+
+ + {/* Stats */} +
+ {STAT_ITEMS.map((stat) => ( +
+ +

{stat.value}

+

{stat.label}

+
+ ))} +
+
+ + {/* Admin actions */} +
+

+ Actions admin +

+
+
+

Role

+

Modifier le role de l'utilisateur

+
+ +
+
+
+
+

Bannir

+

Empecher l'acces a l'application

+
+ handleUpdate({ banned })} + /> +
+
+ + {/* Scan history */} +
+
+

Historique des scans

+
+
+ {user.scans.length === 0 ? ( +

Aucun scan

+ ) : ( + user.scans.map((scan) => ( +
+
+

{scan.disease?.name || "Non identifie"}

+

{formatDate(scan.createdAt)}

+
+
+ {scan.disease?.severity && ( + + {scan.disease.severity} + + )} + + {Math.round(scan.confidence * 100)}% + +
+
+ )) + )} +
+
+
+ ); +} diff --git a/vineye-admin/app/(admin)/users/page.tsx b/vineye-admin/app/(admin)/users/page.tsx new file mode 100644 index 0000000..50e8e84 --- /dev/null +++ b/vineye-admin/app/(admin)/users/page.tsx @@ -0,0 +1,23 @@ +import { prisma } from "@/lib/prisma"; +import UsersClient from "./users-client"; + +export const dynamic = "force-dynamic"; + +export default async function UsersPage() { + const users = await prisma.user.findMany({ + select: { + id: true, + name: true, + email: true, + role: true, + xp: true, + level: true, + banned: true, + createdAt: true, + _count: { select: { scans: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return ; +} diff --git a/vineye-admin/app/(admin)/users/users-client.tsx b/vineye-admin/app/(admin)/users/users-client.tsx new file mode 100644 index 0000000..14ec026 --- /dev/null +++ b/vineye-admin/app/(admin)/users/users-client.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { formatDateShort } from "@/lib/utils"; + +type User = { + id: string; + name: string; + email: string; + role: string; + xp: number; + level: number; + banned: boolean; + createdAt: Date; + _count: { scans: number }; +}; + +export default function UsersClient({ users }: { users: User[] }) { + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState("ALL"); + + const filtered = users.filter((u) => { + const matchSearch = + u.name.toLowerCase().includes(search.toLowerCase()) || + u.email.toLowerCase().includes(search.toLowerCase()); + const matchRole = roleFilter === "ALL" || u.role === roleFilter; + return matchSearch && matchRole; + }); + + return ( +
+
+

+ Utilisateurs +

+

{users.length} utilisateurs

+
+ +
+
+ + setSearch(e.target.value)} + className="pl-9 rounded-xl bg-card border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40" + /> +
+
+ {["ALL", "USER", "ADMIN"].map((role) => ( + + ))} +
+
+ +
+ + + + Utilisateur + Role + XP + Niveau + Scans + Inscrit + Statut + + + + {filtered.map((user) => ( + + + + + + {user.name.charAt(0).toUpperCase()} + + +
+

{user.name}

+

{user.email}

+
+ +
+ + + {user.role} + + + {user.xp} + {user.level} + {user._count.scans} + {formatDateShort(user.createdAt)} + + {user.banned ? ( + + Banni + + ) : ( + + + Actif + + )} + +
+ ))} +
+
+ {filtered.length === 0 && ( +

Aucun utilisateur trouve

+ )} +
+ + {/* Mobile cards */} +
+ {filtered.map((user) => ( + +
+
+ + + {user.name.charAt(0).toUpperCase()} + + +
+

{user.name}

+

{user.email}

+
+ + {user.role} + +
+
+ + ))} +
+
+ ); +} diff --git a/vineye-admin/app/(auth)/layout.tsx b/vineye-admin/app/(auth)/layout.tsx new file mode 100644 index 0000000..826d38c --- /dev/null +++ b/vineye-admin/app/(auth)/layout.tsx @@ -0,0 +1,10 @@ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {/* Ambient glow */} +
+
+ {children} +
+ ); +} diff --git a/vineye-admin/app/(auth)/login/page.tsx b/vineye-admin/app/(auth)/login/page.tsx new file mode 100644 index 0000000..d658c21 --- /dev/null +++ b/vineye-admin/app/(auth)/login/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Grape, Loader2, Eye, EyeOff } from "lucide-react"; +import { signIn } from "@/lib/auth-client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (loading) return; + + const trimmedEmail = email.trim().toLowerCase(); + if (!trimmedEmail || !password) { + toast.error("Veuillez remplir tous les champs"); + return; + } + + setLoading(true); + try { + await signIn.email( + { email: trimmedEmail, password }, + { + onSuccess: () => { + router.push("/dashboard"); + }, + onError: (ctx) => { + toast.error(ctx.error.message || "Identifiants incorrects"); + }, + } + ); + } catch { + toast.error("Une erreur est survenue"); + } finally { + setLoading(false); + } + } + + return ( +
+ {/* Logo */} +
+
+ +
+

+ VinEye Admin +

+

+ Connectez-vous pour acceder au panel +

+
+ + {/* Form card */} +
+
+
+ + setEmail(e.target.value)} + placeholder="admin@vineye.app" + className="h-11 rounded-xl bg-[oklch(0.12_0.005_60)] border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40 focus:ring-vine/20 transition-colors" + autoComplete="email" + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + className="h-11 rounded-xl bg-[oklch(0.12_0.005_60)] border-[oklch(0.22_0.005_60)] text-cream placeholder:text-stone-700 focus:border-vine/40 focus:ring-vine/20 pr-10 transition-colors" + autoComplete="current-password" + required + /> + +
+
+ + +
+
+ + {/* Footer */} +

+ VinEye — Detection des maladies de la vigne +

+
+ ); +} diff --git a/vineye-admin/app/api/alerts/[id]/route.ts b/vineye-admin/app/api/alerts/[id]/route.ts new file mode 100644 index 0000000..61c2757 --- /dev/null +++ b/vineye-admin/app/api/alerts/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { alertSchema } from "@/lib/validations"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const alert = await prisma.seasonAlert.findUnique({ where: { id } }); + if (!alert) { + return Response.json({ error: "Alerte introuvable" }, { status: 404 }); + } + + return Response.json({ data: alert }); +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.seasonAlert.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Alerte introuvable" }, { status: 404 }); + } + + const body = await request.json(); + const result = alertSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const alert = await prisma.seasonAlert.update({ + where: { id }, + data: result.data, + }); + + return Response.json({ data: alert }); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.seasonAlert.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Alerte introuvable" }, { status: 404 }); + } + + await prisma.seasonAlert.delete({ where: { id } }); + return Response.json({ message: "Alerte supprimee" }); +} diff --git a/vineye-admin/app/api/alerts/route.ts b/vineye-admin/app/api/alerts/route.ts new file mode 100644 index 0000000..d2e3701 --- /dev/null +++ b/vineye-admin/app/api/alerts/route.ts @@ -0,0 +1,44 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { alertSchema } from "@/lib/validations"; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const region = searchParams.get("region"); + const active = searchParams.get("active"); + + const where: Record = {}; + if (region) where.region = region; + if (active !== null) where.active = active === "true"; + + const alerts = await prisma.seasonAlert.findMany({ + where, + orderBy: { createdAt: "desc" }, + }); + + return Response.json({ data: alerts }); +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const body = await request.json(); + const result = alertSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const alert = await prisma.seasonAlert.create({ + data: result.data, + }); + + return Response.json({ data: alert }, { status: 201 }); +} diff --git a/vineye-admin/app/api/auth/[...all]/route.ts b/vineye-admin/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..5b67b06 --- /dev/null +++ b/vineye-admin/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/vineye-admin/app/api/diseases/[id]/route.ts b/vineye-admin/app/api/diseases/[id]/route.ts new file mode 100644 index 0000000..cd3346a --- /dev/null +++ b/vineye-admin/app/api/diseases/[id]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { diseaseSchema } from "@/lib/validations"; +import { slugify } from "@/lib/utils"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const disease = await prisma.disease.findUnique({ where: { id } }); + if (!disease) { + return Response.json({ error: "Maladie introuvable" }, { status: 404 }); + } + + return Response.json({ data: disease }); +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.disease.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Maladie introuvable" }, { status: 404 }); + } + + const body = await request.json(); + const result = diseaseSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const data = result.data; + const slug = data.slug || slugify(data.name); + + const slugConflict = await prisma.disease.findFirst({ + where: { slug, id: { not: id } }, + }); + if (slugConflict) { + return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); + } + + const disease = await prisma.disease.update({ + where: { id }, + data: { ...data, slug }, + }); + + return Response.json({ data: disease }); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.disease.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Maladie introuvable" }, { status: 404 }); + } + + await prisma.disease.delete({ where: { id } }); + return Response.json({ message: "Maladie supprimee" }); +} diff --git a/vineye-admin/app/api/diseases/route.ts b/vineye-admin/app/api/diseases/route.ts new file mode 100644 index 0000000..bd4972b --- /dev/null +++ b/vineye-admin/app/api/diseases/route.ts @@ -0,0 +1,53 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { diseaseSchema } from "@/lib/validations"; +import { slugify } from "@/lib/utils"; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const type = searchParams.get("type"); + const published = searchParams.get("published"); + + const where: Record = {}; + if (type) where.type = type; + if (published !== null) where.published = published === "true"; + + const diseases = await prisma.disease.findMany({ + where, + orderBy: { createdAt: "desc" }, + }); + + return Response.json({ data: diseases }); +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const body = await request.json(); + const result = diseaseSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const data = result.data; + const slug = data.slug || slugify(data.name); + + const existing = await prisma.disease.findUnique({ where: { slug } }); + if (existing) { + return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); + } + + const disease = await prisma.disease.create({ + data: { ...data, slug }, + }); + + return Response.json({ data: disease }, { status: 201 }); +} diff --git a/vineye-admin/app/api/guides/[id]/route.ts b/vineye-admin/app/api/guides/[id]/route.ts new file mode 100644 index 0000000..c4bb618 --- /dev/null +++ b/vineye-admin/app/api/guides/[id]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { guideSchema } from "@/lib/validations"; +import { slugify } from "@/lib/utils"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + + const guide = await prisma.guide.findUnique({ where: { id } }); + if (!guide) { + return Response.json({ error: "Guide introuvable" }, { status: 404 }); + } + + return Response.json({ data: guide }); +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.guide.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Guide introuvable" }, { status: 404 }); + } + + const body = await request.json(); + const result = guideSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const data = result.data; + const slug = data.slug || slugify(data.title); + + const slugConflict = await prisma.guide.findFirst({ + where: { slug, id: { not: id } }, + }); + if (slugConflict) { + return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); + } + + const guide = await prisma.guide.update({ + where: { id }, + data: { ...data, slug }, + }); + + return Response.json({ data: guide }); +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + const existing = await prisma.guide.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Guide introuvable" }, { status: 404 }); + } + + await prisma.guide.delete({ where: { id } }); + return Response.json({ message: "Guide supprime" }); +} diff --git a/vineye-admin/app/api/guides/route.ts b/vineye-admin/app/api/guides/route.ts new file mode 100644 index 0000000..2608f36 --- /dev/null +++ b/vineye-admin/app/api/guides/route.ts @@ -0,0 +1,51 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { guideSchema } from "@/lib/validations"; +import { slugify } from "@/lib/utils"; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const published = searchParams.get("published"); + + const where: Record = {}; + if (published !== null) where.published = published === "true"; + + const guides = await prisma.guide.findMany({ + where, + orderBy: { order: "asc" }, + }); + + return Response.json({ data: guides }); +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const body = await request.json(); + const result = guideSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const data = result.data; + const slug = data.slug || slugify(data.title); + + const existing = await prisma.guide.findUnique({ where: { slug } }); + if (existing) { + return Response.json({ error: "Ce slug existe deja" }, { status: 409 }); + } + + const guide = await prisma.guide.create({ + data: { ...data, slug }, + }); + + return Response.json({ data: guide }, { status: 201 }); +} diff --git a/vineye-admin/app/api/scans/[id]/route.ts b/vineye-admin/app/api/scans/[id]/route.ts new file mode 100644 index 0000000..c76e03c --- /dev/null +++ b/vineye-admin/app/api/scans/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + + const scan = await prisma.scan.findUnique({ + where: { id }, + include: { + user: { select: { name: true, email: true } }, + disease: { select: { name: true, severity: true } }, + }, + }); + + if (!scan) { + return Response.json({ error: "Scan introuvable" }, { status: 404 }); + } + + return Response.json({ data: scan }); +} diff --git a/vineye-admin/app/api/scans/route.ts b/vineye-admin/app/api/scans/route.ts new file mode 100644 index 0000000..59e8793 --- /dev/null +++ b/vineye-admin/app/api/scans/route.ts @@ -0,0 +1,90 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAuth, requireAdmin } from "@/lib/auth-guard"; +import { scanSchema } from "@/lib/validations"; + +export async function GET() { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const [totalScans, scansThisMonth, recentScans, diseaseStats] = await Promise.all([ + prisma.scan.count(), + prisma.scan.count({ + where: { + createdAt: { + gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1), + }, + }, + }), + prisma.scan.findMany({ + select: { + id: true, + confidence: true, + createdAt: true, + user: { select: { name: true } }, + disease: { select: { name: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 10, + }), + prisma.scan.groupBy({ + by: ["diseaseId"], + _count: { id: true }, + where: { diseaseId: { not: null } }, + orderBy: { _count: { id: "desc" } }, + take: 5, + }), + ]); + + // Resolve disease names for stats + const diseaseIds = diseaseStats + .map((s) => s.diseaseId) + .filter((id): id is string => id !== null); + + const diseases = await prisma.disease.findMany({ + where: { id: { in: diseaseIds } }, + select: { id: true, name: true }, + }); + + const topDiseases = diseaseStats.map((stat) => ({ + name: diseases.find((d) => d.id === stat.diseaseId)?.name || "Inconnu", + count: stat._count.id, + })); + + return Response.json({ + data: { + totalScans, + scansThisMonth, + recentScans, + topDiseases, + }, + }); +} + +export async function POST(request: NextRequest) { + const auth = await requireAuth(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const body = await request.json(); + const result = scanSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const scan = await prisma.scan.create({ + data: { + ...result.data, + userId: auth.session.user.id, + }, + }); + + return Response.json({ data: scan }, { status: 201 }); +} diff --git a/vineye-admin/app/api/users/[id]/route.ts b/vineye-admin/app/api/users/[id]/route.ts new file mode 100644 index 0000000..a01c850 --- /dev/null +++ b/vineye-admin/app/api/users/[id]/route.ts @@ -0,0 +1,96 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; +import { z } from "zod/v4"; + +const patchUserSchema = z.object({ + role: z.enum(["USER", "ADMIN"]).optional(), + banned: z.boolean().optional(), + bannedReason: z.string().max(500).trim().optional().nullable(), +}); + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + role: true, + xp: true, + level: true, + banned: true, + bannedReason: true, + createdAt: true, + scans: { + select: { + id: true, + confidence: true, + createdAt: true, + disease: { select: { name: true, severity: true } }, + }, + orderBy: { createdAt: "desc" }, + take: 20, + }, + _count: { select: { scans: true } }, + }, + }); + + if (!user) { + return Response.json({ error: "Utilisateur introuvable" }, { status: 404 }); + } + + return Response.json({ data: user }); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { id } = await params; + + const existing = await prisma.user.findUnique({ where: { id } }); + if (!existing) { + return Response.json({ error: "Utilisateur introuvable" }, { status: 404 }); + } + + const body = await request.json(); + const result = patchUserSchema.safeParse(body); + + if (!result.success) { + return Response.json( + { error: "Validation failed", details: result.error.flatten() }, + { status: 400 } + ); + } + + const user = await prisma.user.update({ + where: { id }, + data: result.data, + select: { + id: true, + name: true, + email: true, + role: true, + banned: true, + bannedReason: true, + }, + }); + + return Response.json({ data: user }); +} diff --git a/vineye-admin/app/api/users/route.ts b/vineye-admin/app/api/users/route.ts new file mode 100644 index 0000000..90cb66e --- /dev/null +++ b/vineye-admin/app/api/users/route.ts @@ -0,0 +1,41 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/auth-guard"; + +export async function GET(request: NextRequest) { + const auth = await requireAdmin(); + if ("error" in auth) { + return Response.json({ error: auth.error }, { status: auth.status }); + } + + const { searchParams } = request.nextUrl; + const role = searchParams.get("role"); + const search = searchParams.get("search"); + + const where: Record = {}; + if (role) where.role = role; + if (search) { + where.OR = [ + { name: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + ]; + } + + const users = await prisma.user.findMany({ + where, + select: { + id: true, + name: true, + email: true, + role: true, + xp: true, + level: true, + banned: true, + createdAt: true, + _count: { select: { scans: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + + return Response.json({ data: users }); +} diff --git a/vineye-admin/app/favicon.ico b/vineye-admin/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/vineye-admin/app/favicon.ico differ diff --git a/vineye-admin/app/globals.css b/vineye-admin/app/globals.css new file mode 100644 index 0000000..7c1ebef --- /dev/null +++ b/vineye-admin/app/globals.css @@ -0,0 +1,207 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-outfit); + --font-mono: var(--font-mono); + --font-display: var(--font-fraunces); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); + + /* VinEye "Cave a Vin" palette */ + --color-vine: #4ADE80; + --color-vine-dim: #166534; + --color-gold: #D97706; + --color-gold-dim: #92400E; + --color-wine: #BE123C; + --color-cream: #FAFAF9; + --color-stone-800: #292524; + --color-stone-700: #44403C; + --color-stone-600: #57534E; + --color-stone-400: #A8A29E; +} + +:root { + /* Warm stone dark palette — "Cave a Vin" */ + --background: oklch(0.11 0.005 60); + --foreground: oklch(0.93 0.005 80); + --card: oklch(0.15 0.005 60); + --card-foreground: oklch(0.93 0.005 80); + --popover: oklch(0.15 0.005 60); + --popover-foreground: oklch(0.93 0.005 80); + --primary: oklch(0.72 0.19 150); + --primary-foreground: oklch(0.13 0.01 150); + --secondary: oklch(0.19 0.005 60); + --secondary-foreground: oklch(0.85 0.005 80); + --muted: oklch(0.19 0.005 60); + --muted-foreground: oklch(0.58 0.01 75); + --accent: oklch(0.19 0.008 60); + --accent-foreground: oklch(0.93 0.005 80); + --destructive: oklch(0.55 0.22 25); + --border: oklch(0.23 0.006 60); + --input: oklch(0.20 0.005 60); + --ring: oklch(0.72 0.19 150); + --radius: 0.625rem; + + --chart-1: oklch(0.72 0.19 150); + --chart-2: oklch(0.68 0.14 85); + --chart-3: oklch(0.65 0.16 300); + --chart-4: oklch(0.70 0.13 230); + --chart-5: oklch(0.62 0.20 25); + + --sidebar: oklch(0.13 0.005 60); + --sidebar-foreground: oklch(0.78 0.005 80); + --sidebar-primary: oklch(0.72 0.19 150); + --sidebar-primary-foreground: oklch(0.13 0.01 150); + --sidebar-accent: oklch(0.18 0.015 150); + --sidebar-accent-foreground: oklch(0.78 0.12 150); + --sidebar-border: oklch(0.20 0.005 60); + --sidebar-ring: oklch(0.72 0.19 150); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +/* ─── Glow utilities ─── */ +.glow-green { + box-shadow: + 0 0 0 1px oklch(0.72 0.19 150 / 0.15), + 0 0 20px -5px oklch(0.72 0.19 150 / 0.15); +} +.glow-green-sm { + box-shadow: + 0 0 0 1px oklch(0.72 0.19 150 / 0.10), + 0 0 12px -4px oklch(0.72 0.19 150 / 0.12); +} +.glow-gold { + box-shadow: + 0 0 0 1px oklch(0.68 0.14 85 / 0.15), + 0 0 20px -5px oklch(0.68 0.14 85 / 0.15); +} +.glow-wine { + box-shadow: + 0 0 0 1px oklch(0.55 0.22 25 / 0.15), + 0 0 20px -5px oklch(0.55 0.22 25 / 0.15); +} + +/* ─── Glass card ─── */ +.glass-card { + background: oklch(0.15 0.005 60 / 0.6); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid oklch(0.28 0.006 60 / 0.5); +} + +/* ─── Gradient border ─── */ +.gradient-border { + position: relative; +} +.gradient-border::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: linear-gradient( + 135deg, + oklch(0.72 0.19 150 / 0.4), + oklch(0.68 0.14 85 / 0.2), + oklch(0.72 0.19 150 / 0.1) + ); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + -webkit-mask-composite: xor; + pointer-events: none; +} + +/* ─── Vine pulse animation ─── */ +@keyframes vine-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} +.animate-vine-pulse { + animation: vine-pulse 3s ease-in-out infinite; +} + +/* ─── Subtle grain texture ─── */ +.grain::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + opacity: 0.03; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); +} + +/* ─── Scrollbar styling ─── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: oklch(0.30 0.005 60); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: oklch(0.38 0.005 60); +} + +/* ─── Selection color ─── */ +::selection { + background: oklch(0.72 0.19 150 / 0.3); + color: oklch(0.95 0.005 80); +} diff --git a/vineye-admin/app/layout.tsx b/vineye-admin/app/layout.tsx new file mode 100644 index 0000000..217f41e --- /dev/null +++ b/vineye-admin/app/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { Fraunces, Outfit } from "next/font/google"; +import { Toaster } from "@/components/ui/sonner"; +import "./globals.css"; + +const fraunces = Fraunces({ + variable: "--font-fraunces", + subsets: ["latin"], + display: "swap", +}); + +const outfit = Outfit({ + variable: "--font-outfit", + subsets: ["latin"], + display: "swap", +}); + +export const metadata: Metadata = { + title: "VinEye Admin", + description: "Panel d'administration VinEye — Gestion des maladies de la vigne", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + + ); +} diff --git a/vineye-admin/app/page.tsx b/vineye-admin/app/page.tsx new file mode 100644 index 0000000..c3a1c90 --- /dev/null +++ b/vineye-admin/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function RootPage() { + redirect("/dashboard"); +} diff --git a/vineye-admin/components.json b/vineye-admin/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/vineye-admin/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/vineye-admin/components/admin/alert-form.tsx b/vineye-admin/components/admin/alert-form.tsx new file mode 100644 index 0000000..48b8c01 --- /dev/null +++ b/vineye-admin/components/admin/alert-form.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; + +interface AlertFormProps { + initialData?: { + id?: string; + title: string; + titleEn: string; + message: string; + messageEn: string; + type: string; + region: string; + active: boolean; + activeFrom: string; + activeTo: string; + }; + mode: "create" | "edit"; +} + +export default function AlertForm({ initialData, mode }: AlertFormProps) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + + const [title, setTitle] = useState(initialData?.title ?? ""); + const [titleEn, setTitleEn] = useState(initialData?.titleEn ?? ""); + const [message, setMessage] = useState(initialData?.message ?? ""); + const [messageEn, setMessageEn] = useState(initialData?.messageEn ?? ""); + const [type, setType] = useState(initialData?.type ?? "WARNING"); + const [region, setRegion] = useState(initialData?.region ?? "bordeaux"); + const [active, setActive] = useState(initialData?.active ?? true); + const [activeFrom, setActiveFrom] = useState(initialData?.activeFrom ?? ""); + const [activeTo, setActiveTo] = useState(initialData?.activeTo ?? ""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (loading) return; + + if (!title.trim() || !message.trim()) { + toast.error("Veuillez remplir les champs obligatoires"); + return; + } + + setLoading(true); + + const body = { + title: title.trim(), + titleEn: titleEn.trim(), + message: message.trim(), + messageEn: messageEn.trim(), + type, + region: region.trim(), + active, + activeFrom: activeFrom || undefined, + activeTo: activeTo || null, + }; + + try { + const url = mode === "create" ? "/api/alerts" : `/api/alerts/${initialData?.id}`; + const method = mode === "create" ? "POST" : "PUT"; + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const data = await res.json(); + toast.error(data.error || "Erreur"); + return; + } + + toast.success(mode === "create" ? "Alerte creee" : "Alerte mise a jour"); + router.push("/alerts"); + router.refresh(); + } catch { + toast.error("Une erreur est survenue"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ +

+ {mode === "create" ? "Nouvelle alerte" : `Modifier — ${initialData?.title}`} +

+
+ +
+ + +

Informations

+
+
+ + setTitle(e.target.value)} className="rounded-xl" required /> +
+
+ + setTitleEn(e.target.value)} className="rounded-xl" /> +
+
+
+ +