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 718d6fe..32c6670 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ 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. -
+
+ {/* En-tête simplifié */} +
+

+ + Vue d'ensemble +

+

+ Tableau de bord administrateur Cercle GPT +

+
-
- } + > + + + + {/* Graphiques en temps réel */} +
+

+ + Statistiques en temps réel +

+ + } + > + + +
+ + {/* Grille pour activité utilisateurs et actions */} +
- + + {/* 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 ( +