Compare commits

...

21 Commits

Author SHA1 Message Date
Biqoz
1a590d60c3 ordre 2025-11-30 13:27:50 +01:00
Biqoz
a34d29bf1e usage good 2025-11-27 15:27:45 +01:00
Biqoz
414253aff0 calcul accurate 2025-11-27 15:12:57 +01:00
Biqoz
e6a9d41ebd conversation clan 2025-11-27 14:23:08 +01:00
Biqoz
73f97919ac modifs complete 2025-11-27 13:58:47 +01:00
Biqoz
5b8b3c84c9 user nouveau onglet 2025-11-16 01:53:27 +01:00
Biqoz
b552504723 user nouveau onglet 2025-11-16 01:50:50 +01:00
Biqoz
b1881f06ef user nouveau onglet 2025-11-16 01:40:09 +01:00
Biqoz
ad575641a1 user nouveau onglet 2025-11-16 01:34:01 +01:00
Biqoz
0d95eca1ee bouton recherche 2025-11-05 15:25:18 +01:00
Biqoz
dde1c8ba93 amélioration recherche utilisateurs avec bouton de validation
- Ajout d'un système de recherche avec bouton "Rechercher"
- Support de la validation par touche Entrée
- Recherche côté serveur avec filtres MongoDB sur nom et email
- Réinitialisation automatique de la page lors d'une nouvelle recherche
- Suppression du debounce automatique pour un contrôle utilisateur total

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 15:20:19 +01:00
nBiqoz
1c7bca8e35 applicatif 3M, user, chart 2025-10-31 20:51:39 +01:00
nBiqoz
0efe96f4e2 user reaearch and fix function delete 2025-10-20 17:17:03 +02:00
nBiqoz
e0232b1fcb user management 2025-10-08 10:23:07 +02:00
nBiqoz
6e4dda0ecd user management 2025-10-08 10:14:38 +02:00
nBiqoz
80f075d04d clean 2025-10-07 18:23:23 +02:00
nBiqoz
2e35417697 clean 2025-10-07 18:09:21 +02:00
nBiqoz
bb0d61f528 clean 2025-10-07 17:33:33 +02:00
nBiqoz
0e184721c8 clean 2025-10-06 20:17:09 +02:00
nBiqoz
5b68ffdfb6 new 2025-10-06 19:19:57 +02:00
nBiqoz
0f2adca44a new 2025-10-06 19:16:20 +02:00
66 changed files with 6439 additions and 981 deletions

View File

@@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"Bash(cat:*)",
"Bash(awk:*)",
"Bash(useCollection.tmp)",
"Bash(npm run build:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)",
"Bash(npm install:*)",
"Bash(node debug-search.js:*)",
"Bash(node update-referent.js:*)",
"Bash(node:*)",
"Bash(curl:*)",
"Bash(npx tsc:*)"
],
"deny": [],
"ask": []
}
}

13
CLAUDE.md Normal file
View File

@@ -0,0 +1,13 @@
## CRITICAL: File Editing on Windows
### ⚠️ MANDATORY: Always Use Backslashes on Windows for File Paths
When using Edit or MultiEdit tools on Windows, you MUST use backslashes (\) in file paths, NOT forward slashes (/).
#### ❌ WRONG - Will cause errors:
Edit(file_path: "D:/repos/project/file.tsx", ...)
#### ✅ CORRECT - Always works:
Edit(file_path: "D:\repos\project\file.tsx", ...)

View File

