Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /admin — dashboard du centre de communication : les chiffres qui comptent3// (nouvelles demandes, non-lues, urgentes, investisseurs, candidatures,4// légal, sécurité, résolues, délai de première réponse) + demandes récentes.5// Chaque chiffre respecte le RBAC de l'admin connecté.6import { redirect } from "next/navigation";7import { getAdminSession } from "@/lib/comms/admin-auth";8import { dashboardStats, recentSubmissions } from "@/lib/comms/queries";9import { categoryBySlug, PRIORITIES, statusesFor } from "@/lib/comms/categories";1011export const dynamic = "force-dynamic";1213function fmtDate(iso: string): string {14 return iso.replace("T", " ").slice(0, 16);15}1617export default async function AdminDashboard() {18 const session = await getAdminSession();19 if (!session) redirect("/admin/connexion");20 const s = dashboardStats(session);21 const recent = recentSubmissions(session, 8);2223 const TILES: { label: string; value: string | number; href: string; tone?: "lime" | "danger" }[] = [24 { label: "Nouvelles aujourd'hui", value: s.today, href: "/admin/inbox", tone: "lime" },25 { label: "Non lues", value: s.unread, href: "/admin/inbox" },26 { label: "Ouvertes", value: s.open, href: "/admin/inbox" },27 { label: "Urgentes / hautes", value: s.urgent, href: "/admin/inbox?priority=urgente", tone: s.urgent > 0 ? "danger" : undefined },28 { label: "Investisseurs ouvertes", value: s.investors, href: "/admin/inbox?category=investisseurs" },29 { label: "Candidatures (30 j)", value: s.jobs, href: "/admin/candidatures" },30 { label: "Légal & Loi 25 ouvertes", value: s.legal, href: "/admin/inbox?category=legal" },31 { label: "Signalements sécurité", value: s.security, href: "/admin/inbox?category=securite" },32 { label: "Résolues cette semaine", value: s.resolvedWeek, href: "/admin/inbox?view=toutes" },33 {34 label: "1re réponse (moy. 30 j)",35 value: s.avgFirstResponseH != null ? `${s.avgFirstResponseH} h` : "—",36 href: "/admin/inbox",37 },38 ];3940 return (41 <>42 <div className="flex flex-wrap items-end justify-between gap-4">43 <div>44 <p className="kicker">Communication Hub</p>45 <h1 className="gk-display mt-2 text-[clamp(24px,3.5vw,34px)] leading-tight font-bold tracking-[-0.03em]">46 Bonjour, {session.displayName.split(" ")[0]}.47 </h1>48 </div>49 <a href="/admin/inbox" className="btn btn-primary">50 Ouvrir l'inbox {s.unread > 0 ? `(${s.unread} non lues)` : ""}51 </a>52 </div>5354 <div className="mt-8 grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">55 {TILES.map((t) => (56 <a57 key={t.label}58 href={t.href}59 className={`gk-card gk-card-hover p-4 no-underline ${60 t.tone === "lime" ? "!bg-lime-soft" : t.tone === "danger" ? "!bg-[var(--danger-soft)]" : ""61 }`}62 >63 <p className="stat-huge !text-[clamp(26px,3vw,38px)]">{t.value}</p>64 <p className="klabel mt-2 leading-snug normal-case">{t.label}</p>65 </a>66 ))}67 </div>6869 <div className="sec-head mt-12">70 <p className="kicker">Demandes récentes</p>71 <a href="/admin/inbox" className="sec-index no-underline hover:text-green">72 Tout voir →73 </a>74 </div>75 <div className="gk-card mt-5 overflow-hidden !shadow-none">76 {recent.length === 0 ? (77 <p className="gk-mono p-6 text-[12px] text-ink-3">78 Aucune demande pour l'instant — le centre de communication est prêt.79 </p>80 ) : (81 recent.map((r) => {82 const cat = categoryBySlug(r.category);83 return (84 <a85 key={r.id}86 href={`/admin/demande/${r.reference}`}87 className={`adm-row ${r.is_read ? "" : "adm-row--unread"}`}88 >89 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">90 <span className="gk-mono text-[11px] font-bold text-green">{r.reference}</span>91 <span className="adm-chip">{cat?.short ?? r.category}</span>92 <span className={`adm-chip adm-chip--${r.priority}`}>93 {PRIORITIES[r.priority as keyof typeof PRIORITIES] ?? r.priority}94 </span>95 <span className="adm-chip adm-chip--ghost">96 {statusesFor(r.category)[r.status] ?? r.status}97 </span>98 <span className="gk-mono ml-auto text-[10.5px] text-ink-3">{fmtDate(r.created_at)}</span>99 </div>100 <p className="mt-1 truncate text-[13.5px]">101 <strong>102 {r.is_anonymous103 ? "Anonyme"104 : [r.first_name, r.last_name].filter(Boolean).join(" ") || r.email || "—"}105 </strong>106 {r.organization ? ` · ${r.organization}` : ""}107 {r.subject ? ` — ${r.subject}` : r.sub_category ? ` — ${r.sub_category}` : ""}108 </p>109 </a>110 );111 })112 )}113 </div>114 </>115 );116}117