From 13cd637391ce7692c679ec884f0dd809fc27a16c Mon Sep 17 00:00:00 2001 From: nBiqoz Date: Sun, 5 Oct 2025 16:10:35 +0200 Subject: [PATCH] first commit --- app/agents/page.tsx | 14 + app/analytics/page.tsx | 49 + app/api/collections/[collection]/route.ts | 59 + app/api/metrics/route.ts | 75 + app/api/stats/route.ts | 61 + app/api/user-activity/route.ts | 36 + app/collections/page.tsx | 18 + app/conversations/page.tsx | 16 + app/favicon.ico | Bin 25931 -> 19487 bytes app/globals.css | 124 +- app/layout.tsx | 14 +- app/messages/page.tsx | 16 + app/page.tsx | 233 +-- app/roles/page.tsx | 18 + app/settings/page.tsx | 71 + app/transactions/page.tsx | 16 + app/users/page.tsx | 16 + components.json | 22 + components/collections/agents-table.tsx | 55 + .../collections/collection-selector.tsx | 107 ++ components/collections/collection-table.tsx | 123 ++ .../collections/conversations-table.tsx | 561 +++++++ components/collections/messages-table.tsx | 304 ++++ components/collections/roles-table.tsx | 53 + components/collections/transactions-table.tsx | 213 +++ components/collections/users-table.tsx | 170 ++ .../charts/model-distribution-chart.tsx | 95 ++ .../dashboard/charts/model-usage-chart.tsx | 58 + .../charts/real-user-activity-chart.tsx | 110 ++ .../dashboard/charts/simple-bar-chart.tsx | 62 + .../dashboard/charts/simple-stats-chart.tsx | 70 + components/dashboard/charts/usage-chart.tsx | 74 + .../dashboard/charts/user-activity-chart.tsx | 81 + components/dashboard/metric-cards.tsx | 126 ++ components/dashboard/overview-metrics.tsx | 62 + components/dashboard/real-time-stats.tsx | 82 + components/dashboard/recent-transactions.tsx | 62 + components/dashboard/usage-analytics.tsx | 331 ++++ components/layout/sidebar.tsx | 191 +++ components/ui/badge.tsx | 46 + components/ui/button.tsx | 60 + components/ui/card.tsx | 92 ++ components/ui/input.tsx | 21 + components/ui/metric-card.tsx | 47 + components/ui/navigation-menu.tsx | 168 ++ components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 ++ components/ui/sidebar.tsx | 726 ++++++++ components/ui/skeleton.tsx | 13 + components/ui/table.tsx | 116 ++ components/ui/tabs.tsx | 55 + components/ui/tooltip.tsx | 61 + hooks/use-mobile.ts | 19 + hooks/useCollection.ts | 66 + hooks/useMetrics.ts | 34 + hooks/useStats.ts | 54 + hooks/useUserActivity.ts | 45 + lib/db/mongodb.ts | 33 + lib/types/index.ts | 185 +++ lib/utils.ts | 35 + lib/utils/index.ts | 27 + next.config.ts | 1 + package-lock.json | 1468 ++++++++++++++++- package.json | 25 +- public/file.svg | 1 - public/globe.svg | 1 - public/img/logo.png | Bin 0 -> 19487 bytes public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - 70 files changed, 7287 insertions(+), 130 deletions(-) create mode 100644 app/agents/page.tsx create mode 100644 app/analytics/page.tsx create mode 100644 app/api/collections/[collection]/route.ts create mode 100644 app/api/metrics/route.ts create mode 100644 app/api/stats/route.ts create mode 100644 app/api/user-activity/route.ts create mode 100644 app/collections/page.tsx create mode 100644 app/conversations/page.tsx create mode 100644 app/messages/page.tsx create mode 100644 app/roles/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/transactions/page.tsx create mode 100644 app/users/page.tsx create mode 100644 components.json create mode 100644 components/collections/agents-table.tsx create mode 100644 components/collections/collection-selector.tsx create mode 100644 components/collections/collection-table.tsx create mode 100644 components/collections/conversations-table.tsx create mode 100644 components/collections/messages-table.tsx create mode 100644 components/collections/roles-table.tsx create mode 100644 components/collections/transactions-table.tsx create mode 100644 components/collections/users-table.tsx create mode 100644 components/dashboard/charts/model-distribution-chart.tsx create mode 100644 components/dashboard/charts/model-usage-chart.tsx create mode 100644 components/dashboard/charts/real-user-activity-chart.tsx create mode 100644 components/dashboard/charts/simple-bar-chart.tsx create mode 100644 components/dashboard/charts/simple-stats-chart.tsx create mode 100644 components/dashboard/charts/usage-chart.tsx create mode 100644 components/dashboard/charts/user-activity-chart.tsx create mode 100644 components/dashboard/metric-cards.tsx create mode 100644 components/dashboard/overview-metrics.tsx create mode 100644 components/dashboard/real-time-stats.tsx create mode 100644 components/dashboard/recent-transactions.tsx create mode 100644 components/dashboard/usage-analytics.tsx create mode 100644 components/layout/sidebar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/metric-card.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 hooks/use-mobile.ts create mode 100644 hooks/useCollection.ts create mode 100644 hooks/useMetrics.ts create mode 100644 hooks/useStats.ts create mode 100644 hooks/useUserActivity.ts create mode 100644 lib/db/mongodb.ts create mode 100644 lib/types/index.ts create mode 100644 lib/utils.ts create mode 100644 lib/utils/index.ts delete mode 100644 public/file.svg delete mode 100644 public/globe.svg create mode 100644 public/img/logo.png delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg diff --git a/app/agents/page.tsx b/app/agents/page.tsx new file mode 100644 index 0000000..988f770 --- /dev/null +++ b/app/agents/page.tsx @@ -0,0 +1,14 @@ +import { AgentsTable } from "@/components/collections/agents-table"; + +export default function AgentsPage() { + return ( +
+
+

Agents

+

Gestion des agents Cercle GPT

+
+ + +
+ ); +} diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx new file mode 100644 index 0000000..a92dfde --- /dev/null +++ b/app/analytics/page.tsx @@ -0,0 +1,49 @@ +import { Suspense } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { UsageAnalytics } from "@/components/dashboard/usage-analytics"; +import { RecentTransactions } from "@/components/dashboard/recent-transactions"; +import { BarChart3 } from "lucide-react"; + +function AnalyticsSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + +
+
+
+
+
+
+ ))} +
+ ); +} + +export default function AnalyticsPage() { + return ( +
+
+

+ + Analytics détaillées +

+

+ Analyses approfondies des performances et de l'utilisation de + Cercle GPT +

+
+ + }> +
+ {/* Analytics des utilisateurs */} + + + {/* Transactions récentes - toute la largeur */} + +
+
+
+ ); +} diff --git a/app/api/collections/[collection]/route.ts b/app/api/collections/[collection]/route.ts new file mode 100644 index 0000000..88f6bc2 --- /dev/null +++ b/app/api/collections/[collection]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDatabase } from '@/lib/db/mongodb'; + +const ALLOWED_COLLECTIONS = [ + 'accessroles', 'aclentries', 'actions', 'agentcategories', 'agents', + 'assistants', 'balances', 'banners', 'conversations', 'conversationtags', + 'files', 'groups', 'keys', 'memoryentries', 'messages', 'pluginauths', + 'presets', 'projects', 'promptgroups', 'prompts', 'roles', 'sessions', + 'sharedlinks', 'tokens', 'toolcalls', 'transactions', 'users' +]; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ collection: string }> } +) { + const { collection } = await params; + + try { + + if (!ALLOWED_COLLECTIONS.includes(collection)) { + return NextResponse.json( + { error: 'Collection non autorisée' }, + { status: 400 } + ); + } + + const { searchParams } = new URL(request.url); + const page = parseInt(searchParams.get('page') || '1'); + const limit = parseInt(searchParams.get('limit') || '20'); + const filter = JSON.parse(searchParams.get('filter') || '{}'); + + const db = await getDatabase(); + const skip = (page - 1) * limit; + + const [data, total] = await Promise.all([ + db.collection(collection) + .find(filter) + .skip(skip) + .limit(limit) + .sort({ createdAt: -1 }) + .toArray(), + db.collection(collection).countDocuments(filter) + ]); + + return NextResponse.json({ + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit) + }); + } catch (error) { + console.error(`Erreur lors de la récupération de ${collection}:`, error); + return NextResponse.json( + { error: 'Erreur serveur' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/metrics/route.ts b/app/api/metrics/route.ts new file mode 100644 index 0000000..3fbe1a7 --- /dev/null +++ b/app/api/metrics/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { getDatabase } from "@/lib/db/mongodb"; + +export async function GET() { + try { + const db = await getDatabase(); + + // Récupérer toutes les données nécessaires en parallèle + const [users, conversations, transactions, balances] = await Promise.all([ + db.collection("users").find({}).toArray(), + db.collection("conversations").find({}).toArray(), + db.collection("transactions").find({}).toArray(), + db.collection("balances").find({}).toArray(), + ]); + + // Calculer les utilisateurs actifs (dernière semaine) + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + const activeUsers = users.filter((user) => { + const lastActivity = new Date(user.updatedAt || user.createdAt); + return lastActivity >= oneWeekAgo; + }).length; + + // Calculer les administrateurs + const totalAdmins = users.filter(user => user.role === 'ADMIN').length; + + // Calculer les conversations actives (dernière semaine) + const activeConversations = conversations.filter((conv) => { + const lastActivity = new Date(conv.updatedAt || conv.createdAt); + return lastActivity >= oneWeekAgo; + }).length; + + // Calculer le total des messages + const totalMessages = conversations.reduce( + (sum, conv) => sum + (Array.isArray(conv.messages) ? conv.messages.length : 0), + 0 + ); + + // Calculer le total des tokens depuis les transactions + const totalTokensConsumed = transactions.reduce((sum, transaction) => { + return sum + Math.abs(Number(transaction.rawAmount) || 0); + }, 0); + + // Calculer le total des crédits depuis balances + const totalCredits = balances.reduce((sum, balance) => { + return sum + (Number(balance.tokenCredits) || 0); + }, 0); + + // Récupérer les transactions récentes (dernières 10) + const recentTransactions = transactions + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 10) + .map(transaction => ({ + _id: transaction._id, + description: `Transaction ${transaction.tokenType} - ${transaction.model}`, + amount: transaction.rawAmount, + type: transaction.rawAmount > 0 ? 'credit' : 'debit', + createdAt: transaction.createdAt + })); + + return NextResponse.json({ + totalUsers: users.length, + activeUsers, + totalAdmins, + totalCredits, + activeConversations, + totalMessages: totalMessages, + totalTokensConsumed, + recentTransactions + }); + } catch (error) { + console.error("Erreur lors du calcul des métriques:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts new file mode 100644 index 0000000..59083d5 --- /dev/null +++ b/app/api/stats/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { getDatabase } from "@/lib/db/mongodb"; + +export async function GET() { + try { + const db = await getDatabase(); + + // Récupérer toutes les transactions + const transactions = await db.collection("transactions").find({}).toArray(); + + // Calculer les tokens par jour (7 derniers jours) + const dailyStats = []; + const today = new Date(); + const dayNames = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"]; + + for (let i = 6; i >= 0; i--) { + const date = new Date(today); + date.setDate(date.getDate() - i); + date.setHours(0, 0, 0, 0); + + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + 1); + + const dayTransactions = transactions.filter(transaction => { + const transactionDate = new Date(transaction.createdAt); + return transactionDate >= date && transactionDate < nextDate; + }); + + const totalTokens = dayTransactions.reduce((sum, transaction) => { + return sum + Math.abs(Number(transaction.rawAmount) || 0); + }, 0); + + dailyStats.push({ + name: dayNames[date.getDay()], + value: totalTokens + }); + } + + // Calculer la répartition par modèle (vraies données) + const modelStats = new Map(); + + transactions.forEach(transaction => { + const model = transaction.model || "Inconnu"; + const tokens = Math.abs(Number(transaction.rawAmount) || 0); + modelStats.set(model, (modelStats.get(model) || 0) + tokens); + }); + + // Convertir en array et trier par usage + const modelData = Array.from(modelStats.entries()) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); + + return NextResponse.json({ + dailyTokens: dailyStats, + modelDistribution: modelData + }); + } catch (error) { + console.error("Erreur lors du calcul des statistiques:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/user-activity/route.ts b/app/api/user-activity/route.ts new file mode 100644 index 0000000..85b5563 --- /dev/null +++ b/app/api/user-activity/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getDatabase } from "@/lib/db/mongodb"; + +export async function GET() { + try { + const db = await getDatabase(); + + // Récupérer tous les utilisateurs + const users = await db.collection("users").find({}).toArray(); + + // Calculer les utilisateurs actifs (dernière semaine) + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + let activeUsers = 0; + let inactiveUsers = 0; + + users.forEach(user => { + const lastActivity = new Date(user.updatedAt || user.createdAt); + if (lastActivity >= oneWeekAgo) { + activeUsers++; + } else { + inactiveUsers++; + } + }); + + return NextResponse.json({ + activeUsers, + inactiveUsers, + totalUsers: users.length + }); + } catch (error) { + console.error("Erreur lors du calcul de l'activité des utilisateurs:", error); + return NextResponse.json({ error: "Erreur serveur" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/collections/page.tsx b/app/collections/page.tsx new file mode 100644 index 0000000..923a285 --- /dev/null +++ b/app/collections/page.tsx @@ -0,0 +1,18 @@ +import { CollectionSelector } from "@/components/collections/collection-selector"; + +export default function CollectionsPage() { + return ( +
+
+

+ Collections +

+

+ Explorez toutes les collections de votre base Cercle GPT +

+
+ + +
+ ); +} diff --git a/app/conversations/page.tsx b/app/conversations/page.tsx new file mode 100644 index 0000000..fe58375 --- /dev/null +++ b/app/conversations/page.tsx @@ -0,0 +1,16 @@ +import { ConversationsTable } from "@/components/collections/conversations-table"; + +export default function ConversationsPage() { + return ( +
+
+

Conversations

+

+ Gestion des conversations Cercle GPTTT +

+
+ + +
+ ); +} diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fea4835ec2d246af9800eddb7ffb276240c..32c667050ef6d40d32527a677acb84fcd7e877da 100644 GIT binary patch literal 19487 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 JPDHLkV1n+P1VaD- literal 25931 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 LrJugUA?W`A8`#=m diff --git a/app/globals.css b/app/globals.css index a2dc41e..dc98be7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,122 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --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) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; } } - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..1c2be10 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Sidebar } from "@/components/layout/sidebar"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Dashboard - Cercle GPT", + description: "Dashboard d'administration pour Cercle GPT", }; export default function RootLayout({ @@ -23,11 +24,16 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} +
+ +
+
{children}
+
+
); diff --git a/app/messages/page.tsx b/app/messages/page.tsx new file mode 100644 index 0000000..653e31f --- /dev/null +++ b/app/messages/page.tsx @@ -0,0 +1,16 @@ +import { MessagesTable } from "@/components/collections/messages-table"; + +export default function MessagesPage() { + return ( +
+
+

Messages

+

+ Historique des messages Cercle GPT +

+
+ + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 21b686d..4e1970a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,103 +1,146 @@ -import Image from "next/image"; +import { Suspense } from "react"; +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { OverviewMetrics } from "@/components/dashboard/overview-metrics"; +import { RealTimeStats } from "@/components/dashboard/real-time-stats"; +import { RealUserActivityChart } from "@/components/dashboard/charts/real-user-activity-chart"; +import { + Users, + MessageSquare, + CreditCard, + BarChart3, + TrendingUp, + Activity, +} from "lucide-react"; -export default function Home() { +export default function Dashboard() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+
- + + {/* Actions rapides épurées */} +
+ + + + + Utilisateurs + + + +

+ Gérer les comptes utilisateurs +

+ + + +
+
+ + + + + + Conversations + + + +

+ Consulter les discussions +

+ + + +
+
+ + + + + + Transactions + + + +

+ Historique des paiements +

+ + + +
+
+ + + + + + Analytics + + + +

+ Analyses détaillées +

+ + + +
+
+
+
); } diff --git a/app/roles/page.tsx b/app/roles/page.tsx new file mode 100644 index 0000000..f404aae --- /dev/null +++ b/app/roles/page.tsx @@ -0,0 +1,18 @@ +import { RolesTable } from "@/components/collections/roles-table"; + +export default function RolesPage() { + return ( +
+
+

+ Rôles et Permissions +

+

+ Gestion des rôles d'accès Cercle GPT +

+
+ + +
+ ); +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..55126f7 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,71 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +export default function SettingsPage() { + return ( +
+
+

Paramètres

+

+ Configuration du dashboard Cercle GPT +

+
+ +
+ + + Connexion MongoDB + + +
+
+ Statut: + Connecté +
+
+ + Base de données: + + Cercle GPT +
+
+ + Collections: + + 29 collections +
+
+
+
+ + + + Informations système + + +
+
+ + Version Next.js: + + 15.5.4 +
+
+ + Version Node.js: + + {process.version} +
+
+ + Environnement: + + {process.env.NODE_ENV} +
+
+
+
+
+
+ ); +} diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx new file mode 100644 index 0000000..0ca7ef8 --- /dev/null +++ b/app/transactions/page.tsx @@ -0,0 +1,16 @@ +import { TransactionsTable } from "@/components/collections/transactions-table"; + +export default function TransactionsPage() { + return ( +
+
+

Transactions

+

+ Historique des transactions Cercle GPT +

+
+ + +
+ ); +} diff --git a/app/users/page.tsx b/app/users/page.tsx new file mode 100644 index 0000000..3eb8f82 --- /dev/null +++ b/app/users/page.tsx @@ -0,0 +1,16 @@ +import { UsersTable } from "@/components/collections/users-table"; + +export default function UsersPage() { + return ( +
+
+

Utilisateurs

+

+ Gestion des utilisateurs Cercle GPTT +

+
+ + +
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/components/collections/agents-table.tsx b/components/collections/agents-table.tsx new file mode 100644 index 0000000..dc80bc9 --- /dev/null +++ b/components/collections/agents-table.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { CollectionTable } from "@/components/collections/collection-table"; +import { Badge } from "@/components/ui/badge"; +import { Agent } from "@/lib/types"; + +export function AgentsTable() { + const columns = [ + { + key: "_id", + label: "ID", + render: (value: unknown) => ( + {String(value).slice(-8)} + ) + }, + { + key: "name", + label: "Nom", + render: (value: unknown) => ( + {String(value)} + ) + }, + { + key: "description", + label: "Description", + render: (value: unknown) => ( + {String(value) || '-'} + ) + }, + { + key: "category", + label: "Catégorie", + render: (value: unknown) => ( + {String(value)} + ) + }, + { + key: "isActive", + label: "Statut", + render: (value: unknown) => ( + + {value ? 'Actif' : 'Inactif'} + + ) + } + ]; + + return ( + + collectionName="agents" + title="Liste des agents" + columns={columns} + /> + ); +} \ No newline at end of file diff --git a/components/collections/collection-selector.tsx b/components/collections/collection-selector.tsx new file mode 100644 index 0000000..c90b1cc --- /dev/null +++ b/components/collections/collection-selector.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState } from "react"; +import { CollectionTable } from "@/components/collections/collection-table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { CollectionItem } from "@/lib/types"; + +const COLLECTIONS = [ + "accessroles", + "aclentries", + "actions", + "agentcategories", + "agents", + "assistants", + "balances", + "banners", + "conversations", + "conversationtags", + "files", + "groups", + "keys", + "memoryentries", + "messages", + "pluginauths", + "presets", + "projects", + "promptgroups", + "prompts", + "roles", + "sessions", + "sharedlinks", + "tokens", + "toolcalls", + "transactions", + "users", +]; + +export function CollectionSelector() { + const [selectedCollection, setSelectedCollection] = useState("users"); + + // Colonnes génériques pour toutes les collections + const genericColumns = [ + { + key: "_id", + label: "ID", + render: (value: unknown) => ( + {String(value).slice(-8)} + ), + }, + { + key: "name", + label: "Nom", + render: (value: unknown) => String(value) || "-", + }, + { + key: "email", + label: "Email", + render: (value: unknown) => String(value) || "-", + }, + { + key: "createdAt", + label: "Créé le", + render: (value: unknown) => { + if (!value) return "-"; + try { + return new Date(String(value)).toLocaleDateString("fr-FR"); + } catch { + return String(value); + } + }, + }, + ]; + + return ( +
+ + + Sélectionner une collection + + +
+ {COLLECTIONS.map((collection) => ( + + ))} +
+
+
+ + + collectionName={selectedCollection} + title={`Collection: ${selectedCollection}`} + columns={genericColumns} + /> +
+ ); +} diff --git a/components/collections/collection-table.tsx b/components/collections/collection-table.tsx new file mode 100644 index 0000000..873142c --- /dev/null +++ b/components/collections/collection-table.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useCollection } from "@/hooks/useCollection"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useState } from "react"; + +interface CollectionTableProps> { + collectionName: string; + title: string; + columns: Array<{ + key: string; + label: string; + render?: (value: unknown, item: T) => React.ReactNode; + }>; +} + +export function CollectionTable>({ + collectionName, + title, + columns +}: CollectionTableProps) { + const [page, setPage] = useState(1); + const { data, loading, error, total, totalPages } = useCollection( + collectionName, + { page, limit: 20 } + ); + + if (loading) { + return ( + + + {title} + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ + + ); + } + + if (error) { + return ( + + + {title} + + +
+ Erreur: {error} +
+
+
+ ); + } + + return ( + + + {title} ({total} éléments) + + + + + + {columns.map((column) => ( + {column.label} + ))} + + + + {data.map((item, index) => ( + + {columns.map((column) => ( + + {column.render + ? column.render(item[column.key], item) + : String(item[column.key] || '-') + } + + ))} + + ))} + +
+ + {totalPages > 1 && ( +
+

+ Page {page} sur {totalPages} +

+
+ + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/collections/conversations-table.tsx b/components/collections/conversations-table.tsx new file mode 100644 index 0000000..ea2cbc4 --- /dev/null +++ b/components/collections/conversations-table.tsx @@ -0,0 +1,561 @@ +"use client"; + +import { useState } from "react"; +import { useCollection } from "@/hooks/useCollection"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ChevronLeft, + ChevronRight, + Users, + MessageSquare, + Calendar, + X, + User, + Bot, +} from "lucide-react"; +import { formatDate } from "@/lib/utils"; +import { + LibreChatConversation, + LibreChatUser, + LibreChatMessage, +} from "@/lib/types"; + +// Types pour les messages étendus +interface ExtendedMessage extends LibreChatMessage { + content?: Array<{ type: string; text: string }> | string; + message?: Record; + parts?: Array; + metadata?: { text?: string }; + [key: string]: unknown; +} + +export function ConversationsTable() { + const [page, setPage] = useState(1); + const [selectedConversationId, setSelectedConversationId] = useState< + string | null + >(null); + const [selectedUserId, setSelectedUserId] = useState(null); + + const limit = 10; + + // Charger toutes les conversations pour le groupement côté client + const { + data: conversations = [], + total = 0, + loading, + } = useCollection("conversations", { + limit: 1000, + page: 1, // Remplacer skip par page + }); + + const { data: users = [] } = useCollection("users", { + limit: 1000, + }); + + // Charger les messages seulement si une conversation est sélectionnée + const { data: messages = [] } = useCollection("messages", { + limit: 1000, + filter: selectedConversationId + ? { conversationId: selectedConversationId } + : {}, + }); + + const userMap = new Map(users.map((user) => [user._id, user])); + + const getUserDisplayName = (userId: string): string => { + if (userId === "unknown") return "Utilisateur inconnu"; + const user = userMap.get(userId); + if (user) { + return ( + user.name || + user.username || + user.email || + `Utilisateur ${userId.slice(-8)}` + ); + } + return `Utilisateur ${userId.slice(-8)}`; + }; + + const getUserEmail = (userId: string): string | null => { + if (userId === "unknown") return null; + const user = userMap.get(userId); + return user?.email || null; + }; + + // Fonction améliorée pour extraire le contenu du message + const getMessageContent = (message: LibreChatMessage): string => { + // Fonction helper pour nettoyer le texte + const cleanText = (text: string): string => { + return text.trim().replace(/\n\s*\n/g, "\n"); + }; + + // 1. Vérifier le tableau content (structure LibreChat) + const messageObj = message as ExtendedMessage; + if (messageObj.content && Array.isArray(messageObj.content)) { + for (const contentItem of messageObj.content) { + if ( + contentItem && + typeof contentItem === "object" && + contentItem.text + ) { + return cleanText(contentItem.text); + } + } + } + + // 2. Essayer le champ text principal + if ( + message.text && + typeof message.text === "string" && + message.text.trim() + ) { + return cleanText(message.text); + } + + // 3. Essayer le champ content (format legacy string) + if ( + messageObj.content && + typeof messageObj.content === "string" && + messageObj.content.trim() + ) { + return cleanText(messageObj.content); + } + + // 4. Vérifier s'il y a des propriétés imbriquées + if (message.message && typeof message.message === "object") { + const nestedMessage = message.message as Record; + if (nestedMessage.content && typeof nestedMessage.content === "string") { + return cleanText(nestedMessage.content); + } + if (nestedMessage.text && typeof nestedMessage.text === "string") { + return cleanText(nestedMessage.text); + } + } + + // 5. Vérifier les propriétés spécifiques à LibreChat + // Parfois le contenu est dans une propriété 'parts' + if ( + messageObj.parts && + Array.isArray(messageObj.parts) && + messageObj.parts.length > 0 + ) { + const firstPart = messageObj.parts[0]; + if (typeof firstPart === "string") { + return cleanText(firstPart); + } + if (firstPart && typeof firstPart === "object" && firstPart.text) { + return cleanText(firstPart.text); + } + } + + // 6. Vérifier si c'est un message avec des métadonnées + if (messageObj.metadata && messageObj.metadata.text) { + return cleanText(messageObj.metadata.text); + } + + // 7. Vérifier les propriétés alternatives + const alternativeFields = ["body", "messageText", "textContent", "data"]; + for (const field of alternativeFields) { + const value = messageObj[field]; + if (value && typeof value === "string" && value.trim()) { + return cleanText(value); + } + } + + // Debug: afficher la structure du message si aucun contenu n'est trouvé + console.log("Message sans contenu trouvé:", { + messageId: message.messageId, + isCreatedByUser: message.isCreatedByUser, + keys: Object.keys(messageObj), + content: messageObj.content, + text: messageObj.text, + }); + + return "Contenu non disponible"; + }; + + const handleShowMessages = (conversationId: string, userId: string) => { + if ( + selectedConversationId === conversationId && + selectedUserId === userId + ) { + setSelectedConversationId(null); + setSelectedUserId(null); + } else { + setSelectedConversationId(conversationId); + setSelectedUserId(userId); + } + }; + + const handleCloseMessages = () => { + setSelectedConversationId(null); + setSelectedUserId(null); + }; + + const getStatus = (conversation: LibreChatConversation) => { + if (conversation.isArchived) return "archived"; + return "active"; + }; + + const getStatusLabel = (status: string) => { + switch (status) { + case "archived": + return "Archivée"; + case "active": + return "Active"; + default: + return "Inconnue"; + } + }; + + const getStatusVariant = (status: string) => { + switch (status) { + case "archived": + return "outline" as const; + case "active": + return "default" as const; + default: + return "secondary" as const; + } + }; + + if (loading) { + return ( + + +
Chargement des conversations...
+
+
+ ); + } + + // Grouper les conversations par utilisateur + const groupedConversations = conversations.reduce((acc, conversation) => { + const userId = conversation.user || "unknown"; + if (!acc[userId]) { + acc[userId] = []; + } + acc[userId].push(conversation); + return acc; + }, {} as Record); + + // Pagination des groupes d'utilisateurs + const totalPages = Math.ceil( + Object.keys(groupedConversations).length / limit + ); + const skip = (page - 1) * limit; + const userIds = Object.keys(groupedConversations).slice(skip, skip + limit); + + return ( +
+ + + + + Conversations par utilisateur + +

+ {Object.keys(groupedConversations).length} utilisateurs •{" "} + {conversations.length} conversations au total +

+
+ +
+ {userIds.map((userId) => { + const conversations = groupedConversations[userId]; + const totalMessages = conversations.reduce( + (sum, conv) => sum + (conv.messages?.length || 0), + 0 + ); + const activeConversations = conversations.filter( + (conv) => !conv.isArchived + ).length; + const archivedConversations = conversations.filter( + (conv) => conv.isArchived + ).length; + const userName = getUserDisplayName(userId); + const userEmail = getUserEmail(userId); + + return ( +
+
+
+ + {userId === "unknown" ? "unknown" : userId.slice(-8)} + +
+ {userName} + {userEmail && ( + + {userEmail} + + )} +
+ + {conversations.length} conversation + {conversations.length > 1 ? "s" : ""} + + {activeConversations > 0 && ( + + {activeConversations} actives + + )} + {archivedConversations > 0 && ( + + {archivedConversations} archivées + + )} +
+
+
+ + {totalMessages} message{totalMessages > 1 ? "s" : ""} +
+
+ + Dernière:{" "} + {formatDate( + new Date( + Math.max( + ...conversations.map((c) => + new Date(c.updatedAt).getTime() + ) + ) + ) + )} +
+
+
+ +
+ + + + ID + Titre + Endpoint + Modèle + Messages + Statut + Créée le + + + + {conversations + .sort( + (a, b) => + new Date(b.updatedAt).getTime() - + new Date(a.updatedAt).getTime() + ) + .map((conversation) => { + const status = getStatus(conversation); + const messageCount = + conversation.messages?.length || 0; + + return ( + + + + {String(conversation._id).slice(-8)} + + + + + {String(conversation.title) || "Sans titre"} + + + + + {String(conversation.endpoint).slice(0, 20)} + {String(conversation.endpoint).length > 20 + ? "..." + : ""} + + + + + {String(conversation.model)} + + + + + handleShowMessages( + conversation.conversationId, + userId + ) + } + > + {messageCount} + + + + + {getStatusLabel(status)} + + + + + {formatDate(conversation.createdAt)} + + + + ); + })} + +
+
+ + {/* Section des messages pour cet utilisateur */} + {selectedConversationId && selectedUserId === userId && ( +
+
+

+ + Messages de la conversation +

+ +
+

+ Conversation ID: {selectedConversationId} +

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

+ Aucun message trouvé pour cette conversation +

+ ) : ( + messages + .sort( + (a, b) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime() + ) + .map((message) => { + const content = getMessageContent(message); + return ( +
+
+ {message.isCreatedByUser ? ( + + ) : ( + + )} +
+
+
+ + {message.isCreatedByUser + ? "Utilisateur" + : "Assistant"} + + + {formatDate(message.createdAt)} + + {message.tokenCount > 0 && ( + + {message.tokenCount} tokens + + )} + + {message._id.slice(-8)} + +
+
+ {content} +
+ {message.error && ( + + Erreur + + )} +
+
+ ); + }) + )} +
+
+ )} +
+ ); + })} +
+ + {totalPages > 1 && ( +
+

+ Page {page} sur {totalPages} • {total} conversations au total +

+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/components/collections/messages-table.tsx b/components/collections/messages-table.tsx new file mode 100644 index 0000000..49200a9 --- /dev/null +++ b/components/collections/messages-table.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useCollection } from "@/hooks/useCollection"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ChevronLeft, ChevronRight, User, Bot } from "lucide-react"; +import { formatDate } from "@/lib/utils"; +import { + LibreChatMessage, + LibreChatUser, + LibreChatConversation, +} from "@/lib/types"; + +// Définir des interfaces pour les types de contenu +interface MessageContentItem { + text?: string; + content?: string; + type?: string; +} + +interface MessagePart { + text?: string; + content?: string; + type?: string; +} + +interface MessageWithParts extends LibreChatMessage { + parts?: MessagePart[]; + content?: MessageContentItem[] | string; +} + +export function MessagesTable() { + const [page, setPage] = useState(1); + const limit = 20; + + // Charger les messages + const { + data: messages = [], + total = 0, + loading: messagesLoading, + } = useCollection("messages", { + page, + limit, + }); + + // Charger les utilisateurs pour les noms + const { data: users = [] } = useCollection("users", { + limit: 1000, + }); + + // Charger les conversations pour les titres + const { data: conversations = [] } = useCollection( + "conversations", + { + limit: 1000, + } + ); + + // Créer des maps pour les lookups + const userMap = useMemo(() => { + return new Map(users.map((user) => [user._id, user])); + }, [users]); + + const conversationMap = useMemo(() => { + return new Map( + conversations.map((conv) => [conv.conversationId || conv._id, conv]) + ); + }, [conversations]); + + const totalPages = Math.ceil(total / limit); + + const handlePrevPage = () => { + setPage((prev) => Math.max(1, prev - 1)); + }; + + const handleNextPage = () => { + setPage((prev) => Math.min(totalPages, prev + 1)); + }; + + // Fonction pour extraire le contenu du message + const getMessageContent = (message: LibreChatMessage): string => { + try { + // Vérifier le champ text principal + if (message.text && typeof message.text === "string") { + return message.text.trim(); + } + + // Traiter le message comme ayant potentiellement des parties + const messageWithParts = message as MessageWithParts; + + // Vérifier le champ content (peut être un array ou string) + if (messageWithParts.content) { + if (typeof messageWithParts.content === "string") { + return messageWithParts.content.trim(); + } + if (Array.isArray(messageWithParts.content)) { + // Extraire le texte des objets content + const textContent = messageWithParts.content + .map((item: MessageContentItem | string) => { + if (typeof item === "string") return item; + if (item && typeof item === "object" && item.text) + return item.text; + if (item && typeof item === "object" && item.content) + return item.content; + return ""; + }) + .filter(Boolean) + .join(" "); + + if (textContent.trim()) return textContent.trim(); + } + } + + // Vérifier les propriétés alternatives + if (messageWithParts.parts && Array.isArray(messageWithParts.parts)) { + const textContent = messageWithParts.parts + .map((part: MessagePart) => { + if (typeof part === "string") return part; + if (part && typeof part === "object" && part.text) return part.text; + return ""; + }) + .filter(Boolean) + .join(" "); + + if (textContent.trim()) return textContent.trim(); + } + + return "Contenu non disponible"; + } catch (error) { + console.error("Erreur lors de l'extraction du contenu:", error); + return "Erreur de lecture du contenu"; + } + }; + + // Fonction pour obtenir le nom d'utilisateur + const getUserName = (userId: string): string => { + if (!userId || userId === "undefined") return "Utilisateur inconnu"; + const user = userMap.get(userId); + return user?.name || user?.email || `Utilisateur ${userId.slice(-8)}`; + }; + + // Fonction pour obtenir le titre de la conversation + const getConversationTitle = (conversationId: string): string => { + if (!conversationId || conversationId === "undefined") + return "Conversation inconnue"; + + const conversation = conversationMap.get(conversationId); + if (conversation && conversation.title) { + return conversation.title; + } + return `Conversation ${conversationId.slice(-6)}`; + }; + + if (messagesLoading) { + return ( + + + Messages + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ + + ); + } + + return ( + + + Messages récents ({total}) + + +
+ + + + ID + Conversation + Utilisateur + Rôle + Contenu + Tokens + Créé le + + + + {messages.map((message) => { + const content = getMessageContent(message); + const userName = getUserName(message.user); + const conversationTitle = getConversationTitle( + message.conversationId + ); + const isUser = message.isCreatedByUser; + + return ( + + + + {message._id.slice(-8)} + + + +
+ + {message.conversationId?.slice(-8) || "N/A"} + +
+ {conversationTitle} +
+
+
+ +
+ + {message.user?.slice(-8) || "N/A"} + +
{userName}
+
+
+ + + {isUser ? ( + + ) : ( + + )} + {isUser ? "Utilisateur" : "Assistant"} + + + +
+

+ {content.length > 100 + ? `${content.substring(0, 100)}...` + : content} +

+
+
+ + {message.tokenCount > 0 && ( + + {message.tokenCount} + + )} + + + + {formatDate(new Date(message.createdAt))} + + +
+ ); + })} +
+
+
+ + {/* Pagination */} +
+
+ Page {page} sur {totalPages} ({total} éléments au total) +
+
+ + +
+
+
+
+ ); +} diff --git a/components/collections/roles-table.tsx b/components/collections/roles-table.tsx new file mode 100644 index 0000000..2060611 --- /dev/null +++ b/components/collections/roles-table.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { CollectionTable } from "@/components/collections/collection-table"; +import { Badge } from "@/components/ui/badge"; +import { AccessRole } from "@/lib/types"; + +export function RolesTable() { + const columns = [ + { + key: "_id", + label: "ID", + render: (value: unknown) => ( + {String(value).slice(-8)} + ), + }, + { + key: "name", + label: "Nom du rôle", + render: (value: unknown) => ( + {String(value)} + ), + }, + { + key: "permissions", + label: "Permissions", + render: (value: unknown) => { + if (!Array.isArray(value)) return "-"; + return ( +
+ {value.slice(0, 3).map((permission, index) => ( + + {String(permission)} + + ))} + {value.length > 3 && ( + + +{value.length - 3} + + )} +
+ ); + }, + }, + ]; + + return ( + + collectionName="accessroles" + title="Liste des rôles" + columns={columns} + /> + ); +} \ No newline at end of file diff --git a/components/collections/transactions-table.tsx b/components/collections/transactions-table.tsx new file mode 100644 index 0000000..2e19165 --- /dev/null +++ b/components/collections/transactions-table.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useCollection } from "@/hooks/useCollection"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { formatDate, formatCurrency } from "@/lib/utils"; +import { LibreChatTransaction, LibreChatUser } from "@/lib/types"; + +// Interface étendue pour les transactions avec description optionnelle +interface TransactionWithDescription extends LibreChatTransaction { + description?: string; +} + +export function TransactionsTable() { + const { data: transactions, loading } = + useCollection("transactions"); + const { data: users } = useCollection("users"); + + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + + // Créer une map pour les lookups rapides des utilisateurs + const usersMap = useMemo(() => { + if (!users) return new Map(); + return new Map(users.map((user) => [user._id, user])); + }, [users]); + + const totalPages = Math.ceil((transactions?.length || 0) / itemsPerPage); + + const handlePrevPage = () => { + setCurrentPage((prev) => Math.max(1, prev - 1)); + }; + + const handleNextPage = () => { + setCurrentPage((prev) => Math.min(totalPages, prev + 1)); + }; + + // Fonction pour obtenir le nom d'utilisateur + const getUserName = (userId: string): string => { + if (!userId || userId === "undefined") return "Utilisateur inconnu"; + const user = usersMap.get(userId); + return user?.name || user?.email || `Utilisateur ${userId.slice(-8)}`; + }; + + // Fonction pour formater le montant en euros + const formatAmount = (rawAmount: number): string => { + // Convertir les tokens en euros (exemple: 1000 tokens = 1 euro) + const euros = rawAmount / 1000; + return formatCurrency(euros); + }; + + // Fonction pour obtenir la description + const getDescription = (transaction: LibreChatTransaction): string => { + const transactionWithDesc = transaction as TransactionWithDescription; + + if (transactionWithDesc.description && + typeof transactionWithDesc.description === 'string' && + transactionWithDesc.description !== "undefined") { + return transactionWithDesc.description; + } + + // Générer une description basée sur le type et le montant + const amount = Math.abs(Number(transaction.rawAmount) || 0); + if (amount > 0) { + return `Consommation de ${amount.toLocaleString()} tokens`; + } + + return "Transaction sans description"; + }; + + if (loading) { + return ( + + + Transactions + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ + + ); + } + + return ( + + + + Transactions récentes ({transactions?.length || 0}) + + + +
+ + + + ID + Utilisateur + Type + Montant + Tokens + Description + Date + + + + {transactions + ?.slice( + (currentPage - 1) * itemsPerPage, + currentPage * itemsPerPage + ) + .map((transaction) => { + const userName = getUserName(transaction.user); + const description = getDescription(transaction); + const tokenAmount = Math.abs( + Number(transaction.rawAmount) || 0 + ); + const isCredit = Number(transaction.rawAmount) > 0; + + return ( + + + + {transaction._id.slice(-8)} + + + +
+ + {transaction.user?.slice(-8) || "N/A"} + +
{userName}
+
+
+ + + {isCredit ? "Crédit" : "Débit"} + + + + + {formatAmount(transaction.rawAmount)} + + + + {tokenAmount > 0 && ( + + {tokenAmount.toLocaleString()} tokens + + )} + + + + {description} + + + + + {formatDate(new Date(transaction.createdAt))} + + +
+ ); + })} +
+
+
+ + {/* Pagination */} +
+
+ Page {currentPage} sur {totalPages} ({transactions?.length || 0}{" "} + éléments au total) +
+
+ + +
+
+
+
+ ); +} diff --git a/components/collections/users-table.tsx b/components/collections/users-table.tsx new file mode 100644 index 0000000..1279c30 --- /dev/null +++ b/components/collections/users-table.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { useCollection } from "@/hooks/useCollection"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { formatDate } from "@/lib/utils"; +import { LibreChatUser, LibreChatBalance } from "@/lib/types"; + + +export function UsersTable() { + const [page, setPage] = useState(1); + const limit = 20; + + // Charger les utilisateurs + const { + data: users = [], + total = 0, + loading: usersLoading, + } = useCollection("users", { + page, + limit, + }); + + // Charger tous les balances pour associer les crédits + const { data: balances = [] } = useCollection("balances", { + limit: 1000, // Charger tous les balances + }); + + // Créer une map des crédits par utilisateur + const creditsMap = useMemo(() => { + const map = new Map(); + balances.forEach((balance) => { + map.set(balance.user, balance.tokenCredits || 0); + }); + return map; + }, [balances]); + + const totalPages = Math.ceil(total / limit); + + const handlePrevPage = () => { + setPage((prev) => Math.max(1, prev - 1)); + }; + + const handleNextPage = () => { + setPage((prev) => Math.min(totalPages, prev + 1)); + }; + + if (usersLoading) { + return ( + + + Liste des utilisateurs + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ + + ); + } + + return ( + + + Liste des utilisateurs ({total}) + + +
+ + + + ID + Nom + Email + Rôle + Crédits + Statut + Créé le + + + + {users.map((user) => { + const userCredits = creditsMap.get(user._id) || 0; + const isActive = new Date(user.updatedAt || user.createdAt) > + new Date(Date.now() - 5 * 60 * 1000); // 5 minutes en millisecondes + + return ( + + + + {user._id.slice(-8)} + + + + {user.name} + + + {user.email} + + + + {user.role} + + + + + {userCredits.toLocaleString()} crédits + + + + + {isActive ? 'Actif' : 'Inactif'} + + + + + {formatDate(new Date(user.createdAt))} + + + + ); + })} + +
+
+ + {/* Pagination */} +
+
+ Page {page} sur {totalPages} ({total} éléments au total) +
+
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/dashboard/charts/model-distribution-chart.tsx b/components/dashboard/charts/model-distribution-chart.tsx new file mode 100644 index 0000000..aeeee0a --- /dev/null +++ b/components/dashboard/charts/model-distribution-chart.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; + +interface ModelDistributionChartProps { + title: string; + data: Array<{ + name: string; + value: number; + }>; +} + +interface TooltipPayload { + value: number; + payload: { + name: string; + value: number; + }; +} + +interface CustomTooltipProps { + active?: boolean; + payload?: TooltipPayload[]; +} + +const CustomTooltip = ({ active, payload }: CustomTooltipProps) => { + if (active && payload && payload.length) { + return ( +
+

+ {`${payload[0].value.toLocaleString()} tokens`} +

+

+ {payload[0].payload.name} +

+
+ ); + } + return null; +}; + +export function ModelDistributionChart({ + title, + data, +}: ModelDistributionChartProps) { + return ( + + + + {title} + + + + + + + + + } /> + + + + + + ); +} diff --git a/components/dashboard/charts/model-usage-chart.tsx b/components/dashboard/charts/model-usage-chart.tsx new file mode 100644 index 0000000..11d36b9 --- /dev/null +++ b/components/dashboard/charts/model-usage-chart.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer +} from "recharts"; + +interface ModelUsageChartProps { + data: Record; +} + +export function ModelUsageChart({ data }: ModelUsageChartProps) { + const chartData = Object.entries(data).map(([model, usage]) => ({ + model: model.replace('gpt-', 'GPT-').replace('claude-', 'Claude-'), + usage, + })); + + return ( + + + Usage par modèle + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/components/dashboard/charts/real-user-activity-chart.tsx b/components/dashboard/charts/real-user-activity-chart.tsx new file mode 100644 index 0000000..a8cec6a --- /dev/null +++ b/components/dashboard/charts/real-user-activity-chart.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, + Legend, +} from "recharts"; +import { useUserActivity } from "@/hooks/useUserActivity"; +import { AlertCircle } from "lucide-react"; + +export function RealUserActivityChart() { + const { activity, loading, error } = useUserActivity(); + + if (loading) { + return ( + + +
+ + + ); + } + + if (error || !activity) { + return ( + + +
+ +

+ Erreur lors du chargement +

+
+
+
+ ); + } + + const data = [ + { + name: "Utilisateurs actifs", + value: activity.activeUsers, + color: "#22c55e", // Vert clair pour actifs + }, + { + name: "Utilisateurs inactifs", + value: activity.inactiveUsers, + color: "#ef4444", // Rouge pour inactifs + }, + ]; + + const total = activity.activeUsers + activity.inactiveUsers; + + return ( + + + + Activité des utilisateurs + +

+ Actifs = connectés dans les 7 derniers jours +

+
+ + + + + {data.map((entry, index) => ( + + ))} + + [ + `${value} utilisateurs (${((value / total) * 100).toFixed( + 1 + )}%)`, + "", + ]} + /> + ( + + {value}: {entry.payload?.value} ( + {((entry.payload?.value / total) * 100).toFixed(1)}%) + + )} + /> + + + +
+ ); +} diff --git a/components/dashboard/charts/simple-bar-chart.tsx b/components/dashboard/charts/simple-bar-chart.tsx new file mode 100644 index 0000000..167b5ce --- /dev/null +++ b/components/dashboard/charts/simple-bar-chart.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer +} from "recharts"; + +interface SimpleBarChartProps { + title: string; + data: Array<{ + name: string; + value: number; + }>; + color?: string; +} + +export function SimpleBarChart({ title, data, color = "hsl(var(--primary))" }: SimpleBarChartProps) { + return ( + + + {title} + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/components/dashboard/charts/simple-stats-chart.tsx b/components/dashboard/charts/simple-stats-chart.tsx new file mode 100644 index 0000000..7b2d06c --- /dev/null +++ b/components/dashboard/charts/simple-stats-chart.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer +} from "recharts"; + +interface SimpleStatsChartProps { + title: string; + data: Array<{ + name: string; + value: number; + }>; + color?: string; +} + +export function SimpleStatsChart({ title, data, color = "hsl(var(--primary))" }: SimpleStatsChartProps) { + return ( + + + {title} + + + + + + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/components/dashboard/charts/usage-chart.tsx b/components/dashboard/charts/usage-chart.tsx new file mode 100644 index 0000000..7a7d2da --- /dev/null +++ b/components/dashboard/charts/usage-chart.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer +} from "recharts"; + +interface UsageChartProps { + data: Array<{ + date: string; + conversations: number; + tokens: number; + }>; +} + +export function UsageChart({ data }: UsageChartProps) { + return ( + + + Usage quotidien + + + + + + new Date(value).toLocaleDateString('fr-FR', { + month: 'short', + day: 'numeric' + })} + /> + + new Date(value).toLocaleDateString('fr-FR')} + formatter={(value: number, name: string) => { + if (name === 'tokens') { + return [Math.round(value / 1000), "Tokens (k)"]; + } + return [value, name]; + }} + /> + + + + + + + ); +} \ No newline at end of file diff --git a/components/dashboard/charts/user-activity-chart.tsx b/components/dashboard/charts/user-activity-chart.tsx new file mode 100644 index 0000000..20441c4 --- /dev/null +++ b/components/dashboard/charts/user-activity-chart.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, + Legend +} from "recharts"; + +interface UserActivityChartProps { + activeUsers: number; + inactiveUsers: number; +} + +export function UserActivityChart({ activeUsers, inactiveUsers }: UserActivityChartProps) { + const data = [ + { + name: 'Utilisateurs actifs', + value: activeUsers, + color: '#22c55e' // Vert clair pour actifs + }, + { + name: 'Utilisateurs inactifs', + value: inactiveUsers, + color: '#ef4444' // Rouge pour inactifs + }, + ]; + + const total = activeUsers + inactiveUsers; + + return ( + + + Activité des utilisateurs +

+ Actifs = connectés dans les 7 derniers jours +

+
+ + + + + {data.map((entry, index) => ( + + ))} + + [ + `${value} utilisateurs (${((value / total) * 100).toFixed(1)}%)`, + '' + ]} + /> + ( + + {value}: {entry.payload?.value} ({((entry.payload?.value / total) * 100).toFixed(1)}%) + + )} + /> + + + +
+ ); +} \ No newline at end of file diff --git a/components/dashboard/metric-cards.tsx b/components/dashboard/metric-cards.tsx new file mode 100644 index 0000000..74cde4f --- /dev/null +++ b/components/dashboard/metric-cards.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Users, + MessageSquare, + CreditCard, + TrendingUp, + TrendingDown, + Activity, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface MetricCardProps { + title: string; + value: string; + change?: { + value: number; + type: "increase" | "decrease"; + }; + icon: React.ComponentType<{ className?: string }>; + description?: string; +} + +function MetricCard({ + title, + value, + change, + icon: Icon, + description, +}: MetricCardProps) { + return ( + + + + {title} + + + + +
{value}
+ {description && ( +

{description}

+ )} + {change && ( +
+ {change.type === "increase" ? ( + + ) : ( + + )} + + {change.type === "increase" ? "+" : "-"} + {change.value}% + + par rapport au mois dernier +
+ )} +
+
+ ); +} + +interface MetricCardsProps { + metrics: { + totalUsers: number; + activeUsers: number; + totalConversations: number; + totalMessages: number; + totalTokensConsumed: number; + totalCreditsUsed: number; + }; +} + +export function MetricCards({ metrics }: MetricCardsProps) { + return ( +
+ + + + + + + Crédits totaux + + + + +
+ {metrics.totalCreditsUsed.toLocaleString()} +
+

+ crédits disponibles +

+
+ + +23% + par rapport au mois dernier +
+
+
+
+ ); +} diff --git a/components/dashboard/overview-metrics.tsx b/components/dashboard/overview-metrics.tsx new file mode 100644 index 0000000..6519701 --- /dev/null +++ b/components/dashboard/overview-metrics.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useMetrics } from "@/hooks/useMetrics"; +import { MetricCard } from "@/components/ui/metric-card"; +import { Users, UserCheck, Shield, Coins, MessageSquare, FileText } from "lucide-react"; + +export function OverviewMetrics() { + const { metrics, loading, error } = useMetrics(); + + if (loading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ); + } + + if (error || !metrics) { + return ( +
+ Erreur lors du chargement des métriques +
+ ); + } + + return ( +
+ + + + + + +
+ ); +} \ No newline at end of file diff --git a/components/dashboard/real-time-stats.tsx b/components/dashboard/real-time-stats.tsx new file mode 100644 index 0000000..e575aad --- /dev/null +++ b/components/dashboard/real-time-stats.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { useStats } from "@/hooks/useStats"; +import { SimpleStatsChart } from "./charts/simple-stats-chart"; +import { ModelDistributionChart } from "./charts/model-distribution-chart"; +import { AlertCircle } from "lucide-react"; + +export function RealTimeStats() { + const { stats, loading, error } = useStats(); + + if (loading) { + return ( +
+
+
+
+ ); + } + + if (error) { + return ( +
+ + +
+ +

+ Erreur lors du chargement des données +

+
+
+
+ + +
+ +

+ Erreur lors du chargement des données +

+
+
+
+
+ ); + } + + if (!stats) { + return ( +
+ + +

+ Aucune donnée disponible +

+
+
+ + +

+ Aucune donnée disponible +

+
+
+
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/components/dashboard/recent-transactions.tsx b/components/dashboard/recent-transactions.tsx new file mode 100644 index 0000000..9fdc529 --- /dev/null +++ b/components/dashboard/recent-transactions.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useMetrics } from "@/hooks/useMetrics"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { formatDate } from "@/lib/utils"; + +export function RecentTransactions() { + const { metrics, loading } = useMetrics(); + + if (loading) { + return ( + + + Transactions récentes + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ + + ); + } + + return ( + + + Transactions récentes + + +
+ {metrics?.recentTransactions.map((transaction) => ( +
+
+

{transaction.description}

+

+ {formatDate(new Date(transaction.createdAt))} +

+
+
+ + {transaction.type === "credit" ? "+" : "-"} + {Math.abs(transaction.amount).toLocaleString()} tokens + +
+
+ ))} +
+
+
+ ); +} diff --git a/components/dashboard/usage-analytics.tsx b/components/dashboard/usage-analytics.tsx new file mode 100644 index 0000000..114b916 --- /dev/null +++ b/components/dashboard/usage-analytics.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Users, MessageSquare, DollarSign, Activity } from "lucide-react"; +import { useCollection } from "@/hooks/useCollection"; + +import { + LibreChatUser, + LibreChatConversation, + LibreChatTransaction, + LibreChatBalance, +} from "@/lib/types"; + +interface UsageStats { + totalUsers: number; + activeUsers: number; + totalConversations: number; + totalMessages: number; + totalTokensConsumed: number; + totalCreditsUsed: number; + averageTokensPerUser: number; + topUsers: Array<{ + userId: string; + userName: string; + conversations: number; + tokens: number; + credits: number; + }>; +} + +export function UsageAnalytics() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + const { data: users = [] } = useCollection("users", { limit: 1000 }); + const { data: conversations = [] } = useCollection("conversations", { limit: 1000 }); + const { data: transactions = [] } = useCollection("transactions", { limit: 1000 }); + const { data: balances = [] } = useCollection("balances", { limit: 1000 }); + + const calculateStats = useCallback(() => { + if (!users.length) { + return; + } + + setLoading(true); + + // Console log pour débugger les données balances + console.log("=== DONNÉES BALANCES RÉCUPÉRÉES ==="); + console.log("Nombre total d'entrées balances:", balances.length); + console.log("Toutes les entrées balances:", balances); + + // NOUVEAU : Console log pour débugger les utilisateurs + console.log("=== DONNÉES UTILISATEURS ==="); + console.log("Nombre total d'utilisateurs:", users.length); + console.log("Premiers 5 utilisateurs:", users.slice(0, 5)); + + // Analyser les doublons + const userCounts = new Map(); + balances.forEach(balance => { + const userId = balance.user; + userCounts.set(userId, (userCounts.get(userId) || 0) + 1); + }); + + const duplicateUsers = Array.from(userCounts.entries()).filter(([_, count]) => count > 1); + console.log("Utilisateurs avec plusieurs entrées:", duplicateUsers); + + // Afficher quelques exemples d'entrées + console.log("Premières 5 entrées:", balances.slice(0, 5)); + + // Calculer le total brut (avec doublons) + const totalBrut = balances.reduce((sum, balance) => sum + (balance.tokenCredits || 0), 0); + console.log("Total brut (avec doublons potentiels):", totalBrut); + + // NOUVEAU : Identifier les utilisateurs fantômes + console.log("=== ANALYSE DES UTILISATEURS FANTÔMES ==="); + const userIds = new Set(users.map(user => user._id)); + const balanceUserIds = balances.map(balance => balance.user); + const phantomUsers = balanceUserIds.filter(userId => !userIds.has(userId)); + const uniquePhantomUsers = [...new Set(phantomUsers)]; + + console.log("Utilisateurs fantômes (ont des balances mais n'existent plus):", uniquePhantomUsers); + console.log("Nombre d'utilisateurs fantômes:", uniquePhantomUsers.length); + + // Calculer les crédits des utilisateurs fantômes + const phantomCredits = balances + .filter(balance => uniquePhantomUsers.includes(balance.user)) + .reduce((sum, balance) => sum + (balance.tokenCredits || 0), 0); + + console.log("Crédits des utilisateurs fantômes:", phantomCredits); + console.log("Crédits des vrais utilisateurs:", totalBrut - phantomCredits); + + // Calculer les utilisateurs actifs (5 dernières minutes) + const fiveMinutesAgo = new Date(); + fiveMinutesAgo.setMinutes(fiveMinutesAgo.getMinutes() - 5); + const activeUsers = users.filter((user) => { + const lastActivity = new Date(user.updatedAt || user.createdAt); + return lastActivity >= fiveMinutesAgo; + }).length; + + // CORRECTION : Créer une map des crédits par utilisateur en évitant les doublons + const creditsMap = new Map(); + + // Grouper les balances par utilisateur + const balancesByUser = new Map(); + balances.forEach((balance) => { + const userId = balance.user; + if (!balancesByUser.has(userId)) { + balancesByUser.set(userId, []); + } + balancesByUser.get(userId)!.push(balance); + }); + + // Pour chaque utilisateur, prendre seulement la dernière entrée + balancesByUser.forEach((userBalances, userId) => { + if (userBalances.length > 0) { + // Trier par date de mise à jour (plus récent en premier) + const sortedBalances = userBalances.sort((a, b) => { + const aDate = new Date((a.updatedAt as string) || (a.createdAt as string) || 0); + const bDate = new Date((b.updatedAt as string) || (b.createdAt as string) || 0); + return bDate.getTime() - aDate.getTime(); + }); + + // Prendre la plus récente + const latestBalance = sortedBalances[0]; + creditsMap.set(userId, latestBalance.tokenCredits || 0); + } + }); + + // Initialiser les stats par utilisateur + const userStats = new Map< + string, + { + userName: string; + conversations: number; + tokens: number; + credits: number; + } + >(); + + users.forEach((user) => { + userStats.set(user._id, { + userName: user.name || user.email || "Utilisateur inconnu", + conversations: 0, + tokens: 0, + credits: creditsMap.get(user._id) || 0, + }); + }); + + // Calculer les conversations par utilisateur + conversations.forEach((conv) => { + const userStat = userStats.get(conv.user); + if (userStat) { + userStat.conversations++; + } + }); + + // Calculer les tokens par utilisateur depuis les transactions + let totalTokensConsumed = 0; + transactions.forEach((transaction) => { + const userStat = userStats.get(transaction.user); + if (userStat && transaction.rawAmount) { + const tokens = Math.abs(Number(transaction.rawAmount) || 0); + userStat.tokens += tokens; + totalTokensConsumed += tokens; + } + }); + + // CORRECTION : Calculer le total des crédits depuis la map corrigée + const totalCreditsUsed = Array.from(creditsMap.values()).reduce( + (sum, credits) => sum + credits, + 0 + ); + + // Tous les utilisateurs triés par tokens puis conversations + const allUsers = Array.from(userStats.entries()) + .map(([userId, stats]) => ({ + userId, + ...stats, + })) + .sort((a, b) => { + // Trier d'abord par tokens, puis par conversations si tokens égaux + if (b.tokens !== a.tokens) { + return b.tokens - a.tokens; + } + return b.conversations - a.conversations; + }); + + const totalMessages = conversations.reduce( + (sum, conv) => + sum + (Array.isArray(conv.messages) ? conv.messages.length : 0), + 0 + ); + + setStats({ + totalUsers: users.length, + activeUsers, + totalConversations: conversations.length, + totalMessages, + totalTokensConsumed, + totalCreditsUsed, + averageTokensPerUser: + users.length > 0 ? totalTokensConsumed / users.length : 0, + topUsers: allUsers, // Afficher tous les utilisateurs + }); + + setLoading(false); + }, [users, conversations, transactions, balances]); + + useEffect(() => { + calculateStats(); + }, [calculateStats]); + + if (loading || !stats) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + +
+
+
+
+
+
+ ))} +
+ ); + } + + return ( +
+
+ + + + Utilisateurs totaux + + + + +
{stats.totalUsers}
+

+ {stats.activeUsers} actifs cette semaine +

+
+
+ + + + Conversations + + + +
{stats.totalConversations}
+

+ {stats.totalMessages} messages au total +

+
+
+ + + + + Tokens consommés + + + + +
+ {stats.totalTokensConsumed.toLocaleString()} +
+

+ {Math.round(stats.averageTokensPerUser)} par utilisateur +

+
+
+ + + + + Crédits totaux + + + + +
+ {stats.totalCreditsUsed.toLocaleString()} +
+

crédits disponibles

+
+
+
+ + + + Tous les utilisateurs + + +
+ {stats.topUsers.map((user, index) => ( +
+
+ #{index + 1} +
+

{user.userName}

+

+ {user.conversations} conversations +

+
+
+
+

+ {user.tokens.toLocaleString()} tokens +

+

+ {user.credits.toLocaleString()} crédits +

+
+
+ ))} +
+
+
+
+ ); +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx new file mode 100644 index 0000000..b0e8966 --- /dev/null +++ b/components/layout/sidebar.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import Image from "next/image"; +import { + LayoutDashboard, + Users, + MessageSquare, + CreditCard, + Settings, + Database, + FileText, + Shield, + Bot, + ChevronLeft, + ChevronRight, + BarChart3, + Activity, +} from "lucide-react"; + +interface NavigationItem { + name: string; + href: string; + icon: React.ElementType; + badge?: string | null; +} + +const navigation: NavigationItem[] = [ + { + name: "Vue d'ensemble", + href: "/", + icon: LayoutDashboard, + badge: null, + }, + { + name: "Analytics", + href: "/analytics", + icon: BarChart3, + badge: "Nouveau", + }, +]; + +const dataNavigation: NavigationItem[] = [ + { name: "Utilisateurs", href: "/users", icon: Users, badge: null }, + { + name: "Conversations", + href: "/conversations", + icon: MessageSquare, + badge: null, + }, + { name: "Messages", href: "/messages", icon: FileText, badge: null }, + { + name: "Transactions", + href: "/transactions", + icon: CreditCard, + badge: null, + }, +]; + +const systemNavigation: NavigationItem[] = [ + { name: "Agents", href: "/agents", icon: Bot, badge: null }, + { name: "Rôles", href: "/roles", icon: Shield, badge: null }, + { name: "Collections", href: "/collections", icon: Database, badge: null }, + { name: "Paramètres", href: "/settings", icon: Settings, badge: null }, +]; + +export function Sidebar() { + const [collapsed, setCollapsed] = useState(false); + const pathname = usePathname(); + + const NavSection = ({ + title, + items, + showTitle = true, + }: { + title: string; + items: NavigationItem[]; + showTitle?: boolean; + }) => ( +
+ {!collapsed && showTitle && ( +

+ {title} +

+ )} + {items.map((item) => { + const isActive = pathname === item.href; + return ( + + + + ); + })} +
+ ); + + return ( +
+ {/* Header */} +
+ {!collapsed && ( +
+
+ Cercle GPT Logo +
+
+

Cercle GPT

+

Admin Dashboard

+
+
+ )} + +
+ + {/* Navigation */} + + + {/* Footer */} + {!collapsed && ( +
+
+
+ +
+
+

Système en ligne

+

Tout fonctionne

+
+
+
+ )} +
+ ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..21409a0 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..8916905 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/components/ui/metric-card.tsx b/components/ui/metric-card.tsx new file mode 100644 index 0000000..d0762c0 --- /dev/null +++ b/components/ui/metric-card.tsx @@ -0,0 +1,47 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { LucideIcon } from "lucide-react"; +import { formatNumber } from "@/lib/utils"; + +interface MetricCardProps { + title: string; + value: number | string; + icon: LucideIcon; + trend?: { + value: number; + isPositive: boolean; + }; + className?: string; +} + +export function MetricCard({ + title, + value, + icon: Icon, + trend, + className +}: MetricCardProps) { + return ( + + + + {title} + + + + +
+ {typeof value === 'number' ? formatNumber(value) : value} +
+ {trend && ( + + {trend.isPositive ? '+' : ''}{trend.value}% + + )} +
+
+ ); +} \ No newline at end of file diff --git a/components/ui/navigation-menu.tsx b/components/ui/navigation-menu.tsx new file mode 100644 index 0000000..1199945 --- /dev/null +++ b/components/ui/navigation-menu.tsx @@ -0,0 +1,168 @@ +import * as React from "react" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function NavigationMenu({ + className, + children, + viewport = true, + ...props +}: React.ComponentProps & { + viewport?: boolean +}) { + return ( + + {children} + {viewport && } + + ) +} + +function NavigationMenuList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function NavigationMenuItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1" +) + +function NavigationMenuTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + {children}{" "} + + ) +} + +function NavigationMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function NavigationMenuViewport({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ +
+ ) +} + +function NavigationMenuLink({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function NavigationMenuIndicator({ + className, + ...props +}: React.ComponentProps) { + return ( + +
+ + ) +} + +export { + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, + navigationMenuTriggerStyle, +} diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..275381c --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..84649ad --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 0000000..1ee5a45 --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,726 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, VariantProps } from "class-variance-authority" +import { PanelLeftIcon } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +