Compare commits
9 Commits
0d95eca1ee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a590d60c3 | ||
|
|
a34d29bf1e | ||
|
|
414253aff0 | ||
|
|
e6a9d41ebd | ||
|
|
73f97919ac | ||
|
|
5b8b3c84c9 | ||
|
|
b552504723 | ||
|
|
b1881f06ef | ||
|
|
ad575641a1 |
@@ -7,7 +7,13 @@
|
|||||||
"Bash(npm run build:*)",
|
"Bash(npm run build:*)",
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(git push)"
|
"Bash(git push)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(node debug-search.js:*)",
|
||||||
|
"Bash(node update-referent.js:*)",
|
||||||
|
"Bash(node:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(npx tsc:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
117
app/api/add-credits-single/route.ts
Normal file
117
app/api/add-credits-single/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,33 +51,86 @@ export async function GET(
|
|||||||
const limit = parseInt(searchParams.get("limit") || "20");
|
const limit = parseInt(searchParams.get("limit") || "20");
|
||||||
const filter = JSON.parse(searchParams.get("filter") || "{}");
|
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
|
// Gestion spéciale pour la collection users avec recherche par email ou id
|
||||||
if (collection === "users") {
|
if (collection === "users") {
|
||||||
const email = searchParams.get("email");
|
const email = searchParams.get("email");
|
||||||
const id = searchParams.get("id");
|
const id = searchParams.get("id");
|
||||||
const search = searchParams.get("search"); // ✅ AJOUTER cette ligne
|
const search = searchParams.get("search");
|
||||||
|
const referent = searchParams.get("referent");
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
filter.email = email.toLowerCase();
|
filter.email = email.toLowerCase();
|
||||||
} else if (id) {
|
} else if (id) {
|
||||||
// Vérifier si l'ID est un ObjectId valide
|
|
||||||
if (ObjectId.isValid(id)) {
|
if (ObjectId.isValid(id)) {
|
||||||
filter._id = new ObjectId(id);
|
filter._id = new ObjectId(id);
|
||||||
} else {
|
} else {
|
||||||
// Si l'ID n'est pas valide, retourner une erreur
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "ID utilisateur invalide" },
|
{ error: "ID utilisateur invalide" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (search) {
|
} else if (search) {
|
||||||
// ✅ AJOUTER ce bloc
|
|
||||||
// Recherche partielle sur nom et email
|
|
||||||
filter.$or = [
|
filter.$or = [
|
||||||
{ name: { $regex: search, $options: "i" } },
|
{ name: { $regex: search, $options: "i" } },
|
||||||
{ email: { $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 db = await getDatabase();
|
||||||
@@ -94,6 +147,11 @@ export async function GET(
|
|||||||
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({
|
return NextResponse.json({
|
||||||
data,
|
data,
|
||||||
total,
|
total,
|
||||||
|
|||||||
32
app/api/debug-users/route.ts
Normal file
32
app/api/debug-users/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export default function ConversationsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Conversations</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Conversations</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Gestion des conversations Cercle GPTT
|
Gestion des conversations Cercle GPT
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
import { QueryProvider } from "@/components/providers/query-provider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -28,12 +29,14 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<div className="flex h-screen">
|
<QueryProvider>
|
||||||
<Sidebar />
|
<div className="flex h-screen">
|
||||||
<main className="flex-1 overflow-auto">
|
<Sidebar />
|
||||||
<div className="container mx-auto p-6">{children}</div>
|
<main className="flex-1 overflow-auto">
|
||||||
</main>
|
<div className="container mx-auto p-6">{children}</div>
|
||||||
</div>
|
</main>
|
||||||
|
</div>
|
||||||
|
</QueryProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
194
app/referents/[referent]/[cours]/page.tsx
Normal file
194
app/referents/[referent]/[cours]/page.tsx
Normal 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
181
app/referents/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Database, Server, Settings } from "lucide-react";
|
import { Database, Server, Settings } from "lucide-react";
|
||||||
|
|
||||||
import AddCredits from "@/components/dashboard/add-credits";
|
import AddCredits from "@/components/dashboard/add-credits";
|
||||||
|
import AddCreditsSingleUser from "@/components/dashboard/add-credits-single-user";
|
||||||
import UserManagement from "@/components/dashboard/user-management";
|
import UserManagement from "@/components/dashboard/user-management";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -79,8 +80,11 @@ export default function SettingsPage() {
|
|||||||
Gestion des Crédits
|
Gestion des Crédits
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
<AddCredits />
|
<AddCreditsSingleUser />
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<AddCredits />
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function UsersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Utilisateurs</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Utilisateurs</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Gestion des utilisateurs Cercle GPTT
|
Gestion des utilisateurs Cercle GPT
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "react";
|
||||||
import { useCollection } from "@/hooks/useCollection";
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Users,
|
ChevronDown,
|
||||||
|
Search,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Calendar,
|
|
||||||
X,
|
X,
|
||||||
User,
|
User,
|
||||||
Bot,
|
Bot,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -30,529 +24,465 @@ import {
|
|||||||
LibreChatMessage,
|
LibreChatMessage,
|
||||||
} from "@/lib/types";
|
} 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
|
// Types pour les messages étendus
|
||||||
interface ExtendedMessage extends LibreChatMessage {
|
interface ExtendedMessage extends LibreChatMessage {
|
||||||
content?: Array<{ type: string; text: string }> | string;
|
content?: Array<{ type: string; text: string }> | string;
|
||||||
message?: Record<string, unknown>;
|
|
||||||
parts?: Array<string | { text: string }>;
|
parts?: Array<string | { text: string }>;
|
||||||
metadata?: { text?: string };
|
|
||||||
[key: string]: unknown;
|
[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() {
|
export function ConversationsTable() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [selectedConversationId, setSelectedConversationId] = useState<
|
const [searchInput, setSearchInput] = useState("");
|
||||||
string | null
|
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set());
|
||||||
>(null);
|
const [expandedConversation, setExpandedConversation] = useState<string | null>(null);
|
||||||
const [selectedUserId, setSelectedUserId] = 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 {
|
const {
|
||||||
data: conversations = [],
|
data: conversationsData,
|
||||||
total = 0,
|
isLoading: conversationsLoading,
|
||||||
loading,
|
isFetching: conversationsFetching,
|
||||||
} = useCollection<LibreChatConversation>("conversations", {
|
} = useQuery({
|
||||||
limit: 1000,
|
queryKey: ["conversations", debouncedSearch],
|
||||||
page: 1, // Remplacer skip par page
|
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", {
|
// Query users (cache long car ça change rarement)
|
||||||
limit: 1000,
|
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
|
// Query messages de la conversation sélectionnée
|
||||||
const { data: messages = [] } = useCollection<LibreChatMessage>("messages", {
|
const { data: messagesData, isLoading: messagesLoading } = useQuery({
|
||||||
limit: 1000,
|
queryKey: ["messages", expandedConversation],
|
||||||
filter: selectedConversationId
|
queryFn: () =>
|
||||||
? { conversationId: selectedConversationId }
|
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 => {
|
// Map des users pour lookup rapide
|
||||||
if (userId === "unknown") return "Utilisateur inconnu";
|
const userMap = useMemo(() => new Map(users.map((u) => [u._id, u])), [users]);
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grouper les conversations par utilisateur
|
// Grouper les conversations par utilisateur
|
||||||
const groupedConversations = conversations.reduce((acc, conversation) => {
|
const groupedByUser = useMemo((): UserGroup[] => {
|
||||||
const userId = conversation.user || "unknown";
|
const groups: Record<string, LibreChatConversation[]> = {};
|
||||||
if (!acc[userId]) {
|
conversations.forEach((conv) => {
|
||||||
acc[userId] = [];
|
const uId = String(conv.user);
|
||||||
}
|
if (!groups[uId]) groups[uId] = [];
|
||||||
acc[userId].push(conversation);
|
groups[uId].push(conv);
|
||||||
return acc;
|
});
|
||||||
}, {} as Record<string, LibreChatConversation[]>);
|
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
|
// Pagination sur les groupes d'utilisateurs
|
||||||
const totalPages = Math.ceil(
|
const totalUserGroups = groupedByUser.length;
|
||||||
Object.keys(groupedConversations).length / limit
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Users className="h-5 w-5" />
|
<CardTitle className="flex items-center gap-2">
|
||||||
Conversations par utilisateur
|
<MessageSquare className="h-5 w-5" />
|
||||||
</CardTitle>
|
Conversations
|
||||||
<p className="text-sm text-muted-foreground">
|
<Badge variant="secondary" className="ml-2">
|
||||||
{Object.keys(groupedConversations).length} utilisateurs •{" "}
|
{total}
|
||||||
{conversations.length} conversations au total
|
</Badge>
|
||||||
</p>
|
{conversationsFetching && !conversationsLoading && (
|
||||||
</CardHeader>
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
<CardContent>
|
)}
|
||||||
<div className="space-y-6">
|
</CardTitle>
|
||||||
{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 (
|
{/* Barre de recherche */}
|
||||||
<div key={userId} className="border rounded-lg p-4">
|
<div className="relative w-full sm:w-96">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<div className="flex items-center gap-2">
|
<Input
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
ref={searchInputRef}
|
||||||
{userId === "unknown" ? "unknown" : userId.slice(-8)}
|
placeholder="Rechercher par nom ou email..."
|
||||||
</Badge>
|
value={searchInput}
|
||||||
<div className="flex flex-col">
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
<span className="font-semibold">{userName}</span>
|
className="pl-10 pr-10"
|
||||||
{userEmail && (
|
/>
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
{userEmail}
|
{isSearching ? (
|
||||||
</span>
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
)}
|
) : searchInput ? (
|
||||||
</div>
|
<button
|
||||||
<Badge variant="secondary">
|
onClick={clearSearch}
|
||||||
{conversations.length} conversation
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
{conversations.length > 1 ? "s" : ""}
|
type="button"
|
||||||
</Badge>
|
>
|
||||||
{activeConversations > 0 && (
|
<X className="h-4 w-4" />
|
||||||
<Badge variant="default" className="text-xs">
|
</button>
|
||||||
{activeConversations} actives
|
) : null}
|
||||||
</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}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
Fermer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Conversation ID: {selectedConversationId}
|
|
||||||
</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
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
messages
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(a.createdAt).getTime() -
|
|
||||||
new Date(b.createdAt).getTime()
|
|
||||||
)
|
|
||||||
.map((message) => {
|
|
||||||
const content = getMessageContent(message);
|
|
||||||
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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{message.isCreatedByUser ? (
|
|
||||||
<User className="h-5 w-5 text-blue-600" />
|
|
||||||
) : (
|
|
||||||
<Bot className="h-5 w-5 text-gray-600" />
|
|
||||||
)}
|
|
||||||
</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)}
|
|
||||||
</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">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
{message.error && (
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Erreur
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between mt-6">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Page {page} sur {totalPages} • {total} conversations au total
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage(page - 1)}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
Précédent
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page === totalPages}
|
|
||||||
>
|
|
||||||
Suivant
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{debouncedSearch && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{total} résultat{total > 1 ? "s" : ""} pour "{debouncedSearch}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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 "{debouncedSearch}"</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>
|
||||||
|
) : (
|
||||||
|
<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((msg) => {
|
||||||
|
const content = getMessageContent(msg);
|
||||||
|
const isUser = msg.isCreatedByUser;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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 mt-0.5">
|
||||||
|
{isUser ? (
|
||||||
|
<User className="h-4 w-4 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<Bot className="h-4 w-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm whitespace-pre-wrap break-words">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Page {page} / {totalPages} ({totalUserGroups} utilisateurs)
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Préc.
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
Suiv.
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -14,20 +14,28 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { LibreChatUser, LibreChatBalance } from "@/lib/types";
|
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() {
|
export function UsersTable() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [searchInput, setSearchInput] = useState(""); // Ce que l'utilisateur tape
|
const [searchInput, setSearchInput] = useState(""); // Ce que l'utilisateur tape
|
||||||
const [activeSearch, setActiveSearch] = useState(""); // Ce qui est réellement recherché
|
const [activeSearch, setActiveSearch] = useState(""); // Ce qui est réellement recherché
|
||||||
|
const [activeReferent, setActiveReferent] = useState<string | undefined>(undefined);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
|
|
||||||
// Réinitialiser la page à 1 quand une nouvelle recherche est lancée
|
// Réinitialiser la page à 1 quand une nouvelle recherche ou filtre est lancé
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [activeSearch]);
|
}, [activeSearch, activeReferent]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: users = [],
|
data: users = [],
|
||||||
@@ -37,8 +45,21 @@ export function UsersTable() {
|
|||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
search: activeSearch,
|
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
|
// Fonction pour lancer la recherche
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
setActiveSearch(searchInput);
|
setActiveSearch(searchInput);
|
||||||
@@ -99,7 +120,22 @@ export function UsersTable() {
|
|||||||
<CardTitle>
|
<CardTitle>
|
||||||
Liste des utilisateurs ({total})
|
Liste des utilisateurs ({total})
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex gap-2 w-full sm:w-auto">
|
<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">
|
<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" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -121,22 +157,20 @@ export function UsersTable() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead className="w-20">ID</TableHead>
|
||||||
<TableHead>Nom</TableHead>
|
<TableHead>Nom</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Rôle</TableHead>
|
<TableHead className="w-32">Référent</TableHead>
|
||||||
<TableHead>Crédits</TableHead>
|
<TableHead className="w-20">Rôle</TableHead>
|
||||||
<TableHead>Statut</TableHead>
|
<TableHead className="w-32">Crédits</TableHead>
|
||||||
<TableHead>Créé le</TableHead>
|
<TableHead className="w-28">Créé le</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.length > 0 ? (
|
{users.length > 0 ? (
|
||||||
users.map((user) => {
|
users.map((user) => {
|
||||||
const userCredits = creditsMap.get(user._id) || 0;
|
const userCredits = creditsMap.get(user._id) || 0;
|
||||||
const isActive =
|
const referentColor = user.referent ? (REFERENT_COLORS[user.referent] || "#6B7280") : null;
|
||||||
new Date(user.updatedAt || user.createdAt) >
|
|
||||||
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={user._id}>
|
<TableRow key={user._id}>
|
||||||
@@ -146,11 +180,37 @@ export function UsersTable() {
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="font-medium">{user.name}</span>
|
<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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm">{user.email}</span>
|
<span className="text-sm">{user.email}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{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>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
@@ -165,13 +225,6 @@ export function UsersTable() {
|
|||||||
{userCredits.toLocaleString()} crédits
|
{userCredits.toLocaleString()} crédits
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
<Badge variant={isActive ? "default" : "secondary"}>
|
|
||||||
{isActive ? "Actif" : "Inactif"}
|
|
||||||
</Badge>
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{formatDate(user.createdAt)}
|
{formatDate(user.createdAt)}
|
||||||
|
|||||||
274
components/dashboard/add-credits-single-user.tsx
Normal file
274
components/dashboard/add-credits-single-user.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -118,7 +118,8 @@ export default function CreateUser() {
|
|||||||
} else {
|
} else {
|
||||||
setResult({
|
setResult({
|
||||||
success: false,
|
success: false,
|
||||||
message: data.error || "Erreur lors de la création de l'utilisateur",
|
message:
|
||||||
|
data.error || "Erreur lors de la création de l'utilisateur",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -185,7 +186,9 @@ export default function CreateUser() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirmPassword">Confirmer le mot de passe *</Label>
|
<Label htmlFor="confirmPassword">
|
||||||
|
Confirmer le mot de passe *
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -204,7 +207,9 @@ export default function CreateUser() {
|
|||||||
<Label htmlFor="role">Rôle</Label>
|
<Label htmlFor="role">Rôle</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onValueChange={(value: string) => handleInputChange("role", value)}
|
onValueChange={(value: string) =>
|
||||||
|
handleInputChange("role", value)
|
||||||
|
}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -229,13 +234,10 @@ export default function CreateUser() {
|
|||||||
{result.success && result.user && (
|
{result.success && result.user && (
|
||||||
<div className="mt-2 text-sm">
|
<div className="mt-2 text-sm">
|
||||||
<strong>Détails:</strong>
|
<strong>Détails:</strong>
|
||||||
<br />
|
<br />• ID: {result.user.id}
|
||||||
• ID: {result.user.id}
|
<br />• Email: {result.user.email}
|
||||||
<br />
|
<br />• Rôle: {result.user.role}
|
||||||
• Email: {result.user.email}
|
<br />• Crédits initiaux: 3,000,000 tokens
|
||||||
<br />
|
|
||||||
• Rôle: {result.user.role}
|
|
||||||
<br />• Crédits initiaux: 5,000,000 tokens
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
@@ -262,7 +264,9 @@ export default function CreateUser() {
|
|||||||
<div className="mt-6 p-4 bg-muted rounded-lg">
|
<div className="mt-6 p-4 bg-muted rounded-lg">
|
||||||
<h4 className="font-medium mb-2">Informations importantes :</h4>
|
<h4 className="font-medium mb-2">Informations importantes :</h4>
|
||||||
<ul className="text-sm text-muted-foreground space-y-1">
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
<li>• L'utilisateur recevra automatiquement 5,000,000 tokens</li>
|
<li>
|
||||||
|
• L'utilisateur recevra automatiquement 5,000,000 tokens
|
||||||
|
</li>
|
||||||
<li>• Le mot de passe sera hashé de manière sécurisée</li>
|
<li>• Le mot de passe sera hashé de manière sécurisée</li>
|
||||||
<li>• L'email doit être unique dans le système</li>
|
<li>• L'email doit être unique dans le système</li>
|
||||||
<li>• L'utilisateur pourra se connecter immédiatement</li>
|
<li>• L'utilisateur pourra se connecter immédiatement</li>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
LibreChatUser,
|
LibreChatUser,
|
||||||
LibreChatConversation,
|
LibreChatConversation,
|
||||||
LibreChatBalance,
|
LibreChatBalance,
|
||||||
LibreChatMessage,
|
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
interface DashboardUser {
|
interface DashboardUser {
|
||||||
@@ -22,30 +21,15 @@ interface DashboardUser {
|
|||||||
credits: number;
|
credits: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interfaces pour les collections tokens et toolcalls
|
// Interface pour les transactions (vraie source de consommation)
|
||||||
interface TokenDocument {
|
interface TransactionDocument {
|
||||||
_id: string;
|
_id: string;
|
||||||
user?: string;
|
user: unknown; // ObjectId dans MongoDB
|
||||||
userId?: string;
|
rawAmount?: number;
|
||||||
amount?: number;
|
tokenType?: string;
|
||||||
tokens?: number;
|
model?: string;
|
||||||
count?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolcallDocument {
|
|
||||||
_id: string;
|
|
||||||
user?: string;
|
|
||||||
userId?: string;
|
|
||||||
tokens?: number;
|
|
||||||
tokenCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidDate = (value: unknown): value is string | number | Date => {
|
|
||||||
if (!value) return false;
|
|
||||||
const date = new Date(value as string | number | Date);
|
|
||||||
return !isNaN(date.getTime());
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DashboardUsersList() {
|
export function DashboardUsersList() {
|
||||||
const [topUsers, setTopUsers] = useState<DashboardUser[]>([]);
|
const [topUsers, setTopUsers] = useState<DashboardUser[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -57,20 +41,12 @@ export function DashboardUsersList() {
|
|||||||
useCollection<LibreChatConversation>("conversations", { limit: 1000 });
|
useCollection<LibreChatConversation>("conversations", { limit: 1000 });
|
||||||
const { data: balances, loading: balancesLoading } =
|
const { data: balances, loading: balancesLoading } =
|
||||||
useCollection<LibreChatBalance>("balances", { limit: 1000 });
|
useCollection<LibreChatBalance>("balances", { limit: 1000 });
|
||||||
const { data: messages, loading: messagesLoading } =
|
// Transactions = vraie source de consommation de tokens
|
||||||
useCollection<LibreChatMessage>("messages", { limit: 1000 });
|
const { data: transactions, loading: transactionsLoading } =
|
||||||
const { data: tokens, loading: tokensLoading } =
|
useCollection<TransactionDocument>("transactions", { limit: 10000 });
|
||||||
useCollection<TokenDocument>("tokens", { limit: 1000 });
|
|
||||||
const { data: toolcalls, loading: toolcallsLoading } =
|
|
||||||
useCollection<ToolcallDocument>("toolcalls", { limit: 1000 });
|
|
||||||
|
|
||||||
const processUsers = useCallback(() => {
|
const processUsers = useCallback(() => {
|
||||||
if (
|
if (!users?.length || !conversations?.length || !balances?.length) {
|
||||||
!users?.length ||
|
|
||||||
!conversations?.length ||
|
|
||||||
!balances?.length ||
|
|
||||||
!messages?.length
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,153 +54,45 @@ export function DashboardUsersList() {
|
|||||||
console.log("Users:", users.length);
|
console.log("Users:", users.length);
|
||||||
console.log("Conversations:", conversations.length);
|
console.log("Conversations:", conversations.length);
|
||||||
console.log("Balances:", balances.length);
|
console.log("Balances:", balances.length);
|
||||||
console.log("Messages:", messages.length);
|
console.log("Transactions:", transactions?.length || 0);
|
||||||
console.log("Tokens collection:", tokens?.length || 0);
|
|
||||||
console.log("Toolcalls collection:", toolcalls?.length || 0);
|
|
||||||
|
|
||||||
const processedUsers: DashboardUser[] = [];
|
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) => {
|
users.forEach((user: LibreChatUser) => {
|
||||||
// Obtenir les conversations de l'utilisateur
|
const userId = user._id;
|
||||||
|
|
||||||
|
// Obtenir les conversations de l'utilisateur (user dans conversations est un string)
|
||||||
const userConversations = conversations.filter(
|
const userConversations = conversations.filter(
|
||||||
(conv: LibreChatConversation) => conv.user === user._id
|
(conv: LibreChatConversation) => String(conv.user) === userId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Obtenir les messages de l'utilisateur
|
// Obtenir les tokens depuis les transactions (vraie consommation)
|
||||||
const userMessages = messages.filter(
|
const tokensFromTransactions = tokensByUser.get(userId) || 0;
|
||||||
(msg: LibreChatMessage) => msg.user === user._id
|
|
||||||
|
// Obtenir le balance de l'utilisateur
|
||||||
|
const userBalance = balances.find(
|
||||||
|
(balance: LibreChatBalance) => String(balance.user) === userId
|
||||||
);
|
);
|
||||||
|
const credits = userBalance?.tokenCredits || 0;
|
||||||
|
|
||||||
// Calculer les tokens depuis les messages
|
// Ajouter l'utilisateur s'il a consommé des tokens
|
||||||
const totalTokensFromMessages = userMessages.reduce(
|
if (tokensFromTransactions > 0) {
|
||||||
(sum: number, msg: LibreChatMessage) => sum + (msg.tokenCount || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculer les tokens depuis les conversations de l'utilisateur
|
|
||||||
const userConversationIds = userConversations.map(
|
|
||||||
(conv) => conv.conversationId
|
|
||||||
);
|
|
||||||
const conversationMessages = messages.filter((msg: LibreChatMessage) =>
|
|
||||||
userConversationIds.includes(msg.conversationId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalTokensFromConversations = conversationMessages.reduce(
|
|
||||||
(sum: number, msg: LibreChatMessage) => sum + (msg.tokenCount || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Vérifier les collections tokens et toolcalls
|
|
||||||
let tokensFromTokensCollection = 0;
|
|
||||||
let tokensFromToolcalls = 0;
|
|
||||||
|
|
||||||
if (tokens?.length) {
|
|
||||||
const userTokens = tokens.filter(
|
|
||||||
(token: TokenDocument) =>
|
|
||||||
token.user === user._id || token.userId === user._id
|
|
||||||
);
|
|
||||||
tokensFromTokensCollection = userTokens.reduce(
|
|
||||||
(sum: number, token: TokenDocument) => {
|
|
||||||
return sum + (token.amount || token.tokens || token.count || 0);
|
|
||||||
},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toolcalls?.length) {
|
|
||||||
const userToolcalls = toolcalls.filter(
|
|
||||||
(toolcall: ToolcallDocument) =>
|
|
||||||
toolcall.user === user._id || toolcall.userId === user._id
|
|
||||||
);
|
|
||||||
tokensFromToolcalls = userToolcalls.reduce(
|
|
||||||
(sum: number, toolcall: ToolcallDocument) => {
|
|
||||||
return sum + (toolcall.tokens || toolcall.tokenCount || 0);
|
|
||||||
},
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Obtenir les balances de l'utilisateur
|
|
||||||
const userBalances = balances.filter(
|
|
||||||
(balance: LibreChatBalance) => balance.user === user._id
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trier par date de mise à jour (plus récent en premier)
|
|
||||||
const sortedBalances = userBalances.sort((a, b) => {
|
|
||||||
const dateA = a.updatedAt || a.createdAt;
|
|
||||||
const dateB = b.updatedAt || b.createdAt;
|
|
||||||
|
|
||||||
// Vérifier que les dates sont valides avant de les comparer
|
|
||||||
if (isValidDate(dateA) && isValidDate(dateB)) {
|
|
||||||
return (
|
|
||||||
new Date(dateB as string | number | Date).getTime() -
|
|
||||||
new Date(dateA as string | number | Date).getTime()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si seulement une date existe et est valide, la privilégier
|
|
||||||
if (isValidDate(dateA) && !isValidDate(dateB)) return -1;
|
|
||||||
if (!isValidDate(dateA) && isValidDate(dateB)) return 1;
|
|
||||||
|
|
||||||
// Si aucune date n'existe ou n'est valide, garder l'ordre actuel
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prendre la balance la plus récente
|
|
||||||
const latestBalance = sortedBalances[0];
|
|
||||||
const credits = latestBalance ? latestBalance.tokenCredits || 0 : 0;
|
|
||||||
|
|
||||||
// Calculer les tokens réellement consommés depuis les messages (approche principale)
|
|
||||||
const totalTokens = Math.max(
|
|
||||||
totalTokensFromMessages,
|
|
||||||
totalTokensFromConversations,
|
|
||||||
tokensFromTokensCollection,
|
|
||||||
tokensFromToolcalls
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculer les tokens depuis les crédits seulement si on n'a pas de données de messages
|
|
||||||
const INITIAL_CREDITS = 3000000;
|
|
||||||
const creditsUsed = INITIAL_CREDITS - credits;
|
|
||||||
const tokensFromCredits = creditsUsed > 0 ? creditsUsed : 0;
|
|
||||||
|
|
||||||
// Si on n'a pas de tokens depuis les messages mais qu'on a une consommation de crédits significative
|
|
||||||
const finalTokens = totalTokens > 0 ? totalTokens :
|
|
||||||
(tokensFromCredits > 0 && tokensFromCredits < INITIAL_CREDITS) ? tokensFromCredits : 0;
|
|
||||||
|
|
||||||
// Log de débogage très détaillé
|
|
||||||
console.log(`👤 User ${user.name || user.email}:`, {
|
|
||||||
conversations: userConversations.length,
|
|
||||||
userMessages: userMessages.length,
|
|
||||||
conversationMessages: conversationMessages.length,
|
|
||||||
tokensFromMessages: totalTokensFromMessages,
|
|
||||||
tokensFromConversations: totalTokensFromConversations,
|
|
||||||
tokensFromTokensCollection: tokensFromTokensCollection,
|
|
||||||
tokensFromToolcalls: tokensFromToolcalls,
|
|
||||||
currentCredits: credits,
|
|
||||||
creditsUsed: creditsUsed,
|
|
||||||
tokensFromCredits: tokensFromCredits,
|
|
||||||
finalTokens: finalTokens,
|
|
||||||
willBeIncluded: finalTokens > 0,
|
|
||||||
messagesSample: userMessages.slice(0, 2).map((m) => ({
|
|
||||||
tokenCount: m.tokenCount,
|
|
||||||
model: m.model,
|
|
||||||
isCreatedByUser: m.isCreatedByUser,
|
|
||||||
conversationId: m.conversationId,
|
|
||||||
})),
|
|
||||||
conversationsSample: userConversations.slice(0, 2).map((c) => ({
|
|
||||||
conversationId: c.conversationId,
|
|
||||||
messagesCount: c.messages?.length || 0,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ajouter l'utilisateur s'il a consommé des tokens (éviter les faux positifs de 3M tokens)
|
|
||||||
if (finalTokens > 0 && finalTokens < INITIAL_CREDITS) {
|
|
||||||
processedUsers.push({
|
processedUsers.push({
|
||||||
userId: user._id,
|
userId: userId,
|
||||||
userName:
|
userName: user.name || user.username || user.email || "Utilisateur inconnu",
|
||||||
user.name || user.username || user.email || "Utilisateur inconnu",
|
|
||||||
conversations: userConversations.length,
|
conversations: userConversations.length,
|
||||||
tokens: finalTokens,
|
tokens: tokensFromTransactions,
|
||||||
credits: credits,
|
credits: credits,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -239,26 +107,18 @@ export function DashboardUsersList() {
|
|||||||
totalUsers: users.length,
|
totalUsers: users.length,
|
||||||
usersWithActivity: processedUsers.length,
|
usersWithActivity: processedUsers.length,
|
||||||
top5Users: sortedUsers.length,
|
top5Users: sortedUsers.length,
|
||||||
allProcessedUsers: processedUsers.map(u => ({
|
|
||||||
name: u.userName,
|
|
||||||
conversations: u.conversations,
|
|
||||||
tokens: u.tokens,
|
|
||||||
credits: u.credits
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
console.log("✅ Top 5 users processed:", sortedUsers);
|
console.log("✅ Top 5 users:", sortedUsers);
|
||||||
setTopUsers(sortedUsers);
|
setTopUsers(sortedUsers);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [users, conversations, balances, messages, tokens, toolcalls]);
|
}, [users, conversations, balances, transactions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const allDataLoaded =
|
const allDataLoaded =
|
||||||
!usersLoading &&
|
!usersLoading &&
|
||||||
!conversationsLoading &&
|
!conversationsLoading &&
|
||||||
!balancesLoading &&
|
!balancesLoading &&
|
||||||
!messagesLoading &&
|
!transactionsLoading;
|
||||||
!tokensLoading &&
|
|
||||||
!toolcallsLoading;
|
|
||||||
|
|
||||||
if (allDataLoaded) {
|
if (allDataLoaded) {
|
||||||
processUsers();
|
processUsers();
|
||||||
@@ -269,9 +129,7 @@ export function DashboardUsersList() {
|
|||||||
usersLoading,
|
usersLoading,
|
||||||
conversationsLoading,
|
conversationsLoading,
|
||||||
balancesLoading,
|
balancesLoading,
|
||||||
messagesLoading,
|
transactionsLoading,
|
||||||
tokensLoading,
|
|
||||||
toolcallsLoading,
|
|
||||||
processUsers,
|
processUsers,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function UsageAnalytics() {
|
|||||||
|
|
||||||
const { data: users = [] } = useCollection<LibreChatUser>("users", { limit: 1000 });
|
const { data: users = [] } = useCollection<LibreChatUser>("users", { limit: 1000 });
|
||||||
const { data: conversations = [] } = useCollection<LibreChatConversation>("conversations", { 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 { data: balances = [] } = useCollection<LibreChatBalance>("balances", { limit: 1000 });
|
||||||
|
|
||||||
const calculateStats = useCallback(() => {
|
const calculateStats = useCallback(() => {
|
||||||
@@ -56,7 +56,7 @@ export function UsageAnalytics() {
|
|||||||
// Analyser les doublons dans les balances
|
// Analyser les doublons dans les balances
|
||||||
const userCounts = new Map<string, number>();
|
const userCounts = new Map<string, number>();
|
||||||
balances.forEach((balance) => {
|
balances.forEach((balance) => {
|
||||||
const userId = balance.user;
|
const userId = String(balance.user);
|
||||||
userCounts.set(userId, (userCounts.get(userId) || 0) + 1);
|
userCounts.set(userId, (userCounts.get(userId) || 0) + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export function UsageAnalytics() {
|
|||||||
const duplicateDetails = Array.from(userCounts.entries())
|
const duplicateDetails = Array.from(userCounts.entries())
|
||||||
.filter(([, count]) => count > 1)
|
.filter(([, count]) => count > 1)
|
||||||
.map(([userId, count]) => {
|
.map(([userId, count]) => {
|
||||||
const userBalances = balances.filter(b => b.user === userId);
|
const userBalances = balances.filter(b => String(b.user) === userId);
|
||||||
const totalCredits = userBalances.reduce((sum, b) => sum + (b.tokenCredits || 0), 0);
|
const totalCredits = userBalances.reduce((sum, b) => sum + (b.tokenCredits || 0), 0);
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
@@ -96,7 +96,7 @@ export function UsageAnalytics() {
|
|||||||
// NOUVEAU : Identifier les utilisateurs fantômes
|
// NOUVEAU : Identifier les utilisateurs fantômes
|
||||||
console.log("=== ANALYSE DES UTILISATEURS FANTÔMES ===");
|
console.log("=== ANALYSE DES UTILISATEURS FANTÔMES ===");
|
||||||
const userIds = new Set(users.map(user => user._id));
|
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 phantomUsers = balanceUserIds.filter(userId => !userIds.has(userId));
|
||||||
const uniquePhantomUsers = [...new Set(phantomUsers)];
|
const uniquePhantomUsers = [...new Set(phantomUsers)];
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ export function UsageAnalytics() {
|
|||||||
|
|
||||||
// Calculer les crédits des utilisateurs fantômes
|
// Calculer les crédits des utilisateurs fantômes
|
||||||
const phantomCredits = balances
|
const phantomCredits = balances
|
||||||
.filter(balance => uniquePhantomUsers.includes(balance.user))
|
.filter(balance => uniquePhantomUsers.includes(String(balance.user)))
|
||||||
.reduce((sum, balance) => sum + (balance.tokenCredits || 0), 0);
|
.reduce((sum, balance) => sum + (balance.tokenCredits || 0), 0);
|
||||||
|
|
||||||
console.log("Crédits des utilisateurs fantômes:", phantomCredits);
|
console.log("Crédits des utilisateurs fantômes:", phantomCredits);
|
||||||
@@ -113,7 +113,7 @@ export function UsageAnalytics() {
|
|||||||
|
|
||||||
// Analyser les utilisateurs fantômes
|
// Analyser les utilisateurs fantômes
|
||||||
const phantomDetails = uniquePhantomUsers.map(userId => {
|
const phantomDetails = uniquePhantomUsers.map(userId => {
|
||||||
const userBalances = balances.filter(b => b.user === userId);
|
const userBalances = balances.filter(b => String(b.user) === userId);
|
||||||
const totalCredits = userBalances.reduce((sum, b) => sum + (b.tokenCredits || 0), 0);
|
const totalCredits = userBalances.reduce((sum, b) => sum + (b.tokenCredits || 0), 0);
|
||||||
return { userId, totalCredits, count: userBalances.length };
|
return { userId, totalCredits, count: userBalances.length };
|
||||||
});
|
});
|
||||||
@@ -134,13 +134,13 @@ export function UsageAnalytics() {
|
|||||||
// Grouper les balances par utilisateur
|
// Grouper les balances par utilisateur
|
||||||
const balancesByUser = new Map<string, LibreChatBalance[]>();
|
const balancesByUser = new Map<string, LibreChatBalance[]>();
|
||||||
balances.forEach((balance) => {
|
balances.forEach((balance) => {
|
||||||
const userId = balance.user;
|
const balanceUserId = String(balance.user);
|
||||||
// Ignorer les utilisateurs fantômes (qui n'existent plus)
|
// Ignorer les utilisateurs fantômes (qui n'existent plus)
|
||||||
if (users.some(user => user._id === userId)) {
|
if (users.some(user => user._id === balanceUserId)) {
|
||||||
if (!balancesByUser.has(userId)) {
|
if (!balancesByUser.has(balanceUserId)) {
|
||||||
balancesByUser.set(userId, []);
|
balancesByUser.set(balanceUserId, []);
|
||||||
}
|
}
|
||||||
balancesByUser.get(userId)!.push(balance);
|
balancesByUser.get(balanceUserId)!.push(balance);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,17 +186,19 @@ export function UsageAnalytics() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Calculer les conversations par utilisateur
|
// Calculer les conversations par utilisateur
|
||||||
|
// conv.user est un STRING
|
||||||
conversations.forEach((conv) => {
|
conversations.forEach((conv) => {
|
||||||
const userStat = userStats.get(conv.user);
|
const userStat = userStats.get(String(conv.user));
|
||||||
if (userStat) {
|
if (userStat) {
|
||||||
userStat.conversations++;
|
userStat.conversations++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculer les tokens par utilisateur depuis les transactions
|
// Calculer les tokens par utilisateur depuis les transactions
|
||||||
|
// transaction.user est un ObjectId, on le convertit en string
|
||||||
let totalTokensConsumed = 0;
|
let totalTokensConsumed = 0;
|
||||||
transactions.forEach((transaction) => {
|
transactions.forEach((transaction) => {
|
||||||
const userStat = userStats.get(transaction.user);
|
const userStat = userStats.get(String(transaction.user));
|
||||||
if (userStat && transaction.rawAmount) {
|
if (userStat && transaction.rawAmount) {
|
||||||
const tokens = Math.abs(Number(transaction.rawAmount) || 0);
|
const tokens = Math.abs(Number(transaction.rawAmount) || 0);
|
||||||
userStat.tokens += tokens;
|
userStat.tokens += tokens;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
Mail,
|
Mail,
|
||||||
|
GraduationCap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { User as SupabaseUser } from "@supabase/supabase-js";
|
import type { User as SupabaseUser } from "@supabase/supabase-js";
|
||||||
|
|
||||||
@@ -37,16 +38,16 @@ const navigationGroups = [
|
|||||||
{
|
{
|
||||||
name: "Données",
|
name: "Données",
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
name: "Utilisateurs",
|
|
||||||
href: "/users",
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Conversations",
|
name: "Conversations",
|
||||||
href: "/conversations",
|
href: "/conversations",
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Utilisateurs",
|
||||||
|
href: "/users",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Messages",
|
name: "Messages",
|
||||||
href: "/messages",
|
href: "/messages",
|
||||||
@@ -57,6 +58,11 @@ const navigationGroups = [
|
|||||||
href: "/transactions",
|
href: "/transactions",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Groupes",
|
||||||
|
href: "/referents",
|
||||||
|
icon: GraduationCap,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
24
components/providers/query-provider.tsx
Normal file
24
components/providers/query-provider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
debug-analytics.js
Normal file
27
debug-analytics.js
Normal 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
38
debug-tokens.js
Normal 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);
|
||||||
@@ -7,6 +7,7 @@ interface UseCollectionOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
filter?: Record<string, unknown>;
|
filter?: Record<string, unknown>;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
referent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CollectionResponse<T> {
|
interface CollectionResponse<T> {
|
||||||
@@ -27,7 +28,7 @@ export function useCollection<T = Record<string, unknown>>(
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
|
||||||
const { page = 1, limit = 20, filter = {}, search } = options;
|
const { page = 1, limit = 20, filter = {}, search, referent } = options;
|
||||||
|
|
||||||
// Mémoriser la chaîne JSON du filtre pour éviter les re-renders inutiles
|
// Mémoriser la chaîne JSON du filtre pour éviter les re-renders inutiles
|
||||||
const filterString = useMemo(() => JSON.stringify(filter), [filter]);
|
const filterString = useMemo(() => JSON.stringify(filter), [filter]);
|
||||||
@@ -43,6 +44,9 @@ export function useCollection<T = Record<string, unknown>>(
|
|||||||
if (search) {
|
if (search) {
|
||||||
params.append("search", search);
|
params.append("search", search);
|
||||||
}
|
}
|
||||||
|
if (referent) {
|
||||||
|
params.append("referent", referent);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/collections/${collectionName}?${params}`
|
`/api/collections/${collectionName}?${params}`
|
||||||
@@ -59,7 +63,7 @@ export function useCollection<T = Record<string, unknown>>(
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [collectionName, page, limit, filterString, search]);
|
}, [collectionName, page, limit, filterString, search, referent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface LibreChatUser extends Record<string, unknown> {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
__v: number;
|
__v: number;
|
||||||
|
// Champs additionnels pour les étudiants
|
||||||
|
prenom?: string;
|
||||||
|
nom?: string;
|
||||||
|
referent?: string;
|
||||||
|
cours?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibreChatConversation extends Record<string, unknown> {
|
export interface LibreChatConversation extends Record<string, unknown> {
|
||||||
|
|||||||
146
package-lock.json
generated
146
package-lock.json
generated
@@ -20,18 +20,21 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "^2.58.0",
|
"@supabase/supabase-js": "^2.58.0",
|
||||||
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/mongodb": "^4.0.6",
|
"@types/mongodb": "^4.0.6",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"mongodb": "^6.20.0",
|
"mongodb": "^6.20.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -2295,6 +2298,32 @@
|
|||||||
"tailwindcss": "4.1.14"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -3054,6 +3083,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -3458,6 +3496,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -3512,6 +3563,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -3548,6 +3608,18 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3848,6 +3920,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -4659,6 +4743,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -7062,6 +7155,18 @@
|
|||||||
"memory-pager": "^1.0.2"
|
"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": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -7815,6 +7920,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -7846,6 +7969,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||||
|
|||||||
@@ -21,18 +21,21 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@supabase/ssr": "^0.7.0",
|
"@supabase/ssr": "^0.7.0",
|
||||||
"@supabase/supabase-js": "^2.58.0",
|
"@supabase/supabase-js": "^2.58.0",
|
||||||
|
"@tanstack/react-query": "^5.90.11",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/mongodb": "^4.0.6",
|
"@types/mongodb": "^4.0.6",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"mongodb": "^6.20.0",
|
"mongodb": "^6.20.0",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
|||||||
BIN
public/list_users/Emmanuel_WATHELE.xlsx
Normal file
BIN
public/list_users/Emmanuel_WATHELE.xlsx
Normal file
Binary file not shown.
BIN
public/list_users/IHECS.xlsx
Normal file
BIN
public/list_users/IHECS.xlsx
Normal file
Binary file not shown.
114
scripts/README.md
Normal file
114
scripts/README.md
Normal 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
167
scripts/import-ihecs.js
Normal 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
168
scripts/import-users.js
Normal 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();
|
||||||
Reference in New Issue
Block a user