@@ -2,6 +2,7 @@ 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() {

View File

@@ -0,0 +1,117 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/db/mongodb";
import { ObjectId } from "mongodb";
export async function POST(request: Request) {
try {
const { userId } = await request.json();
if (!userId) {
return NextResponse.json(
{ success: false, error: "userId est requis" },
{ status: 400 }
);
}
const db = await getDatabase();
const CREDITS_TO_ADD = 3000000; // 3 millions de tokens
// Vérifier que l'utilisateur existe
let userObjectId: ObjectId;
try {
userObjectId = new ObjectId(userId);
} catch {
return NextResponse.json(
{ success: false, error: "ID utilisateur invalide" },
{ status: 400 }
);
}
const user = await db.collection("users").findOne({ _id: userObjectId });
if (!user) {
return NextResponse.json(
{ success: false, error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
console.log(
`🎯 Ajout de ${CREDITS_TO_ADD.toLocaleString()} crédits à: ${user.email || user.name}`
);
// Chercher la balance existante - essayer avec ObjectId ET string
let existingBalance = await db
.collection("balances")
.findOne({ user: userObjectId });
// Si pas trouvé avec ObjectId, essayer avec string
if (!existingBalance) {
existingBalance = await db
.collection("balances")
.findOne({ user: userId });
}
console.log(`📊 Balance existante trouvée: ${existingBalance ? "OUI" : "NON"}`);
if (existingBalance) {
console.log(`📊 Balance ID: ${existingBalance._id}, Crédits actuels: ${existingBalance.tokenCredits}`);
}
let newBalance: number;
if (existingBalance) {
// Mettre à jour la balance existante avec $inc pour être sûr
const currentCredits = existingBalance.tokenCredits || 0;
newBalance = currentCredits + CREDITS_TO_ADD;
const updateResult = await db.collection("balances").updateOne(
{ _id: existingBalance._id },
{
$inc: { tokenCredits: CREDITS_TO_ADD },
$set: { lastRefill: new Date() },
}
);
console.log(`✅ Update result: matchedCount=${updateResult.matchedCount}, modifiedCount=${updateResult.modifiedCount}`);
console.log(
`✅ Balance mise à jour: ${currentCredits.toLocaleString()}${newBalance.toLocaleString()}`
);
} else {
// Créer une nouvelle balance
newBalance = CREDITS_TO_ADD;
const insertResult = await db.collection("balances").insertOne({
user: userObjectId,
tokenCredits: newBalance,
autoRefillEnabled: false,
lastRefill: new Date(),
refillAmount: 0,
refillIntervalUnit: "month",
refillIntervalValue: 1,
__v: 0,
});
console.log(`🆕 Nouvelle balance créée: ${insertResult.insertedId} avec ${newBalance.toLocaleString()} crédits`);
}
return NextResponse.json({
success: true,
message: `${CREDITS_TO_ADD.toLocaleString()} crédits ajoutés à ${user.email || user.name}`,
newBalance,
user: {
id: user._id.toString(),
name: user.name,
email: user.email,
},
});
} catch (error) {
console.error("Erreur lors de l'ajout des crédits:", error);
return NextResponse.json(
{
success: false,
error: "Erreur serveur lors de l'ajout des crédits",
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,139 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/db/mongodb";
export async function POST() {
try {
const db = await getDatabase();
const CREDITS_TO_ADD = 3000000; // 3 millions de tokens
console.log(
`🚀 DÉBUT: Ajout de ${CREDITS_TO_ADD.toLocaleString()} crédits à tous les utilisateurs`
);
// Récupérer tous les utilisateurs existants
const users = await db.collection("users").find({}).toArray();
console.log(`👥 Utilisateurs trouvés: ${users.length}`);
if (users.length === 0) {
return NextResponse.json({
success: false,
message: "Aucun utilisateur trouvé",
});
}
// Récupérer toutes les balances existantes
const existingBalances = await db.collection("balances").find({}).toArray();
const existingBalanceUserIds = new Set(
existingBalances.map((balance) => balance.user.toString())
);
console.log(`💰 Balances existantes: ${existingBalances.length}`);
let updatedCount = 0;
let createdCount = 0;
let totalCreditsAdded = 0;
// Pour chaque utilisateur
for (const user of users) {
const userId = user._id;
if (existingBalanceUserIds.has(userId.toString())) {
// Utilisateur a déjà une balance - ajouter les crédits
const updateResult = await db.collection("balances").updateOne(
{ user: userId },
{
$inc: { tokenCredits: CREDITS_TO_ADD },
$set: { lastRefill: new Date() },
}
);
if (updateResult.modifiedCount > 0) {
updatedCount++;
totalCreditsAdded += CREDITS_TO_ADD;
console.log(
`✅ Crédits ajoutés pour l'utilisateur: ${user.email || user.name}`
);
}
} else {
// Utilisateur n'a pas de balance - créer une nouvelle balance
const newBalance = {
user: userId,
tokenCredits: CREDITS_TO_ADD,
autoRefillEnabled: false,
lastRefill: new Date(),
refillAmount: 0,
refillIntervalUnit: "month",
refillIntervalValue: 1,
__v: 0,
};
await db.collection("balances").insertOne(newBalance);
createdCount++;
totalCreditsAdded += CREDITS_TO_ADD;
console.log(
`🆕 Nouvelle balance créée pour: ${user.email || user.name}`
);
}
}
console.log(`✅ TERMINÉ:`);
console.log(`- Balances mises à jour: ${updatedCount}`);
console.log(`- Nouvelles balances créées: ${createdCount}`);
console.log(
`- Total crédits ajoutés: ${totalCreditsAdded.toLocaleString()}`
);
return NextResponse.json({
success: true,
statistics: {
totalUsers: users.length,
updatedBalances: updatedCount,
createdBalances: createdCount,
creditsPerUser: CREDITS_TO_ADD,
totalCreditsAdded,
},
message: `${CREDITS_TO_ADD.toLocaleString()} crédits ajoutés à ${
users.length
} utilisateurs (${updatedCount} mis à jour, ${createdCount} créés)`,
});
} catch (error) {
console.error("Erreur lors de l'ajout des crédits:", error);
return NextResponse.json(
{
success: false,
error: "Erreur serveur lors de l'ajout des crédits",
},
{ status: 500 }
);
}
}
// Endpoint pour vérifier les crédits actuels
export async function GET() {
try {
const db = await getDatabase();
const users = await db.collection("users").find({}).toArray();
const balances = await db.collection("balances").find({}).toArray();
const totalCredits = balances.reduce(
(sum, balance) => sum + (balance.tokenCredits || 0),
0
);
const averageCredits =
balances.length > 0 ? totalCredits / balances.length : 0;
return NextResponse.json({
statistics: {
totalUsers: users.length,
totalBalances: balances.length,
totalCredits,
averageCredits: Math.round(averageCredits),
usersWithoutBalance: users.length - balances.length,
},
});
} catch (error) {
console.error("Erreur lors de la récupération des statistiques:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/db/mongodb";
export async function POST() {
try {
const db = await getDatabase();
// Récupérer tous les utilisateurs existants
const users = await db.collection("users").find({}).toArray();
const userIds = new Set(users.map(user => user._id.toString()));
// Récupérer toutes les balances
const balances = await db.collection("balances").find({}).toArray();
// Identifier les balances fantômes
const phantomBalances = balances.filter(balance =>
!userIds.has(balance.user.toString())
);
// Calculer les statistiques avant nettoyage
const totalBalances = balances.length;
const phantomCount = phantomBalances.length;
const phantomCredits = phantomBalances.reduce(
(sum, balance) => sum + (balance.tokenCredits || 0),
0
);
console.log(`🗑️ SUPPRESSION: ${phantomCount} balances fantômes détectées`);
console.log(`💰 CRÉDITS FANTÔMES: ${phantomCredits}`);
// SUPPRESSION DÉFINITIVE des balances fantômes
const deleteResult = await db.collection("balances").deleteMany({
user: { $nin: Array.from(userIds) }
});
console.log(`✅ SUPPRIMÉES: ${deleteResult.deletedCount} balances`);
return NextResponse.json({
success: true,
statistics: {
totalBalances,
phantomCount,
phantomCredits,
cleanedCount: deleteResult.deletedCount
},
message: `${deleteResult.deletedCount} balances fantômes supprimées définitivement`
});
} catch (error) {
console.error("Erreur lors du nettoyage des balances:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
// Endpoint pour analyser sans nettoyer
export async function GET() {
try {
const db = await getDatabase();
const users = await db.collection("users").find({}).toArray();
const userIds = new Set(users.map(user => user._id.toString()));
const balances = await db.collection("balances").find({}).toArray();
const phantomBalances = balances.filter(balance =>
!userIds.has(balance.user.toString())
);
const phantomCredits = phantomBalances.reduce(
(sum, balance) => sum + (balance.tokenCredits || 0),
0
);
// Analyser les doublons aussi
const userCounts = new Map<string, number>();
balances.forEach(balance => {
const userId = balance.user.toString();
userCounts.set(userId, (userCounts.get(userId) || 0) + 1);
});
const duplicates = Array.from(userCounts.entries())
.filter(([, count]) => count > 1)
.map(([userId, count]) => ({
userId,
count,
isPhantom: !userIds.has(userId)
}));
return NextResponse.json({
analysis: {
totalBalances: balances.length,
totalUsers: users.length,
phantomBalances: phantomBalances.length,
phantomCredits,
duplicateUsers: duplicates.length,
duplicates: duplicates.slice(0, 10) // Premiers 10 exemples
}
});
} catch (error) {
console.error("Erreur lors de l'analyse des balances:", error);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@@ -1,12 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/lib/db/mongodb';
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/db/mongodb";
import { ObjectId } from "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'
"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(
@@ -16,44 +39,128 @@ export async function GET(
const { collection } = await params;
try {
if (!ALLOWED_COLLECTIONS.includes(collection)) {
return NextResponse.json(
{ error: 'Collection non autorisée' },
{ 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 page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const filter = JSON.parse(searchParams.get("filter") || "{}");
// Debug logging pour messages
if (collection === "messages") {
console.log("[API Messages] Filter reçu:", JSON.stringify(filter));
console.log("[API Messages] Raw filter param:", searchParams.get("filter"));
}
// Gestion spéciale pour la collection users avec recherche par email ou id
if (collection === "users") {
const email = searchParams.get("email");
const id = searchParams.get("id");
const search = searchParams.get("search");
const referent = searchParams.get("referent");
if (email) {
filter.email = email.toLowerCase();
} else if (id) {
if (ObjectId.isValid(id)) {
filter._id = new ObjectId(id);
} else {
return NextResponse.json(
{ error: "ID utilisateur invalide" },
{ status: 400 }
);
}
} else if (search) {
filter.$or = [
{ name: { $regex: search, $options: "i" } },
{ email: { $regex: search, $options: "i" } },
];
}
// Filtre par référent (peut être combiné avec search)
if (referent) {
filter.referent = referent;
}
}
// Gestion spéciale pour conversations - recherche par nom/email d'utilisateur
if (collection === "conversations") {
const search = searchParams.get("search");
const userId = searchParams.get("userId");
if (userId) {
// Recherche directe par userId (stocké comme string dans conversations)
filter.user = userId;
} else if (search && search.trim()) {
// Normaliser la recherche (enlever accents pour recherche insensible aux accents)
const normalizedSearch = search
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
const db = await getDatabase();
// Recherche users avec nom/email/username (insensible casse + accents)
const matchingUsers = await db
.collection("users")
.find({
$or: [
{ name: { $regex: normalizedSearch, $options: "i" } },
{ email: { $regex: normalizedSearch, $options: "i" } },
{ username: { $regex: normalizedSearch, $options: "i" } },
],
})
.project({ _id: 1 })
.toArray();
if (matchingUsers.length > 0) {
// IMPORTANT: Convertir en strings car user dans conversations est stocké comme string
const userIds = matchingUsers.map((u) => u._id.toString());
filter.user = { $in: userIds };
} else {
return NextResponse.json({
data: [],
total: 0,
page,
limit,
totalPages: 0,
});
}
}
}
const db = await getDatabase();
const skip = (page - 1) * limit;
const [data, total] = await Promise.all([
db.collection(collection)
db
.collection(collection)
.find(filter)
.skip(skip)
.limit(limit)
.sort({ createdAt: -1 })
.toArray(),
db.collection(collection).countDocuments(filter)
db.collection(collection).countDocuments(filter),
]);
// Debug logging pour messages
if (collection === "messages") {
console.log("[API Messages] Résultats:", data.length, "messages, total:", total);
}
return NextResponse.json({
data,
total,
page,
limit,
totalPages: Math.ceil(total / 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 }
);
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}

View File

@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/db/mongodb";
import bcrypt from "bcryptjs";
import { ObjectId } from "mongodb";
export async function POST(request: NextRequest) {
try {
const { name, email, password, role = "USER" } = await request.json();
// Validation des données
if (!name || !email || !password) {
return NextResponse.json(
{ error: "Nom, email et mot de passe sont requis" },
{ status: 400 }
);
}
// Validation de l'email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: "Format d'email invalide" },
{ status: 400 }
);
}
// Validation du mot de passe (minimum 8 caractères)
if (password.length < 8) {
return NextResponse.json(
{ error: "Le mot de passe doit contenir au moins 8 caractères" },
{ status: 400 }
);
}
// Validation du rôle
if (!["USER", "ADMIN"].includes(role)) {
return NextResponse.json(
{ error: "Rôle invalide. Doit être USER ou ADMIN" },
{ status: 400 }
);
}
const db = await getDatabase();
// Vérifier si l'utilisateur existe déjà
const existingUser = await db.collection("users").findOne({ email });
if (existingUser) {
return NextResponse.json(
{ error: "Un utilisateur avec cet email existe déjà" },
{ status: 409 }
);
}
// Hasher le mot de passe
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Créer le nouvel utilisateur
const newUser = {
name,
username: email.split("@")[0], // Utiliser la partie avant @ comme username
email,
emailVerified: false,
password: hashedPassword,
avatar: null,
provider: "local",
role,
plugins: [],
twoFactorEnabled: false,
termsAccepted: true,
personalization: {
memories: false,
_id: new ObjectId(),
},
backupCodes: [],
refreshToken: [],
createdAt: new Date(),
updatedAt: new Date(),
__v: 0,
};
// Insérer l'utilisateur dans la base de données
const result = await db.collection("users").insertOne(newUser);
if (!result.insertedId) {
return NextResponse.json(
{ error: "Erreur lors de la création de l'utilisateur" },
{ status: 500 }
);
}
// Créer une balance initiale pour l'utilisateur
const initialBalance = {
user: result.insertedId,
tokenCredits: 3000000, // 3 millions de tokens par défaut
autoRefillEnabled: false,
lastRefill: new Date(),
refillAmount: 0,
refillIntervalUnit: "month",
refillIntervalValue: 1,
__v: 0,
};
await db.collection("balances").insertOne(initialBalance);
console.log(`✅ Nouvel utilisateur créé: ${email} (${role})`);
return NextResponse.json({
success: true,
message: `Utilisateur ${name} créé avec succès`,
user: {
id: result.insertedId,
name,
email,
role,
createdAt: newUser.createdAt,
},
});
} catch (error) {
console.error("Erreur lors de la création de l'utilisateur:", error);
return NextResponse.json(
{ error: "Erreur serveur lors de la création de l'utilisateur" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/db/mongodb";
export async function GET() {
try {
const db = await getDatabase();
// Récupérer 5 users pour debug
const users = await db.collection("users")
.find({ referent: { $exists: true } })
.limit(5)
.toArray();
return NextResponse.json({
success: true,
count: users.length,
users: users.map(u => ({
_id: u._id,
name: u.name,
email: u.email,
referent: u.referent,
cours: u.cours,
})),
});
} catch (error) {
console.error("Debug error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,160 @@
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "@/lib/db/mongodb";
import { ObjectId } from "mongodb";
interface QueryFilter {
_id?: ObjectId;
email?: string;
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("id");
const email = searchParams.get("email");
if (!userId && !email) {
return NextResponse.json(
{ error: "ID utilisateur ou email requis" },
{ status: 400 }
);
}
const db = await getDatabase();
const usersCollection = db.collection("users");
const balancesCollection = db.collection("balances");
// Construire la requête de recherche
const query: QueryFilter = {};
if (userId) {
// Vérifier si l'ID est un ObjectId valide
if (!ObjectId.isValid(userId)) {
return NextResponse.json(
{ error: "ID utilisateur invalide" },
{ status: 400 }
);
}
query._id = new ObjectId(userId);
} else if (email) {
query.email = email.toLowerCase();
}
// Vérifier si l'utilisateur existe
const existingUser = await usersCollection.findOne(query);
if (!existingUser) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// Supprimer l'utilisateur
const deleteUserResult = await usersCollection.deleteOne(query);
// Supprimer le solde associé
const deleteBalanceResult = await balancesCollection.deleteOne({
user: existingUser._id
});
if (deleteUserResult.deletedCount === 0) {
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'utilisateur" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: "Utilisateur supprimé avec succès",
deletedUser: {
id: existingUser._id.toString(),
name: existingUser.name,
email: existingUser.email,
role: existingUser.role
},
balanceDeleted: deleteBalanceResult.deletedCount > 0
});
} catch (error) {
console.error("Erreur lors de la suppression de l'utilisateur:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const { userId, email } = await request.json();
if (!userId && !email) {
return NextResponse.json(
{ error: "ID utilisateur ou email requis" },
{ status: 400 }
);
}
const db = await getDatabase();
const usersCollection = db.collection("users");
const balancesCollection = db.collection("balances");
// Construire la requête de recherche
const query: QueryFilter = {};
if (userId) {
// Vérifier si l'ID est un ObjectId valide
if (!ObjectId.isValid(userId)) {
return NextResponse.json(
{ error: "ID utilisateur invalide" },
{ status: 400 }
);
}
query._id = new ObjectId(userId);
} else if (email) {
query.email = email.toLowerCase();
}
// Vérifier si l'utilisateur existe
const existingUser = await usersCollection.findOne(query);
if (!existingUser) {
return NextResponse.json(
{ error: "Utilisateur non trouvé" },
{ status: 404 }
);
}
// Supprimer l'utilisateur
const deleteUserResult = await usersCollection.deleteOne(query);
// Supprimer le solde associé
const deleteBalanceResult = await balancesCollection.deleteOne({
user: existingUser._id
});
if (deleteUserResult.deletedCount === 0) {
return NextResponse.json(
{ error: "Erreur lors de la suppression de l'utilisateur" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: "Utilisateur supprimé avec succès",
deletedUser: {
id: existingUser._id.toString(),
name: existingUser.name,
email: existingUser.email,
role: existingUser.role
},
balanceDeleted: deleteBalanceResult.deletedCount > 0
});
} catch (error) {
console.error("Erreur lors de la suppression de l'utilisateur:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}

View File

@@ -13,21 +13,21 @@ export async function GET() {
db.collection("balances").find({}).toArray(),
]);
// Calculer les utilisateurs actifs (dernière semaine)
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
// Calculer les utilisateurs actifs (30 derniers jours)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const activeUsers = users.filter((user) => {
const lastActivity = new Date(user.updatedAt || user.createdAt);
return lastActivity >= oneWeekAgo;
return lastActivity >= thirtyDaysAgo;
}).length;
// Calculer les administrateurs
const totalAdmins = users.filter(user => user.role === 'ADMIN').length;
// Calculer les conversations actives (dernière semaine)
// Calculer les conversations actives (30 derniers jours)
const activeConversations = conversations.filter((conv) => {
const lastActivity = new Date(conv.updatedAt || conv.createdAt);
return lastActivity >= oneWeekAgo;
return lastActivity >= thirtyDaysAgo;
}).length;
// Calculer le total des messages
@@ -41,32 +41,27 @@ export async function GET() {
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);
// Calculer le total des crédits
const totalCreditsUsed = balances.reduce(
(sum, balance) => sum + (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
}));
.slice(0, 10);
return NextResponse.json({
totalUsers: users.length,
activeUsers,
totalAdmins,
totalCredits,
totalCredits: totalCreditsUsed,
activeConversations,
totalMessages: totalMessages,
totalMessages,
totalTokensConsumed,
recentTransactions
totalCreditsUsed,
recentTransactions,
});
} catch (error) {
console.error("Erreur lors du calcul des métriques:", error);

View File

@@ -8,6 +8,28 @@ export async function GET() {
// Récupérer toutes les transactions
const transactions = await db.collection("transactions").find({}).toArray();
// Récupérer les conversations pour analyser les connexions utilisateurs
const conversations = await db.collection("conversations").find({}).toArray();
// Récupérer les messages pour une analyse plus précise de l'activité
const messages = await db.collection("messages").find({}).toArray();
console.log(`Total transactions trouvées: ${transactions.length}`);
// Vérifier les champs de date disponibles dans les transactions
if (transactions.length > 0) {
const sampleTransaction = transactions[0];
console.log("Exemple de transaction:", {
_id: sampleTransaction._id,
createdAt: sampleTransaction.createdAt,
updatedAt: sampleTransaction.updatedAt,
date: sampleTransaction.date,
timestamp: sampleTransaction.timestamp,
rawAmount: sampleTransaction.rawAmount,
model: sampleTransaction.model
});
}
// Calculer les tokens par jour (7 derniers jours)
const dailyStats = [];
const today = new Date();
@@ -22,14 +44,44 @@ export async function GET() {
nextDate.setDate(nextDate.getDate() + 1);
const dayTransactions = transactions.filter(transaction => {
const transactionDate = new Date(transaction.createdAt);
// Essayer différents champs de date
let transactionDate = null;
if (transaction.createdAt) {
transactionDate = new Date(transaction.createdAt);
} else if (transaction.updatedAt) {
transactionDate = new Date(transaction.updatedAt);
} else if (transaction.date) {
transactionDate = new Date(transaction.date);
} else if (transaction.timestamp) {
transactionDate = new Date(transaction.timestamp);
} else if (transaction._id && transaction._id.getTimestamp) {
// Utiliser le timestamp de l'ObjectId MongoDB
transactionDate = transaction._id.getTimestamp();
}
if (!transactionDate || isNaN(transactionDate.getTime())) {
return false;
}
return transactionDate >= date && transactionDate < nextDate;
});
const totalTokens = dayTransactions.reduce((sum, transaction) => {
return sum + Math.abs(Number(transaction.rawAmount) || 0);
// Essayer différents champs pour les tokens
let tokens = 0;
if (transaction.rawAmount) {
tokens = Math.abs(Number(transaction.rawAmount) || 0);
} else if (transaction.amount) {
tokens = Math.abs(Number(transaction.amount) || 0);
} else if (transaction.tokens) {
tokens = Math.abs(Number(transaction.tokens) || 0);
}
return sum + tokens;
}, 0);
console.log(`${dayNames[date.getDay()]} (${date.toISOString().split('T')[0]}): ${dayTransactions.length} transactions, ${totalTokens} tokens`);
dailyStats.push({
name: dayNames[date.getDay()],
value: totalTokens
@@ -40,9 +92,19 @@ export async function GET() {
const modelStats = new Map<string, number>();
transactions.forEach(transaction => {
const model = transaction.model || "Inconnu";
const tokens = Math.abs(Number(transaction.rawAmount) || 0);
const model = transaction.model || transaction.modelName || "Inconnu";
let tokens = 0;
if (transaction.rawAmount) {
tokens = Math.abs(Number(transaction.rawAmount) || 0);
} else if (transaction.amount) {
tokens = Math.abs(Number(transaction.amount) || 0);
} else if (transaction.tokens) {
tokens = Math.abs(Number(transaction.tokens) || 0);
}
if (tokens > 0) {
modelStats.set(model, (modelStats.get(model) || 0) + tokens);
}
});
// Convertir en array et trier par usage
@@ -50,8 +112,75 @@ export async function GET() {
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
// Calculer les connexions utilisateurs par jour (7 derniers jours)
const dailyConnections = [];
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);
// Analyser l'activité des utilisateurs via les messages
const activeUsers = new Set();
messages.forEach(message => {
let messageDate = null;
if (message.createdAt) {
messageDate = new Date(message.createdAt);
} else if (message.updatedAt) {
messageDate = new Date(message.updatedAt);
} else if (message._id && message._id.getTimestamp) {
messageDate = message._id.getTimestamp();
}
if (messageDate && messageDate >= date && messageDate < nextDate) {
if (message.user && message.isCreatedByUser) {
activeUsers.add(message.user);
}
}
});
// Aussi analyser via les conversations créées ce jour-là
conversations.forEach(conversation => {
let convDate = null;
if (conversation.createdAt) {
convDate = new Date(conversation.createdAt);
} else if (conversation.updatedAt) {
convDate = new Date(conversation.updatedAt);
} else if (conversation._id && conversation._id.getTimestamp) {
convDate = conversation._id.getTimestamp();
}
if (convDate && convDate >= date && convDate < nextDate) {
if (conversation.user) {
activeUsers.add(conversation.user);
}
}
});
console.log(`${dayNames[date.getDay()]} (${date.toISOString().split('T')[0]}): ${activeUsers.size} utilisateurs actifs`);
dailyConnections.push({
name: dayNames[date.getDay()],
value: activeUsers.size
});
}
console.log("Statistiques calculées:", {
dailyStats,
dailyConnections,
totalModels: modelData.length,
topModel: modelData[0]
});
return NextResponse.json({
dailyTokens: dailyStats,
dailyConnections: dailyConnections,
modelDistribution: modelData
});
} catch (error) {

View File

@@ -8,16 +8,16 @@ export async function GET() {
// 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);
// Calculer les utilisateurs actifs (30 derniers jours)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
let activeUsers = 0;
let inactiveUsers = 0;
users.forEach(user => {
const lastActivity = new Date(user.updatedAt || user.createdAt);
if (lastActivity >= oneWeekAgo) {
if (lastActivity >= thirtyDaysAgo) {
activeUsers++;
} else {
inactiveUsers++;

View File

@@ -6,7 +6,7 @@ export default function ConversationsPage() {
<div>
<h1 className="text-3xl font-bold tracking-tight">Conversations</h1>
<p className="text-muted-foreground">
Gestion des conversations Cercle GPTTT
Gestion des conversations Cercle GPT
</p>
</div>

View File

@@ -51,51 +51,53 @@
--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: oklch(0.145 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);
--secondary: oklch(0.969 0 0);
--secondary-foreground: oklch(0.145 0 0);
--muted: oklch(0.969 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);
--accent: oklch(0.969 0 0);
--accent-foreground: oklch(0.145 0 0);
--destructive: oklch(0.627 0.265 303.9);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.898 0 0);
--input: oklch(0.898 0 0);
--ring: oklch(0.145 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.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);
--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);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.145 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);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.145 0 0);
--secondary: oklch(0.205 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);
--muted: oklch(0.205 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.205 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%);
--destructive: oklch(0.627 0.265 303.9);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.205 0 0);
--input: oklch(0.205 0 0);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
@@ -120,3 +122,31 @@
@apply bg-background text-foreground;
}
}
@layer utilities {
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
.animate-accordion-down {
animation: accordion-down 0.2s ease-out;
}
.animate-accordion-up {
animation: accordion-up 0.2s ease-out;
}
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/layout/sidebar";
import { QueryProvider } from "@/components/providers/query-provider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -28,12 +29,14 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<QueryProvider>
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="container mx-auto p-6">{children}</div>
</main>
</div>
</QueryProvider>
</body>
</html>
);

194
app/login/page.tsx Normal file
View File

@@ -0,0 +1,194 @@
"use client";
import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, Mail, Lock, Shield } from "lucide-react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
// Fonction pour traduire les erreurs Supabase en français
const getErrorMessage = (error: string) => {
const errorMessages: { [key: string]: string } = {
"Invalid login credentials": "Identifiants de connexion invalides",
"Email not confirmed": "Email non confirmé",
"Too many requests": "Trop de tentatives de connexion",
"User not found": "Utilisateur non trouvé",
"Invalid email": "Adresse email invalide",
"Password should be at least 6 characters": "Le mot de passe doit contenir au moins 6 caractères",
"Email rate limit exceeded": "Limite de tentatives dépassée",
"Invalid email or password": "Email ou mot de passe incorrect",
"Account not found": "Compte non trouvé",
"Invalid credentials": "Identifiants incorrects",
"Authentication failed": "Échec de l'authentification",
"Access denied": "Accès refusé",
"Unauthorized": "Non autorisé",
};
// Chercher une correspondance exacte
if (errorMessages[error]) {
return errorMessages[error];
}
// Chercher une correspondance partielle
for (const [englishError, frenchError] of Object.entries(errorMessages)) {
if (error.toLowerCase().includes(englishError.toLowerCase())) {
return frenchError;
}
}
// Message par défaut si aucune correspondance
return "Erreur de connexion. Vérifiez vos identifiants.";
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
// Validation côté client
if (!email || !password) {
setError("Veuillez remplir tous les champs");
setLoading(false);
return;
}
if (!email.includes("@")) {
setError("Veuillez entrer une adresse email valide");
setLoading(false);
return;
}
const supabase = createClient();
try {
const { data, error: authError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) {
setError(getErrorMessage(authError.message));
return;
}
if (data.user) {
router.push("/");
router.refresh();
}
} catch (error) {
console.error('Login error:', error);
setError("Une erreur inattendue est survenue. Veuillez réessayer.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-lg border border-gray-200">
<CardHeader className="space-y-6 pb-8">
<div className="flex justify-center">
<div className="relative w-16 h-16 rounded-full bg-gray-100 p-2">
<Image
src="/img/logo.png"
alt="Logo"
width={48}
height={48}
className="rounded-full"
/>
</div>
</div>
<div className="text-center space-y-2">
<CardTitle className="text-2xl font-bold text-gray-900 flex items-center justify-center gap-2">
<Shield className="w-6 h-6 text-gray-700" />
Admin Dashboard
</CardTitle>
<p className="text-gray-600 text-sm">
Connectez-vous pour accéder au tableau de bord
</p>
</div>
</CardHeader>
<CardContent className="space-y-6">
{error && (
<Alert className="border-red-200 bg-red-50">
<AlertDescription className="text-red-700">
{error}
</AlertDescription>
</Alert>
)}
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-gray-700">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="email"
type="email"
placeholder="admin@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="pl-10 border-gray-300 focus:border-gray-900 focus:ring-gray-900"
required
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-gray-700">
Mot de passe
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 border-gray-300 focus:border-gray-900 focus:ring-gray-900"
required
/>
</div>
</div>
<Button
type="submit"
className="w-full bg-gray-900 hover:bg-gray-800 text-white font-medium py-2.5 transition-colors"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connexion en cours...
</>
) : (
"Se connecter"
)}
</Button>
</form>
<div className="text-center">
<p className="text-xs text-gray-500">
Accès réservé aux administrateurs autorisés
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -5,6 +5,7 @@ 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 { DashboardUsersList } from "@/components/dashboard/dashboard-users-list";
import {
Users,
MessageSquare,
@@ -28,7 +29,7 @@ export default function Dashboard() {
</p>
</div>
{/* Métriques principales */}
{/* Métriques principales - maintenant avec tokens consommés */}
<Suspense
fallback={<div className="h-32 bg-muted animate-pulse rounded-lg" />}
>
@@ -49,10 +50,9 @@ export default function Dashboard() {
</Suspense>
</div>
{/* Grille pour activité utilisateurs et actions */}
<div className="grid gap-6 md:grid-cols-3">
{/* Activité des utilisateurs avec vraies données */}
<div className="md:col-span-1">
{/* Grille pour activité utilisateurs et top utilisateurs */}
<div className="grid gap-6 md:grid-cols-2">
{/* Activité des utilisateurs */}
<Suspense
fallback={
<div className="h-64 bg-muted animate-pulse rounded-lg" />
@@ -60,10 +60,19 @@ export default function Dashboard() {
>
<RealUserActivityChart />
</Suspense>
{/* Top 5 utilisateurs - nouveau composant */}
<Suspense
fallback={
<div className="h-64 bg-muted animate-pulse rounded-lg" />
}
>
<DashboardUsersList />
</Suspense>
</div>
{/* Actions rapides épurées */}
<div className="md:col-span-2 grid gap-4 md:grid-cols-2">
{/* Actions rapides */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="hover:shadow-md transition-shadow border-l-4 border-l-blue-500">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center">
@@ -141,6 +150,5 @@ export default function Dashboard() {
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ArrowLeft, Mail } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { LibreChatUser, LibreChatBalance } from "@/lib/types";
import { useCollection } from "@/hooks/useCollection";
// Couleurs prédéfinies pour les référents
const REFERENT_COLORS: Record<string, string> = {
"Emmanuel WATHELE": "#3B82F6",
"IHECS": "#10B981",
};
export default function CoursDetailPage() {
const params = useParams();
const router = useRouter();
const referent = decodeURIComponent(params.referent as string);
const cours = decodeURIComponent(params.cours as string);
const [students, setStudents] = useState<LibreChatUser[]>([]);
const [loading, setLoading] = useState(true);
// Charger tous les balances
const { data: balances = [] } = useCollection<LibreChatBalance>("balances", {
limit: 1000,
});
// Créer une map des crédits par utilisateur
const creditsMap = useMemo(() => {
const map = new Map<string, number>();
balances.forEach((balance) => {
map.set(balance.user, balance.tokenCredits || 0);
});
return map;
}, [balances]);
useEffect(() => {
const fetchStudents = async () => {
try {
const response = await fetch(
"/api/collections/users?page=1&limit=1000&filter={}"
);
const result = await response.json();
if (result.data) {
// Filtrer les étudiants pour ce référent et ce cours
const filtered = result.data.filter(
(user: { referent?: string; cours?: string }) =>
user.referent === referent && user.cours === cours
);
setStudents(filtered);
}
} catch (error) {
console.error("Erreur lors du chargement des étudiants:", error);
} finally {
setLoading(false);
}
};
fetchStudents();
}, [referent, cours]);
const couleur = REFERENT_COLORS[referent] || "#6B7280";
if (loading) {
return (
<div className="space-y-6">
<Button
variant="ghost"
onClick={() => router.push("/referents")}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour aux référents
</Button>
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<Button
variant="ghost"
onClick={() => router.push("/referents")}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour aux référents
</Button>
<div className="flex items-center gap-3">
<div
className="w-6 h-6 rounded-full flex-shrink-0"
style={{ backgroundColor: couleur }}
/>
<div>
<h1 className="text-3xl font-bold tracking-tight">{referent}</h1>
<p className="text-muted-foreground">{cours}</p>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>
Étudiants ({students.length})
</CardTitle>
</div>
</CardHeader>
<CardContent>
{students.length === 0 ? (
<div className="text-center py-12">
<p className="text-sm text-gray-500">
Aucun étudiant dans ce cours
</p>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Crédits</TableHead>
<TableHead>Créé le</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((student) => (
<TableRow key={student._id}>
<TableCell>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: couleur }}
/>
<span className="font-medium">
{student.prenom} {student.nom}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-gray-400" />
<span className="text-sm">{student.email}</span>
</div>
</TableCell>
<TableCell>
<Badge variant={student.role === "ADMIN" ? "default" : "secondary"}>
{student.role}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm">
{(creditsMap.get(student._id) || 0).toLocaleString()} tokens
</span>
</TableCell>
<TableCell className="text-sm text-gray-500">
{formatDate(student.createdAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

181
app/referents/page.tsx Normal file
View File

@@ -0,0 +1,181 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { GraduationCap, Users, ChevronRight } from "lucide-react";
import Link from "next/link";
// Couleurs prédéfinies pour les référents
const REFERENT_COLORS: Record<string, string> = {
"Emmanuel WATHELE": "#3B82F6", // Bleu
"IHECS": "#10B981", // Vert
};
interface ReferentData {
nom: string;
couleur: string;
cours: string[];
nombreEtudiants: number;
}
export default function ReferentsPage() {
const [referents, setReferents] = useState<ReferentData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchReferents = async () => {
try {
// Récupérer tous les users pour compter par référent
const response = await fetch(
"/api/collections/users?page=1&limit=1000&filter={}"
);
const result = await response.json();
if (result.data) {
// Grouper par référent
const referentsMap = new Map<string, ReferentData>();
result.data.forEach((user: { referent?: string; cours?: string }) => {
if (user.referent) {
if (!referentsMap.has(user.referent)) {
referentsMap.set(user.referent, {
nom: user.referent,
couleur: REFERENT_COLORS[user.referent] || "#6B7280", // Gris par défaut
cours: [],
nombreEtudiants: 0,
});
}
const ref = referentsMap.get(user.referent)!;
ref.nombreEtudiants++;
// Ajouter le cours s'il n'existe pas déjà
if (user.cours && !ref.cours.includes(user.cours)) {
ref.cours.push(user.cours);
}
}
});
setReferents(Array.from(referentsMap.values()));
}
} catch (error) {
console.error("Erreur lors du chargement des référents:", error);
} finally {
setLoading(false);
}
};
fetchReferents();
}, []);
if (loading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Référents</h1>
<p className="text-muted-foreground">
Gestion des référents et leurs cours
</p>
</div>
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 bg-muted animate-pulse rounded" />
))}
</div>
</CardContent>
</Card>
</div>
);
}
if (referents.length === 0) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Référents</h1>
<p className="text-muted-foreground">
Gestion des référents et leurs cours
</p>
</div>
<Card>
<CardContent className="p-6">
<div className="text-center py-12">
<GraduationCap className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-semibold text-gray-900">
Aucun référent
</h3>
<p className="mt-1 text-sm text-gray-500">
Importez des utilisateurs avec référents pour commencer.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Référents</h1>
<p className="text-muted-foreground">
Gestion des référents et leurs cours
</p>
</div>
<div className="grid gap-4">
{referents.map((referent) => (
<Card key={referent.nom} className="hover:shadow-md transition-shadow">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Point coloré */}
<div
className="w-4 h-4 rounded-full flex-shrink-0"
style={{ backgroundColor: referent.couleur }}
/>
<div>
<CardTitle className="text-xl">{referent.nom}</CardTitle>
<div className="flex items-center gap-2 mt-1">
<Users className="h-4 w-4 text-gray-500" />
<span className="text-sm text-gray-500">
{referent.nombreEtudiants} étudiant
{referent.nombreEtudiants > 1 ? "s" : ""}
</span>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700">Cours :</h4>
<div className="space-y-2">
{referent.cours.map((cours) => (
<Link
key={cours}
href={`/referents/${encodeURIComponent(referent.nom)}/${encodeURIComponent(cours)}`}
>
<div className="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: referent.couleur }}
/>
<span className="text-sm font-medium">{cours}</span>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
</div>
</Link>
))}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -1,5 +1,10 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Database, Server, Settings } from "lucide-react";
import AddCredits from "@/components/dashboard/add-credits";
import AddCreditsSingleUser from "@/components/dashboard/add-credits-single-user";
import UserManagement from "@/components/dashboard/user-management";
export default function SettingsPage() {
return (
@@ -7,65 +12,81 @@ export default function SettingsPage() {
<div>
<h1 className="text-3xl font-bold tracking-tight">Paramètres</h1>
<p className="text-muted-foreground">
Configuration du dashboard Cercle GPT
Configuration et maintenance du système
</p>
</div>
{/* Informations système */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Connexion MongoDB</CardTitle>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Database className="h-4 w-4" />
Base de données
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Statut:</span>
<span className="text-sm text-muted-foreground">
Statut MongoDB
</span>
<Badge variant="default">Connecté</Badge>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Base de données:
</span>
<span className="text-sm font-mono">Cercle GPT</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Collections:
</span>
<span className="text-sm">29 collections</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Informations système</CardTitle>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Server className="h-4 w-4" />
Informations système
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Version Next.js:
Version Next.js
</span>
<span className="text-sm">15.5.4</span>
<Badge variant="outline">15.0.3</Badge>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Version Node.js:
Version Node.js
</span>
<span className="text-sm">{process.version}</span>
<Badge variant="outline">{process.version}</Badge>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Environnement:
Environnement
</span>
<Badge variant="outline">{process.env.NODE_ENV}</Badge>
<Badge variant="outline">Development</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Gestion des utilisateurs */}
<UserManagement />
{/* Gestion des crédits */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-6 w-6" />
Gestion des Crédits
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<AddCreditsSingleUser />
<div className="border-t pt-6">
<AddCredits />
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -6,7 +6,7 @@ export default function UsersPage() {
<div>
<h1 className="text-3xl font-bold tracking-tight">Utilisateurs</h1>
<p className="text-muted-foreground">
Gestion des utilisateurs Cercle GPTT
Gestion des utilisateurs Cercle GPT
</p>
</div>

View File

@@ -1,27 +1,21 @@
"use client";
import { useState } from "react";
import { useCollection } from "@/hooks/useCollection";
import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
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 { Input } from "@/components/ui/input";
import {
ChevronLeft,
ChevronRight,
Users,
ChevronDown,
Search,
MessageSquare,
Calendar,
X,
User,
Bot,
Loader2,
} from "lucide-react";
import { formatDate } from "@/lib/utils";
import {
@@ -30,496 +24,429 @@ import {
LibreChatMessage,
} from "@/lib/types";
// Hook debounce personnalisé
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Types pour les messages étendus
interface ExtendedMessage extends LibreChatMessage {
content?: Array<{ type: string; text: string }> | string;
message?: Record<string, unknown>;
parts?: Array<string | { text: string }>;
metadata?: { text?: string };
[key: string]: unknown;
}
// Type pour les groupes d'utilisateurs
interface UserGroup {
userId: string;
conversations: LibreChatConversation[];
totalMessages: number;
}
// Fetcher générique
async function fetchCollection<T>(
collection: string,
params: Record<string, string | number>
): Promise<{ data: T[]; total: number; totalPages: number }> {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
searchParams.set(key, String(value));
}
});
const url = `/api/collections/${collection}?${searchParams}`;
console.log("[fetchCollection]", collection, "URL:", url);
const response = await fetch(url);
if (!response.ok) throw new Error(`Erreur lors du chargement de ${collection}`);
const data = await response.json();
console.log("[fetchCollection]", collection, "Results:", data.data?.length, "items");
return data;
}
export function ConversationsTable() {
const [page, setPage] = useState(1);
const [selectedConversationId, setSelectedConversationId] = useState<
string | null
>(null);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState("");
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set());
const [expandedConversation, setExpandedConversation] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const limit = 10;
const usersPerPage = 10; // Nombre de groupes d'utilisateurs par page
const debouncedSearch = useDebounce(searchInput, 250);
// Charger toutes les conversations pour le groupement côté client
// Reset page et expanded states quand la recherche change
useEffect(() => {
setPage(1);
setExpandedUsers(new Set());
setExpandedConversation(null);
}, [debouncedSearch]);
// Query conversations avec TanStack Query - limite élevée pour groupement client
const {
data: conversations = [],
total = 0,
loading,
} = useCollection<LibreChatConversation>("conversations", {
limit: 1000,
page: 1, // Remplacer skip par page
data: conversationsData,
isLoading: conversationsLoading,
isFetching: conversationsFetching,
} = useQuery({
queryKey: ["conversations", debouncedSearch],
queryFn: () =>
fetchCollection<LibreChatConversation>("conversations", {
page: 1,
limit: 1000, // Charger beaucoup pour grouper côté client
search: debouncedSearch,
}),
placeholderData: keepPreviousData,
staleTime: 30000, // 30 secondes
});
const { data: users = [] } = useCollection<LibreChatUser>("users", {
limit: 1000,
// Query users (cache long car ça change rarement)
const { data: usersData } = useQuery({
queryKey: ["users", "all"],
queryFn: () => fetchCollection<LibreChatUser>("users", { limit: 1000 }),
staleTime: 1000 * 60 * 5, // 5 minutes
});
// Charger les messages seulement si une conversation est sélectionnée
const { data: messages = [] } = useCollection<LibreChatMessage>("messages", {
limit: 1000,
filter: selectedConversationId
? { conversationId: selectedConversationId }
: {},
// Query messages de la conversation sélectionnée
const { data: messagesData, isLoading: messagesLoading } = useQuery({
queryKey: ["messages", expandedConversation],
queryFn: () =>
fetchCollection<LibreChatMessage>("messages", {
limit: 500,
filter: JSON.stringify({ conversationId: expandedConversation }),
}),
enabled: !!expandedConversation,
staleTime: 30000,
});
const userMap = new Map(users.map((user) => [user._id, user]));
const conversations = conversationsData?.data ?? [];
const total = conversationsData?.total ?? 0;
const users = usersData?.data ?? [];
const messages = messagesData?.data ?? [];
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<string, unknown>;
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 (
<Card>
<CardContent className="p-6">
<div className="text-center">Chargement des conversations...</div>
</CardContent>
</Card>
);
}
// Map des users pour lookup rapide
const userMap = useMemo(() => new Map(users.map((u) => [u._id, u])), [users]);
// 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<string, LibreChatConversation[]>);
const groupedByUser = useMemo((): UserGroup[] => {
const groups: Record<string, LibreChatConversation[]> = {};
conversations.forEach((conv) => {
const uId = String(conv.user);
if (!groups[uId]) groups[uId] = [];
groups[uId].push(conv);
});
return Object.entries(groups)
.map(([userId, convs]) => ({
userId,
conversations: convs.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
),
totalMessages: convs.reduce((sum, c) => sum + (c.messages?.length || 0), 0),
}))
.sort((a, b) => {
// Trier par date de dernière conversation
const aDate = new Date(a.conversations[0]?.updatedAt || 0).getTime();
const bDate = new Date(b.conversations[0]?.updatedAt || 0).getTime();
return bDate - aDate;
});
}, [conversations]);
// Pagination des groupes d'utilisateurs
const totalPages = Math.ceil(
Object.keys(groupedConversations).length / limit
// Pagination sur les groupes d'utilisateurs
const totalUserGroups = groupedByUser.length;
const totalPages = Math.ceil(totalUserGroups / usersPerPage);
const paginatedGroups = groupedByUser.slice(
(page - 1) * usersPerPage,
page * usersPerPage
);
const skip = (page - 1) * limit;
const userIds = Object.keys(groupedConversations).slice(skip, skip + limit);
// Toggle user group expansion
const toggleUserExpanded = useCallback((userId: string) => {
setExpandedUsers((prev) => {
const next = new Set(prev);
if (next.has(userId)) {
next.delete(userId);
// Fermer aussi la conversation si elle appartient à cet utilisateur
setExpandedConversation(null);
} else {
next.add(userId);
}
return next;
});
}, []);
// Toggle conversation expansion
const toggleConversationExpanded = useCallback((conversationId: string) => {
console.log("[Conversations] Toggle conversation:", conversationId);
setExpandedConversation((prev) =>
prev === conversationId ? null : conversationId
);
}, []);
const getUserInfo = useCallback(
(userId: string) => {
const user = userMap.get(userId);
return {
name: user?.name || user?.username || `User ${userId.slice(-6)}`,
email: user?.email || null,
};
},
[userMap]
);
// Extraction du contenu des messages
const getMessageContent = useCallback((message: LibreChatMessage): string => {
const msg = message as ExtendedMessage;
if (msg.content && Array.isArray(msg.content)) {
for (const item of msg.content) {
if (item?.text) return item.text.trim();
}
}
if (message.text?.trim()) return message.text.trim();
if (typeof msg.content === "string" && msg.content.trim()) return msg.content.trim();
if (msg.parts?.[0]) {
const part = msg.parts[0];
if (typeof part === "string") return part.trim();
if (part?.text) return part.text.trim();
}
return "Contenu non disponible";
}, []);
const clearSearch = () => {
setSearchInput("");
searchInputRef.current?.focus();
};
const isSearching = searchInput !== debouncedSearch || conversationsFetching;
return (
<div className="space-y-6">
<div className="space-y-4">
<Card>
<CardHeader>
<CardHeader className="pb-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Conversations par utilisateur
</CardTitle>
<p className="text-sm text-muted-foreground">
{Object.keys(groupedConversations).length} utilisateurs {" "}
{conversations.length} conversations au total
</p>
</CardHeader>
<CardContent>
<div className="space-y-6">
{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 (
<div key={userId} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono text-xs">
{userId === "unknown" ? "unknown" : userId.slice(-8)}
</Badge>
<div className="flex flex-col">
<span className="font-semibold">{userName}</span>
{userEmail && (
<span className="text-xs text-muted-foreground">
{userEmail}
</span>
)}
</div>
<Badge variant="secondary">
{conversations.length} conversation
{conversations.length > 1 ? "s" : ""}
</Badge>
{activeConversations > 0 && (
<Badge variant="default" className="text-xs">
{activeConversations} actives
</Badge>
)}
{archivedConversations > 0 && (
<Badge variant="outline" className="text-xs">
{archivedConversations} archivées
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<MessageSquare className="h-4 w-4" />
{totalMessages} message{totalMessages > 1 ? "s" : ""}
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Dernière:{" "}
{formatDate(
new Date(
Math.max(
...conversations.map((c) =>
new Date(c.updatedAt).getTime()
)
)
)
)}
</div>
</div>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Titre</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Modèle</TableHead>
<TableHead>Messages</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Créée le</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={conversation._id}>
<TableCell>
<span className="font-mono text-xs">
{String(conversation._id).slice(-8)}
</span>
</TableCell>
<TableCell>
<span className="max-w-xs truncate block">
{String(conversation.title) || "Sans titre"}
</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{String(conversation.endpoint).slice(0, 20)}
{String(conversation.endpoint).length > 20
? "..."
: ""}
</Badge>
</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground font-mono">
{String(conversation.model)}
</span>
</TableCell>
<TableCell>
<Badge
variant="outline"
className="text-xs cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() =>
handleShowMessages(
conversation.conversationId,
userId
)
}
>
{messageCount}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={getStatusVariant(status)}
className="text-xs"
>
{getStatusLabel(status)}
</Badge>
</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{formatDate(conversation.createdAt)}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* Section des messages pour cet utilisateur */}
{selectedConversationId && selectedUserId === userId && (
<div className="mt-6 border-t pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Messages de la conversation
</h3>
<Button
variant="outline"
size="sm"
onClick={handleCloseMessages}
Conversations
<Badge variant="secondary" className="ml-2">
{total}
</Badge>
{conversationsFetching && !conversationsLoading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</CardTitle>
{/* Barre de recherche */}
<div className="relative w-full sm:w-96">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Rechercher par nom ou email..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : searchInput ? (
<button
onClick={clearSearch}
className="text-muted-foreground hover:text-foreground transition-colors"
type="button"
>
<X className="h-4 w-4" />
Fermer
</Button>
</button>
) : null}
</div>
<p className="text-sm text-muted-foreground mb-4">
Conversation ID: {selectedConversationId}
</div>
</div>
{debouncedSearch && (
<p className="text-sm text-muted-foreground mt-2">
{total} résultat{total > 1 ? "s" : ""} pour &quot;{debouncedSearch}&quot;
</p>
<div className="space-y-4 max-h-96 overflow-y-auto border rounded-lg p-4 bg-gray-50">
{messages.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
Aucun message trouvé pour cette conversation
)}
</CardHeader>
<CardContent>
{conversationsLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : conversations.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{debouncedSearch ? (
<>
<Search className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p>Aucun résultat pour &quot;{debouncedSearch}&quot;</p>
<Button variant="link" onClick={clearSearch} className="mt-2">
Effacer la recherche
</Button>
</>
) : (
<p>Aucune conversation</p>
)}
</div>
) : (
<>
{/* Liste des groupes d'utilisateurs */}
<div className="space-y-2">
{paginatedGroups.map((group) => {
const userInfo = getUserInfo(group.userId);
const isUserExpanded = expandedUsers.has(group.userId);
return (
<div key={group.userId} className="border rounded-lg overflow-hidden">
{/* Header du groupe utilisateur */}
<button
onClick={() => toggleUserExpanded(group.userId)}
className="w-full flex items-center gap-3 p-4 bg-muted/30 hover:bg-muted/50 transition-colors text-left"
>
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform flex-shrink-0 ${
isUserExpanded ? "rotate-0" : "-rotate-90"
}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{userInfo.name}</span>
{userInfo.email && (
<span className="text-sm text-muted-foreground">
({userInfo.email})
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant="secondary">
{group.conversations.length} conversation{group.conversations.length > 1 ? "s" : ""}
</Badge>
<Badge variant="outline" className="text-xs">
{group.totalMessages} msg{group.totalMessages > 1 ? "s" : ""}
</Badge>
</div>
</button>
{/* Liste des conversations de l'utilisateur */}
{isUserExpanded && (
<div className="border-t">
{group.conversations.map((conv) => {
const msgCount = conv.messages?.length || 0;
const isConvExpanded = expandedConversation === conv.conversationId;
return (
<Fragment key={conv._id}>
<button
onClick={() => toggleConversationExpanded(conv.conversationId)}
className={`w-full flex items-center gap-3 p-3 pl-10 hover:bg-muted/30 transition-colors text-left border-b last:border-b-0 ${
isConvExpanded ? "bg-muted/20" : ""
}`}
>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform flex-shrink-0 ${
isConvExpanded ? "rotate-0" : "-rotate-90"
}`}
/>
<div className="flex-1 min-w-0">
<span className="truncate block text-sm font-medium">
{String(conv.title) || "Sans titre"}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant="outline" className="text-xs font-mono">
{String(conv.endpoint).slice(0, 8)}
</Badge>
<Badge variant="secondary" className="text-xs">
{msgCount} msg{msgCount > 1 ? "s" : ""}
</Badge>
<Badge
variant={conv.isArchived ? "outline" : "default"}
className="text-xs"
>
{conv.isArchived ? "Archivée" : "Active"}
</Badge>
<span className="text-xs text-muted-foreground">
{formatDate(conv.updatedAt)}
</span>
</div>
</button>
{/* Messages de la conversation */}
{isConvExpanded && (
<div className="bg-slate-50 p-4 pl-14 border-b">
{messagesLoading ? (
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Chargement des messages...</span>
</div>
) : messages.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">
Aucun message dans cette conversation
</p>
) : (
messages
<div className="space-y-3 max-h-[400px] overflow-y-auto">
{messages
.sort(
(a, b) =>
new Date(a.createdAt).getTime() -
new Date(b.createdAt).getTime()
)
.map((message) => {
const content = getMessageContent(message);
.map((msg) => {
const content = getMessageContent(msg);
const isUser = msg.isCreatedByUser;
return (
<div
key={message._id}
className={`flex gap-3 p-4 rounded-lg ${
message.isCreatedByUser
? "bg-blue-50 border-l-4 border-l-blue-500"
: "bg-white border-l-4 border-l-gray-500"
key={msg._id}
className={`flex gap-3 p-3 rounded-lg border-l-4 ${
isUser
? "bg-blue-50 border-l-blue-500"
: "bg-white border-l-gray-300"
}`}
>
<div className="flex-shrink-0">
{message.isCreatedByUser ? (
<User className="h-5 w-5 text-blue-600" />
<div className="flex-shrink-0 mt-0.5">
{isUser ? (
<User className="h-4 w-4 text-blue-600" />
) : (
<Bot className="h-5 w-5 text-gray-600" />
<Bot className="h-4 w-4 text-gray-500" />
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Badge
variant={
message.isCreatedByUser
? "default"
: "secondary"
}
className="text-xs"
>
{message.isCreatedByUser
? "Utilisateur"
: "Assistant"}
</Badge>
<span className="text-xs text-muted-foreground">
{formatDate(message.createdAt)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-medium">
{isUser ? "Utilisateur" : "Assistant"}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(msg.createdAt)}
</span>
{msg.tokenCount > 0 && (
<span className="text-xs text-muted-foreground">
({msg.tokenCount} tokens)
</span>
{message.tokenCount > 0 && (
<Badge
variant="outline"
className="text-xs"
>
{message.tokenCount} tokens
</Badge>
)}
<Badge
variant="outline"
className="text-xs font-mono"
>
{message._id.slice(-8)}
</Badge>
</div>
<div className="text-sm whitespace-pre-wrap">
<div className="text-sm whitespace-pre-wrap break-words">
{content}
</div>
{message.error && (
<Badge
variant="destructive"
className="text-xs"
>
Erreur
</Badge>
)}
</div>
</div>
);
})
})}
</div>
)}
</div>
)}
</Fragment>
);
})}
</div>
)}
</div>
@@ -527,33 +454,36 @@ export function ConversationsTable() {
})}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {page} sur {totalPages} {total} conversations au total
Page {page} / {totalPages} ({totalUserGroups} utilisateurs)
</p>
<div className="flex items-center gap-2">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Précédent
Préc.
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Suivant
Suiv.
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>

View File

@@ -14,7 +14,7 @@ import {
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 { formatDate } from "@/lib/utils";
import { LibreChatTransaction, LibreChatUser } from "@/lib/types";
// Interface étendue pour les transactions avec description optionnelle
@@ -53,20 +53,15 @@ export function TransactionsTable() {
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") {
if (
transactionWithDesc.description &&
typeof transactionWithDesc.description === "string" &&
transactionWithDesc.description !== "undefined"
) {
return transactionWithDesc.description;
}
@@ -111,7 +106,6 @@ export function TransactionsTable() {
<TableHead>ID</TableHead>
<TableHead>Utilisateur</TableHead>
<TableHead>Type</TableHead>
<TableHead>Montant</TableHead>
<TableHead>Tokens</TableHead>
<TableHead>Description</TableHead>
<TableHead>Date</TableHead>
@@ -151,11 +145,6 @@ export function TransactionsTable() {
{isCredit ? "Crédit" : "Débit"}
</Badge>
</TableCell>
<TableCell>
<span className="font-semibold">
{formatAmount(transaction.rawAmount)}
</span>
</TableCell>
<TableCell>
{tokenAmount > 0 && (
<Badge variant="outline" className="text-xs">

View File

@@ -0,0 +1,215 @@
"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 { Input } from "@/components/ui/input";
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { LibreChatUser, LibreChatBalance } from "@/lib/types";
export function UsersTable() {
const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const limit = 20;
const {
data: users = [],
total = 0,
loading: usersLoading,
} = useCollection<LibreChatUser>("users", {
page,
limit,
// ✅ AJOUTER le searchTerm ici
search: searchTerm,
});
// Charger tous les balances pour associer les crédits
const { data: balances = [] } = useCollection<LibreChatBalance>("balances", {
limit: 1000, // Charger tous les balances
});
// Créer une map des crédits par utilisateur
const creditsMap = useMemo(() => {
const map = new Map<string, number>();
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 (
<Card>
<CardHeader>
<CardTitle>Liste des utilisateurs</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<CardTitle>
Liste des utilisateurs ({searchTerm ? users.length : total})
</CardTitle>
<div className="relative w-full sm:w-80">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Rechercher par nom ou email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Crédits</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Créé le</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length > 0 ? (
users.map((user) => {
const userCredits = creditsMap.get(user._id) || 0;
const isActive =
new Date(user.updatedAt || user.createdAt) >
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 jours en millisecondes
return (
<TableRow key={user._id}>
<TableCell>
<span className="font-mono text-xs">
{user._id.slice(-8)}
</span>
</TableCell>
<TableCell>
<span className="font-medium">{user.name}</span>
</TableCell>
<TableCell>
<span className="text-sm">{user.email}</span>
</TableCell>
<TableCell>
<Badge
variant={
user.role === "ADMIN" ? "default" : "secondary"
}
>
{user.role}
</Badge>
</TableCell>
<TableCell>
<span className="font-semibold">
{userCredits.toLocaleString()} crédits
</span>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? "Actif" : "Inactif"}
</Badge>
</span>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{formatDate(user.createdAt)}
</span>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell
colSpan={7}
className="text-center py-8 text-muted-foreground"
>
{searchTerm
? `Aucun utilisateur trouvé pour "${searchTerm}"`
: "Aucun utilisateur trouvé"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination - masquée lors de la recherche */}
{!searchTerm && (
<div className="flex items-center justify-between space-x-2 py-4">
<div className="text-sm text-muted-foreground">
Page {page} sur {totalPages} ({total} éléments au total)
</div>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={page >= totalPages}
>
Suivant
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Info de recherche */}
{searchTerm && (
<div className="py-4 text-sm text-muted-foreground">
{users.length} résultat(s) trouvé(s) pour &quot;{searchTerm}
&quot;
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { useCollection } from "@/hooks/useCollection";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -13,16 +13,30 @@ import {
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Input } from "@/components/ui/input";
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { LibreChatUser, LibreChatBalance } from "@/lib/types";
// Couleurs prédéfinies pour les référents
const REFERENT_COLORS: Record<string, string> = {
"Emmanuel WATHELE": "#3B82F6", // Bleu
"IHECS": "#10B981", // Vert
"Patrice De La Broise": "#F59E0B", // Orange
};
export function UsersTable() {
const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState(""); // Ce que l'utilisateur tape
const [activeSearch, setActiveSearch] = useState(""); // Ce qui est réellement recherché
const [activeReferent, setActiveReferent] = useState<string | undefined>(undefined);
const limit = 20;
// Charger les utilisateurs
// Réinitialiser la page à 1 quand une nouvelle recherche ou filtre est lancé
useEffect(() => {
setPage(1);
}, [activeSearch, activeReferent]);
const {
data: users = [],
total = 0,
@@ -30,11 +44,37 @@ export function UsersTable() {
} = useCollection<LibreChatUser>("users", {
page,
limit,
search: activeSearch,
referent: activeReferent,
});
const handleReferentClick = (referent: string) => {
if (activeReferent === referent) {
setActiveReferent(undefined); // Toggle off
} else {
setActiveReferent(referent);
}
};
const clearReferentFilter = () => {
setActiveReferent(undefined);
};
// Fonction pour lancer la recherche
const handleSearch = () => {
setActiveSearch(searchInput);
};
// Gérer la touche Entrée
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleSearch();
}
};
// Charger tous les balances pour associer les crédits
const { data: balances = [] } = useCollection<LibreChatBalance>("balances", {
limit: 1000, // Charger tous les balances
limit: 1000,
});
// Créer une map des crédits par utilisateur
@@ -76,27 +116,61 @@ export function UsersTable() {
return (
<Card>
<CardHeader>
<CardTitle>Liste des utilisateurs ({total})</CardTitle>
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<CardTitle>
Liste des utilisateurs ({total})
</CardTitle>
<div className="flex gap-2 w-full sm:w-auto items-center">
{activeReferent && (
<Badge
variant="secondary"
className="flex items-center gap-1 px-3 py-1"
style={{ backgroundColor: REFERENT_COLORS[activeReferent] || "#6B7280", color: "white" }}
>
{activeReferent}
<button
onClick={clearReferentFilter}
className="ml-1 hover:bg-white/20 rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
)}
<div className="relative flex-1 sm:w-80">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Rechercher par nom ou email..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyPress={handleKeyPress}
className="pl-10"
/>
</div>
<Button onClick={handleSearch} variant="default">
Rechercher
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead className="w-20">ID</TableHead>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Crédits</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Créé le</TableHead>
<TableHead className="w-32">Référent</TableHead>
<TableHead className="w-20">Rôle</TableHead>
<TableHead className="w-32">Crédits</TableHead>
<TableHead className="w-28">Créé le</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => {
{users.length > 0 ? (
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
const referentColor = user.referent ? (REFERENT_COLORS[user.referent] || "#6B7280") : null;
return (
<TableRow key={user._id}>
@@ -106,13 +180,43 @@ export function UsersTable() {
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{referentColor && (
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: referentColor }}
title={user.referent}
/>
)}
<span className="font-medium">{user.name}</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm">{user.email}</span>
</TableCell>
<TableCell>
<Badge variant={user.role === 'ADMIN' ? 'default' : 'secondary'}>
{user.referent ? (
<button
onClick={() => handleReferentClick(user.referent!)}
className={`text-sm truncate block max-w-[120px] hover:underline cursor-pointer transition-colors ${
activeReferent === user.referent
? "font-bold text-primary"
: "text-foreground hover:text-primary"
}`}
title={`Cliquer pour filtrer par ${user.referent}`}
>
{user.referent}
</button>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</TableCell>
<TableCell>
<Badge
variant={
user.role === "ADMIN" ? "default" : "secondary"
}
>
{user.role}
</Badge>
</TableCell>
@@ -121,19 +225,26 @@ export function UsersTable() {
{userCredits.toLocaleString()} crédits
</span>
</TableCell>
<TableCell>
<Badge variant={isActive ? 'default' : 'destructive'}>
{isActive ? 'Actif' : 'Inactif'}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{formatDate(new Date(user.createdAt))}
{formatDate(user.createdAt)}
</span>
</TableCell>
</TableRow>
);
})}
})
) : (
<TableRow>
<TableCell
colSpan={7}
className="text-center py-8 text-muted-foreground"
>
{activeSearch
? `Aucun utilisateur trouvé pour "${activeSearch}"`
: "Aucun utilisateur trouvé"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
@@ -141,7 +252,15 @@ export function UsersTable() {
{/* Pagination */}
<div className="flex items-center justify-between space-x-2 py-4">
<div className="text-sm text-muted-foreground">
Page {page} sur {totalPages} ({total} éléments au total)
{activeSearch ? (
<span>
{total} résultat(s) pour &quot;{activeSearch}&quot; - Page {page} sur {totalPages}
</span>
) : (
<span>
Page {page} sur {totalPages} ({total} utilisateurs au total)
</span>
)}
</div>
<div className="flex space-x-2">
<Button

View File

@@ -0,0 +1,274 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Search, User, CheckCircle, Coins } from "lucide-react";
interface UserResult {
_id: string;
name: string;
username: string;
email: string;
role: string;
referent?: string;
prenom?: string;
nom?: string;
createdAt: string;
}
interface BalanceInfo {
tokenCredits: number;
lastRefill?: string;
}
export default function AddCreditsSingleUser() {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<UserResult[]>([]);
const [selectedUser, setSelectedUser] = useState<UserResult | null>(null);
const [userBalance, setUserBalance] = useState<BalanceInfo | null>(null);
const [searching, setSearching] = useState(false);
const [loadingBalance, setLoadingBalance] = useState(false);
const [adding, setAdding] = useState(false);
const [success, setSuccess] = useState<{ newBalance: number } | null>(null);
const searchUsers = async () => {
if (!searchTerm.trim()) return;
setSearching(true);
setSelectedUser(null);
setUserBalance(null);
setSuccess(null);
try {
const response = await fetch(
`/api/collections/users?search=${encodeURIComponent(searchTerm)}&limit=10`
);
const data = await response.json();
if (data.data) {
setSearchResults(data.data);
}
} catch (error) {
console.error("Erreur lors de la recherche:", error);
} finally {
setSearching(false);
}
};
const selectUser = async (user: UserResult) => {
setSelectedUser(user);
setSearchResults([]);
setSuccess(null);
setLoadingBalance(true);
try {
// Récupérer la balance de l'utilisateur
const response = await fetch(
`/api/collections/balances?search=${user._id}&limit=1`
);
const data = await response.json();
if (data.data && data.data.length > 0) {
setUserBalance({
tokenCredits: data.data[0].tokenCredits || 0,
lastRefill: data.data[0].lastRefill,
});
} else {
setUserBalance({ tokenCredits: 0 });
}
} catch (error) {
console.error("Erreur lors de la récupération de la balance:", error);
setUserBalance({ tokenCredits: 0 });
} finally {
setLoadingBalance(false);
}
};
const addCredits = async () => {
if (!selectedUser) return;
if (
!confirm(
`Êtes-vous sûr de vouloir ajouter 3 millions de crédits à ${selectedUser.name || selectedUser.email} ?`
)
) {
return;
}
setAdding(true);
try {
const response = await fetch("/api/add-credits-single", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ userId: selectedUser._id }),
});
const data = await response.json();
if (data.success) {
setSuccess({ newBalance: data.newBalance });
setUserBalance({ tokenCredits: data.newBalance });
} else {
alert("Erreur: " + (data.error || data.message));
}
} catch (error) {
console.error("Erreur lors de l'ajout des crédits:", error);
alert("Erreur lors de l'ajout des crédits");
} finally {
setAdding(false);
}
};
const reset = () => {
setSearchTerm("");
setSearchResults([]);
setSelectedUser(null);
setUserBalance(null);
setSuccess(null);
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Coins className="h-5 w-5" />
Ajouter des Crédits à un Utilisateur
</CardTitle>
<CardDescription>
Rechercher un utilisateur et lui ajouter 3 millions de tokens
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Barre de recherche */}
<div className="flex gap-2">
<Input
placeholder="Rechercher par nom, email ou username..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchUsers()}
/>
<Button onClick={searchUsers} disabled={searching}>
<Search className="h-4 w-4 mr-2" />
{searching ? "..." : "Rechercher"}
</Button>
</div>
{/* Résultats de recherche */}
{searchResults.length > 0 && (
<div className="border rounded-lg divide-y">
{searchResults.map((user) => (
<div
key={user._id}
className="p-3 hover:bg-gray-50 cursor-pointer flex items-center justify-between"
onClick={() => selectUser(user)}
>
<div className="flex items-center gap-3">
<User className="h-5 w-5 text-gray-400" />
<div>
<p className="font-medium">{user.name || user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
<Badge variant="outline">{user.role}</Badge>
</div>
))}
</div>
)}
{/* Utilisateur sélectionné */}
{selectedUser && (
<div className="border rounded-lg p-4 bg-blue-50 space-y-3">
<h4 className="font-semibold text-blue-800 flex items-center gap-2">
<CheckCircle className="h-5 w-5" />
Utilisateur sélectionné
</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-600">ID:</span>
<p className="font-mono text-xs bg-white p-1 rounded mt-1">
{selectedUser._id}
</p>
</div>
<div>
<span className="text-gray-600">Nom:</span>
<p className="font-medium">
{selectedUser.name || selectedUser.username}
</p>
</div>
<div>
<span className="text-gray-600">Email:</span>
<p className="font-medium">{selectedUser.email}</p>
</div>
<div>
<span className="text-gray-600">Rôle:</span>
<Badge variant="secondary" className="ml-2">
{selectedUser.role}
</Badge>
</div>
{selectedUser.referent && (
<div>
<span className="text-gray-600">Référent:</span>
<p className="font-medium">{selectedUser.referent}</p>
</div>
)}
<div>
<span className="text-gray-600">Crédits actuels:</span>
{loadingBalance ? (
<p className="text-gray-500">Chargement...</p>
) : (
<p className="font-bold text-lg text-green-600">
{userBalance?.tokenCredits.toLocaleString() || "0"}
</p>
)}
</div>
</div>
{/* Boutons d'action */}
<div className="flex gap-2 pt-2 border-t">
<Button
onClick={addCredits}
disabled={adding || loadingBalance}
className="flex-1 bg-green-600 hover:bg-green-700"
>
{adding ? "Ajout en cours..." : "Ajouter 3M tokens"}
</Button>
<Button onClick={reset} variant="outline">
Annuler
</Button>
</div>
</div>
)}
{/* Message de succès */}
{success && (
<div className="border rounded-lg p-4 bg-green-50">
<h4 className="font-semibold text-green-800 flex items-center gap-2">
<CheckCircle className="h-5 w-5" />
Crédits ajoutés avec succès !
</h4>
<p className="text-green-700 mt-2">
Nouveau solde:{" "}
<span className="font-bold">
{success.newBalance.toLocaleString()}
</span>{" "}
tokens
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, DollarSign, Users, TrendingUp } from "lucide-react";
interface AddCreditsStats {
totalUsers: number;
totalBalances: number;
totalCredits: number;
averageCredits: number;
usersWithoutBalance: number;
}
interface AddCreditsResult {
totalUsers: number;
updatedBalances: number;
createdBalances: number;
creditsPerUser: number;
totalCreditsAdded: number;
}
export default function AddCredits() {
const [stats, setStats] = useState<AddCreditsStats | null>(null);
const [result, setResult] = useState<AddCreditsResult | null>(null);
const [loading, setLoading] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const analyzeCurrentCredits = async () => {
setAnalyzing(true);
try {
const response = await fetch("/api/add-credits");
const data = await response.json();
if (data.statistics) {
setStats(data.statistics);
}
} catch (error) {
console.error("Erreur lors de l'analyse:", error);
} finally {
setAnalyzing(false);
}
};
const addCreditsToAllUsers = async () => {
if (
!confirm(
"Êtes-vous sûr de vouloir ajouter 3 millions de crédits à TOUS les utilisateurs ? Cette action est irréversible."
)
) {
return;
}
setLoading(true);
try {
const response = await fetch("/api/add-credits", {
method: "POST",
});
const data = await response.json();
if (data.success) {
setResult(data.statistics);
// Rafraîchir les stats
await analyzeCurrentCredits();
} else {
alert("Erreur: " + (data.error || data.message));
}
} catch (error) {
console.error("Erreur lors de l'ajout des crédits:", error);
alert("Erreur lors de l'ajout des crédits");
} finally {
setLoading(false);
}
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" />
Ajouter des Crédits
</CardTitle>
<CardDescription>
Ajouter 3 millions de tokens à tous les utilisateurs existants
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Bouton d'analyse */}
<div className="flex gap-2">
<Button
onClick={analyzeCurrentCredits}
disabled={analyzing}
variant="outline"
>
{analyzing ? "Analyse..." : "Analyser les crédits actuels"}
</Button>
</div>
{/* Statistiques actuelles */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-blue-50 p-3 rounded-lg">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium">Utilisateurs</span>
</div>
<p className="text-2xl font-bold text-blue-600">
{stats.totalUsers}
</p>
</div>
<div className="bg-green-50 p-3 rounded-lg">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">Total Crédits</span>
</div>
<p className="text-2xl font-bold text-green-600">
{stats.totalCredits.toLocaleString()}
</p>
</div>
<div className="bg-purple-50 p-3 rounded-lg">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium">Moyenne</span>
</div>
<p className="text-2xl font-bold text-purple-600">
{stats.averageCredits.toLocaleString()}
</p>
</div>
<div className="bg-orange-50 p-3 rounded-lg">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-orange-600" />
<span className="text-sm font-medium">Sans Balance</span>
</div>
<p className="text-2xl font-bold text-orange-600">
{stats.usersWithoutBalance}
</p>
</div>
</div>
)}
{/* Bouton d'ajout de crédits */}
{stats && (
<div className="border-t pt-4">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<h4 className="font-semibold text-yellow-800 mb-2">
Action Importante
</h4>
<p className="text-yellow-700 text-sm">
Cette action va ajouter <strong>3,000,000 crédits</strong> à
chacun des {stats.totalUsers} utilisateurs.
<br />
Total de crédits qui seront ajoutés:{" "}
<strong>{(stats.totalUsers * 3000000).toLocaleString()}</strong>
</p>
</div>
<Button
onClick={addCreditsToAllUsers}
disabled={loading}
className="w-full bg-green-600 hover:bg-green-700"
>
{loading
? "Ajout en cours..."
: `Ajouter 3M crédits à ${stats.totalUsers} utilisateurs`}
</Button>
</div>
)}
{/* Résultats */}
{result && (
<div className="border-t pt-4">
<h4 className="font-semibold text-green-600 mb-3">
Crédits ajoutés avec succès !
</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">Balances mises à jour:</span>
<Badge variant="secondary" className="ml-2">
{result.updatedBalances}
</Badge>
</div>
<div>
<span className="text-gray-600">Nouvelles balances:</span>
<Badge variant="secondary" className="ml-2">
{result.createdBalances}
</Badge>
</div>
<div>
<span className="text-gray-600">Crédits par utilisateur:</span>
<Badge variant="secondary" className="ml-2">
{result.creditsPerUser.toLocaleString()}
</Badge>
</div>
<div>
<span className="text-gray-600">Total ajouté:</span>
<Badge variant="secondary" className="ml-2">
{result.totalCreditsAdded.toLocaleString()}
</Badge>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -13,42 +13,203 @@ import {
interface ModelDistributionChartProps {
title: string;
subtitle?: string;
data: Array<{
name: string;
value: number;
}>;
}
interface TooltipPayload {
value: number;
payload: {
color?: string;
models?: Array<{
name: string;
value: number;
}>;
}>;
showLegend?: boolean;
totalTokens?: number;
}
interface ModelData {
name: string;
value: number;
}
interface StackedDataEntry {
provider: string;
total: number;
models: ModelData[];
baseColor: string;
modelColors: string[];
[key: string]: string | number | ModelData[] | string[];
}
interface ModelInfo {
color: string;
name: string;
provider: string;
}
// Couleurs par fournisseur (couleurs de base)
const providerColors: { [key: string]: string } = {
Anthropic: "#7C3AED", // Violet vif
OpenAI: "#059669", // Vert turquoise vif
"Mistral AI": "#D97706", // Orange vif
Meta: "#DB2777", // Rose/Magenta vif
Google: "#2563EB", // Bleu vif
Cohere: "#0891B2", // Cyan vif
};
// Fonction pour générer des variations de couleur pour les modèles d'un même provider
const generateModelColors = (baseColor: string, modelCount: number) => {
const colors = [];
for (let i = 0; i < modelCount; i++) {
// Créer des variations en ajustant la luminosité
const opacity = 1 - i * 0.15; // De 1.0 à 0.4 environ
colors.push(
`${baseColor}${Math.round(opacity * 255)
.toString(16)
.padStart(2, "0")}`
);
}
return colors;
};
// Fonction pour regrouper les modèles par fournisseur et préparer les données pour le graphique empilé
const prepareStackedData = (
modelData: Array<{ name: string; value: number }>
) => {
const providerMap: {
[key: string]: {
value: number;
models: Array<{ name: string; value: number }>;
};
}
} = {};
interface CustomTooltipProps {
modelData.forEach((model) => {
let provider = "";
// Déterminer le fournisseur basé sur le nom du modèle
if (
model.name.toLowerCase().includes("claude") ||
model.name.toLowerCase().includes("anthropic")
) {
provider = "Anthropic";
} else if (
model.name.toLowerCase().includes("gpt") ||
model.name.toLowerCase().includes("openai")
) {
provider = "OpenAI";
} else if (model.name.toLowerCase().includes("mistral")) {
provider = "Mistral AI";
} else if (
model.name.toLowerCase().includes("llama") ||
model.name.toLowerCase().includes("meta")
) {
provider = "Meta";
} else if (
model.name.toLowerCase().includes("palm") ||
model.name.toLowerCase().includes("gemini") ||
model.name.toLowerCase().includes("google")
) {
provider = "Google";
} else if (model.name.toLowerCase().includes("cohere")) {
provider = "Cohere";
} else {
provider = "Autres";
}
if (!providerMap[provider]) {
providerMap[provider] = { value: 0, models: [] };
}
providerMap[provider].value += model.value;
providerMap[provider].models.push(model);
});
// Créer les données pour le graphique empilé
const stackedData: StackedDataEntry[] = Object.entries(providerMap).map(
([providerName, data]) => {
const baseColor = providerColors[providerName] || "#6B7280";
const modelColors = generateModelColors(baseColor, data.models.length);
// Créer un objet avec le provider comme clé et chaque modèle comme propriété
const stackedEntry: StackedDataEntry = {
provider: providerName,
total: data.value,
models: data.models,
baseColor,
modelColors,
};
// Ajouter chaque modèle comme propriété séparée pour le stacking
data.models.forEach((model, index) => {
const modelKey = `${providerName}_${model.name}`;
stackedEntry[modelKey] = model.value;
stackedEntry[`${modelKey}_color`] = modelColors[index];
stackedEntry[`${modelKey}_name`] = model.name;
});
return stackedEntry;
}
);
// Créer la liste de tous les modèles uniques pour les barres
const allModelKeys: string[] = [];
const modelInfo: { [key: string]: ModelInfo } = {};
stackedData.forEach((entry) => {
entry.models.forEach((model, index) => {
const modelKey = `${entry.provider}_${model.name}`;
allModelKeys.push(modelKey);
modelInfo[modelKey] = {
color: entry.modelColors[index],
name: model.name,
provider: entry.provider,
};
});
});
return { stackedData, allModelKeys, modelInfo };
};
interface CustomStackedTooltipProps {
active?: boolean;
payload?: TooltipPayload[];
payload?: Array<{
payload: StackedDataEntry;
}>;
label?: string;
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
const CustomStackedTooltip = ({
active,
payload,
label,
}: CustomStackedTooltipProps) => {
if (active && payload && payload.length) {
const providerData = payload[0].payload;
const totalTokens = providerData.total;
return (
<div style={{
backgroundColor: "hsl(var(--background))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
padding: "8px",
fontSize: "12px"
}}>
<p style={{ margin: 0, color: "#ff0000" }}>
{`${payload[0].value.toLocaleString()} tokens`}
<div className="bg-white p-3 border rounded-lg shadow-lg">
<p className="font-semibold">{label}</p>
<p className="text-blue-600 font-medium">
Total: {totalTokens.toLocaleString()} tokens
</p>
<p style={{ margin: 0, color: "#ff0000" }}>
{payload[0].payload.name}
{providerData.models && (
<div className="mt-2 space-y-1">
<p className="text-sm font-medium text-gray-600">Modèles:</p>
{providerData.models.map((model, index) => (
<div key={index} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: providerData.modelColors[index] }}
/>
<p className="text-xs text-gray-700">
{model.name}: {model.value.toLocaleString()} tokens
</p>
</div>
))}
</div>
)}
</div>
);
}
return null;
@@ -56,39 +217,148 @@ const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
export function ModelDistributionChart({
title,
subtitle,
data,
totalTokens,
}: ModelDistributionChartProps) {
// Si les données sont déjà groupées par fournisseur, les utiliser directement
// Sinon, les regrouper automatiquement
const modelData = data[0]?.models
? data.flatMap((d) => d.models || [])
: data;
const { stackedData, allModelKeys, modelInfo } =
prepareStackedData(modelData);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
)}
</CardHeader>
<CardContent className="pt-0">
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data}>
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={stackedData}
margin={{ top: 10, right: 10, left: 10, bottom: 60 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/20" />
<XAxis
dataKey="name"
dataKey="provider"
axisLine={false}
tickLine={false}
className="text-xs fill-muted-foreground"
tick={false}
angle={-45}
textAnchor="end"
height={80}
interval={0}
/>
<YAxis
axisLine={false}
tickLine={false}
className="text-xs fill-muted-foreground"
tickFormatter={(value) => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
return value.toString();
}}
/>
<Tooltip content={<CustomTooltip />} />
<Tooltip content={<CustomStackedTooltip />} />
{/* Créer une barre empilée pour chaque modèle */}
{allModelKeys.map((modelKey) => (
<Bar
dataKey="value"
fill="#000000"
radius={[4, 4, 0, 0]}
key={modelKey}
dataKey={modelKey}
stackId="models"
fill={modelInfo[modelKey].color}
radius={0}
/>
))}
</BarChart>
</ResponsiveContainer>
{/* Légende des providers avec leurs couleurs de base */}
<div className="mt-4 grid grid-cols-2 gap-3">
{stackedData.map((item, index) => (
<div
key={index}
className="p-3 rounded-lg border border-muted/20 bg-muted/5 hover:bg-muted/10 transition-colors"
style={{
borderLeftColor: item.baseColor,
borderLeftWidth: "3px",
borderLeftStyle: "solid",
}}
>
<div className="flex items-center gap-2 mb-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: item.baseColor }}
></div>
<h3
className="text-sm font-medium"
style={{ color: item.baseColor }}
>
{item.provider}
</h3>
</div>
<p className="text-lg font-semibold text-foreground">
{item.total.toLocaleString()}
</p>
<p className="text-xs text-muted-foreground">
{item.models.length} modèle{item.models.length > 1 ? "s" : ""}
</p>
</div>
))}
</div>
{/* Total général */}
{totalTokens && (
<div className="mt-4 pt-3 border-t border-muted/20 text-center">
<p className="text-sm text-muted-foreground">
Total général:{" "}
<span className="font-semibold text-foreground">
{totalTokens.toLocaleString()}
</span>{" "}
tokens
</p>
</div>
)}
{/* Légende détaillée des modèles avec leurs couleurs spécifiques */}
<div className="mt-4 pt-3 border-t border-muted/20">
<h4 className="text-sm font-medium text-muted-foreground mb-3 text-center">
Modèles par provider
</h4>
<div className="space-y-3">
{stackedData.map((provider) => (
<div key={provider.provider} className="space-y-1">
<h5
className="text-xs font-medium"
style={{ color: provider.baseColor }}
>
{provider.provider}
</h5>
<div className="flex flex-wrap gap-x-4 gap-y-1 ml-2">
{provider.models.map((model, index) => (
<div key={index} className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: provider.modelColors[index] }}
></div>
<span className="text-xs text-muted-foreground">
{model.name} ({model.value.toLocaleString()})
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);

View File

@@ -44,12 +44,12 @@ export function RealUserActivityChart() {
{
name: "Utilisateurs actifs",
value: activity.activeUsers,
color: "#22c55e", // Vert clair pour actifs
color: "#000000", // Noir pour actifs
},
{
name: "Utilisateurs inactifs",
value: activity.inactiveUsers,
color: "#ef4444", // Rouge pour inactifs
color: "#666666", // Gris pour inactifs
},
];
@@ -62,7 +62,7 @@ export function RealUserActivityChart() {
Activité des utilisateurs
</CardTitle>
<p className="text-sm text-muted-foreground">
Actifs = connectés dans les 7 derniers jours
Actifs = connectés dans les 30 derniers jours
</p>
</CardHeader>
<CardContent>
@@ -74,8 +74,10 @@ export function RealUserActivityChart() {
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={5}
paddingAngle={2}
dataKey="value"
stroke="#ffffff"
strokeWidth={2}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
@@ -86,17 +88,20 @@ export function RealUserActivityChart() {
backgroundColor: "hsl(var(--background))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
fontSize: "12px"
}}
formatter={(value: number) => [
`${value} utilisateurs (${((value / total) * 100).toFixed(
1
)}%)`,
`${value} utilisateurs (${((value / total) * 100).toFixed(1)}%)`,
"",
]}
/>
<Legend
wrapperStyle={{
paddingTop: "20px",
fontSize: "12px"
}}
formatter={(value, entry) => (
<span style={{ color: entry.color }}>
<span style={{ color: entry.color, fontWeight: 500 }}>
{value}: {entry.payload?.value} (
{((entry.payload?.value / total) * 100).toFixed(1)}%)
</span>

View File

@@ -31,8 +31,8 @@ export function SimpleStatsChart({ title, data, color = "hsl(var(--primary))" }:
<AreaChart data={data}>
<defs>
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3}/>
<stop offset="95%" stopColor={color} stopOpacity={0}/>
<stop offset="5%" stopColor={color} stopOpacity={0.8}/>
<stop offset="95%" stopColor={color} stopOpacity={0.2}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/20" />
@@ -46,6 +46,11 @@ export function SimpleStatsChart({ title, data, color = "hsl(var(--primary))" }:
axisLine={false}
tickLine={false}
className="text-xs fill-muted-foreground"
tickFormatter={(value) => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
return value.toString();
}}
/>
<Tooltip
contentStyle={{
@@ -54,12 +59,16 @@ export function SimpleStatsChart({ title, data, color = "hsl(var(--primary))" }:
borderRadius: '8px',
fontSize: '12px'
}}
formatter={(value: number) => [
value >= 1000 ? `${(value / 1000).toFixed(1)}K tokens` : `${value} tokens`,
'Tokens consommés'
]}
/>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
strokeWidth={3}
fill="url(#colorGradient)"
/>
</AreaChart>

View File

@@ -20,12 +20,12 @@ export function UserActivityChart({ activeUsers, inactiveUsers }: UserActivityCh
{
name: 'Utilisateurs actifs',
value: activeUsers,
color: '#22c55e' // Vert clair pour actifs
color: '#000000' // Noir pour actifs
},
{
name: 'Utilisateurs inactifs',
value: inactiveUsers,
color: '#ef4444' // Rouge pour inactifs
color: '#666666' // Gris pour inactifs
},
];
@@ -36,7 +36,7 @@ export function UserActivityChart({ activeUsers, inactiveUsers }: UserActivityCh
<CardHeader>
<CardTitle className="text-base font-medium">Activité des utilisateurs</CardTitle>
<p className="text-sm text-muted-foreground">
Actifs = connectés dans les 7 derniers jours
Actifs = connectés dans les 30 derniers jours
</p>
</CardHeader>
<CardContent>
@@ -48,8 +48,10 @@ export function UserActivityChart({ activeUsers, inactiveUsers }: UserActivityCh
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={5}
paddingAngle={2}
dataKey="value"
stroke="#ffffff"
strokeWidth={2}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
@@ -60,16 +62,22 @@ export function UserActivityChart({ activeUsers, inactiveUsers }: UserActivityCh
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
fontSize: '12px'
}}
formatter={(value: number) => [
`${value} utilisateurs (${((value / total) * 100).toFixed(1)}%)`,
''
'',
]}
/>
<Legend
wrapperStyle={{
paddingTop: "20px",
fontSize: "12px"
}}
formatter={(value, entry) => (
<span style={{ color: entry.color }}>
{value}: {entry.payload?.value} ({((entry.payload?.value / total) * 100).toFixed(1)}%)
<span style={{ color: entry.color, fontWeight: 500 }}>
{value}: {entry.payload?.value} (
{((entry.payload?.value / total) * 100).toFixed(1)}%)
</span>
)}
/>

View File

@@ -0,0 +1,80 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from "recharts";
interface UserConnectionsChartProps {
title: string;
data: Array<{
name: string;
value: number;
}>;
color?: string;
}
export function UserConnectionsChart({ title, data, color = "hsl(var(--chart-2))" }: UserConnectionsChartProps) {
console.log("UserConnectionsChart - data:", data);
console.log("UserConnectionsChart - title:", title);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data}>
<defs>
<linearGradient id="connectionsGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.8}/>
<stop offset="95%" stopColor={color} stopOpacity={0.2}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/20" />
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
className="text-xs fill-muted-foreground"
/>
<YAxis
axisLine={false}
tickLine={false}
className="text-xs fill-muted-foreground"
tickFormatter={(value) => {
return value.toString();
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
fontSize: '12px'
}}
formatter={(value: number) => [
`${value} utilisateur${value > 1 ? 's' : ''}`,
'Connexions'
]}
/>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={3}
fill="url(#connectionsGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,278 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { UserPlus, Loader2, CheckCircle, AlertCircle } from "lucide-react";
interface CreateUserResult {
success: boolean;
message: string;
user?: {
id: string;
name: string;
email: string;
role: string;
createdAt: string;
};
error?: string;
}
export default function CreateUser() {
const [formData, setFormData] = useState({
name: "",
email: "",
password: "",
confirmPassword: "",
role: "USER",
});
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<CreateUserResult | null>(null);
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Réinitialiser le résultat quand l'utilisateur modifie le formulaire
if (result) {
setResult(null);
}
};
const validateForm = () => {
if (!formData.name.trim()) {
return "Le nom est requis";
}
if (!formData.email.trim()) {
return "L&apos;email est requis";
}
if (!formData.password) {
return "Le mot de passe est requis";
}
if (formData.password.length < 8) {
return "Le mot de passe doit contenir au moins 8 caractères";
}
if (formData.password !== formData.confirmPassword) {
return "Les mots de passe ne correspondent pas";
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
return "Format d&apos;email invalide";
}
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateForm();
if (validationError) {
setResult({
success: false,
message: validationError,
});
return;
}
setIsLoading(true);
setResult(null);
try {
const response = await fetch("/api/create-user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: formData.name.trim(),
email: formData.email.trim().toLowerCase(),
password: formData.password,
role: formData.role,
}),
});
const data = await response.json();
if (response.ok) {
setResult({
success: true,
message: data.message,
user: data.user,
});
// Réinitialiser le formulaire en cas de succès
setFormData({
name: "",
email: "",
password: "",
confirmPassword: "",
role: "USER",
});
} else {
setResult({
success: false,
message:
data.error || "Erreur lors de la création de l&apos;utilisateur",
});
}
} catch (error) {
console.error("Erreur lors de la création de l&apos;utilisateur:", error);
setResult({
success: false,
message: "Erreur de connexion au serveur",
});
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Créer un nouvel utilisateur
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Nom complet *</Label>
<Input
id="name"
type="text"
placeholder="Ex: Jean Dupont"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
disabled={isLoading}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Adresse email *</Label>
<Input
id="email"
type="email"
placeholder="Ex: jean.dupont@example.com"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
disabled={isLoading}
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="password">Mot de passe *</Label>
<Input
id="password"
type="password"
placeholder="Minimum 8 caractères"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
disabled={isLoading}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirmer le mot de passe *
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Répéter le mot de passe"
value={formData.confirmPassword}
onChange={(e) =>
handleInputChange("confirmPassword", e.target.value)
}
disabled={isLoading}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role">Rôle</Label>
<Select
value={formData.role}
onValueChange={(value: string) =>
handleInputChange("role", value)
}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un rôle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USER">Utilisateur</SelectItem>
<SelectItem value="ADMIN">Administrateur</SelectItem>
</SelectContent>
</Select>
</div>
{result && (
<Alert variant={result.success ? "default" : "destructive"}>
{result.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>
{result.message}
{result.success && result.user && (
<div className="mt-2 text-sm">
<strong>Détails:</strong>
<br /> ID: {result.user.id}
<br /> Email: {result.user.email}
<br /> Rôle: {result.user.role}
<br /> Crédits initiaux: 3,000,000 tokens
</div>
)}
</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Création en cours...
</>
) : (
<>
<UserPlus className="mr-2 h-4 w-4" />
Créer l&apos;utilisateur
</>
)}
</Button>
</div>
</form>
<div className="mt-6 p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Informations importantes :</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li>
L&apos;utilisateur recevra automatiquement 5,000,000 tokens
</li>
<li> Le mot de passe sera hashé de manière sécurisée</li>
<li> L&apos;email doit être unique dans le système</li>
<li> L&apos;utilisateur pourra se connecter immédiatement</li>
</ul>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,246 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Users } from "lucide-react";
import Link from "next/link";
import { useCollection } from "@/hooks/useCollection";
import {
LibreChatUser,
LibreChatConversation,
LibreChatBalance,
} from "@/lib/types";
interface DashboardUser {
userId: string;
userName: string;
conversations: number;
tokens: number;
credits: number;
}
// Interface pour les transactions (vraie source de consommation)
interface TransactionDocument {
_id: string;
user: unknown; // ObjectId dans MongoDB
rawAmount?: number;
tokenType?: string;
model?: string;
}
export function DashboardUsersList() {
const [topUsers, setTopUsers] = useState<DashboardUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Récupérer toutes les données nécessaires
const { data: users, loading: usersLoading } =
useCollection<LibreChatUser>("users", { limit: 1000 });
const { data: conversations, loading: conversationsLoading } =
useCollection<LibreChatConversation>("conversations", { limit: 1000 });
const { data: balances, loading: balancesLoading } =
useCollection<LibreChatBalance>("balances", { limit: 1000 });
// Transactions = vraie source de consommation de tokens
const { data: transactions, loading: transactionsLoading } =
useCollection<TransactionDocument>("transactions", { limit: 10000 });
const processUsers = useCallback(() => {
if (!users?.length || !conversations?.length || !balances?.length) {
return;
}
console.log("🔄 Processing users data...");
console.log("Users:", users.length);
console.log("Conversations:", conversations.length);
console.log("Balances:", balances.length);
console.log("Transactions:", transactions?.length || 0);
const processedUsers: DashboardUser[] = [];
// Créer une map des transactions par utilisateur (user est un ObjectId, on compare en string)
const tokensByUser = new Map<string, number>();
if (transactions?.length) {
transactions.forEach((tx: TransactionDocument) => {
// tx.user est un ObjectId, on le convertit en string pour la comparaison
const txUserId = String(tx.user);
const tokens = Math.abs(tx.rawAmount || 0);
tokensByUser.set(txUserId, (tokensByUser.get(txUserId) || 0) + tokens);
});
}
users.forEach((user: LibreChatUser) => {
const userId = user._id;
// Obtenir les conversations de l'utilisateur (user dans conversations est un string)
const userConversations = conversations.filter(
(conv: LibreChatConversation) => String(conv.user) === userId
);
// Obtenir les tokens depuis les transactions (vraie consommation)
const tokensFromTransactions = tokensByUser.get(userId) || 0;
// Obtenir le balance de l'utilisateur
const userBalance = balances.find(
(balance: LibreChatBalance) => String(balance.user) === userId
);
const credits = userBalance?.tokenCredits || 0;
// Ajouter l'utilisateur s'il a consommé des tokens
if (tokensFromTransactions > 0) {
processedUsers.push({
userId: userId,
userName: user.name || user.username || user.email || "Utilisateur inconnu",
conversations: userConversations.length,
tokens: tokensFromTransactions,
credits: credits,
});
}
});
// Trier par tokens consommés (décroissant) et prendre les 5 premiers
const sortedUsers = processedUsers
.sort((a, b) => b.tokens - a.tokens)
.slice(0, 5);
console.log("📊 Processing summary:", {
totalUsers: users.length,
usersWithActivity: processedUsers.length,
top5Users: sortedUsers.length,
});
console.log("✅ Top 5 users:", sortedUsers);
setTopUsers(sortedUsers);
setIsLoading(false);
}, [users, conversations, balances, transactions]);
useEffect(() => {
const allDataLoaded =
!usersLoading &&
!conversationsLoading &&
!balancesLoading &&
!transactionsLoading;
if (allDataLoaded) {
processUsers();
} else {
setIsLoading(true);
}
}, [
usersLoading,
conversationsLoading,
balancesLoading,
transactionsLoading,
processUsers,
]);
if (isLoading) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Top 5 utilisateurs
</CardTitle>
<Button variant="outline" size="sm" disabled>
Chargement...
</Button>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse"></div>
<div>
<div className="h-4 bg-gray-200 rounded w-24 animate-pulse"></div>
<div className="h-3 bg-gray-200 rounded w-16 mt-1 animate-pulse"></div>
</div>
</div>
<div className="text-right">
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
<div className="h-3 bg-gray-200 rounded w-12 mt-1 animate-pulse"></div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (topUsers.length === 0) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Top 5 utilisateurs
</CardTitle>
<Link href="/users">
<Button variant="outline" size="sm">
Voir tous
</Button>
</Link>
</CardHeader>
<CardContent>
<div className="text-center py-8 text-muted-foreground">
Aucun utilisateur trouvé
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Top 5 utilisateurs
</CardTitle>
<Link href="/users">
<Button variant="outline" size="sm">
Voir tous
</Button>
</Link>
</CardHeader>
<CardContent>
<div className="space-y-3">
{topUsers.map((user, index) => (
<div
key={user.userId}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="flex items-center space-x-3">
<Badge
variant="secondary"
className="w-6 h-6 rounded-full p-0 flex items-center justify-center text-xs"
>
{index + 1}
</Badge>
<div>
<p className="font-medium text-sm">{user.userName}</p>
<p className="text-xs text-muted-foreground">
{user.conversations} conversation
{user.conversations !== 1 ? "s" : ""}
</p>
</div>
</div>
<div className="text-right">
<p className="font-medium text-sm">
{user.tokens.toLocaleString()} tokens
</p>
<p className="text-xs text-muted-foreground">
{user.credits.toLocaleString()} crédits
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,321 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { UserMinus, Loader2, CheckCircle, AlertCircle, Trash2 } from "lucide-react";
interface DeleteUserResult {
success: boolean;
message: string;
deletedUser?: {
id: string;
name: string;
email: string;
role: string;
};
balanceDeleted?: boolean;
error?: string;
}
interface FoundUser {
_id: string;
name: string;
email: string;
role: string;
createdAt: string;
}
export default function DeleteUser() {
const [searchData, setSearchData] = useState({
email: "",
userId: "",
});
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<DeleteUserResult | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [foundUser, setFoundUser] = useState<FoundUser | null>(null);
const handleInputChange = (field: string, value: string) => {
setSearchData((prev) => ({ ...prev, [field]: value }));
// Réinitialiser les résultats quand l'utilisateur modifie le formulaire
if (result) {
setResult(null);
}
if (confirmDelete) {
setConfirmDelete(false);
}
if (foundUser) {
setFoundUser(null);
}
};
const validateForm = () => {
if (!searchData.email.trim() && !searchData.userId.trim()) {
return "Email ou ID utilisateur requis";
}
if (searchData.email && searchData.userId) {
return "Veuillez utiliser soit l'email soit l'ID, pas les deux";
}
if (searchData.email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(searchData.email)) {
return "Format d&apos;email invalide";
}
}
return null;
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
const validationError = validateForm();
if (validationError) {
setResult({
success: false,
message: validationError,
});
return;
}
setIsLoading(true);
setResult(null);
setFoundUser(null);
try {
// D'abord, chercher l'utilisateur pour confirmation
const searchParams = new URLSearchParams();
if (searchData.email) {
searchParams.append("email", searchData.email.trim().toLowerCase());
}
if (searchData.userId) {
searchParams.append("id", searchData.userId.trim());
}
const response = await fetch(`/api/collections/users?${searchParams.toString()}`);
const data = await response.json();
if (response.ok && data.data && data.data.length > 0) {
setFoundUser(data.data[0]);
setResult({
success: true,
message: "Utilisateur trouvé. Confirmez la suppression ci-dessous.",
});
} else {
setResult({
success: false,
message: "Utilisateur non trouvé",
});
}
} catch (error) {
console.error("Erreur lors de la recherche:", error);
setResult({
success: false,
message: "Erreur de connexion au serveur",
});
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!foundUser) return;
setIsLoading(true);
setResult(null);
try {
const response = await fetch("/api/delete-user", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
userId: foundUser._id,
}),
});
const data = await response.json();
if (response.ok) {
setResult({
success: true,
message: data.message,
deletedUser: data.deletedUser,
balanceDeleted: data.balanceDeleted,
});
// Réinitialiser le formulaire
setSearchData({
email: "",
userId: "",
});
setFoundUser(null);
setConfirmDelete(false);
} else {
setResult({
success: false,
message: data.error || "Erreur lors de la suppression de l&apos;utilisateur",
});
}
} catch (error) {
console.error("Erreur lors de la suppression:", error);
setResult({
success: false,
message: "Erreur de connexion au serveur",
});
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<UserMinus className="h-5 w-5" />
Supprimer un utilisateur
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSearch} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="email">Adresse email</Label>
<Input
id="email"
type="email"
placeholder="Ex: jean.dupont@example.com"
value={searchData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
disabled={isLoading || !!searchData.userId}
/>
</div>
<div className="space-y-2">
<Label htmlFor="userId">ID Utilisateur</Label>
<Input
id="userId"
type="text"
placeholder="Ex: 507f1f77bcf86cd799439011"
value={searchData.userId}
onChange={(e) => handleInputChange("userId", e.target.value)}
disabled={isLoading || !!searchData.email}
/>
</div>
</div>
{!foundUser && (
<div className="flex justify-end">
<Button type="submit" disabled={isLoading} variant="outline">
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Recherche...
</>
) : (
<>
Rechercher l&apos;utilisateur
</>
)}
</Button>
</div>
)}
</form>
{foundUser && (
<div className="mt-6 p-4 border rounded-lg bg-yellow-50 border-yellow-200">
<h4 className="font-medium mb-2 text-yellow-800">Utilisateur trouvé :</h4>
<div className="text-sm space-y-1 text-yellow-700">
<p><strong>ID:</strong> {foundUser._id}</p>
<p><strong>Nom:</strong> {foundUser.name}</p>
<p><strong>Email:</strong> {foundUser.email}</p>
<p><strong>Rôle:</strong> {foundUser.role}</p>
<p><strong>Créé le:</strong> {new Date(foundUser.createdAt).toLocaleDateString()}</p>
</div>
<div className="mt-4 flex gap-2">
{!confirmDelete ? (
<Button
onClick={() => setConfirmDelete(true)}
variant="destructive"
size="sm"
>
<Trash2 className="mr-2 h-4 w-4" />
Supprimer cet utilisateur
</Button>
) : (
<div className="flex gap-2">
<Button
onClick={handleDelete}
disabled={isLoading}
variant="destructive"
size="sm"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Suppression...
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
Confirmer la suppression
</>
)}
</Button>
<Button
onClick={() => setConfirmDelete(false)}
variant="outline"
size="sm"
disabled={isLoading}
>
Annuler
</Button>
</div>
)}
</div>
</div>
)}
{result && (
<Alert variant={result.success ? "default" : "destructive"} className="mt-4">
{result.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>
{result.message}
{result.success && result.deletedUser && (
<div className="mt-2 text-sm">
<strong>Utilisateur supprimé:</strong>
<br />
Nom: {result.deletedUser.name}
<br />
Email: {result.deletedUser.email}
<br />
Rôle: {result.deletedUser.role}
<br />
Solde supprimé: {result.balanceDeleted ? "Oui" : "Non"}
</div>
)}
</AlertDescription>
</Alert>
)}
<div className="mt-6 p-4 bg-red-50 rounded-lg border border-red-200">
<h4 className="font-medium mb-2 text-red-800"> Attention :</h4>
<ul className="text-sm text-red-700 space-y-1">
<li> Cette action est irréversible</li>
<li> L&apos;utilisateur et son solde seront définitivement supprimés</li>
<li> Toutes les données associées seront perdues</li>
<li> Utilisez cette fonction avec précaution</li>
</ul>
</div>
</CardContent>
</Card>
);
}

View File

@@ -8,8 +8,10 @@ import {
TrendingUp,
TrendingDown,
Activity,
Euro,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { convertCreditsToEuros } from "@/lib/utils/currency";
interface MetricCardProps {
title: string;
@@ -77,6 +79,9 @@ interface MetricCardsProps {
}
export function MetricCards({ metrics }: MetricCardsProps) {
// Conversion des crédits en euros
const creditsConversion = convertCreditsToEuros(metrics.totalCreditsUsed);
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<MetricCard
@@ -105,7 +110,10 @@ export function MetricCards({ metrics }: MetricCardsProps) {
<CardTitle className="text-sm font-medium text-muted-foreground">
Crédits totaux
</CardTitle>
<div className="flex items-center gap-1">
<CreditCard className="h-4 w-4 text-muted-foreground" />
<Euro className="h-4 w-4 text-green-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
@@ -114,7 +122,22 @@ export function MetricCards({ metrics }: MetricCardsProps) {
<p className="text-xs text-muted-foreground mt-1">
crédits disponibles
</p>
<div className="flex items-center space-x-2 text-xs text-muted-foreground mt-1">
{/* Conversion en euros */}
<div className="mt-2 p-2 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-green-800">Valeur en EUR:</span>
<span className="text-lg font-bold text-green-600">
{creditsConversion.formatted.eur}
</span>
</div>
<div className="flex items-center justify-between text-xs text-green-600">
<span>USD: {creditsConversion.formatted.usd}</span>
<span>Taux: 1 USD = 0.92 EUR</span>
</div>
</div>
<div className="flex items-center space-x-2 text-xs text-muted-foreground mt-2">
<TrendingUp className="h-3 w-3 text-green-500" />
<span className="text-green-500">+23%</span>
<span>par rapport au mois dernier</span>

View File

@@ -2,15 +2,24 @@
import { useMetrics } from "@/hooks/useMetrics";
import { MetricCard } from "@/components/ui/metric-card";
import { Users, UserCheck, Shield, Coins, MessageSquare, FileText } from "lucide-react";
import {
Users,
UserCheck,
Coins,
MessageSquare,
FileText,
Euro,
Activity,
} from "lucide-react";
import { convertCreditsToEuros } from "@/lib/utils/currency";
export function OverviewMetrics() {
const { metrics, loading, error } = useMetrics();
if (loading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
))}
</div>
@@ -25,38 +34,67 @@ export function OverviewMetrics() {
);
}
// Conversion des crédits en euros
const creditsInEuros = convertCreditsToEuros(metrics.totalCredits);
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<MetricCard
title="Utilisateurs totaux"
value={metrics.totalUsers}
icon={Users}
/>
<div className="space-y-4">
{/* Ligne 1: Utilisateurs actifs, Conversations actives, Tokens consommés */}
<div className="grid gap-4 md:grid-cols-3">
<MetricCard
title="Utilisateurs actifs"
value={metrics.activeUsers}
icon={UserCheck}
/>
<MetricCard
title="Administrateurs"
value={metrics.totalAdmins}
icon={Shield}
/>
<MetricCard
title="Crédits totaux"
value={metrics.totalCredits}
icon={Coins}
/>
<MetricCard
title="Conversations actives"
value={metrics.activeConversations}
icon={MessageSquare}
/>
<MetricCard
title="Tokens consommés"
value={metrics.totalTokensConsumed?.toLocaleString() || "0"}
icon={Activity}
description={`${Math.round(
(metrics.totalTokensConsumed || 0) / (metrics.totalUsers || 1)
)} par utilisateur`}
/>
</div>
{/* Ligne 2: Utilisateurs totaux, Messages totaux, Crédits totaux */}
<div className="grid gap-4 md:grid-cols-3">
<MetricCard
title="Utilisateurs totaux"
value={metrics.totalUsers}
icon={Users}
/>
<MetricCard
title="Messages totaux"
value={metrics.totalMessages}
icon={FileText}
/>
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-600">Crédits totaux</h3>
<div className="flex items-center gap-1">
<Coins className="h-4 w-4 text-gray-400" />
<Euro className="h-4 w-4 text-green-600" />
</div>
</div>
<div className="text-2xl font-bold mb-1">
{metrics.totalCredits.toLocaleString()}
</div>
<div className="text-sm text-gray-500 mb-2">crédits disponibles</div>
<div className="p-2 bg-green-50 rounded border border-green-200">
<div className="text-sm font-semibold text-green-800">
{creditsInEuros.formatted.eur}
</div>
<div className="text-xs text-green-600">
{creditsInEuros.formatted.usd} USD
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,17 +1,23 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useStats } from "@/hooks/useStats";
import { SimpleStatsChart } from "./charts/simple-stats-chart";
import { UserConnectionsChart } from "./charts/user-connections-chart";
import { ModelDistributionChart } from "./charts/model-distribution-chart";
import { AlertCircle } from "lucide-react";
export function RealTimeStats() {
const { stats, loading, error } = useStats();
console.log("RealTimeStats - stats:", stats);
console.log("RealTimeStats - loading:", loading);
console.log("RealTimeStats - error:", error);
if (loading) {
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-6">
<div className="h-64 bg-muted animate-pulse rounded-lg" />
<div className="h-64 bg-muted animate-pulse rounded-lg" />
</div>
@@ -20,7 +26,7 @@ export function RealTimeStats() {
if (error) {
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-center h-64">
<div className="text-center">
@@ -47,7 +53,7 @@ export function RealTimeStats() {
if (!stats) {
return (
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-center h-64">
<p className="text-sm text-muted-foreground">
@@ -67,7 +73,25 @@ export function RealTimeStats() {
}
return (
<div className="grid gap-6 md:grid-cols-2">
<Tabs defaultValue="connections" className="space-y-6">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="connections">Nombre de connexions par utilisateur/par jour</TabsTrigger>
<TabsTrigger value="tokens">Tokens consommés par jour</TabsTrigger>
</TabsList>
<TabsContent value="connections" className="space-y-6">
<UserConnectionsChart
title="Nombre de connexions par utilisateur/par jour"
data={stats.dailyConnections || []}
color="hsl(var(--chart-2))"
/>
<ModelDistributionChart
title="Répartition par modèle"
data={stats.modelDistribution}
/>
</TabsContent>
<TabsContent value="tokens" className="space-y-6">
<SimpleStatsChart
title="Tokens consommés par jour"
data={stats.dailyTokens}
@@ -77,6 +101,7 @@ export function RealTimeStats() {
title="Répartition par modèle"
data={stats.modelDistribution}
/>
</div>
</TabsContent>
</Tabs>
);
}

View File

@@ -3,8 +3,9 @@
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 { Users, MessageSquare, DollarSign, Activity, Euro } from "lucide-react";
import { useCollection } from "@/hooks/useCollection";
import { convertCreditsToEuros } from "@/lib/utils/currency";
import {
LibreChatUser,
@@ -36,7 +37,7 @@ export function UsageAnalytics() {
const { data: users = [] } = useCollection<LibreChatUser>("users", { limit: 1000 });
const { data: conversations = [] } = useCollection<LibreChatConversation>("conversations", { limit: 1000 });
const { data: transactions = [] } = useCollection<LibreChatTransaction>("transactions", { limit: 1000 });
const { data: transactions = [] } = useCollection<LibreChatTransaction>("transactions", { limit: 10000 });
const { data: balances = [] } = useCollection<LibreChatBalance>("balances", { limit: 1000 });
const calculateStats = useCallback(() => {
@@ -46,24 +47,20 @@ export function UsageAnalytics() {
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);
console.log("=== CALCUL DES STATISTIQUES ===");
console.log("Utilisateurs:", users.length);
console.log("Conversations:", conversations.length);
console.log("Transactions:", transactions.length);
console.log("Balances:", balances.length);
// 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
// Analyser les doublons dans les balances
const userCounts = new Map<string, number>();
balances.forEach(balance => {
const userId = balance.user;
balances.forEach((balance) => {
const userId = String(balance.user);
userCounts.set(userId, (userCounts.get(userId) || 0) + 1);
});
const duplicateUsers = Array.from(userCounts.entries()).filter(([_, count]) => count > 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
@@ -73,10 +70,33 @@ export function UsageAnalytics() {
const totalBrut = balances.reduce((sum, balance) => sum + (balance.tokenCredits || 0), 0);
console.log("Total brut (avec doublons potentiels):", totalBrut);
// Ajouter des logs détaillés pour comprendre le problème
console.log("=== DIAGNOSTIC DÉTAILLÉ ===");
// Analyser les doublons
const duplicateDetails = Array.from(userCounts.entries())
.filter(([, count]) => count > 1)
.map(([userId, count]) => {
const userBalances = balances.filter(b => String(b.user) === userId);
const totalCredits = userBalances.reduce((sum, b) => sum + (b.tokenCredits || 0), 0);
return {
userId,
count,
totalCredits,
balances: userBalances.map(b => ({
credits: b.tokenCredits,
createdAt: b.createdAt,
updatedAt: b.updatedAt
}))
};
});
console.log("Détails des doublons:", duplicateDetails);
// 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 balanceUserIds = balances.map(balance => String(balance.user));
const phantomUsers = balanceUserIds.filter(userId => !userIds.has(userId));
const uniquePhantomUsers = [...new Set(phantomUsers)];
@@ -85,46 +105,63 @@ export function UsageAnalytics() {
// Calculer les crédits des utilisateurs fantômes
const phantomCredits = balances
.filter(balance => uniquePhantomUsers.includes(balance.user))
.filter(balance => uniquePhantomUsers.includes(String(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);
// Analyser les utilisateurs fantômes
const phantomDetails = uniquePhantomUsers.map(userId => {
const userBalances = balances.filter(b => String(b.user) === userId);
const totalCredits = userBalances.reduce((sum, b) => sum + (b.tokenCredits || 0), 0);
return { userId, totalCredits, count: userBalances.length };
});
console.log("Détails des utilisateurs fantômes:", phantomDetails);
// Calculer les utilisateurs actifs (30 derniers jours)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const activeUsers = users.filter((user) => {
const lastActivity = new Date(user.updatedAt || user.createdAt);
return lastActivity >= fiveMinutesAgo;
return lastActivity >= thirtyDaysAgo;
}).length;
// CORRECTION : Créer une map des crédits par utilisateur en évitant les doublons
// CORRECTION AMÉLIORÉE : Créer une map des crédits par utilisateur
const creditsMap = new Map<string, number>();
// Grouper les balances par utilisateur
const balancesByUser = new Map<string, LibreChatBalance[]>();
balances.forEach((balance) => {
const userId = balance.user;
if (!balancesByUser.has(userId)) {
balancesByUser.set(userId, []);
const balanceUserId = String(balance.user);
// Ignorer les utilisateurs fantômes (qui n'existent plus)
if (users.some(user => user._id === balanceUserId)) {
if (!balancesByUser.has(balanceUserId)) {
balancesByUser.set(balanceUserId, []);
}
balancesByUser.get(balanceUserId)!.push(balance);
}
balancesByUser.get(userId)!.push(balance);
});
// Pour chaque utilisateur, prendre seulement la dernière entrée
// Pour chaque utilisateur, calculer les crédits selon votre logique métier
balancesByUser.forEach((userBalances, userId) => {
if (userBalances.length > 0) {
// Trier par date de mise à jour (plus récent en premier)
// OPTION A: Prendre la balance la plus récente
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();
});
creditsMap.set(userId, sortedBalances[0].tokenCredits || 0);
// Prendre la plus récente
const latestBalance = sortedBalances[0];
creditsMap.set(userId, latestBalance.tokenCredits || 0);
// OPTION B: Sommer toutes les balances (si c'est votre logique)
// const totalCredits = userBalances.reduce((sum, balance) => sum + (balance.tokenCredits || 0), 0);
// creditsMap.set(userId, totalCredits);
// OPTION C: Prendre la balance avec le plus de crédits
// const maxCredits = Math.max(...userBalances.map(b => b.tokenCredits || 0));
// creditsMap.set(userId, maxCredits);
}
});
@@ -149,17 +186,19 @@ export function UsageAnalytics() {
});
// Calculer les conversations par utilisateur
// conv.user est un STRING
conversations.forEach((conv) => {
const userStat = userStats.get(conv.user);
const userStat = userStats.get(String(conv.user));
if (userStat) {
userStat.conversations++;
}
});
// Calculer les tokens par utilisateur depuis les transactions
// transaction.user est un ObjectId, on le convertit en string
let totalTokensConsumed = 0;
transactions.forEach((transaction) => {
const userStat = userStats.get(transaction.user);
const userStat = userStats.get(String(transaction.user));
if (userStat && transaction.rawAmount) {
const tokens = Math.abs(Number(transaction.rawAmount) || 0);
userStat.tokens += tokens;
@@ -167,12 +206,16 @@ export function UsageAnalytics() {
}
});
// CORRECTION : Calculer le total des crédits depuis la map corrigée
// Calculer le total des crédits depuis la map corrigée (sans doublons ni fantômes)
const totalCreditsUsed = Array.from(creditsMap.values()).reduce(
(sum, credits) => sum + credits,
0
);
console.log("=== RÉSULTATS CORRIGÉS ===");
console.log("Crédits totaux (sans doublons ni fantômes):", totalCreditsUsed);
console.log("Utilisateurs avec crédits:", creditsMap.size);
// Tous les utilisateurs triés par tokens puis conversations
const allUsers = Array.from(userStats.entries())
.map(([userId, stats]) => ({
@@ -242,7 +285,7 @@ export function UsageAnalytics() {
<CardContent>
<div className="text-2xl font-bold">{stats.totalUsers}</div>
<p className="text-xs text-muted-foreground">
{stats.activeUsers} actifs cette semaine
{stats.activeUsers} actifs ce mois
</p>
</CardContent>
</Card>
@@ -282,13 +325,26 @@ export function UsageAnalytics() {
<CardTitle className="text-sm font-medium">
Crédits totaux
</CardTitle>
<div className="flex items-center gap-1">
<DollarSign className="h-4 w-4 text-muted-foreground" />
<Euro className="h-4 w-4 text-green-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.totalCreditsUsed.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">crédits disponibles</p>
{/* Conversion en euros */}
<div className="mt-2 p-2 bg-green-50 rounded-lg border border-green-200">
<div className="text-sm font-medium text-green-800">
Valeur: {convertCreditsToEuros(stats.totalCreditsUsed).formatted.eur}
</div>
<div className="text-xs text-green-600">
({convertCreditsToEuros(stats.totalCreditsUsed).formatted.usd} USD)
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,56 @@
"use client";
import { UserPlus, UserMinus, Users } from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import CreateUser from "./create-user";
import DeleteUser from "./delete-user";
export default function UserManagement() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-6 w-6" />
Gestion des Utilisateurs
</CardTitle>
</CardHeader>
<CardContent>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="create-user">
<AccordionTrigger className="text-left">
<div className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-green-600" />
<span>Créer un nouvel utilisateur</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="pt-4">
<CreateUser />
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="delete-user">
<AccordionTrigger className="text-left">
<div className="flex items-center gap-2">
<UserMinus className="h-5 w-5 text-red-600" />
<span>Supprimer un utilisateur</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="pt-4">
<DeleteUser />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
);
}

View File

@@ -1,12 +1,11 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } 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 { createClient } from "@/lib/supabase/client";
import Image from "next/image";
import {
LayoutDashboard,
@@ -20,136 +19,165 @@ import {
Bot,
ChevronLeft,
ChevronRight,
BarChart3,
Activity,
LogOut,
User,
Mail,
GraduationCap,
} from "lucide-react";
import type { User as SupabaseUser } from "@supabase/supabase-js";
interface NavigationItem {
name: string;
href: string;
icon: React.ElementType;
badge?: string | null;
}
const navigation: NavigationItem[] = [
{
name: "Vue d'ensemble",
href: "/",
icon: LayoutDashboard,
badge: null,
},
const topLevelNavigation = [
{
name: "Analytics",
href: "/analytics",
icon: BarChart3,
badge: "Nouveau",
href: "/",
icon: LayoutDashboard,
},
];
const dataNavigation: NavigationItem[] = [
{ name: "Utilisateurs", href: "/users", icon: Users, badge: null },
const navigationGroups = [
{
name: "Données",
items: [
{
name: "Conversations",
href: "/conversations",
icon: MessageSquare,
badge: null,
},
{ name: "Messages", href: "/messages", icon: FileText, badge: null },
{
name: "Utilisateurs",
href: "/users",
icon: Users,
},
{
name: "Messages",
href: "/messages",
icon: FileText,
},
{
name: "Transactions",
href: "/transactions",
icon: CreditCard,
badge: null,
},
{
name: "Groupes",
href: "/referents",
icon: GraduationCap,
},
],
},
{
name: "Système",
items: [
{
name: "Collections",
href: "/collections",
icon: Database,
},
{
name: "Agents",
href: "/agents",
icon: Bot,
},
{
name: "Rôles",
href: "/roles",
icon: Shield,
},
{
name: "Paramètres",
href: "/settings",
icon: Settings,
},
],
},
];
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 router = useRouter();
const [collapsed, setCollapsed] = useState(false);
const [user, setUser] = useState<SupabaseUser | null>(null);
const [loading, setLoading] = useState(true);
const supabase = createClient();
const NavSection = ({
title,
items,
showTitle = true,
}: {
title: string;
items: NavigationItem[];
showTitle?: boolean;
}) => (
<div className="space-y-2">
{!collapsed && showTitle && (
<h3 className="px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{title}
</h3>
)}
{items.map((item) => {
const isActive = pathname === item.href;
return (
<Link key={item.name} href={item.href}>
<Button
variant={isActive ? "secondary" : "ghost"}
className={cn(
"w-full justify-start h-9 px-3",
collapsed && "px-2 justify-center",
isActive && "bg-secondary font-medium"
)}
>
<item.icon className={cn("h-4 w-4", collapsed ? "" : "mr-3")} />
{!collapsed && (
<div className="flex items-center justify-between w-full">
<span>{item.name}</span>
{item.badge && (
<Badge variant="secondary" className="text-xs">
{item.badge}
</Badge>
)}
</div>
)}
</Button>
</Link>
);
})}
</div>
useEffect(() => {
const getUser = async () => {
try {
const {
data: { user },
} = await supabase.auth.getUser();
setUser(user);
} catch (error) {
console.error(
"Erreur lors de la récupération de l'utilisateur:",
error
);
setUser(null);
} finally {
setLoading(false);
}
};
getUser();
// Écouter les changements d'authentification
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
setUser(session?.user || null);
setLoading(false);
});
return () => subscription.unsubscribe();
}, [supabase.auth]);
const handleLogout = async () => {
try {
await supabase.auth.signOut();
router.push("/login");
router.refresh();
} catch (error) {
console.error("Erreur lors de la déconnexion:", error);
}
};
// Ne pas afficher la sidebar si l'utilisateur n'est pas connecté ou en cours de chargement
if (loading || !user) {
return null;
}
return (
<div
className={cn(
"flex flex-col h-screen bg-background border-r border-border transition-all duration-300 ease-in-out",
"flex flex-col h-screen bg-white border-r border-gray-200 transition-all duration-300",
collapsed ? "w-16" : "w-64"
)}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0">
{!collapsed && (
<div className="flex items-center space-x-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center">
<div className="flex items-center space-x-3">
<div className="relative w-8 h-8">
<Image
src="/img/logo.png"
alt="Cercle GPT Logo"
width={32}
height={32}
className="rounded-lg"
alt="Logo"
fill
className="object-contain"
/>
</div>
<div>
<h1 className="text-sm font-semibold">Cercle GPT</h1>
<p className="text-xs text-muted-foreground">Admin Dashboard</p>
<h1 className="text-lg font-semibold text-gray-900">
Cercle GPT
</h1>
<p className="text-xs text-gray-500">Admin Dashboard</p>
</div>
</div>
)}
<Button
variant="ghost"
size="icon"
size="sm"
onClick={() => setCollapsed(!collapsed)}
className="h-8 w-8"
className="p-1.5 hover:bg-gray-100"
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
@@ -160,32 +188,112 @@ export function Sidebar() {
</div>
{/* Navigation */}
<nav className="flex-1 p-3 space-y-6 overflow-y-auto">
<NavSection title="Dashboard" items={navigation} showTitle={false} />
<nav className="flex-1 p-4 space-y-6 overflow-y-auto">
{/* Navigation de niveau supérieur */}
<div className="space-y-1">
{topLevelNavigation.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-gray-900 text-white"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-900"
)}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>{item.name}</span>}
</Link>
);
})}
</div>
{!collapsed && <Separator />}
{/* Séparateur */}
<div className="border-t border-gray-200"></div>
<NavSection title="Données" items={dataNavigation} />
{/* Groupes de navigation */}
{navigationGroups.map((group) => (
<div key={group.name}>
{/* Titre du groupe */}
{!collapsed && (
<h3 className="px-3 mb-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
{group.name}
</h3>
)}
{!collapsed && <Separator />}
{/* Séparateur visuel quand collapsed */}
{collapsed && (
<div className="mb-2 mx-auto w-8 h-px bg-gray-300"></div>
)}
<NavSection title="Système" items={systemNavigation} />
{/* Items du groupe */}
<div className="space-y-1">
{group.items.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive
? "bg-gray-900 text-white"
: "text-gray-700 hover:bg-gray-100 hover:text-gray-900"
)}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>{item.name}</span>}
</Link>
);
})}
</div>
</div>
))}
</nav>
{/* Footer */}
{/* Section utilisateur connecté */}
<div className="p-4 border-t border-gray-200 space-y-3 flex-shrink-0 bg-white">
{/* Informations utilisateur */}
<div
className={cn(
"flex items-center space-x-3 px-3 py-2 bg-gray-50 rounded-md",
collapsed && "justify-center"
)}
>
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center">
<User className="h-4 w-4 text-gray-600" />
</div>
</div>
{!collapsed && (
<div className="p-3 border-t border-border">
<div className="flex items-center space-x-3 p-2 rounded-lg bg-muted/50">
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
<Activity className="h-4 w-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium">Système en ligne</p>
<p className="text-xs text-muted-foreground">Tout fonctionne</p>
</div>
<p className="text-sm font-medium text-gray-900 truncate">
Administrateur
</p>
<div className="flex items-center space-x-1">
<Mail className="h-3 w-3 text-gray-400" />
<p className="text-xs text-gray-500 truncate">{user.email}</p>
</div>
</div>
)}
</div>
{/* Bouton de déconnexion */}
<Button
variant="outline"
onClick={handleLogout}
className={cn(
"w-full flex items-center space-x-2 text-sm font-medium border-gray-200 hover:bg-gray-50",
collapsed && "justify-center px-2"
)}
>
<LogOut className="h-4 w-4" />
{!collapsed && <span>Déconnexion</span>}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
gcTime: 1000 * 60 * 5, // 5 minutes (anciennement cacheTime)
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

46
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertDescription }

26
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -7,6 +7,7 @@ interface MetricCardProps {
title: string;
value: number | string;
icon: LucideIcon;
description?: string;
trend?: {
value: number;
isPositive: boolean;
@@ -18,6 +19,7 @@ export function MetricCard({
title,
value,
icon: Icon,
description,
trend,
className
}: MetricCardProps) {
@@ -33,6 +35,11 @@ export function MetricCard({
<div className="text-2xl font-bold">
{typeof value === 'number' ? formatNumber(value) : value}
</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">
{description}
</p>
)}
{trend && (
<Badge
variant={trend.isPositive ? "default" : "destructive"}

160
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

27
debug-analytics.js Normal file
View File

@@ -0,0 +1,27 @@
const { MongoClient } = require('mongodb');
async function debug() {
const uri = 'mongodb://7qV5rRanD6UwANNO:NK1EFfaKpJZcTQlmm7pDUUI7Yk7yqxN6@51.254.197.189:27017/librechat?authSource=admin';
const client = new MongoClient(uri);
await client.connect();
const db = client.db('librechat');
// Vérifier les types
const user = await db.collection('users').findOne({});
const tx = await db.collection('transactions').findOne({});
const conv = await db.collection('conversations').findOne({});
console.log('=== TYPES DES IDs ===');
console.log('user._id:', typeof user._id, '->', user._id);
console.log('transaction.user:', typeof tx.user, '->', tx.user);
console.log('conversation.user:', typeof conv.user, '->', conv.user);
console.log('\n=== COMPARAISONS ===');
console.log('user._id === tx.user:', user._id === tx.user);
console.log('String(user._id) === String(tx.user):', String(user._id) === String(tx.user));
console.log('user._id.toString() === conv.user:', user._id.toString() === conv.user);
await client.close();
}
debug().catch(console.error);

38
debug-tokens.js Normal file
View File

@@ -0,0 +1,38 @@
const { MongoClient } = require('mongodb');
async function debug() {
const uri = 'mongodb://7qV5rRanD6UwANNO:NK1EFfaKpJZcTQlmm7pDUUI7Yk7yqxN6@51.254.197.189:27017/librechat?authSource=admin';
const client = new MongoClient(uri);
await client.connect();
const db = client.db('librechat');
const usersToCheck = ['Kleyntssens', 'Lilou Guillaume', 'Isabelle De Witte', 'Flavie Pochet'];
console.log('=== VERIFICATION TOKENS DEPUIS TRANSACTIONS ===\n');
for (const searchName of usersToCheck) {
const user = await db.collection('users').findOne({ name: { $regex: searchName, $options: 'i' } });
if (!user) {
console.log('NOT FOUND:', searchName);
continue;
}
const convCount = await db.collection('conversations').countDocuments({ user: user._id.toString() });
const txs = await db.collection('transactions').find({ user: user._id }).toArray();
const tokensFromTx = txs.reduce((sum, t) => sum + Math.abs(t.rawAmount || 0), 0);
const balance = await db.collection('balances').findOne({ user: user._id });
const credits = balance?.tokenCredits || 0;
console.log('---');
console.log('User:', user.name);
console.log('Conversations:', convCount);
console.log('Tokens (VRAI depuis transactions):', tokensFromTx.toLocaleString());
console.log('Credits:', credits.toLocaleString());
console.log('Nb transactions:', txs.length);
}
await client.close();
}
debug().catch(console.error);

View File

@@ -0,0 +1,73 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
interface UseCollectionOptions {
page?: number;
limit?: number;
filter?: Record<string, unknown>;
search?: string;
}
interface CollectionResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export function useCollection<T = Record<string, unknown>>(
collectionName: string,
options: UseCollectionOptions = {}
) {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const { page = 1, limit = 20, filter = {}, search } = options;
// Mémoriser la chaîne JSON du filtre pour éviter les re-renders inutiles
const filterString = useMemo(() => JSON.stringify(filter), [filter]);
const fetchData = useCallback(async () => {
try {
setLoading(true);
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
filter: filterString,
});
if (search) {
params.append("search", search);
}
const response = await fetch(
`/api/collections/${collectionName}?${params}`
);
if (!response.ok)
throw new Error(`Erreur lors du chargement de ${collectionName}`);
const result: CollectionResponse<T> = await response.json();
setData(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inconnue");
} finally {
setLoading(false);
}
}, [collectionName, page, limit, filterString]);
useEffect(() => {
fetchData();
}, [fetchData]);
const refetch = useCallback(() => {
return fetchData();
}, [fetchData]);
return { data, loading, error, total, totalPages, refetch };
}

View File

@@ -1,11 +1,13 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo } from "react";
interface UseCollectionOptions {
page?: number;
limit?: number;
filter?: Record<string, unknown>;
search?: string;
referent?: string;
}
interface CollectionResponse<T> {
@@ -26,7 +28,7 @@ export function useCollection<T = Record<string, unknown>>(
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const { page = 1, limit = 20, filter = {} } = options;
const { page = 1, limit = 20, filter = {}, search, referent } = options;
// Mémoriser la chaîne JSON du filtre pour éviter les re-renders inutiles
const filterString = useMemo(() => JSON.stringify(filter), [filter]);
@@ -37,22 +39,31 @@ export function useCollection<T = Record<string, unknown>>(
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
filter: filterString
filter: filterString,
});
if (search) {
params.append("search", search);
}
if (referent) {
params.append("referent", referent);
}
const response = await fetch(`/api/collections/${collectionName}?${params}`);
if (!response.ok) throw new Error(`Erreur lors du chargement de ${collectionName}`);
const response = await fetch(
`/api/collections/${collectionName}?${params}`
);
if (!response.ok)
throw new Error(`Erreur lors du chargement de ${collectionName}`);
const result: CollectionResponse<T> = await response.json();
setData(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue');
setError(err instanceof Error ? err.message : "Erreur inconnue");
} finally {
setLoading(false);
}
}, [collectionName, page, limit, filterString]);
}, [collectionName, page, limit, filterString, search, referent]);
useEffect(() => {
fetchData();

View File

@@ -0,0 +1,73 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
interface UseCollectionOptions {
page?: number;
limit?: number;
filter?: Record<string, unknown>;
search?: string;
}
interface CollectionResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export function useCollection<T = Record<string, unknown>>(
collectionName: string,
options: UseCollectionOptions = {}
) {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const { page = 1, limit = 20, filter = {}, search } = options;
// Mémoriser la chaîne JSON du filtre pour éviter les re-renders inutiles
const filterString = useMemo(() => JSON.stringify(filter), [filter]);
const fetchData = useCallback(async () => {
try {
setLoading(true);
const params = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
filter: filterString,
});
if (search) {
params.append("search", search);
}
const response = await fetch(
`/api/collections/${collectionName}?${params}`
);
if (!response.ok)
throw new Error(`Erreur lors du chargement de ${collectionName}`);
const result: CollectionResponse<T> = await response.json();
setData(result.data);
setTotal(result.total);
setTotalPages(result.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inconnue");
} finally {
setLoading(false);
}
}, [collectionName, page, limit, filterString]);
useEffect(() => {
fetchData();
}, [fetchData]);
const refetch = useCallback(() => {
return fetchData();
}, [fetchData]);
return { data, loading, error, total, totalPages, refetch };
}

View File

@@ -7,6 +7,11 @@ interface DailyToken {
value: number;
}
interface DailyConnection {
name: string;
value: number;
}
interface ModelDistribution {
name: string;
value: number;
@@ -14,6 +19,7 @@ interface ModelDistribution {
interface StatsData {
dailyTokens: DailyToken[];
dailyConnections: DailyConnection[];
modelDistribution: ModelDistribution[];
}

8
lib/supabase/client.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}

29
lib/supabase/server.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createServerClient as createSupabaseServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createServerClient() {
const cookieStore = await cookies()
return createSupabaseServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet: Array<{ name: string; value: string; options?: Record<string, unknown> }>) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}

View File

@@ -21,6 +21,11 @@ export interface LibreChatUser extends Record<string, unknown> {
createdAt: Date;
updatedAt: Date;
__v: number;
// Champs additionnels pour les étudiants
prenom?: string;
nom?: string;
referent?: string;
cours?: string;
}
export interface LibreChatConversation extends Record<string, unknown> {

49
lib/utils/currency.ts Normal file
View File

@@ -0,0 +1,49 @@
// Fonction de conversion des crédits en euros
export function convertCreditsToEuros(credits: number): {
usd: number;
eur: number;
formatted: {
usd: string;
eur: string;
};
} {
// Donc : 1 million de crédits = 1 USD
const usdAmount = credits / 1_000_000;
// Taux de change USD → EUR (vous pouvez ajuster ce taux)
// Taux approximatif actuel : 1 USD ≈ 0.92 EUR
const USD_TO_EUR_RATE = 0.92;
const eurAmount = usdAmount * USD_TO_EUR_RATE;
return {
usd: usdAmount,
eur: eurAmount,
formatted: {
usd: `$${usdAmount.toFixed(2)}`,
eur: `${eurAmount.toFixed(2)}`,
},
};
}
// Fonction pour formater les crédits avec conversion
export function formatCreditsWithCurrency(credits: number): string {
const conversion = convertCreditsToEuros(credits);
return `${credits.toLocaleString()} crédits (${conversion.formatted.eur})`;
}
// Fonction pour obtenir le taux de change en temps réel (optionnel)
export async function getCurrentExchangeRate(): Promise<number> {
try {
// Vous pouvez utiliser une API gratuite comme exchangerate-api.com
const response = await fetch(
"https://api.exchangerate-api.com/v4/latest/USD"
);
const data = await response.json();
return data.rates.EUR || 0.92; // Fallback au taux fixe
} catch {
console.warn(
"Impossible de récupérer le taux de change, utilisation du taux fixe"
);
return 0.92; // Taux fixe de fallback
}
}

65
middleware.ts Normal file
View File

@@ -0,0 +1,65 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet: Array<{ name: string; value: string; options?: Record<string, unknown> }>) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// Rafraîchir la session
const { data: { user } } = await supabase.auth.getUser()
// Si l'utilisateur est sur la page de login
if (request.nextUrl.pathname === '/login') {
if (user) {
// Utilisateur connecté, rediriger vers le dashboard
const redirectUrl = new URL('/', request.url)
return NextResponse.redirect(redirectUrl)
}
// Utilisateur non connecté, autoriser l'accès à la page de login
return supabaseResponse
}
// Pour toutes les autres pages, vérifier l'authentification
if (!user) {
// Utilisateur non connecté, rediriger vers login
const redirectUrl = new URL('/login', request.url)
return NextResponse.redirect(redirectUrl)
}
return supabaseResponse
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|public|img).*)',
],
}

457
package-lock.json generated
View File

@@ -8,23 +8,33 @@
"name": "admin-dashboard",
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.58.0",
"@tanstack/react-query": "^5.90.11",
"@types/bcryptjs": "^2.4.6",
"@types/mongodb": "^4.0.6",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"lucide-react": "^0.544.0",
"mongodb": "^6.20.0",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"recharts": "^3.2.1",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -1024,12 +1034,49 @@
"node": ">=12.4.0"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -1053,6 +1100,36 @@
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -1245,6 +1322,29 @@
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
@@ -1439,6 +1539,49 @@
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
@@ -1761,6 +1904,115 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.72.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.72.0.tgz",
"integrity": "sha512-4+bnUrtTDK1YD0/FCx2YtMiQH5FGu9Jlf4IQi5kcqRwRwqp2ey39V61nHNdH86jm3DIzz0aZKiWfTW8qXk1swQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.5.0.tgz",
"integrity": "sha512-SXBx6Jvp+MOBekeKFu+G11YLYPeVeGQl23eYyAG9+Ro0pQ1aIP0UZNIBxHKNHqxzR0L0n6gysNr2KT3841NATw==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/@supabase/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/@supabase/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.21.4",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.4.tgz",
"integrity": "sha512-TxZCIjxk6/dP9abAi89VQbWWMBbybpGWyvmIzTd79OeravM13OjR/YEYeyUOPcM1C3QyvXkvPZhUfItvmhY1IQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.15.5",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.5.tgz",
"integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.13",
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"ws": "^8.18.2"
}
},
"node_modules/@supabase/ssr": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz",
"integrity": "sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.43.4"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.12.2.tgz",
"integrity": "sha512-SiySHxi3q7gia7NBYpsYRu8gyI0NhFwSORMxbZIxJ/zAVkN6QpwDRan158CJ+UdzD4WB/rQMAGRqIJQP+7ccAQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.58.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.58.0.tgz",
"integrity": "sha512-Tm1RmQpoAKdQr4/8wiayGti/no+If7RtveVZjHR8zbO7hhQjmPW2Ok5ZBPf1MGkt5c+9R85AVMsTfSaqAP1sUg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@supabase/auth-js": "2.72.0",
"@supabase/functions-js": "2.5.0",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.21.4",
"@supabase/realtime-js": "2.15.5",
"@supabase/storage-js": "2.12.2"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2046,6 +2298,32 @@
"tailwindcss": "4.1.14"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -2057,6 +2335,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -2154,12 +2438,17 @@
"version": "20.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz",
"integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
@@ -2203,6 +2492,15 @@
"@types/webidl-conversions": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.45.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
@@ -2785,6 +3083,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -3067,6 +3374,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -3180,6 +3496,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3234,6 +3563,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3261,6 +3599,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3561,6 +3920,18 @@
"node": ">=0.10.0"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4372,6 +4743,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -6775,6 +7155,18 @@
"memory-pager": "^1.0.2"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -7280,7 +7672,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -7529,6 +7920,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -7539,6 +7948,48 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@@ -9,23 +9,33 @@
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.58.0",
"@tanstack/react-query": "^5.90.11",
"@types/bcryptjs": "^2.4.6",
"@types/mongodb": "^4.0.6",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"lucide-react": "^0.544.0",
"mongodb": "^6.20.0",
"next": "15.5.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"recharts": "^3.2.1",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

Binary file not shown.

Binary file not shown.

114
scripts/README.md Normal file
View File

@@ -0,0 +1,114 @@
# 📥 Import Automatique d'Utilisateurs depuis Excel
Ce script permet d'importer automatiquement des utilisateurs dans MongoDB depuis un fichier Excel.
## 🚀 Utilisation
### 1. Préparer le fichier Excel
Placer votre fichier `.xlsx` dans le dossier `public/list_users/`
**Format attendu :**
```
Nom Etudiant | Prénom Etudiant | Matricule HE | EMail Etudiant 2
```
**Exemple :**
```
Albarran Perez | Tamara | 1240935 | tamara.albarran@student.ihecs.be
Amjahed | Kawtar | 1241004 | kawtar.amjahed@student.ihecs.be
```
**Nom du fichier :** Le nom doit être le nom du référent avec underscores.
-`Emmanuel_WATHELE.xlsx`
-`Sophie_MARTIN.xlsx`
-`liste.xlsx`
### 2. Configurer le script
Ouvrir `scripts/import-users.js` et modifier ces lignes :
```javascript
const XLSX_FILENAME = "Emmanuel_WATHELE.xlsx"; // ⚠️ Nom du fichier
const DEFAULT_PASSWORD = "IHECS-2025"; // Mot de passe par défaut
const COURS = "Ouverture à l'esprit critique"; // Nom du cours
```
### 3. Lancer l'import
```bash
node scripts/import-users.js
```
## 📊 Ce que fait le script
1. ✅ Lit le fichier Excel
2. ✅ Extrait le référent depuis le nom du fichier (`Emmanuel_WATHELE``Emmanuel WATHELE`)
3. ✅ Hash le mot de passe une seule fois (optimisation)
4. ✅ Pour chaque étudiant :
- Vérifie que l'email est valide
- Vérifie que l'email n'existe pas déjà
- Crée l'utilisateur avec :
- `nom`, `prenom`, `email`
- `referent` (extrait du nom du fichier)
- `cours` (défini dans le script)
- `password` hashé
- `role: "USER"`
- Crée une balance initiale (3 000 000 tokens)
5. ✅ Affiche un résumé détaillé
## 📝 Exemple de résultat
```
🚀 Démarrage de l'import...
📋 Référent: Emmanuel WATHELE
📚 Cours: Ouverture à l'esprit critique
🔑 Password par défaut: IHECS-2025
📁 Lecture du fichier: C:\...\public\list_users\Emmanuel_WATHELE.xlsx
✅ 50 lignes détectées dans le fichier
🔐 Hash du mot de passe...
🔌 Connexion à MongoDB...
✅ Connecté à MongoDB
📥 Import en cours...
[1/50] ✅ Créé: Tamara Albarran Perez (tamara.albarran@student.ihecs.be)
[2/50] ✅ Créé: Kawtar Amjahed (kawtar.amjahed@student.ihecs.be)
[3/50] ⏭️ Ignoré (existe déjà): nezaket.arslan@student.ihecs.be
...
============================================================
📊 RÉSUMÉ DE L'IMPORT
============================================================
✅ Utilisateurs créés: 48
⏭️ Utilisateurs ignorés (déjà existants): 2
❌ Erreurs: 0
============================================================
✨ Import terminé !
```
## ⚠️ Important
- Le script **ignore** les utilisateurs déjà existants (même email)
- Le script **ignore** la colonne "Matricule HE" (non sauvegardée)
- Tous les utilisateurs auront le **même mot de passe** (ils pourront le changer après)
- Chaque utilisateur reçoit **3 000 000 tokens** par défaut
## 🔧 Dépannage
**Erreur : "Cannot find module 'xlsx'"**
```bash
npm install xlsx
```
**Erreur : "connect ECONNREFUSED"**
- Vérifier que MongoDB est démarré
- Vérifier la connexion dans `.env.local`
**Erreur : "File not found"**
- Vérifier que le fichier est bien dans `public/list_users/`
- Vérifier le nom du fichier dans le script

167
scripts/import-ihecs.js Normal file
View File

@@ -0,0 +1,167 @@
import XLSX from 'xlsx';
import { MongoClient, ObjectId } from 'mongodb';
import bcrypt from 'bcryptjs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Charger les variables d'environnement depuis .env.local
dotenv.config({ path: path.join(__dirname, '..', '.env.local') });
// Configuration
const MONGODB_URI = process.env.MONGODB_URI;
const XLSX_FILENAME = "IHECS.xlsx"; // ⚠️ Fichier pour IHECS / M2RP
const DEFAULT_PASSWORD = "IHECS-2025";
const REFERENT = "IHECS";
const COURS = "M2RP";
async function importUsers() {
console.log("🚀 Démarrage de l'import IHECS / M2RP...\n");
try {
// Vérifier que MONGODB_URI est défini
if (!MONGODB_URI) {
throw new Error("MONGODB_URI non défini dans .env.local");
}
console.log(`📋 Référent: ${REFERENT}`);
console.log(`📚 Cours: ${COURS}`);
console.log(`🔑 Password par défaut: ${DEFAULT_PASSWORD}\n`);
// Lire le fichier Excel
const filePath = path.join(__dirname, '..', 'public', 'list_users', XLSX_FILENAME);
console.log(`📁 Lecture du fichier: ${filePath}`);
const workbook = XLSX.readFile(filePath);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const data = XLSX.utils.sheet_to_json(sheet);
console.log(`${data.length} lignes détectées dans le fichier\n`);
// Hash le password une seule fois (optimisation)
console.log("🔐 Hash du mot de passe...");
const hashedPassword = await bcrypt.hash(DEFAULT_PASSWORD, 12);
// Préparer les users
const users = data.map(row => ({
nom: row["Nom de famille"],
prenom: row["Prénom"],
name: `${row["Prénom"]} ${row["Nom de famille"]}`,
email: row["Adresse de courriel"],
username: row["Nom d'utilisateur"] || row["Adresse de courriel"]?.split("@")[0],
password: hashedPassword,
emailVerified: false,
avatar: null,
provider: "local",
role: "USER",
// Nouveaux champs pour le cours et le référent
referent: REFERENT,
cours: COURS,
plugins: [],
twoFactorEnabled: false,
termsAccepted: true,
personalization: {
memories: false,
_id: new ObjectId(),
},
backupCodes: [],
refreshToken: [],
createdAt: new Date(),
updatedAt: new Date(),
__v: 0,
}));
// Connexion à MongoDB
console.log("🔌 Connexion à MongoDB...");
const client = new MongoClient(MONGODB_URI);
await client.connect();
const db = client.db('librechat');
console.log("✅ Connecté à MongoDB\n");
// Import des users
const results = { created: [], errors: [], skipped: [] };
console.log("📥 Import en cours...\n");
for (let i = 0; i < users.length; i++) {
const user = users[i];
const progress = `[${i + 1}/${users.length}]`;
try {
// Validation email
if (!user.email || !user.email.includes('@')) {
console.log(`${progress} ⚠️ Email invalide: ${user.name}`);
results.errors.push({ name: user.name, error: "Email invalide" });
continue;
}
// Vérifier si email existe déjà
const existing = await db.collection("users").findOne({ email: user.email });
if (existing) {
console.log(`${progress} ⏭️ Ignoré (existe déjà): ${user.email}`);
results.skipped.push(user.email);
continue;
}
// Insérer le user
const result = await db.collection("users").insertOne(user);
// Créer la balance initiale
await db.collection("balances").insertOne({
user: result.insertedId,
tokenCredits: 3000000,
autoRefillEnabled: false,
lastRefill: new Date(),
refillAmount: 0,
refillIntervalUnit: "month",
refillIntervalValue: 1,
__v: 0,
});
console.log(`${progress} ✅ Créé: ${user.prenom} ${user.nom} (${user.email})`);
results.created.push(user.email);
} catch (error) {
console.error(`${progress} ❌ Erreur: ${user.email} - ${error.message}`);
results.errors.push({ email: user.email, error: error.message });
}
}
// Fermer la connexion
await client.close();
// Résumé final
console.log("\n" + "=".repeat(60));
console.log("📊 RÉSUMÉ DE L'IMPORT - IHECS / M2RP");
console.log("=".repeat(60));
console.log(`✅ Utilisateurs créés: ${results.created.length}`);
console.log(`⏭️ Utilisateurs ignorés (déjà existants): ${results.skipped.length}`);
console.log(`❌ Erreurs: ${results.errors.length}`);
console.log("=".repeat(60));
if (results.errors.length > 0) {
console.log("\n⚠ DÉTAIL DES ERREURS:");
results.errors.forEach(e => {
console.log(` - ${e.email || e.name}: ${e.error}`);
});
}
console.log("\n✨ Import terminé !\n");
} catch (error) {
console.error("\n❌ ERREUR FATALE:", error.message);
console.error(error);
process.exit(1);
}
process.exit(0);
}
// Lancer l'import
importUsers();

168
scripts/import-users.js Normal file
View File

@@ -0,0 +1,168 @@
import XLSX from 'xlsx';
import { MongoClient, ObjectId } from 'mongodb';
import bcrypt from 'bcryptjs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Charger les variables d'environnement depuis .env.local
dotenv.config({ path: path.join(__dirname, '..', '.env.local') });
// Configuration
const MONGODB_URI = process.env.MONGODB_URI;
const XLSX_FILENAME = "Emmanuel_WATHELE.xlsx"; // ⚠️ À modifier selon le fichier
const DEFAULT_PASSWORD = "IHECS-2025";
const COURS = "Ouverture à l'esprit critique";
async function importUsers() {
console.log("🚀 Démarrage de l'import...\n");
try {
// Vérifier que MONGODB_URI est défini
if (!MONGODB_URI) {
throw new Error("MONGODB_URI non défini dans .env.local");
}
// 1. Extraire le référent depuis le nom du fichier
const referent = XLSX_FILENAME.replace('.xlsx', '').replace(/_/g, ' ');
console.log(`📋 Référent: ${referent}`);
console.log(`📚 Cours: ${COURS}`);
console.log(`🔑 Password par défaut: ${DEFAULT_PASSWORD}\n`);
// 2. Lire le fichier Excel
const filePath = path.join(__dirname, '..', 'public', 'list_users', XLSX_FILENAME);
console.log(`📁 Lecture du fichier: ${filePath}`);
const workbook = XLSX.readFile(filePath);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const data = XLSX.utils.sheet_to_json(sheet);
console.log(`${data.length} lignes détectées dans le fichier\n`);
// 3. Hash le password une seule fois (optimisation)
console.log("🔐 Hash du mot de passe...");
const hashedPassword = await bcrypt.hash(DEFAULT_PASSWORD, 12);
// 4. Préparer les users
const users = data.map(row => ({
nom: row["Nom Etudiant"],
prenom: row["Prénom Etudiant"],
name: `${row["Prénom Etudiant"]} ${row["Nom Etudiant"]}`,
email: row["EMail Etudiant 2"],
username: row["EMail Etudiant 2"]?.split("@")[0],
password: hashedPassword,
emailVerified: false,
avatar: null,
provider: "local",
role: "USER",
// Nouveaux champs pour le cours et le référent
referent: referent,
cours: COURS,
plugins: [],
twoFactorEnabled: false,
termsAccepted: true,
personalization: {
memories: false,
_id: new ObjectId(),
},
backupCodes: [],
refreshToken: [],
createdAt: new Date(),
updatedAt: new Date(),
__v: 0,
}));
// 5. Connexion à MongoDB
console.log("🔌 Connexion à MongoDB...");
const client = new MongoClient(MONGODB_URI);
await client.connect();
const db = client.db('librechat');
console.log("✅ Connecté à MongoDB\n");
// 6. Import des users
const results = { created: [], errors: [], skipped: [] };
console.log("📥 Import en cours...\n");
for (let i = 0; i < users.length; i++) {
const user = users[i];
const progress = `[${i + 1}/${users.length}]`;
try {
// Validation email
if (!user.email || !user.email.includes('@')) {
console.log(`${progress} ⚠️ Email invalide: ${user.name}`);
results.errors.push({ name: user.name, error: "Email invalide" });
continue;
}
// Vérifier si email existe déjà
const existing = await db.collection("users").findOne({ email: user.email });
if (existing) {
console.log(`${progress} ⏭️ Ignoré (existe déjà): ${user.email}`);
results.skipped.push(user.email);
continue;
}
// Insérer le user
const result = await db.collection("users").insertOne(user);
// Créer la balance initiale
await db.collection("balances").insertOne({
user: result.insertedId,
tokenCredits: 3000000,
autoRefillEnabled: false,
lastRefill: new Date(),
refillAmount: 0,
refillIntervalUnit: "month",
refillIntervalValue: 1,
__v: 0,
});
console.log(`${progress} ✅ Créé: ${user.prenom} ${user.nom} (${user.email})`);
results.created.push(user.email);
} catch (error) {
console.error(`${progress} ❌ Erreur: ${user.email} - ${error.message}`);
results.errors.push({ email: user.email, error: error.message });
}
}
// 7. Fermer la connexion
await client.close();
// 8. Résumé final
console.log("\n" + "=".repeat(60));
console.log("📊 RÉSUMÉ DE L'IMPORT");
console.log("=".repeat(60));
console.log(`✅ Utilisateurs créés: ${results.created.length}`);
console.log(`⏭️ Utilisateurs ignorés (déjà existants): ${results.skipped.length}`);
console.log(`❌ Erreurs: ${results.errors.length}`);
console.log("=".repeat(60));
if (results.errors.length > 0) {
console.log("\n⚠ DÉTAIL DES ERREURS:");
results.errors.forEach(e => {
console.log(` - ${e.email || e.name}: ${e.error}`);
});
}
console.log("\n✨ Import terminé !\n");
} catch (error) {
console.error("\n❌ ERREUR FATALE:", error.message);
console.error(error);
process.exit(1);
}
process.exit(0);
}
// Lancer l'import
importUsers();