Compare commits
21 Commits
96dd721fcb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a590d60c3 | ||
|
|
a34d29bf1e | ||
|
|
414253aff0 | ||
|
|
e6a9d41ebd | ||
|
|
73f97919ac | ||
|
|
5b8b3c84c9 | ||
|
|
b552504723 | ||
|
|
b1881f06ef | ||
|
|
ad575641a1 | ||
|
|
0d95eca1ee | ||
|
|
dde1c8ba93 | ||
|
|
1c7bca8e35 | ||
|
|
0efe96f4e2 | ||
|
|
e0232b1fcb | ||
|
|
6e4dda0ecd | ||
|
|
80f075d04d | ||
|
|
2e35417697 | ||
|
|
bb0d61f528 | ||
|
|
0e184721c8 | ||
|
|
5b68ffdfb6 | ||
|
|
0f2adca44a |
21
.claude/settings.local.json
Normal file
21
.claude/settings.local.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(awk:*)",
|
||||||
|
"Bash(useCollection.tmp)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(node debug-search.js:*)",
|
||||||
|
"Bash(node update-referent.js:*)",
|
||||||
|
"Bash(node:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(npx tsc:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
13
CLAUDE.md
Normal file
13
CLAUDE.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
## CRITICAL: File Editing on Windows
|
||||||
|
|
||||||
|
### ⚠️ MANDATORY: Always Use Backslashes on Windows for File Paths
|
||||||
|
|
||||||
|
When using Edit or MultiEdit tools on Windows, you MUST use backslashes (\) in file paths, NOT forward slashes (/).
|
||||||
|
|
||||||
|
#### ❌ WRONG - Will cause errors:
|
||||||
|
|
||||||
|
Edit(file_path: "D:/repos/project/file.tsx", ...)
|
||||||
|
|
||||||
|
#### ✅ CORRECT - Always works:
|
||||||
|
|
||||||
|
Edit(file_path: "D:\repos\project\file.tsx", ...)
|
||||||
@@ -2,6 +2,7 @@ import { Suspense } from "react";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { UsageAnalytics } from "@/components/dashboard/usage-analytics";
|
import { UsageAnalytics } from "@/components/dashboard/usage-analytics";
|
||||||
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
import { RecentTransactions } from "@/components/dashboard/recent-transactions";
|
||||||
|
|
||||||
import { BarChart3 } from "lucide-react";
|
import { BarChart3 } from "lucide-react";
|
||||||
|
|
||||||
function AnalyticsSkeleton() {
|
function AnalyticsSkeleton() {
|
||||||
|
|||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/api/add-credits/route.ts
Normal file
139
app/api/add-credits/route.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getDatabase } from "@/lib/db/mongodb";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const db = await getDatabase();
|
||||||
|
const CREDITS_TO_ADD = 3000000; // 3 millions de tokens
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🚀 DÉBUT: Ajout de ${CREDITS_TO_ADD.toLocaleString()} crédits à tous les utilisateurs`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Récupérer tous les utilisateurs existants
|
||||||
|
const users = await db.collection("users").find({}).toArray();
|
||||||
|
console.log(`👥 Utilisateurs trouvés: ${users.length}`);
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: "Aucun utilisateur trouvé",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer toutes les balances existantes
|
||||||
|
const existingBalances = await db.collection("balances").find({}).toArray();
|
||||||
|
const existingBalanceUserIds = new Set(
|
||||||
|
existingBalances.map((balance) => balance.user.toString())
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`💰 Balances existantes: ${existingBalances.length}`);
|
||||||
|
|
||||||
|
let updatedCount = 0;
|
||||||
|
let createdCount = 0;
|
||||||
|
let totalCreditsAdded = 0;
|
||||||
|
|
||||||
|
// Pour chaque utilisateur
|
||||||
|
for (const user of users) {
|
||||||
|
const userId = user._id;
|
||||||
|
|
||||||
|
if (existingBalanceUserIds.has(userId.toString())) {
|
||||||
|
// Utilisateur a déjà une balance - ajouter les crédits
|
||||||
|
const updateResult = await db.collection("balances").updateOne(
|
||||||
|
{ user: userId },
|
||||||
|
{
|
||||||
|
$inc: { tokenCredits: CREDITS_TO_ADD },
|
||||||
|
$set: { lastRefill: new Date() },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateResult.modifiedCount > 0) {
|
||||||
|
updatedCount++;
|
||||||
|
totalCreditsAdded += CREDITS_TO_ADD;
|
||||||
|
console.log(
|
||||||
|
`✅ Crédits ajoutés pour l'utilisateur: ${user.email || user.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Utilisateur n'a pas de balance - créer une nouvelle balance
|
||||||
|
const newBalance = {
|
||||||
|
user: userId,
|
||||||
|
tokenCredits: CREDITS_TO_ADD,
|
||||||
|
autoRefillEnabled: false,
|
||||||
|
lastRefill: new Date(),
|
||||||
|
refillAmount: 0,
|
||||||
|
refillIntervalUnit: "month",
|
||||||
|
refillIntervalValue: 1,
|
||||||
|
__v: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.collection("balances").insertOne(newBalance);
|
||||||
|
createdCount++;
|
||||||
|
totalCreditsAdded += CREDITS_TO_ADD;
|
||||||
|
console.log(
|
||||||
|
`🆕 Nouvelle balance créée pour: ${user.email || user.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ TERMINÉ:`);
|
||||||
|
console.log(`- Balances mises à jour: ${updatedCount}`);
|
||||||
|
console.log(`- Nouvelles balances créées: ${createdCount}`);
|
||||||
|
console.log(
|
||||||
|
`- Total crédits ajoutés: ${totalCreditsAdded.toLocaleString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
statistics: {
|
||||||
|
totalUsers: users.length,
|
||||||
|
updatedBalances: updatedCount,
|
||||||
|
createdBalances: createdCount,
|
||||||
|
creditsPerUser: CREDITS_TO_ADD,
|
||||||
|
totalCreditsAdded,
|
||||||
|
},
|
||||||
|
message: `${CREDITS_TO_ADD.toLocaleString()} crédits ajoutés à ${
|
||||||
|
users.length
|
||||||
|
} utilisateurs (${updatedCount} mis à jour, ${createdCount} créés)`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de l'ajout des crédits:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: "Erreur serveur lors de l'ajout des crédits",
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint pour vérifier les crédits actuels
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
const users = await db.collection("users").find({}).toArray();
|
||||||
|
const balances = await db.collection("balances").find({}).toArray();
|
||||||
|
|
||||||
|
const totalCredits = balances.reduce(
|
||||||
|
(sum, balance) => sum + (balance.tokenCredits || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const averageCredits =
|
||||||
|
balances.length > 0 ? totalCredits / balances.length : 0;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
statistics: {
|
||||||
|
totalUsers: users.length,
|
||||||
|
totalBalances: balances.length,
|
||||||
|
totalCredits,
|
||||||
|
averageCredits: Math.round(averageCredits),
|
||||||
|
usersWithoutBalance: users.length - balances.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la récupération des statistiques:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/api/cleanup/balances/route.ts
Normal file
104
app/api/cleanup/balances/route.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getDatabase } from "@/lib/db/mongodb";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Récupérer tous les utilisateurs existants
|
||||||
|
const users = await db.collection("users").find({}).toArray();
|
||||||
|
const userIds = new Set(users.map(user => user._id.toString()));
|
||||||
|
|
||||||
|
// Récupérer toutes les balances
|
||||||
|
const balances = await db.collection("balances").find({}).toArray();
|
||||||
|
|
||||||
|
// Identifier les balances fantômes
|
||||||
|
const phantomBalances = balances.filter(balance =>
|
||||||
|
!userIds.has(balance.user.toString())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculer les statistiques avant nettoyage
|
||||||
|
const totalBalances = balances.length;
|
||||||
|
const phantomCount = phantomBalances.length;
|
||||||
|
const phantomCredits = phantomBalances.reduce(
|
||||||
|
(sum, balance) => sum + (balance.tokenCredits || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`🗑️ SUPPRESSION: ${phantomCount} balances fantômes détectées`);
|
||||||
|
console.log(`💰 CRÉDITS FANTÔMES: ${phantomCredits}`);
|
||||||
|
|
||||||
|
// SUPPRESSION DÉFINITIVE des balances fantômes
|
||||||
|
const deleteResult = await db.collection("balances").deleteMany({
|
||||||
|
user: { $nin: Array.from(userIds) }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ SUPPRIMÉES: ${deleteResult.deletedCount} balances`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
statistics: {
|
||||||
|
totalBalances,
|
||||||
|
phantomCount,
|
||||||
|
phantomCredits,
|
||||||
|
cleanedCount: deleteResult.deletedCount
|
||||||
|
},
|
||||||
|
message: `${deleteResult.deletedCount} balances fantômes supprimées définitivement`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du nettoyage des balances:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint pour analyser sans nettoyer
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
const users = await db.collection("users").find({}).toArray();
|
||||||
|
const userIds = new Set(users.map(user => user._id.toString()));
|
||||||
|
|
||||||
|
const balances = await db.collection("balances").find({}).toArray();
|
||||||
|
|
||||||
|
const phantomBalances = balances.filter(balance =>
|
||||||
|
!userIds.has(balance.user.toString())
|
||||||
|
);
|
||||||
|
|
||||||
|
const phantomCredits = phantomBalances.reduce(
|
||||||
|
(sum, balance) => sum + (balance.tokenCredits || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Analyser les doublons aussi
|
||||||
|
const userCounts = new Map<string, number>();
|
||||||
|
balances.forEach(balance => {
|
||||||
|
const userId = balance.user.toString();
|
||||||
|
userCounts.set(userId, (userCounts.get(userId) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicates = Array.from(userCounts.entries())
|
||||||
|
.filter(([, count]) => count > 1)
|
||||||
|
.map(([userId, count]) => ({
|
||||||
|
userId,
|
||||||
|
count,
|
||||||
|
isPhantom: !userIds.has(userId)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
analysis: {
|
||||||
|
totalBalances: balances.length,
|
||||||
|
totalUsers: users.length,
|
||||||
|
phantomBalances: phantomBalances.length,
|
||||||
|
phantomCredits,
|
||||||
|
duplicateUsers: duplicates.length,
|
||||||
|
duplicates: duplicates.slice(0, 10) // Premiers 10 exemples
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de l'analyse des balances:", error);
|
||||||
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,35 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getDatabase } from '@/lib/db/mongodb';
|
import { getDatabase } from "@/lib/db/mongodb";
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
|
||||||
const ALLOWED_COLLECTIONS = [
|
const ALLOWED_COLLECTIONS = [
|
||||||
'accessroles', 'aclentries', 'actions', 'agentcategories', 'agents',
|
"accessroles",
|
||||||
'assistants', 'balances', 'banners', 'conversations', 'conversationtags',
|
"aclentries",
|
||||||
'files', 'groups', 'keys', 'memoryentries', 'messages', 'pluginauths',
|
"actions",
|
||||||
'presets', 'projects', 'promptgroups', 'prompts', 'roles', 'sessions',
|
"agentcategories",
|
||||||
'sharedlinks', 'tokens', 'toolcalls', 'transactions', 'users'
|
"agents",
|
||||||
|
"assistants",
|
||||||
|
"balances",
|
||||||
|
"banners",
|
||||||
|
"conversations",
|
||||||
|
"conversationtags",
|
||||||
|
"files",
|
||||||
|
"groups",
|
||||||
|
"keys",
|
||||||
|
"memoryentries",
|
||||||
|
"messages",
|
||||||
|
"pluginauths",
|
||||||
|
"presets",
|
||||||
|
"projects",
|
||||||
|
"promptgroups",
|
||||||
|
"prompts",
|
||||||
|
"roles",
|
||||||
|
"sessions",
|
||||||
|
"sharedlinks",
|
||||||
|
"tokens",
|
||||||
|
"toolcalls",
|
||||||
|
"transactions",
|
||||||
|
"users",
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -16,44 +39,128 @@ export async function GET(
|
|||||||
const { collection } = await params;
|
const { collection } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
if (!ALLOWED_COLLECTIONS.includes(collection)) {
|
if (!ALLOWED_COLLECTIONS.includes(collection)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Collection non autorisée' },
|
{ error: "Collection non autorisée" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const page = parseInt(searchParams.get('page') || '1');
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
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
|
||||||
|
if (collection === "users") {
|
||||||
|
const email = searchParams.get("email");
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
const search = searchParams.get("search");
|
||||||
|
const referent = searchParams.get("referent");
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
filter.email = email.toLowerCase();
|
||||||
|
} else if (id) {
|
||||||
|
if (ObjectId.isValid(id)) {
|
||||||
|
filter._id = new ObjectId(id);
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ID utilisateur invalide" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (search) {
|
||||||
|
filter.$or = [
|
||||||
|
{ name: { $regex: search, $options: "i" } },
|
||||||
|
{ email: { $regex: search, $options: "i" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par référent (peut être combiné avec search)
|
||||||
|
if (referent) {
|
||||||
|
filter.referent = referent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestion spéciale pour conversations - recherche par nom/email d'utilisateur
|
||||||
|
if (collection === "conversations") {
|
||||||
|
const search = searchParams.get("search");
|
||||||
|
const userId = searchParams.get("userId");
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
// Recherche directe par userId (stocké comme string dans conversations)
|
||||||
|
filter.user = userId;
|
||||||
|
} else if (search && search.trim()) {
|
||||||
|
// Normaliser la recherche (enlever accents pour recherche insensible aux accents)
|
||||||
|
const normalizedSearch = search
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "");
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Recherche users avec nom/email/username (insensible casse + accents)
|
||||||
|
const matchingUsers = await db
|
||||||
|
.collection("users")
|
||||||
|
.find({
|
||||||
|
$or: [
|
||||||
|
{ name: { $regex: normalizedSearch, $options: "i" } },
|
||||||
|
{ email: { $regex: normalizedSearch, $options: "i" } },
|
||||||
|
{ username: { $regex: normalizedSearch, $options: "i" } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.project({ _id: 1 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (matchingUsers.length > 0) {
|
||||||
|
// IMPORTANT: Convertir en strings car user dans conversations est stocké comme string
|
||||||
|
const userIds = matchingUsers.map((u) => u._id.toString());
|
||||||
|
filter.user = { $in: userIds };
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
db.collection(collection)
|
db
|
||||||
|
.collection(collection)
|
||||||
.find(filter)
|
.find(filter)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.toArray(),
|
.toArray(),
|
||||||
db.collection(collection).countDocuments(filter)
|
db.collection(collection).countDocuments(filter),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Debug logging pour messages
|
||||||
|
if (collection === "messages") {
|
||||||
|
console.log("[API Messages] Résultats:", data.length, "messages, total:", total);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data,
|
data,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
totalPages: Math.ceil(total / limit)
|
totalPages: Math.ceil(total / limit),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Erreur lors de la récupération de ${collection}:`, error);
|
console.error(`Erreur lors de la récupération de ${collection}:`, error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
|
||||||
{ error: 'Erreur serveur' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
126
app/api/create-user/route.ts
Normal file
126
app/api/create-user/route.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getDatabase } from "@/lib/db/mongodb";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { name, email, password, role = "USER" } = await request.json();
|
||||||
|
|
||||||
|
// Validation des données
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Nom, email et mot de passe sont requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation de l'email
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Format d'email invalide" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation du mot de passe (minimum 8 caractères)
|
||||||
|
if (password.length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Le mot de passe doit contenir au moins 8 caractères" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation du rôle
|
||||||
|
if (!["USER", "ADMIN"].includes(role)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Rôle invalide. Doit être USER ou ADMIN" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur existe déjà
|
||||||
|
const existingUser = await db.collection("users").findOne({ email });
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Un utilisateur avec cet email existe déjà" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hasher le mot de passe
|
||||||
|
const saltRounds = 12;
|
||||||
|
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||||
|
|
||||||
|
// Créer le nouvel utilisateur
|
||||||
|
const newUser = {
|
||||||
|
name,
|
||||||
|
username: email.split("@")[0], // Utiliser la partie avant @ comme username
|
||||||
|
email,
|
||||||
|
emailVerified: false,
|
||||||
|
password: hashedPassword,
|
||||||
|
avatar: null,
|
||||||
|
provider: "local",
|
||||||
|
role,
|
||||||
|
plugins: [],
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
termsAccepted: true,
|
||||||
|
personalization: {
|
||||||
|
memories: false,
|
||||||
|
_id: new ObjectId(),
|
||||||
|
},
|
||||||
|
backupCodes: [],
|
||||||
|
refreshToken: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
__v: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insérer l'utilisateur dans la base de données
|
||||||
|
const result = await db.collection("users").insertOne(newUser);
|
||||||
|
|
||||||
|
if (!result.insertedId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur lors de la création de l'utilisateur" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer une balance initiale pour l'utilisateur
|
||||||
|
const initialBalance = {
|
||||||
|
user: result.insertedId,
|
||||||
|
tokenCredits: 3000000, // 3 millions de tokens par défaut
|
||||||
|
autoRefillEnabled: false,
|
||||||
|
lastRefill: new Date(),
|
||||||
|
refillAmount: 0,
|
||||||
|
refillIntervalUnit: "month",
|
||||||
|
refillIntervalValue: 1,
|
||||||
|
__v: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.collection("balances").insertOne(initialBalance);
|
||||||
|
|
||||||
|
console.log(`✅ Nouvel utilisateur créé: ${email} (${role})`);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Utilisateur ${name} créé avec succès`,
|
||||||
|
user: {
|
||||||
|
id: result.insertedId,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
createdAt: newUser.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la création de l'utilisateur:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur serveur lors de la création de l'utilisateur" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
app/api/delete-user/route.ts
Normal file
160
app/api/delete-user/route.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getDatabase } from "@/lib/db/mongodb";
|
||||||
|
import { ObjectId } from "mongodb";
|
||||||
|
|
||||||
|
interface QueryFilter {
|
||||||
|
_id?: ObjectId;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const userId = searchParams.get("id");
|
||||||
|
const email = searchParams.get("email");
|
||||||
|
|
||||||
|
if (!userId && !email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ID utilisateur ou email requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
const usersCollection = db.collection("users");
|
||||||
|
const balancesCollection = db.collection("balances");
|
||||||
|
|
||||||
|
// Construire la requête de recherche
|
||||||
|
const query: QueryFilter = {};
|
||||||
|
if (userId) {
|
||||||
|
// Vérifier si l'ID est un ObjectId valide
|
||||||
|
if (!ObjectId.isValid(userId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ID utilisateur invalide" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
query._id = new ObjectId(userId);
|
||||||
|
} else if (email) {
|
||||||
|
query.email = email.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur existe
|
||||||
|
const existingUser = await usersCollection.findOne(query);
|
||||||
|
if (!existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Utilisateur non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer l'utilisateur
|
||||||
|
const deleteUserResult = await usersCollection.deleteOne(query);
|
||||||
|
|
||||||
|
// Supprimer le solde associé
|
||||||
|
const deleteBalanceResult = await balancesCollection.deleteOne({
|
||||||
|
user: existingUser._id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleteUserResult.deletedCount === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur lors de la suppression de l'utilisateur" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Utilisateur supprimé avec succès",
|
||||||
|
deletedUser: {
|
||||||
|
id: existingUser._id.toString(),
|
||||||
|
name: existingUser.name,
|
||||||
|
email: existingUser.email,
|
||||||
|
role: existingUser.role
|
||||||
|
},
|
||||||
|
balanceDeleted: deleteBalanceResult.deletedCount > 0
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la suppression de l'utilisateur:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur interne du serveur" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { userId, email } = await request.json();
|
||||||
|
|
||||||
|
if (!userId && !email) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ID utilisateur ou email requis" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase();
|
||||||
|
const usersCollection = db.collection("users");
|
||||||
|
const balancesCollection = db.collection("balances");
|
||||||
|
|
||||||
|
// Construire la requête de recherche
|
||||||
|
const query: QueryFilter = {};
|
||||||
|
if (userId) {
|
||||||
|
// Vérifier si l'ID est un ObjectId valide
|
||||||
|
if (!ObjectId.isValid(userId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ID utilisateur invalide" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
query._id = new ObjectId(userId);
|
||||||
|
} else if (email) {
|
||||||
|
query.email = email.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur existe
|
||||||
|
const existingUser = await usersCollection.findOne(query);
|
||||||
|
if (!existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Utilisateur non trouvé" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer l'utilisateur
|
||||||
|
const deleteUserResult = await usersCollection.deleteOne(query);
|
||||||
|
|
||||||
|
// Supprimer le solde associé
|
||||||
|
const deleteBalanceResult = await balancesCollection.deleteOne({
|
||||||
|
user: existingUser._id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deleteUserResult.deletedCount === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur lors de la suppression de l'utilisateur" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Utilisateur supprimé avec succès",
|
||||||
|
deletedUser: {
|
||||||
|
id: existingUser._id.toString(),
|
||||||
|
name: existingUser.name,
|
||||||
|
email: existingUser.email,
|
||||||
|
role: existingUser.role
|
||||||
|
},
|
||||||
|
balanceDeleted: deleteBalanceResult.deletedCount > 0
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la suppression de l'utilisateur:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Erreur interne du serveur" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,21 +13,21 @@ export async function GET() {
|
|||||||
db.collection("balances").find({}).toArray(),
|
db.collection("balances").find({}).toArray(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculer les utilisateurs actifs (dernière semaine)
|
// Calculer les utilisateurs actifs (30 derniers jours)
|
||||||
const oneWeekAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
const activeUsers = users.filter((user) => {
|
const activeUsers = users.filter((user) => {
|
||||||
const lastActivity = new Date(user.updatedAt || user.createdAt);
|
const lastActivity = new Date(user.updatedAt || user.createdAt);
|
||||||
return lastActivity >= oneWeekAgo;
|
return lastActivity >= thirtyDaysAgo;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
// Calculer les administrateurs
|
// Calculer les administrateurs
|
||||||
const totalAdmins = users.filter(user => user.role === 'ADMIN').length;
|
const totalAdmins = users.filter(user => user.role === 'ADMIN').length;
|
||||||
|
|
||||||
// Calculer les conversations actives (dernière semaine)
|
// Calculer les conversations actives (30 derniers jours)
|
||||||
const activeConversations = conversations.filter((conv) => {
|
const activeConversations = conversations.filter((conv) => {
|
||||||
const lastActivity = new Date(conv.updatedAt || conv.createdAt);
|
const lastActivity = new Date(conv.updatedAt || conv.createdAt);
|
||||||
return lastActivity >= oneWeekAgo;
|
return lastActivity >= thirtyDaysAgo;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
// Calculer le total des messages
|
// Calculer le total des messages
|
||||||
@@ -41,32 +41,27 @@ export async function GET() {
|
|||||||
return sum + Math.abs(Number(transaction.rawAmount) || 0);
|
return sum + Math.abs(Number(transaction.rawAmount) || 0);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Calculer le total des crédits depuis balances
|
// Calculer le total des crédits
|
||||||
const totalCredits = balances.reduce((sum, balance) => {
|
const totalCreditsUsed = balances.reduce(
|
||||||
return sum + (Number(balance.tokenCredits) || 0);
|
(sum, balance) => sum + (balance.tokenCredits || 0),
|
||||||
}, 0);
|
0
|
||||||
|
);
|
||||||
|
|
||||||
// Récupérer les transactions récentes (dernières 10)
|
// Récupérer les transactions récentes (dernières 10)
|
||||||
const recentTransactions = transactions
|
const recentTransactions = transactions
|
||||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
.slice(0, 10)
|
.slice(0, 10);
|
||||||
.map(transaction => ({
|
|
||||||
_id: transaction._id,
|
|
||||||
description: `Transaction ${transaction.tokenType} - ${transaction.model}`,
|
|
||||||
amount: transaction.rawAmount,
|
|
||||||
type: transaction.rawAmount > 0 ? 'credit' : 'debit',
|
|
||||||
createdAt: transaction.createdAt
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
totalUsers: users.length,
|
totalUsers: users.length,
|
||||||
activeUsers,
|
activeUsers,
|
||||||
totalAdmins,
|
totalAdmins,
|
||||||
totalCredits,
|
totalCredits: totalCreditsUsed,
|
||||||
activeConversations,
|
activeConversations,
|
||||||
totalMessages: totalMessages,
|
totalMessages,
|
||||||
totalTokensConsumed,
|
totalTokensConsumed,
|
||||||
recentTransactions
|
totalCreditsUsed,
|
||||||
|
recentTransactions,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur lors du calcul des métriques:", error);
|
console.error("Erreur lors du calcul des métriques:", error);
|
||||||
|
|||||||
@@ -8,6 +8,28 @@ export async function GET() {
|
|||||||
// Récupérer toutes les transactions
|
// Récupérer toutes les transactions
|
||||||
const transactions = await db.collection("transactions").find({}).toArray();
|
const transactions = await db.collection("transactions").find({}).toArray();
|
||||||
|
|
||||||
|
// Récupérer les conversations pour analyser les connexions utilisateurs
|
||||||
|
const conversations = await db.collection("conversations").find({}).toArray();
|
||||||
|
|
||||||
|
// Récupérer les messages pour une analyse plus précise de l'activité
|
||||||
|
const messages = await db.collection("messages").find({}).toArray();
|
||||||
|
|
||||||
|
console.log(`Total transactions trouvées: ${transactions.length}`);
|
||||||
|
|
||||||
|
// Vérifier les champs de date disponibles dans les transactions
|
||||||
|
if (transactions.length > 0) {
|
||||||
|
const sampleTransaction = transactions[0];
|
||||||
|
console.log("Exemple de transaction:", {
|
||||||
|
_id: sampleTransaction._id,
|
||||||
|
createdAt: sampleTransaction.createdAt,
|
||||||
|
updatedAt: sampleTransaction.updatedAt,
|
||||||
|
date: sampleTransaction.date,
|
||||||
|
timestamp: sampleTransaction.timestamp,
|
||||||
|
rawAmount: sampleTransaction.rawAmount,
|
||||||
|
model: sampleTransaction.model
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Calculer les tokens par jour (7 derniers jours)
|
// Calculer les tokens par jour (7 derniers jours)
|
||||||
const dailyStats = [];
|
const dailyStats = [];
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -22,14 +44,44 @@ export async function GET() {
|
|||||||
nextDate.setDate(nextDate.getDate() + 1);
|
nextDate.setDate(nextDate.getDate() + 1);
|
||||||
|
|
||||||
const dayTransactions = transactions.filter(transaction => {
|
const dayTransactions = transactions.filter(transaction => {
|
||||||
const transactionDate = new Date(transaction.createdAt);
|
// Essayer différents champs de date
|
||||||
|
let transactionDate = null;
|
||||||
|
|
||||||
|
if (transaction.createdAt) {
|
||||||
|
transactionDate = new Date(transaction.createdAt);
|
||||||
|
} else if (transaction.updatedAt) {
|
||||||
|
transactionDate = new Date(transaction.updatedAt);
|
||||||
|
} else if (transaction.date) {
|
||||||
|
transactionDate = new Date(transaction.date);
|
||||||
|
} else if (transaction.timestamp) {
|
||||||
|
transactionDate = new Date(transaction.timestamp);
|
||||||
|
} else if (transaction._id && transaction._id.getTimestamp) {
|
||||||
|
// Utiliser le timestamp de l'ObjectId MongoDB
|
||||||
|
transactionDate = transaction._id.getTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transactionDate || isNaN(transactionDate.getTime())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return transactionDate >= date && transactionDate < nextDate;
|
return transactionDate >= date && transactionDate < nextDate;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalTokens = dayTransactions.reduce((sum, transaction) => {
|
const totalTokens = dayTransactions.reduce((sum, transaction) => {
|
||||||
return sum + Math.abs(Number(transaction.rawAmount) || 0);
|
// Essayer différents champs pour les tokens
|
||||||
|
let tokens = 0;
|
||||||
|
if (transaction.rawAmount) {
|
||||||
|
tokens = Math.abs(Number(transaction.rawAmount) || 0);
|
||||||
|
} else if (transaction.amount) {
|
||||||
|
tokens = Math.abs(Number(transaction.amount) || 0);
|
||||||
|
} else if (transaction.tokens) {
|
||||||
|
tokens = Math.abs(Number(transaction.tokens) || 0);
|
||||||
|
}
|
||||||
|
return sum + tokens;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
console.log(`${dayNames[date.getDay()]} (${date.toISOString().split('T')[0]}): ${dayTransactions.length} transactions, ${totalTokens} tokens`);
|
||||||
|
|
||||||
dailyStats.push({
|
dailyStats.push({
|
||||||
name: dayNames[date.getDay()],
|
name: dayNames[date.getDay()],
|
||||||
value: totalTokens
|
value: totalTokens
|
||||||
@@ -40,9 +92,19 @@ export async function GET() {
|
|||||||
const modelStats = new Map<string, number>();
|
const modelStats = new Map<string, number>();
|
||||||
|
|
||||||
transactions.forEach(transaction => {
|
transactions.forEach(transaction => {
|
||||||
const model = transaction.model || "Inconnu";
|
const model = transaction.model || transaction.modelName || "Inconnu";
|
||||||
const tokens = Math.abs(Number(transaction.rawAmount) || 0);
|
let tokens = 0;
|
||||||
|
if (transaction.rawAmount) {
|
||||||
|
tokens = Math.abs(Number(transaction.rawAmount) || 0);
|
||||||
|
} else if (transaction.amount) {
|
||||||
|
tokens = Math.abs(Number(transaction.amount) || 0);
|
||||||
|
} else if (transaction.tokens) {
|
||||||
|
tokens = Math.abs(Number(transaction.tokens) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens > 0) {
|
||||||
modelStats.set(model, (modelStats.get(model) || 0) + tokens);
|
modelStats.set(model, (modelStats.get(model) || 0) + tokens);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convertir en array et trier par usage
|
// Convertir en array et trier par usage
|
||||||
@@ -50,8 +112,75 @@ export async function GET() {
|
|||||||
.map(([name, value]) => ({ name, value }))
|
.map(([name, value]) => ({ name, value }))
|
||||||
.sort((a, b) => b.value - a.value);
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
// Calculer les connexions utilisateurs par jour (7 derniers jours)
|
||||||
|
const dailyConnections = [];
|
||||||
|
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const nextDate = new Date(date);
|
||||||
|
nextDate.setDate(nextDate.getDate() + 1);
|
||||||
|
|
||||||
|
// Analyser l'activité des utilisateurs via les messages
|
||||||
|
const activeUsers = new Set();
|
||||||
|
|
||||||
|
messages.forEach(message => {
|
||||||
|
let messageDate = null;
|
||||||
|
|
||||||
|
if (message.createdAt) {
|
||||||
|
messageDate = new Date(message.createdAt);
|
||||||
|
} else if (message.updatedAt) {
|
||||||
|
messageDate = new Date(message.updatedAt);
|
||||||
|
} else if (message._id && message._id.getTimestamp) {
|
||||||
|
messageDate = message._id.getTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageDate && messageDate >= date && messageDate < nextDate) {
|
||||||
|
if (message.user && message.isCreatedByUser) {
|
||||||
|
activeUsers.add(message.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aussi analyser via les conversations créées ce jour-là
|
||||||
|
conversations.forEach(conversation => {
|
||||||
|
let convDate = null;
|
||||||
|
|
||||||
|
if (conversation.createdAt) {
|
||||||
|
convDate = new Date(conversation.createdAt);
|
||||||
|
} else if (conversation.updatedAt) {
|
||||||
|
convDate = new Date(conversation.updatedAt);
|
||||||
|
} else if (conversation._id && conversation._id.getTimestamp) {
|
||||||
|
convDate = conversation._id.getTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convDate && convDate >= date && convDate < nextDate) {
|
||||||
|
if (conversation.user) {
|
||||||
|
activeUsers.add(conversation.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`${dayNames[date.getDay()]} (${date.toISOString().split('T')[0]}): ${activeUsers.size} utilisateurs actifs`);
|
||||||
|
|
||||||
|
dailyConnections.push({
|
||||||
|
name: dayNames[date.getDay()],
|
||||||
|
value: activeUsers.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Statistiques calculées:", {
|
||||||
|
dailyStats,
|
||||||
|
dailyConnections,
|
||||||
|
totalModels: modelData.length,
|
||||||
|
topModel: modelData[0]
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
dailyTokens: dailyStats,
|
dailyTokens: dailyStats,
|
||||||
|
dailyConnections: dailyConnections,
|
||||||
modelDistribution: modelData
|
modelDistribution: modelData
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ export async function GET() {
|
|||||||
// Récupérer tous les utilisateurs
|
// Récupérer tous les utilisateurs
|
||||||
const users = await db.collection("users").find({}).toArray();
|
const users = await db.collection("users").find({}).toArray();
|
||||||
|
|
||||||
// Calculer les utilisateurs actifs (dernière semaine)
|
// Calculer les utilisateurs actifs (30 derniers jours)
|
||||||
const oneWeekAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
let activeUsers = 0;
|
let activeUsers = 0;
|
||||||
let inactiveUsers = 0;
|
let inactiveUsers = 0;
|
||||||
|
|
||||||
users.forEach(user => {
|
users.forEach(user => {
|
||||||
const lastActivity = new Date(user.updatedAt || user.createdAt);
|
const lastActivity = new Date(user.updatedAt || user.createdAt);
|
||||||
if (lastActivity >= oneWeekAgo) {
|
if (lastActivity >= thirtyDaysAgo) {
|
||||||
activeUsers++;
|
activeUsers++;
|
||||||
} else {
|
} else {
|
||||||
inactiveUsers++;
|
inactiveUsers++;
|
||||||
|
|||||||
@@ -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 GPTTT
|
Gestion des conversations Cercle GPT
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -51,51 +51,53 @@
|
|||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.145 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.969 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.145 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.969 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.969 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.145 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.627 0.265 303.9);
|
||||||
--border: oklch(0.922 0 0);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--border: oklch(0.898 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--input: oklch(0.898 0 0);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--ring: oklch(0.145 0 0);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.145 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.145 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.985 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.145 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.205 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.205 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.205 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.627 0.265 303.9);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--border: oklch(0.205 0 0);
|
||||||
|
--input: oklch(0.205 0 0);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
@@ -120,3 +122,31 @@
|
|||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-accordion-down {
|
||||||
|
animation: accordion-down 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-accordion-up {
|
||||||
|
animation: accordion-up 0.2s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`}
|
||||||
>
|
>
|
||||||
|
<QueryProvider>
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="flex-1 overflow-auto">
|
<main className="flex-1 overflow-auto">
|
||||||
<div className="container mx-auto p-6">{children}</div>
|
<div className="container mx-auto p-6">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</QueryProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
194
app/login/page.tsx
Normal file
194
app/login/page.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Loader2, Mail, Lock, Shield } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Fonction pour traduire les erreurs Supabase en français
|
||||||
|
const getErrorMessage = (error: string) => {
|
||||||
|
const errorMessages: { [key: string]: string } = {
|
||||||
|
"Invalid login credentials": "Identifiants de connexion invalides",
|
||||||
|
"Email not confirmed": "Email non confirmé",
|
||||||
|
"Too many requests": "Trop de tentatives de connexion",
|
||||||
|
"User not found": "Utilisateur non trouvé",
|
||||||
|
"Invalid email": "Adresse email invalide",
|
||||||
|
"Password should be at least 6 characters": "Le mot de passe doit contenir au moins 6 caractères",
|
||||||
|
"Email rate limit exceeded": "Limite de tentatives dépassée",
|
||||||
|
"Invalid email or password": "Email ou mot de passe incorrect",
|
||||||
|
"Account not found": "Compte non trouvé",
|
||||||
|
"Invalid credentials": "Identifiants incorrects",
|
||||||
|
"Authentication failed": "Échec de l'authentification",
|
||||||
|
"Access denied": "Accès refusé",
|
||||||
|
"Unauthorized": "Non autorisé",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chercher une correspondance exacte
|
||||||
|
if (errorMessages[error]) {
|
||||||
|
return errorMessages[error];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chercher une correspondance partielle
|
||||||
|
for (const [englishError, frenchError] of Object.entries(errorMessages)) {
|
||||||
|
if (error.toLowerCase().includes(englishError.toLowerCase())) {
|
||||||
|
return frenchError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message par défaut si aucune correspondance
|
||||||
|
return "Erreur de connexion. Vérifiez vos identifiants.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Validation côté client
|
||||||
|
if (!email || !password) {
|
||||||
|
setError("Veuillez remplir tous les champs");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email.includes("@")) {
|
||||||
|
setError("Veuillez entrer une adresse email valide");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error: authError } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
setError(getErrorMessage(authError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.user) {
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
setError("Une erreur inattendue est survenue. Veuillez réessayer.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md shadow-lg border border-gray-200">
|
||||||
|
<CardHeader className="space-y-6 pb-8">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="relative w-16 h-16 rounded-full bg-gray-100 p-2">
|
||||||
|
<Image
|
||||||
|
src="/img/logo.png"
|
||||||
|
alt="Logo"
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<CardTitle className="text-2xl font-bold text-gray-900 flex items-center justify-center gap-2">
|
||||||
|
<Shield className="w-6 h-6 text-gray-700" />
|
||||||
|
Admin Dashboard
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Connectez-vous pour accéder au tableau de bord
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{error && (
|
||||||
|
<Alert className="border-red-200 bg-red-50">
|
||||||
|
<AlertDescription className="text-red-700">
|
||||||
|
{error}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium text-gray-700">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="pl-10 border-gray-300 focus:border-gray-900 focus:ring-gray-900"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium text-gray-700">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="pl-10 border-gray-300 focus:border-gray-900 focus:ring-gray-900"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-gray-900 hover:bg-gray-800 text-white font-medium py-2.5 transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Connexion en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Se connecter"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Accès réservé aux administrateurs autorisés
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/page.tsx
24
app/page.tsx
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { OverviewMetrics } from "@/components/dashboard/overview-metrics";
|
import { OverviewMetrics } from "@/components/dashboard/overview-metrics";
|
||||||
import { RealTimeStats } from "@/components/dashboard/real-time-stats";
|
import { RealTimeStats } from "@/components/dashboard/real-time-stats";
|
||||||
import { RealUserActivityChart } from "@/components/dashboard/charts/real-user-activity-chart";
|
import { RealUserActivityChart } from "@/components/dashboard/charts/real-user-activity-chart";
|
||||||
|
import { DashboardUsersList } from "@/components/dashboard/dashboard-users-list";
|
||||||
import {
|
import {
|
||||||
Users,
|
Users,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -28,7 +29,7 @@ export default function Dashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Métriques principales */}
|
{/* Métriques principales - maintenant avec tokens consommés */}
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={<div className="h-32 bg-muted animate-pulse rounded-lg" />}
|
fallback={<div className="h-32 bg-muted animate-pulse rounded-lg" />}
|
||||||
>
|
>
|
||||||
@@ -49,10 +50,9 @@ export default function Dashboard() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grille pour activité utilisateurs et actions */}
|
{/* Grille pour activité utilisateurs et top utilisateurs */}
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{/* Activité des utilisateurs avec vraies données */}
|
{/* Activité des utilisateurs */}
|
||||||
<div className="md:col-span-1">
|
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="h-64 bg-muted animate-pulse rounded-lg" />
|
<div className="h-64 bg-muted animate-pulse rounded-lg" />
|
||||||
@@ -60,10 +60,19 @@ export default function Dashboard() {
|
|||||||
>
|
>
|
||||||
<RealUserActivityChart />
|
<RealUserActivityChart />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Top 5 utilisateurs - nouveau composant */}
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="h-64 bg-muted animate-pulse rounded-lg" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DashboardUsersList />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions rapides épurées */}
|
{/* Actions rapides */}
|
||||||
<div className="md:col-span-2 grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Card className="hover:shadow-md transition-shadow border-l-4 border-l-blue-500">
|
<Card className="hover:shadow-md transition-shadow border-l-4 border-l-blue-500">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base flex items-center">
|
<CardTitle className="text-base flex items-center">
|
||||||
@@ -141,6 +150,5 @@ export default function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Database, Server, Settings } from "lucide-react";
|
||||||
|
|
||||||
|
import AddCredits from "@/components/dashboard/add-credits";
|
||||||
|
import AddCreditsSingleUser from "@/components/dashboard/add-credits-single-user";
|
||||||
|
import UserManagement from "@/components/dashboard/user-management";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -7,65 +12,81 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Paramètres</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Paramètres</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Configuration du dashboard Cercle GPT
|
Configuration et maintenance du système
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Informations système */}
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Connexion MongoDB</CardTitle>
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
Base de données
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">Statut:</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Statut MongoDB
|
||||||
|
</span>
|
||||||
<Badge variant="default">Connecté</Badge>
|
<Badge variant="default">Connecté</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Base de données:
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-mono">Cercle GPT</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Collections:
|
|
||||||
</span>
|
|
||||||
<span className="text-sm">29 collections</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle>Informations système</CardTitle>
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
Informations système
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Version Next.js:
|
Version Next.js
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm">15.5.4</span>
|
<Badge variant="outline">15.0.3</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Version Node.js:
|
Version Node.js
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm">{process.version}</span>
|
<Badge variant="outline">{process.version}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Environnement:
|
Environnement
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline">{process.env.NODE_ENV}</Badge>
|
<Badge variant="outline">Development</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Gestion des utilisateurs */}
|
||||||
|
<UserManagement />
|
||||||
|
|
||||||
|
{/* Gestion des crédits */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Settings className="h-6 w-6" />
|
||||||
|
Gestion des Crédits
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<AddCreditsSingleUser />
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<AddCredits />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</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,496 +24,429 @@ 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">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Users className="h-5 w-5" />
|
|
||||||
Conversations par utilisateur
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{Object.keys(groupedConversations).length} utilisateurs •{" "}
|
|
||||||
{conversations.length} conversations au total
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{userIds.map((userId) => {
|
|
||||||
const conversations = groupedConversations[userId];
|
|
||||||
const totalMessages = conversations.reduce(
|
|
||||||
(sum, conv) => sum + (conv.messages?.length || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const activeConversations = conversations.filter(
|
|
||||||
(conv) => !conv.isArchived
|
|
||||||
).length;
|
|
||||||
const archivedConversations = conversations.filter(
|
|
||||||
(conv) => conv.isArchived
|
|
||||||
).length;
|
|
||||||
const userName = getUserDisplayName(userId);
|
|
||||||
const userEmail = getUserEmail(userId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={userId} className="border rounded-lg p-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline" className="font-mono text-xs">
|
|
||||||
{userId === "unknown" ? "unknown" : userId.slice(-8)}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-semibold">{userName}</span>
|
|
||||||
{userEmail && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{userEmail}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{conversations.length} conversation
|
|
||||||
{conversations.length > 1 ? "s" : ""}
|
|
||||||
</Badge>
|
|
||||||
{activeConversations > 0 && (
|
|
||||||
<Badge variant="default" className="text-xs">
|
|
||||||
{activeConversations} actives
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{archivedConversations > 0 && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{archivedConversations} archivées
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<MessageSquare className="h-4 w-4" />
|
|
||||||
{totalMessages} message{totalMessages > 1 ? "s" : ""}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
Dernière:{" "}
|
|
||||||
{formatDate(
|
|
||||||
new Date(
|
|
||||||
Math.max(
|
|
||||||
...conversations.map((c) =>
|
|
||||||
new Date(c.updatedAt).getTime()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>ID</TableHead>
|
|
||||||
<TableHead>Titre</TableHead>
|
|
||||||
<TableHead>Endpoint</TableHead>
|
|
||||||
<TableHead>Modèle</TableHead>
|
|
||||||
<TableHead>Messages</TableHead>
|
|
||||||
<TableHead>Statut</TableHead>
|
|
||||||
<TableHead>Créée le</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{conversations
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(b.updatedAt).getTime() -
|
|
||||||
new Date(a.updatedAt).getTime()
|
|
||||||
)
|
|
||||||
.map((conversation) => {
|
|
||||||
const status = getStatus(conversation);
|
|
||||||
const messageCount =
|
|
||||||
conversation.messages?.length || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={conversation._id}>
|
|
||||||
<TableCell>
|
|
||||||
<span className="font-mono text-xs">
|
|
||||||
{String(conversation._id).slice(-8)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="max-w-xs truncate block">
|
|
||||||
{String(conversation.title) || "Sans titre"}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{String(conversation.endpoint).slice(0, 20)}
|
|
||||||
{String(conversation.endpoint).length > 20
|
|
||||||
? "..."
|
|
||||||
: ""}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
|
||||||
{String(conversation.model)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
|
|
||||||
onClick={() =>
|
|
||||||
handleShowMessages(
|
|
||||||
conversation.conversationId,
|
|
||||||
userId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{messageCount}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={getStatusVariant(status)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{getStatusLabel(status)}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(conversation.createdAt)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section des messages pour cet utilisateur */}
|
|
||||||
{selectedConversationId && selectedUserId === userId && (
|
|
||||||
<div className="mt-6 border-t pt-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
|
||||||
<MessageSquare className="h-5 w-5" />
|
<MessageSquare className="h-5 w-5" />
|
||||||
Messages de la conversation
|
Conversations
|
||||||
</h3>
|
<Badge variant="secondary" className="ml-2">
|
||||||
<Button
|
{total}
|
||||||
variant="outline"
|
</Badge>
|
||||||
size="sm"
|
{conversationsFetching && !conversationsLoading && (
|
||||||
onClick={handleCloseMessages}
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
{/* Barre de recherche */}
|
||||||
|
<div className="relative w-full sm:w-96">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
placeholder="Rechercher par nom ou email..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
) : searchInput ? (
|
||||||
|
<button
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
Fermer
|
</button>
|
||||||
</Button>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
</div>
|
||||||
Conversation ID: {selectedConversationId}
|
</div>
|
||||||
|
|
||||||
|
{debouncedSearch && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{total} résultat{total > 1 ? "s" : ""} pour "{debouncedSearch}"
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-4 max-h-96 overflow-y-auto border rounded-lg p-4 bg-gray-50">
|
)}
|
||||||
{messages.length === 0 ? (
|
</CardHeader>
|
||||||
<p className="text-center text-muted-foreground py-8">
|
|
||||||
Aucun message trouvé pour cette conversation
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
messages
|
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||||||
|
{messages
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(a.createdAt).getTime() -
|
new Date(a.createdAt).getTime() -
|
||||||
new Date(b.createdAt).getTime()
|
new Date(b.createdAt).getTime()
|
||||||
)
|
)
|
||||||
.map((message) => {
|
.map((msg) => {
|
||||||
const content = getMessageContent(message);
|
const content = getMessageContent(msg);
|
||||||
|
const isUser = msg.isCreatedByUser;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message._id}
|
key={msg._id}
|
||||||
className={`flex gap-3 p-4 rounded-lg ${
|
className={`flex gap-3 p-3 rounded-lg border-l-4 ${
|
||||||
message.isCreatedByUser
|
isUser
|
||||||
? "bg-blue-50 border-l-4 border-l-blue-500"
|
? "bg-blue-50 border-l-blue-500"
|
||||||
: "bg-white border-l-4 border-l-gray-500"
|
: "bg-white border-l-gray-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
{message.isCreatedByUser ? (
|
{isUser ? (
|
||||||
<User className="h-5 w-5 text-blue-600" />
|
<User className="h-4 w-4 text-blue-600" />
|
||||||
) : (
|
) : (
|
||||||
<Bot className="h-5 w-5 text-gray-600" />
|
<Bot className="h-4 w-4 text-gray-500" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<Badge
|
<span className="text-xs font-medium">
|
||||||
variant={
|
{isUser ? "Utilisateur" : "Assistant"}
|
||||||
message.isCreatedByUser
|
</span>
|
||||||
? "default"
|
<span className="text-xs text-muted-foreground">
|
||||||
: "secondary"
|
{formatDate(msg.createdAt)}
|
||||||
}
|
</span>
|
||||||
className="text-xs"
|
{msg.tokenCount > 0 && (
|
||||||
>
|
<span className="text-xs text-muted-foreground">
|
||||||
{message.isCreatedByUser
|
({msg.tokenCount} tokens)
|
||||||
? "Utilisateur"
|
|
||||||
: "Assistant"}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDate(message.createdAt)}
|
|
||||||
</span>
|
</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>
|
||||||
<div className="text-sm whitespace-pre-wrap">
|
<div className="text-sm whitespace-pre-wrap break-words">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
{message.error && (
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Erreur
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -527,33 +454,36 @@ export function ConversationsTable() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between mt-6">
|
<div className="flex items-center justify-between mt-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Page {page} sur {totalPages} • {total} conversations au total
|
Page {page} / {totalPages} ({totalUserGroups} utilisateurs)
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage(page - 1)}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Précédent
|
Préc.
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage(page + 1)}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
>
|
>
|
||||||
Suivant
|
Suiv.
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ 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 { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { formatDate, formatCurrency } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { LibreChatTransaction, LibreChatUser } from "@/lib/types";
|
import { LibreChatTransaction, LibreChatUser } from "@/lib/types";
|
||||||
|
|
||||||
// Interface étendue pour les transactions avec description optionnelle
|
// Interface étendue pour les transactions avec description optionnelle
|
||||||
@@ -53,20 +53,15 @@ export function TransactionsTable() {
|
|||||||
return user?.name || user?.email || `Utilisateur ${userId.slice(-8)}`;
|
return user?.name || user?.email || `Utilisateur ${userId.slice(-8)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fonction pour formater le montant en euros
|
|
||||||
const formatAmount = (rawAmount: number): string => {
|
|
||||||
// Convertir les tokens en euros (exemple: 1000 tokens = 1 euro)
|
|
||||||
const euros = rawAmount / 1000;
|
|
||||||
return formatCurrency(euros);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fonction pour obtenir la description
|
// Fonction pour obtenir la description
|
||||||
const getDescription = (transaction: LibreChatTransaction): string => {
|
const getDescription = (transaction: LibreChatTransaction): string => {
|
||||||
const transactionWithDesc = transaction as TransactionWithDescription;
|
const transactionWithDesc = transaction as TransactionWithDescription;
|
||||||
|
|
||||||
if (transactionWithDesc.description &&
|
if (
|
||||||
typeof transactionWithDesc.description === 'string' &&
|
transactionWithDesc.description &&
|
||||||
transactionWithDesc.description !== "undefined") {
|
typeof transactionWithDesc.description === "string" &&
|
||||||
|
transactionWithDesc.description !== "undefined"
|
||||||
|
) {
|
||||||
return transactionWithDesc.description;
|
return transactionWithDesc.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +106,6 @@ export function TransactionsTable() {
|
|||||||
<TableHead>ID</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Utilisateur</TableHead>
|
<TableHead>Utilisateur</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead>Montant</TableHead>
|
|
||||||
<TableHead>Tokens</TableHead>
|
<TableHead>Tokens</TableHead>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead>Description</TableHead>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>Date</TableHead>
|
||||||
@@ -151,11 +145,6 @@ export function TransactionsTable() {
|
|||||||
{isCredit ? "Crédit" : "Débit"}
|
{isCredit ? "Crédit" : "Débit"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<span className="font-semibold">
|
|
||||||
{formatAmount(transaction.rawAmount)}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{tokenAmount > 0 && (
|
{tokenAmount > 0 && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
|
|||||||
215
components/collections/users-table.old.tsx
Normal file
215
components/collections/users-table.old.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { useCollection } from "@/hooks/useCollection";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import { LibreChatUser, LibreChatBalance } from "@/lib/types";
|
||||||
|
|
||||||
|
export function UsersTable() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: users = [],
|
||||||
|
total = 0,
|
||||||
|
loading: usersLoading,
|
||||||
|
} = useCollection<LibreChatUser>("users", {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
// ✅ AJOUTER le searchTerm ici
|
||||||
|
search: searchTerm,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Charger tous les balances pour associer les crédits
|
||||||
|
const { data: balances = [] } = useCollection<LibreChatBalance>("balances", {
|
||||||
|
limit: 1000, // Charger tous les balances
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer une map des crédits par utilisateur
|
||||||
|
const creditsMap = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
balances.forEach((balance) => {
|
||||||
|
map.set(balance.user, balance.tokenCredits || 0);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [balances]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
const handlePrevPage = () => {
|
||||||
|
setPage((prev) => Math.max(1, prev - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
setPage((prev) => Math.min(totalPages, prev + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (usersLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Liste des utilisateurs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
|
<CardTitle>
|
||||||
|
Liste des utilisateurs ({searchTerm ? users.length : total})
|
||||||
|
</CardTitle>
|
||||||
|
<div className="relative w-full sm:w-80">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher par nom ou email..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Nom</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Rôle</TableHead>
|
||||||
|
<TableHead>Crédits</TableHead>
|
||||||
|
<TableHead>Statut</TableHead>
|
||||||
|
<TableHead>Créé le</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.length > 0 ? (
|
||||||
|
users.map((user) => {
|
||||||
|
const userCredits = creditsMap.get(user._id) || 0;
|
||||||
|
const isActive =
|
||||||
|
new Date(user.updatedAt || user.createdAt) >
|
||||||
|
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 jours en millisecondes
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={user._id}>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{user._id.slice(-8)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-medium">{user.name}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm">{user.email}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
user.role === "ADMIN" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{userCredits.toLocaleString()} crédits
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
<Badge variant={isActive ? "default" : "secondary"}>
|
||||||
|
{isActive ? "Actif" : "Inactif"}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(user.createdAt)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{searchTerm
|
||||||
|
? `Aucun utilisateur trouvé pour "${searchTerm}"`
|
||||||
|
: "Aucun utilisateur trouvé"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination - masquée lors de la recherche */}
|
||||||
|
{!searchTerm && (
|
||||||
|
<div className="flex items-center justify-between space-x-2 py-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Page {page} sur {totalPages} ({total} éléments au total)
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrevPage}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Précédent
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info de recherche */}
|
||||||
|
{searchTerm && (
|
||||||
|
<div className="py-4 text-sm text-muted-foreground">
|
||||||
|
{users.length} résultat(s) trouvé(s) pour "{searchTerm}
|
||||||
|
"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { useCollection } from "@/hooks/useCollection";
|
import { useCollection } from "@/hooks/useCollection";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@@ -13,16 +13,30 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} 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 { ChevronLeft, ChevronRight } from "lucide-react";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { 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 [activeSearch, setActiveSearch] = useState(""); // Ce qui est réellement recherché
|
||||||
|
const [activeReferent, setActiveReferent] = useState<string | undefined>(undefined);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
|
|
||||||
// Charger les utilisateurs
|
// Réinitialiser la page à 1 quand une nouvelle recherche ou filtre est lancé
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [activeSearch, activeReferent]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: users = [],
|
data: users = [],
|
||||||
total = 0,
|
total = 0,
|
||||||
@@ -30,11 +44,37 @@ export function UsersTable() {
|
|||||||
} = useCollection<LibreChatUser>("users", {
|
} = useCollection<LibreChatUser>("users", {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
|
search: activeSearch,
|
||||||
|
referent: activeReferent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleReferentClick = (referent: string) => {
|
||||||
|
if (activeReferent === referent) {
|
||||||
|
setActiveReferent(undefined); // Toggle off
|
||||||
|
} else {
|
||||||
|
setActiveReferent(referent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReferentFilter = () => {
|
||||||
|
setActiveReferent(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour lancer la recherche
|
||||||
|
const handleSearch = () => {
|
||||||
|
setActiveSearch(searchInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gérer la touche Entrée
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Charger tous les balances pour associer les crédits
|
// Charger tous les balances pour associer les crédits
|
||||||
const { data: balances = [] } = useCollection<LibreChatBalance>("balances", {
|
const { data: balances = [] } = useCollection<LibreChatBalance>("balances", {
|
||||||
limit: 1000, // Charger tous les balances
|
limit: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Créer une map des crédits par utilisateur
|
// Créer une map des crédits par utilisateur
|
||||||
@@ -76,27 +116,61 @@ export function UsersTable() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Liste des utilisateurs ({total})</CardTitle>
|
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
|
<CardTitle>
|
||||||
|
Liste des utilisateurs ({total})
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-2 w-full sm:w-auto items-center">
|
||||||
|
{activeReferent && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="flex items-center gap-1 px-3 py-1"
|
||||||
|
style={{ backgroundColor: REFERENT_COLORS[activeReferent] || "#6B7280", color: "white" }}
|
||||||
|
>
|
||||||
|
{activeReferent}
|
||||||
|
<button
|
||||||
|
onClick={clearReferentFilter}
|
||||||
|
className="ml-1 hover:bg-white/20 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="relative flex-1 sm:w-80">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Rechercher par nom ou email..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSearch} variant="default">
|
||||||
|
Rechercher
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<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.map((user) => {
|
{users.length > 0 ? (
|
||||||
|
users.map((user) => {
|
||||||
const userCredits = creditsMap.get(user._id) || 0;
|
const userCredits = creditsMap.get(user._id) || 0;
|
||||||
const isActive = new Date(user.updatedAt || user.createdAt) >
|
const referentColor = user.referent ? (REFERENT_COLORS[user.referent] || "#6B7280") : null;
|
||||||
new Date(Date.now() - 5 * 60 * 1000); // 5 minutes en millisecondes
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={user._id}>
|
<TableRow key={user._id}>
|
||||||
@@ -106,13 +180,43 @@ export function UsersTable() {
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{referentColor && (
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: referentColor }}
|
||||||
|
title={user.referent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className="font-medium">{user.name}</span>
|
<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>
|
<TableCell>
|
||||||
<Badge variant={user.role === 'ADMIN' ? 'default' : 'secondary'}>
|
{user.referent ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleReferentClick(user.referent!)}
|
||||||
|
className={`text-sm truncate block max-w-[120px] hover:underline cursor-pointer transition-colors ${
|
||||||
|
activeReferent === user.referent
|
||||||
|
? "font-bold text-primary"
|
||||||
|
: "text-foreground hover:text-primary"
|
||||||
|
}`}
|
||||||
|
title={`Cliquer pour filtrer par ${user.referent}`}
|
||||||
|
>
|
||||||
|
{user.referent}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
user.role === "ADMIN" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -121,19 +225,26 @@ export function UsersTable() {
|
|||||||
{userCredits.toLocaleString()} crédits
|
{userCredits.toLocaleString()} crédits
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<Badge variant={isActive ? 'default' : 'destructive'}>
|
|
||||||
{isActive ? 'Actif' : 'Inactif'}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{formatDate(new Date(user.createdAt))}
|
{formatDate(user.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
{activeSearch
|
||||||
|
? `Aucun utilisateur trouvé pour "${activeSearch}"`
|
||||||
|
: "Aucun utilisateur trouvé"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +252,15 @@ export function UsersTable() {
|
|||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="flex items-center justify-between space-x-2 py-4">
|
<div className="flex items-center justify-between space-x-2 py-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Page {page} sur {totalPages} ({total} éléments au total)
|
{activeSearch ? (
|
||||||
|
<span>
|
||||||
|
{total} résultat(s) pour "{activeSearch}" - Page {page} sur {totalPages}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Page {page} sur {totalPages} ({total} utilisateurs au total)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
components/dashboard/add-credits.tsx
Normal file
218
components/dashboard/add-credits.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Plus, DollarSign, Users, TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
|
interface AddCreditsStats {
|
||||||
|
totalUsers: number;
|
||||||
|
totalBalances: number;
|
||||||
|
totalCredits: number;
|
||||||
|
averageCredits: number;
|
||||||
|
usersWithoutBalance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddCreditsResult {
|
||||||
|
totalUsers: number;
|
||||||
|
updatedBalances: number;
|
||||||
|
createdBalances: number;
|
||||||
|
creditsPerUser: number;
|
||||||
|
totalCreditsAdded: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddCredits() {
|
||||||
|
const [stats, setStats] = useState<AddCreditsStats | null>(null);
|
||||||
|
const [result, setResult] = useState<AddCreditsResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [analyzing, setAnalyzing] = useState(false);
|
||||||
|
|
||||||
|
const analyzeCurrentCredits = async () => {
|
||||||
|
setAnalyzing(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/add-credits");
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.statistics) {
|
||||||
|
setStats(data.statistics);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de l'analyse:", error);
|
||||||
|
} finally {
|
||||||
|
setAnalyzing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCreditsToAllUsers = async () => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Êtes-vous sûr de vouloir ajouter 3 millions de crédits à TOUS les utilisateurs ? Cette action est irréversible."
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/add-credits", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setResult(data.statistics);
|
||||||
|
// Rafraîchir les stats
|
||||||
|
await analyzeCurrentCredits();
|
||||||
|
} else {
|
||||||
|
alert("Erreur: " + (data.error || data.message));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de l'ajout des crédits:", error);
|
||||||
|
alert("Erreur lors de l'ajout des crédits");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
Ajouter des Crédits
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ajouter 3 millions de tokens à tous les utilisateurs existants
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Bouton d'analyse */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={analyzeCurrentCredits}
|
||||||
|
disabled={analyzing}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{analyzing ? "Analyse..." : "Analyser les crédits actuels"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistiques actuelles */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-blue-50 p-3 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="text-sm font-medium">Utilisateurs</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">
|
||||||
|
{stats.totalUsers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-green-50 p-3 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm font-medium">Total Crédits</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{stats.totalCredits.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-purple-50 p-3 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-purple-600" />
|
||||||
|
<span className="text-sm font-medium">Moyenne</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-purple-600">
|
||||||
|
{stats.averageCredits.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-orange-50 p-3 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-orange-600" />
|
||||||
|
<span className="text-sm font-medium">Sans Balance</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-orange-600">
|
||||||
|
{stats.usersWithoutBalance}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bouton d'ajout de crédits */}
|
||||||
|
{stats && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||||
|
<h4 className="font-semibold text-yellow-800 mb-2">
|
||||||
|
⚠️ Action Importante
|
||||||
|
</h4>
|
||||||
|
<p className="text-yellow-700 text-sm">
|
||||||
|
Cette action va ajouter <strong>3,000,000 crédits</strong> à
|
||||||
|
chacun des {stats.totalUsers} utilisateurs.
|
||||||
|
<br />
|
||||||
|
Total de crédits qui seront ajoutés:{" "}
|
||||||
|
<strong>{(stats.totalUsers * 3000000).toLocaleString()}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={addCreditsToAllUsers}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "Ajout en cours..."
|
||||||
|
: `Ajouter 3M crédits à ${stats.totalUsers} utilisateurs`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Résultats */}
|
||||||
|
{result && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h4 className="font-semibold text-green-600 mb-3">
|
||||||
|
✅ Crédits ajoutés avec succès !
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Balances mises à jour:</span>
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{result.updatedBalances}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Nouvelles balances:</span>
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{result.createdBalances}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Crédits par utilisateur:</span>
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{result.creditsPerUser.toLocaleString()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Total ajouté:</span>
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{result.totalCreditsAdded.toLocaleString()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,42 +13,203 @@ import {
|
|||||||
|
|
||||||
interface ModelDistributionChartProps {
|
interface ModelDistributionChartProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
data: Array<{
|
data: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number;
|
||||||
}>;
|
color?: string;
|
||||||
}
|
models?: Array<{
|
||||||
|
|
||||||
interface TooltipPayload {
|
|
||||||
value: number;
|
|
||||||
payload: {
|
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
showLegend?: boolean;
|
||||||
|
totalTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StackedDataEntry {
|
||||||
|
provider: string;
|
||||||
|
total: number;
|
||||||
|
models: ModelData[];
|
||||||
|
baseColor: string;
|
||||||
|
modelColors: string[];
|
||||||
|
[key: string]: string | number | ModelData[] | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
color: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couleurs par fournisseur (couleurs de base)
|
||||||
|
const providerColors: { [key: string]: string } = {
|
||||||
|
Anthropic: "#7C3AED", // Violet vif
|
||||||
|
OpenAI: "#059669", // Vert turquoise vif
|
||||||
|
"Mistral AI": "#D97706", // Orange vif
|
||||||
|
Meta: "#DB2777", // Rose/Magenta vif
|
||||||
|
Google: "#2563EB", // Bleu vif
|
||||||
|
Cohere: "#0891B2", // Cyan vif
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour générer des variations de couleur pour les modèles d'un même provider
|
||||||
|
const generateModelColors = (baseColor: string, modelCount: number) => {
|
||||||
|
const colors = [];
|
||||||
|
for (let i = 0; i < modelCount; i++) {
|
||||||
|
// Créer des variations en ajustant la luminosité
|
||||||
|
const opacity = 1 - i * 0.15; // De 1.0 à 0.4 environ
|
||||||
|
colors.push(
|
||||||
|
`${baseColor}${Math.round(opacity * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour regrouper les modèles par fournisseur et préparer les données pour le graphique empilé
|
||||||
|
const prepareStackedData = (
|
||||||
|
modelData: Array<{ name: string; value: number }>
|
||||||
|
) => {
|
||||||
|
const providerMap: {
|
||||||
|
[key: string]: {
|
||||||
|
value: number;
|
||||||
|
models: Array<{ name: string; value: number }>;
|
||||||
};
|
};
|
||||||
}
|
} = {};
|
||||||
|
|
||||||
interface CustomTooltipProps {
|
modelData.forEach((model) => {
|
||||||
|
let provider = "";
|
||||||
|
|
||||||
|
// Déterminer le fournisseur basé sur le nom du modèle
|
||||||
|
if (
|
||||||
|
model.name.toLowerCase().includes("claude") ||
|
||||||
|
model.name.toLowerCase().includes("anthropic")
|
||||||
|
) {
|
||||||
|
provider = "Anthropic";
|
||||||
|
} else if (
|
||||||
|
model.name.toLowerCase().includes("gpt") ||
|
||||||
|
model.name.toLowerCase().includes("openai")
|
||||||
|
) {
|
||||||
|
provider = "OpenAI";
|
||||||
|
} else if (model.name.toLowerCase().includes("mistral")) {
|
||||||
|
provider = "Mistral AI";
|
||||||
|
} else if (
|
||||||
|
model.name.toLowerCase().includes("llama") ||
|
||||||
|
model.name.toLowerCase().includes("meta")
|
||||||
|
) {
|
||||||
|
provider = "Meta";
|
||||||
|
} else if (
|
||||||
|
model.name.toLowerCase().includes("palm") ||
|
||||||
|
model.name.toLowerCase().includes("gemini") ||
|
||||||
|
model.name.toLowerCase().includes("google")
|
||||||
|
) {
|
||||||
|
provider = "Google";
|
||||||
|
} else if (model.name.toLowerCase().includes("cohere")) {
|
||||||
|
provider = "Cohere";
|
||||||
|
} else {
|
||||||
|
provider = "Autres";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!providerMap[provider]) {
|
||||||
|
providerMap[provider] = { value: 0, models: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
providerMap[provider].value += model.value;
|
||||||
|
providerMap[provider].models.push(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer les données pour le graphique empilé
|
||||||
|
const stackedData: StackedDataEntry[] = Object.entries(providerMap).map(
|
||||||
|
([providerName, data]) => {
|
||||||
|
const baseColor = providerColors[providerName] || "#6B7280";
|
||||||
|
const modelColors = generateModelColors(baseColor, data.models.length);
|
||||||
|
|
||||||
|
// Créer un objet avec le provider comme clé et chaque modèle comme propriété
|
||||||
|
const stackedEntry: StackedDataEntry = {
|
||||||
|
provider: providerName,
|
||||||
|
total: data.value,
|
||||||
|
models: data.models,
|
||||||
|
baseColor,
|
||||||
|
modelColors,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter chaque modèle comme propriété séparée pour le stacking
|
||||||
|
data.models.forEach((model, index) => {
|
||||||
|
const modelKey = `${providerName}_${model.name}`;
|
||||||
|
stackedEntry[modelKey] = model.value;
|
||||||
|
stackedEntry[`${modelKey}_color`] = modelColors[index];
|
||||||
|
stackedEntry[`${modelKey}_name`] = model.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
return stackedEntry;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Créer la liste de tous les modèles uniques pour les barres
|
||||||
|
const allModelKeys: string[] = [];
|
||||||
|
const modelInfo: { [key: string]: ModelInfo } = {};
|
||||||
|
|
||||||
|
stackedData.forEach((entry) => {
|
||||||
|
entry.models.forEach((model, index) => {
|
||||||
|
const modelKey = `${entry.provider}_${model.name}`;
|
||||||
|
allModelKeys.push(modelKey);
|
||||||
|
modelInfo[modelKey] = {
|
||||||
|
color: entry.modelColors[index],
|
||||||
|
name: model.name,
|
||||||
|
provider: entry.provider,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stackedData, allModelKeys, modelInfo };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CustomStackedTooltipProps {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
payload?: TooltipPayload[];
|
payload?: Array<{
|
||||||
|
payload: StackedDataEntry;
|
||||||
|
}>;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
const CustomStackedTooltip = ({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
}: CustomStackedTooltipProps) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
|
const providerData = payload[0].payload;
|
||||||
|
const totalTokens = providerData.total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="bg-white p-3 border rounded-lg shadow-lg">
|
||||||
backgroundColor: "hsl(var(--background))",
|
<p className="font-semibold">{label}</p>
|
||||||
border: "1px solid hsl(var(--border))",
|
<p className="text-blue-600 font-medium">
|
||||||
borderRadius: "8px",
|
Total: {totalTokens.toLocaleString()} tokens
|
||||||
padding: "8px",
|
|
||||||
fontSize: "12px"
|
|
||||||
}}>
|
|
||||||
<p style={{ margin: 0, color: "#ff0000" }}>
|
|
||||||
{`${payload[0].value.toLocaleString()} tokens`}
|
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: 0, color: "#ff0000" }}>
|
{providerData.models && (
|
||||||
{payload[0].payload.name}
|
<div className="mt-2 space-y-1">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Modèles:</p>
|
||||||
|
{providerData.models.map((model, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: providerData.modelColors[index] }}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-700">
|
||||||
|
{model.name}: {model.value.toLocaleString()} tokens
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -56,39 +217,148 @@ const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
|
|||||||
|
|
||||||
export function ModelDistributionChart({
|
export function ModelDistributionChart({
|
||||||
title,
|
title,
|
||||||
|
subtitle,
|
||||||
data,
|
data,
|
||||||
|
totalTokens,
|
||||||
}: ModelDistributionChartProps) {
|
}: ModelDistributionChartProps) {
|
||||||
|
// Si les données sont déjà groupées par fournisseur, les utiliser directement
|
||||||
|
// Sinon, les regrouper automatiquement
|
||||||
|
const modelData = data[0]?.models
|
||||||
|
? data.flatMap((d) => d.models || [])
|
||||||
|
: data;
|
||||||
|
const { stackedData, allModelKeys, modelInfo } =
|
||||||
|
prepareStackedData(modelData);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={data}>
|
<BarChart
|
||||||
|
data={stackedData}
|
||||||
|
margin={{ top: 10, right: 10, left: 10, bottom: 60 }}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/20" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/20" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="provider"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
className="text-xs fill-muted-foreground"
|
className="text-xs fill-muted-foreground"
|
||||||
tick={false}
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={80}
|
||||||
|
interval={0}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
className="text-xs fill-muted-foreground"
|
className="text-xs fill-muted-foreground"
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
||||||
|
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
|
||||||
|
return value.toString();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomStackedTooltip />} />
|
||||||
|
|
||||||
|
{/* Créer une barre empilée pour chaque modèle */}
|
||||||
|
{allModelKeys.map((modelKey) => (
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="value"
|
key={modelKey}
|
||||||
fill="#000000"
|
dataKey={modelKey}
|
||||||
radius={[4, 4, 0, 0]}
|
stackId="models"
|
||||||
|
fill={modelInfo[modelKey].color}
|
||||||
|
radius={0}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Légende des providers avec leurs couleurs de base */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
{stackedData.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-3 rounded-lg border border-muted/20 bg-muted/5 hover:bg-muted/10 transition-colors"
|
||||||
|
style={{
|
||||||
|
borderLeftColor: item.baseColor,
|
||||||
|
borderLeftWidth: "3px",
|
||||||
|
borderLeftStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: item.baseColor }}
|
||||||
|
></div>
|
||||||
|
<h3
|
||||||
|
className="text-sm font-medium"
|
||||||
|
style={{ color: item.baseColor }}
|
||||||
|
>
|
||||||
|
{item.provider}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-semibold text-foreground">
|
||||||
|
{item.total.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{item.models.length} modèle{item.models.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total général */}
|
||||||
|
{totalTokens && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-muted/20 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Total général:{" "}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{totalTokens.toLocaleString()}
|
||||||
|
</span>{" "}
|
||||||
|
tokens
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Légende détaillée des modèles avec leurs couleurs spécifiques */}
|
||||||
|
<div className="mt-4 pt-3 border-t border-muted/20">
|
||||||
|
<h4 className="text-sm font-medium text-muted-foreground mb-3 text-center">
|
||||||
|
Modèles par provider
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stackedData.map((provider) => (
|
||||||
|
<div key={provider.provider} className="space-y-1">
|
||||||
|
<h5
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: provider.baseColor }}
|
||||||
|
>
|
||||||
|
{provider.provider}
|
||||||
|
</h5>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 ml-2">
|
||||||
|
{provider.models.map((model, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: provider.modelColors[index] }}
|
||||||
|
></div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{model.name} ({model.value.toLocaleString()})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,12 +44,12 @@ export function RealUserActivityChart() {
|
|||||||
{
|
{
|
||||||
name: "Utilisateurs actifs",
|
name: "Utilisateurs actifs",
|
||||||
value: activity.activeUsers,
|
value: activity.activeUsers,
|
||||||
color: "#22c55e", // Vert clair pour actifs
|
color: "#000000", // Noir pour actifs
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Utilisateurs inactifs",
|
name: "Utilisateurs inactifs",
|
||||||
value: activity.inactiveUsers,
|
value: activity.inactiveUsers,
|
||||||
color: "#ef4444", // Rouge pour inactifs
|
color: "#666666", // Gris pour inactifs
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export function RealUserActivityChart() {
|
|||||||
Activité des utilisateurs
|
Activité des utilisateurs
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Actifs = connectés dans les 7 derniers jours
|
Actifs = connectés dans les 30 derniers jours
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -74,8 +74,10 @@ export function RealUserActivityChart() {
|
|||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={60}
|
innerRadius={60}
|
||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
paddingAngle={5}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
|
stroke="#ffffff"
|
||||||
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
{data.map((entry, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
@@ -86,17 +88,20 @@ export function RealUserActivityChart() {
|
|||||||
backgroundColor: "hsl(var(--background))",
|
backgroundColor: "hsl(var(--background))",
|
||||||
border: "1px solid hsl(var(--border))",
|
border: "1px solid hsl(var(--border))",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px"
|
||||||
}}
|
}}
|
||||||
formatter={(value: number) => [
|
formatter={(value: number) => [
|
||||||
`${value} utilisateurs (${((value / total) * 100).toFixed(
|
`${value} utilisateurs (${((value / total) * 100).toFixed(1)}%)`,
|
||||||
1
|
|
||||||
)}%)`,
|
|
||||||
"",
|
"",
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Legend
|
<Legend
|
||||||
|
wrapperStyle={{
|
||||||
|
paddingTop: "20px",
|
||||||
|
fontSize: "12px"
|
||||||
|
}}
|
||||||
formatter={(value, entry) => (
|
formatter={(value, entry) => (
|
||||||
<span style={{ color: entry.color }}>
|
<span style={{ color: entry.color, fontWeight: 500 }}>
|
||||||
{value}: {entry.payload?.value} (
|
{value}: {entry.payload?.value} (
|
||||||
{((entry.payload?.value / total) * 100).toFixed(1)}%)
|
{((entry.payload?.value / total) * 100).toFixed(1)}%)
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ export function SimpleStatsChart({ title, data, color = "hsl(var(--primary))" }:
|
|||||||
<AreaChart data={data}>
|
<AreaChart data={data}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={color} stopOpacity={0.3}/>
|
<stop offset="5%" stopColor={color} stopOpacity={0.8}/>
|
||||||
<stop offset="95%" stopColor={color} stopOpacity={0}/>
|
<stop offset="95%" stopColor={color} stopOpacity={0.2}/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/20" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/20" />
|
||||||
@@ -46,6 +46,11 @@ export function SimpleStatsChart({ title, data, color = "hsl(var(--primary))" }:
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
className="text-xs fill-muted-foreground"
|
className="text-xs fill-muted-foreground"
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
||||||
|
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
|
||||||
|
return value.toString();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
@@ -54,12 +59,16 @@ export function SimpleStatsChart({ title, data, color = "hsl(var(--primary))" }:
|
|||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '12px'
|
fontSize: '12px'
|
||||||
}}
|
}}
|
||||||
|
formatter={(value: number) => [
|
||||||
|
value >= 1000 ? `${(value / 1000).toFixed(1)}K tokens` : `${value} tokens`,
|
||||||
|
'Tokens consommés'
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={2}
|
strokeWidth={3}
|
||||||
fill="url(#colorGradient)"
|
fill="url(#colorGradient)"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ export function UserActivityChart({ activeUsers, inactiveUsers }: UserActivityCh
|
|||||||
{
|
{
|
||||||
name: 'Utilisateurs actifs',
|
name: 'Utilisateurs actifs',
|
||||||
value: activeUsers,
|
value: activeUsers,
|
||||||
color: '#22c55e' // Vert clair pour actifs
|
color: '#000000' // Noir pour actifs
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Utilisateurs inactifs',
|
name: 'Utilisateurs inactifs',
|
||||||
value: inactiveUsers,
|
value: inactiveUsers,
|
||||||
color: '#ef4444' // Rouge pour inactifs
|
color: '#666666' // Gris pour inactifs
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export function UserActivityChart({ activeUsers, inactiveUsers }: UserActivityCh
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base font-medium">Activité des utilisateurs</CardTitle>
|
<CardTitle className="text-base font-medium">Activité des utilisateurs</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Actifs = connectés dans les 7 derniers jours
|
Actifs = connectés dans les 30 derniers jours
|
||||||
</p>
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -48,8 +48,10 @@ export function UserActivityChart({ activeUsers, inactiveUsers }: UserActivityCh
|
|||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={60}
|
innerRadius={60}
|
||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
paddingAngle={5}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
|
stroke="#ffffff"
|
||||||
|
strokeWidth={2}
|
||||||
>
|
>
|
||||||
{data.map((entry, index) => (
|
{data.map((entry, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
@@ -60,16 +62,22 @@ export function UserActivityChart({ activeUsers, inactiveUsers }: UserActivityCh
|
|||||||
backgroundColor: 'hsl(var(--background))',
|
backgroundColor: 'hsl(var(--background))',
|
||||||
border: '1px solid hsl(var(--border))',
|
border: '1px solid hsl(var(--border))',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px'
|
||||||
}}
|
}}
|
||||||
formatter={(value: number) => [
|
formatter={(value: number) => [
|
||||||
`${value} utilisateurs (${((value / total) * 100).toFixed(1)}%)`,
|
`${value} utilisateurs (${((value / total) * 100).toFixed(1)}%)`,
|
||||||
''
|
'',
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Legend
|
<Legend
|
||||||
|
wrapperStyle={{
|
||||||
|
paddingTop: "20px",
|
||||||
|
fontSize: "12px"
|
||||||
|
}}
|
||||||
formatter={(value, entry) => (
|
formatter={(value, entry) => (
|
||||||
<span style={{ color: entry.color }}>
|
<span style={{ color: entry.color, fontWeight: 500 }}>
|
||||||
{value}: {entry.payload?.value} ({((entry.payload?.value / total) * 100).toFixed(1)}%)
|
{value}: {entry.payload?.value} (
|
||||||
|
{((entry.payload?.value / total) * 100).toFixed(1)}%)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
80
components/dashboard/charts/user-connections-chart.tsx
Normal file
80
components/dashboard/charts/user-connections-chart.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
interface UserConnectionsChartProps {
|
||||||
|
title: string;
|
||||||
|
data: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserConnectionsChart({ title, data, color = "hsl(var(--chart-2))" }: UserConnectionsChartProps) {
|
||||||
|
console.log("UserConnectionsChart - data:", data);
|
||||||
|
console.log("UserConnectionsChart - title:", title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="connectionsGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={color} stopOpacity={0.8}/>
|
||||||
|
<stop offset="95%" stopColor={color} stopOpacity={0.2}/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted/20" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
className="text-xs fill-muted-foreground"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
className="text-xs fill-muted-foreground"
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
return value.toString();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'hsl(var(--background))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [
|
||||||
|
`${value} utilisateur${value > 1 ? 's' : ''}`,
|
||||||
|
'Connexions'
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={3}
|
||||||
|
fill="url(#connectionsGradient)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
278
components/dashboard/create-user.tsx
Normal file
278
components/dashboard/create-user.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { UserPlus, Loader2, CheckCircle, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface CreateUserResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateUser() {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
role: "USER",
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<CreateUserResult | null>(null);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
// Réinitialiser le résultat quand l'utilisateur modifie le formulaire
|
||||||
|
if (result) {
|
||||||
|
setResult(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
return "Le nom est requis";
|
||||||
|
}
|
||||||
|
if (!formData.email.trim()) {
|
||||||
|
return "L'email est requis";
|
||||||
|
}
|
||||||
|
if (!formData.password) {
|
||||||
|
return "Le mot de passe est requis";
|
||||||
|
}
|
||||||
|
if (formData.password.length < 8) {
|
||||||
|
return "Le mot de passe doit contenir au moins 8 caractères";
|
||||||
|
}
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
return "Les mots de passe ne correspondent pas";
|
||||||
|
}
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(formData.email)) {
|
||||||
|
return "Format d'email invalide";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const validationError = validateForm();
|
||||||
|
if (validationError) {
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
message: validationError,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/create-user", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.name.trim(),
|
||||||
|
email: formData.email.trim().toLowerCase(),
|
||||||
|
password: formData.password,
|
||||||
|
role: formData.role,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setResult({
|
||||||
|
success: true,
|
||||||
|
message: data.message,
|
||||||
|
user: data.user,
|
||||||
|
});
|
||||||
|
// Réinitialiser le formulaire en cas de succès
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
role: "USER",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
data.error || "Erreur lors de la création de l'utilisateur",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la création de l'utilisateur:", error);
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
message: "Erreur de connexion au serveur",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
Créer un nouvel utilisateur
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Nom complet *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex: Jean Dupont"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Adresse email *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Ex: jean.dupont@example.com"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Mot de passe *</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Minimum 8 caractères"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">
|
||||||
|
Confirmer le mot de passe *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Répéter le mot de passe"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("confirmPassword", e.target.value)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="role">Rôle</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.role}
|
||||||
|
onValueChange={(value: string) =>
|
||||||
|
handleInputChange("role", value)
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Sélectionner un rôle" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USER">Utilisateur</SelectItem>
|
||||||
|
<SelectItem value="ADMIN">Administrateur</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<Alert variant={result.success ? "default" : "destructive"}>
|
||||||
|
{result.success ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<AlertDescription>
|
||||||
|
{result.message}
|
||||||
|
{result.success && result.user && (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<strong>Détails:</strong>
|
||||||
|
<br />• ID: {result.user.id}
|
||||||
|
<br />• Email: {result.user.email}
|
||||||
|
<br />• Rôle: {result.user.role}
|
||||||
|
<br />• Crédits initiaux: 3,000,000 tokens
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Création en cours...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
Créer l'utilisateur
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-muted rounded-lg">
|
||||||
|
<h4 className="font-medium mb-2">Informations importantes :</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>
|
||||||
|
• L'utilisateur recevra automatiquement 5,000,000 tokens
|
||||||
|
</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'utilisateur pourra se connecter immédiatement</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
components/dashboard/dashboard-users-list.tsx
Normal file
246
components/dashboard/dashboard-users-list.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCollection } from "@/hooks/useCollection";
|
||||||
|
import {
|
||||||
|
LibreChatUser,
|
||||||
|
LibreChatConversation,
|
||||||
|
LibreChatBalance,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
interface DashboardUser {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
conversations: number;
|
||||||
|
tokens: number;
|
||||||
|
credits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface pour les transactions (vraie source de consommation)
|
||||||
|
interface TransactionDocument {
|
||||||
|
_id: string;
|
||||||
|
user: unknown; // ObjectId dans MongoDB
|
||||||
|
rawAmount?: number;
|
||||||
|
tokenType?: string;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardUsersList() {
|
||||||
|
const [topUsers, setTopUsers] = useState<DashboardUser[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Récupérer toutes les données nécessaires
|
||||||
|
const { data: users, loading: usersLoading } =
|
||||||
|
useCollection<LibreChatUser>("users", { limit: 1000 });
|
||||||
|
const { data: conversations, loading: conversationsLoading } =
|
||||||
|
useCollection<LibreChatConversation>("conversations", { limit: 1000 });
|
||||||
|
const { data: balances, loading: balancesLoading } =
|
||||||
|
useCollection<LibreChatBalance>("balances", { limit: 1000 });
|
||||||
|
// Transactions = vraie source de consommation de tokens
|
||||||
|
const { data: transactions, loading: transactionsLoading } =
|
||||||
|
useCollection<TransactionDocument>("transactions", { limit: 10000 });
|
||||||
|
|
||||||
|
const processUsers = useCallback(() => {
|
||||||
|
if (!users?.length || !conversations?.length || !balances?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔄 Processing users data...");
|
||||||
|
console.log("Users:", users.length);
|
||||||
|
console.log("Conversations:", conversations.length);
|
||||||
|
console.log("Balances:", balances.length);
|
||||||
|
console.log("Transactions:", transactions?.length || 0);
|
||||||
|
|
||||||
|
const processedUsers: DashboardUser[] = [];
|
||||||
|
|
||||||
|
// Créer une map des transactions par utilisateur (user est un ObjectId, on compare en string)
|
||||||
|
const tokensByUser = new Map<string, number>();
|
||||||
|
if (transactions?.length) {
|
||||||
|
transactions.forEach((tx: TransactionDocument) => {
|
||||||
|
// tx.user est un ObjectId, on le convertit en string pour la comparaison
|
||||||
|
const txUserId = String(tx.user);
|
||||||
|
const tokens = Math.abs(tx.rawAmount || 0);
|
||||||
|
tokensByUser.set(txUserId, (tokensByUser.get(txUserId) || 0) + tokens);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
users.forEach((user: LibreChatUser) => {
|
||||||
|
const userId = user._id;
|
||||||
|
|
||||||
|
// Obtenir les conversations de l'utilisateur (user dans conversations est un string)
|
||||||
|
const userConversations = conversations.filter(
|
||||||
|
(conv: LibreChatConversation) => String(conv.user) === userId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtenir les tokens depuis les transactions (vraie consommation)
|
||||||
|
const tokensFromTransactions = tokensByUser.get(userId) || 0;
|
||||||
|
|
||||||
|
// Obtenir le balance de l'utilisateur
|
||||||
|
const userBalance = balances.find(
|
||||||
|
(balance: LibreChatBalance) => String(balance.user) === userId
|
||||||
|
);
|
||||||
|
const credits = userBalance?.tokenCredits || 0;
|
||||||
|
|
||||||
|
// Ajouter l'utilisateur s'il a consommé des tokens
|
||||||
|
if (tokensFromTransactions > 0) {
|
||||||
|
processedUsers.push({
|
||||||
|
userId: userId,
|
||||||
|
userName: user.name || user.username || user.email || "Utilisateur inconnu",
|
||||||
|
conversations: userConversations.length,
|
||||||
|
tokens: tokensFromTransactions,
|
||||||
|
credits: credits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trier par tokens consommés (décroissant) et prendre les 5 premiers
|
||||||
|
const sortedUsers = processedUsers
|
||||||
|
.sort((a, b) => b.tokens - a.tokens)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
console.log("📊 Processing summary:", {
|
||||||
|
totalUsers: users.length,
|
||||||
|
usersWithActivity: processedUsers.length,
|
||||||
|
top5Users: sortedUsers.length,
|
||||||
|
});
|
||||||
|
console.log("✅ Top 5 users:", sortedUsers);
|
||||||
|
setTopUsers(sortedUsers);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, [users, conversations, balances, transactions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const allDataLoaded =
|
||||||
|
!usersLoading &&
|
||||||
|
!conversationsLoading &&
|
||||||
|
!balancesLoading &&
|
||||||
|
!transactionsLoading;
|
||||||
|
|
||||||
|
if (allDataLoaded) {
|
||||||
|
processUsers();
|
||||||
|
} else {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
usersLoading,
|
||||||
|
conversationsLoading,
|
||||||
|
balancesLoading,
|
||||||
|
transactionsLoading,
|
||||||
|
processUsers,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Top 5 utilisateurs
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" disabled>
|
||||||
|
Chargement...
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse"></div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-24 animate-pulse"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-16 mt-1 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-12 mt-1 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topUsers.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Top 5 utilisateurs
|
||||||
|
</CardTitle>
|
||||||
|
<Link href="/users">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Voir tous
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Aucun utilisateur trouvé
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" />
|
||||||
|
Top 5 utilisateurs
|
||||||
|
</CardTitle>
|
||||||
|
<Link href="/users">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Voir tous
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{topUsers.map((user, index) => (
|
||||||
|
<div
|
||||||
|
key={user.userId}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="w-6 h-6 rounded-full p-0 flex items-center justify-center text-xs"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Badge>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{user.userName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{user.conversations} conversation
|
||||||
|
{user.conversations !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium text-sm">
|
||||||
|
{user.tokens.toLocaleString()} tokens
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{user.credits.toLocaleString()} crédits
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
components/dashboard/delete-user.tsx
Normal file
321
components/dashboard/delete-user.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { UserMinus, Loader2, CheckCircle, AlertCircle, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface DeleteUserResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
deletedUser?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
balanceDeleted?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FoundUser {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteUser() {
|
||||||
|
const [searchData, setSearchData] = useState({
|
||||||
|
email: "",
|
||||||
|
userId: "",
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [result, setResult] = useState<DeleteUserResult | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [foundUser, setFoundUser] = useState<FoundUser | null>(null);
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setSearchData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
// Réinitialiser les résultats quand l'utilisateur modifie le formulaire
|
||||||
|
if (result) {
|
||||||
|
setResult(null);
|
||||||
|
}
|
||||||
|
if (confirmDelete) {
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}
|
||||||
|
if (foundUser) {
|
||||||
|
setFoundUser(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
if (!searchData.email.trim() && !searchData.userId.trim()) {
|
||||||
|
return "Email ou ID utilisateur requis";
|
||||||
|
}
|
||||||
|
if (searchData.email && searchData.userId) {
|
||||||
|
return "Veuillez utiliser soit l'email soit l'ID, pas les deux";
|
||||||
|
}
|
||||||
|
if (searchData.email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(searchData.email)) {
|
||||||
|
return "Format d'email invalide";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const validationError = validateForm();
|
||||||
|
if (validationError) {
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
message: validationError,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
setFoundUser(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// D'abord, chercher l'utilisateur pour confirmation
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (searchData.email) {
|
||||||
|
searchParams.append("email", searchData.email.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
if (searchData.userId) {
|
||||||
|
searchParams.append("id", searchData.userId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/collections/users?${searchParams.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.data && data.data.length > 0) {
|
||||||
|
setFoundUser(data.data[0]);
|
||||||
|
setResult({
|
||||||
|
success: true,
|
||||||
|
message: "Utilisateur trouvé. Confirmez la suppression ci-dessous.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
message: "Utilisateur non trouvé",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la recherche:", error);
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
message: "Erreur de connexion au serveur",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!foundUser) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/delete-user", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId: foundUser._id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setResult({
|
||||||
|
success: true,
|
||||||
|
message: data.message,
|
||||||
|
deletedUser: data.deletedUser,
|
||||||
|
balanceDeleted: data.balanceDeleted,
|
||||||
|
});
|
||||||
|
// Réinitialiser le formulaire
|
||||||
|
setSearchData({
|
||||||
|
email: "",
|
||||||
|
userId: "",
|
||||||
|
});
|
||||||
|
setFoundUser(null);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
} else {
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
message: data.error || "Erreur lors de la suppression de l'utilisateur",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la suppression:", error);
|
||||||
|
setResult({
|
||||||
|
success: false,
|
||||||
|
message: "Erreur de connexion au serveur",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-red-600">
|
||||||
|
<UserMinus className="h-5 w-5" />
|
||||||
|
Supprimer un utilisateur
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSearch} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Adresse email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Ex: jean.dupont@example.com"
|
||||||
|
value={searchData.email}
|
||||||
|
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||||
|
disabled={isLoading || !!searchData.userId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="userId">ID Utilisateur</Label>
|
||||||
|
<Input
|
||||||
|
id="userId"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex: 507f1f77bcf86cd799439011"
|
||||||
|
value={searchData.userId}
|
||||||
|
onChange={(e) => handleInputChange("userId", e.target.value)}
|
||||||
|
disabled={isLoading || !!searchData.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!foundUser && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={isLoading} variant="outline">
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Recherche...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Rechercher l'utilisateur
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{foundUser && (
|
||||||
|
<div className="mt-6 p-4 border rounded-lg bg-yellow-50 border-yellow-200">
|
||||||
|
<h4 className="font-medium mb-2 text-yellow-800">Utilisateur trouvé :</h4>
|
||||||
|
<div className="text-sm space-y-1 text-yellow-700">
|
||||||
|
<p><strong>ID:</strong> {foundUser._id}</p>
|
||||||
|
<p><strong>Nom:</strong> {foundUser.name}</p>
|
||||||
|
<p><strong>Email:</strong> {foundUser.email}</p>
|
||||||
|
<p><strong>Rôle:</strong> {foundUser.role}</p>
|
||||||
|
<p><strong>Créé le:</strong> {new Date(foundUser.createdAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
{!confirmDelete ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Supprimer cet utilisateur
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Suppression...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Confirmer la suppression
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<Alert variant={result.success ? "default" : "destructive"} className="mt-4">
|
||||||
|
{result.success ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<AlertDescription>
|
||||||
|
{result.message}
|
||||||
|
{result.success && result.deletedUser && (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<strong>Utilisateur supprimé:</strong>
|
||||||
|
<br />
|
||||||
|
• Nom: {result.deletedUser.name}
|
||||||
|
<br />
|
||||||
|
• Email: {result.deletedUser.email}
|
||||||
|
<br />
|
||||||
|
• Rôle: {result.deletedUser.role}
|
||||||
|
<br />
|
||||||
|
• Solde supprimé: {result.balanceDeleted ? "Oui" : "Non"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-red-50 rounded-lg border border-red-200">
|
||||||
|
<h4 className="font-medium mb-2 text-red-800">⚠️ Attention :</h4>
|
||||||
|
<ul className="text-sm text-red-700 space-y-1">
|
||||||
|
<li>• Cette action est irréversible</li>
|
||||||
|
<li>• L'utilisateur et son solde seront définitivement supprimés</li>
|
||||||
|
<li>• Toutes les données associées seront perdues</li>
|
||||||
|
<li>• Utilisez cette fonction avec précaution</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
Activity,
|
Activity,
|
||||||
|
Euro,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { convertCreditsToEuros } from "@/lib/utils/currency";
|
||||||
|
|
||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -77,6 +79,9 @@ interface MetricCardsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MetricCards({ metrics }: MetricCardsProps) {
|
export function MetricCards({ metrics }: MetricCardsProps) {
|
||||||
|
// Conversion des crédits en euros
|
||||||
|
const creditsConversion = convertCreditsToEuros(metrics.totalCreditsUsed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
@@ -105,7 +110,10 @@ export function MetricCards({ metrics }: MetricCardsProps) {
|
|||||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
Crédits totaux
|
Crédits totaux
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Euro className="h-4 w-4 text-green-600" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
@@ -114,7 +122,22 @@ export function MetricCards({ metrics }: MetricCardsProps) {
|
|||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
crédits disponibles
|
crédits disponibles
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center space-x-2 text-xs text-muted-foreground mt-1">
|
|
||||||
|
{/* Conversion en euros */}
|
||||||
|
<div className="mt-2 p-2 bg-green-50 rounded-lg border border-green-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-green-800">Valeur en EUR:</span>
|
||||||
|
<span className="text-lg font-bold text-green-600">
|
||||||
|
{creditsConversion.formatted.eur}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs text-green-600">
|
||||||
|
<span>USD: {creditsConversion.formatted.usd}</span>
|
||||||
|
<span>Taux: 1 USD = 0.92 EUR</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-muted-foreground mt-2">
|
||||||
<TrendingUp className="h-3 w-3 text-green-500" />
|
<TrendingUp className="h-3 w-3 text-green-500" />
|
||||||
<span className="text-green-500">+23%</span>
|
<span className="text-green-500">+23%</span>
|
||||||
<span>par rapport au mois dernier</span>
|
<span>par rapport au mois dernier</span>
|
||||||
|
|||||||
@@ -2,15 +2,24 @@
|
|||||||
|
|
||||||
import { useMetrics } from "@/hooks/useMetrics";
|
import { useMetrics } from "@/hooks/useMetrics";
|
||||||
import { MetricCard } from "@/components/ui/metric-card";
|
import { MetricCard } from "@/components/ui/metric-card";
|
||||||
import { Users, UserCheck, Shield, Coins, MessageSquare, FileText } from "lucide-react";
|
import {
|
||||||
|
Users,
|
||||||
|
UserCheck,
|
||||||
|
Coins,
|
||||||
|
MessageSquare,
|
||||||
|
FileText,
|
||||||
|
Euro,
|
||||||
|
Activity,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { convertCreditsToEuros } from "@/lib/utils/currency";
|
||||||
|
|
||||||
export function OverviewMetrics() {
|
export function OverviewMetrics() {
|
||||||
const { metrics, loading, error } = useMetrics();
|
const { metrics, loading, error } = useMetrics();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -25,38 +34,67 @@ export function OverviewMetrics() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conversion des crédits en euros
|
||||||
|
const creditsInEuros = convertCreditsToEuros(metrics.totalCredits);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="space-y-4">
|
||||||
<MetricCard
|
{/* Ligne 1: Utilisateurs actifs, Conversations actives, Tokens consommés */}
|
||||||
title="Utilisateurs totaux"
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
value={metrics.totalUsers}
|
|
||||||
icon={Users}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Utilisateurs actifs"
|
title="Utilisateurs actifs"
|
||||||
value={metrics.activeUsers}
|
value={metrics.activeUsers}
|
||||||
icon={UserCheck}
|
icon={UserCheck}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
|
||||||
title="Administrateurs"
|
|
||||||
value={metrics.totalAdmins}
|
|
||||||
icon={Shield}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title="Crédits totaux"
|
|
||||||
value={metrics.totalCredits}
|
|
||||||
icon={Coins}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Conversations actives"
|
title="Conversations actives"
|
||||||
value={metrics.activeConversations}
|
value={metrics.activeConversations}
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
/>
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title="Tokens consommés"
|
||||||
|
value={metrics.totalTokensConsumed?.toLocaleString() || "0"}
|
||||||
|
icon={Activity}
|
||||||
|
description={`${Math.round(
|
||||||
|
(metrics.totalTokensConsumed || 0) / (metrics.totalUsers || 1)
|
||||||
|
)} par utilisateur`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ligne 2: Utilisateurs totaux, Messages totaux, Crédits totaux */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<MetricCard
|
||||||
|
title="Utilisateurs totaux"
|
||||||
|
value={metrics.totalUsers}
|
||||||
|
icon={Users}
|
||||||
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Messages totaux"
|
title="Messages totaux"
|
||||||
value={metrics.totalMessages}
|
value={metrics.totalMessages}
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
/>
|
/>
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-600">Crédits totaux</h3>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Coins className="h-4 w-4 text-gray-400" />
|
||||||
|
<Euro className="h-4 w-4 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold mb-1">
|
||||||
|
{metrics.totalCredits.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 mb-2">crédits disponibles</div>
|
||||||
|
<div className="p-2 bg-green-50 rounded border border-green-200">
|
||||||
|
<div className="text-sm font-semibold text-green-800">
|
||||||
|
{creditsInEuros.formatted.eur}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-green-600">
|
||||||
|
{creditsInEuros.formatted.usd} USD
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useStats } from "@/hooks/useStats";
|
import { useStats } from "@/hooks/useStats";
|
||||||
import { SimpleStatsChart } from "./charts/simple-stats-chart";
|
import { SimpleStatsChart } from "./charts/simple-stats-chart";
|
||||||
|
import { UserConnectionsChart } from "./charts/user-connections-chart";
|
||||||
import { ModelDistributionChart } from "./charts/model-distribution-chart";
|
import { ModelDistributionChart } from "./charts/model-distribution-chart";
|
||||||
import { AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
export function RealTimeStats() {
|
export function RealTimeStats() {
|
||||||
const { stats, loading, error } = useStats();
|
const { stats, loading, error } = useStats();
|
||||||
|
|
||||||
|
console.log("RealTimeStats - stats:", stats);
|
||||||
|
console.log("RealTimeStats - loading:", loading);
|
||||||
|
console.log("RealTimeStats - error:", error);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="space-y-6">
|
||||||
<div className="h-64 bg-muted animate-pulse rounded-lg" />
|
<div className="h-64 bg-muted animate-pulse rounded-lg" />
|
||||||
<div className="h-64 bg-muted animate-pulse rounded-lg" />
|
<div className="h-64 bg-muted animate-pulse rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
@@ -20,7 +26,7 @@ export function RealTimeStats() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-center h-64">
|
<CardContent className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -47,7 +53,7 @@ export function RealTimeStats() {
|
|||||||
|
|
||||||
if (!stats) {
|
if (!stats) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center justify-center h-64">
|
<CardContent className="flex items-center justify-center h-64">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -67,7 +73,25 @@ export function RealTimeStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<Tabs defaultValue="connections" className="space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="connections">Nombre de connexions par utilisateur/par jour</TabsTrigger>
|
||||||
|
<TabsTrigger value="tokens">Tokens consommés par jour</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="connections" className="space-y-6">
|
||||||
|
<UserConnectionsChart
|
||||||
|
title="Nombre de connexions par utilisateur/par jour"
|
||||||
|
data={stats.dailyConnections || []}
|
||||||
|
color="hsl(var(--chart-2))"
|
||||||
|
/>
|
||||||
|
<ModelDistributionChart
|
||||||
|
title="Répartition par modèle"
|
||||||
|
data={stats.modelDistribution}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tokens" className="space-y-6">
|
||||||
<SimpleStatsChart
|
<SimpleStatsChart
|
||||||
title="Tokens consommés par jour"
|
title="Tokens consommés par jour"
|
||||||
data={stats.dailyTokens}
|
data={stats.dailyTokens}
|
||||||
@@ -77,6 +101,7 @@ export function RealTimeStats() {
|
|||||||
title="Répartition par modèle"
|
title="Répartition par modèle"
|
||||||
data={stats.modelDistribution}
|
data={stats.modelDistribution}
|
||||||
/>
|
/>
|
||||||
</div>
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Users, MessageSquare, DollarSign, Activity } from "lucide-react";
|
import { Users, MessageSquare, DollarSign, Activity, Euro } from "lucide-react";
|
||||||
import { useCollection } from "@/hooks/useCollection";
|
import { useCollection } from "@/hooks/useCollection";
|
||||||
|
import { convertCreditsToEuros } from "@/lib/utils/currency";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LibreChatUser,
|
LibreChatUser,
|
||||||
@@ -36,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(() => {
|
||||||
@@ -46,24 +47,20 @@ export function UsageAnalytics() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Console log pour débugger les données balances
|
console.log("=== CALCUL DES STATISTIQUES ===");
|
||||||
console.log("=== DONNÉES BALANCES RÉCUPÉRÉES ===");
|
console.log("Utilisateurs:", users.length);
|
||||||
console.log("Nombre total d'entrées balances:", balances.length);
|
console.log("Conversations:", conversations.length);
|
||||||
console.log("Toutes les entrées balances:", balances);
|
console.log("Transactions:", transactions.length);
|
||||||
|
console.log("Balances:", balances.length);
|
||||||
|
|
||||||
// NOUVEAU : Console log pour débugger les utilisateurs
|
// Analyser les doublons dans les balances
|
||||||
console.log("=== DONNÉES UTILISATEURS ===");
|
|
||||||
console.log("Nombre total d'utilisateurs:", users.length);
|
|
||||||
console.log("Premiers 5 utilisateurs:", users.slice(0, 5));
|
|
||||||
|
|
||||||
// Analyser les doublons
|
|
||||||
const userCounts = new Map<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);
|
||||||
});
|
});
|
||||||
|
|
||||||
const duplicateUsers = Array.from(userCounts.entries()).filter(([_, count]) => count > 1);
|
const duplicateUsers = Array.from(userCounts.entries()).filter(([, count]) => count > 1);
|
||||||
console.log("Utilisateurs avec plusieurs entrées:", duplicateUsers);
|
console.log("Utilisateurs avec plusieurs entrées:", duplicateUsers);
|
||||||
|
|
||||||
// Afficher quelques exemples d'entrées
|
// Afficher quelques exemples d'entrées
|
||||||
@@ -73,10 +70,33 @@ export function UsageAnalytics() {
|
|||||||
const totalBrut = balances.reduce((sum, balance) => sum + (balance.tokenCredits || 0), 0);
|
const totalBrut = balances.reduce((sum, balance) => sum + (balance.tokenCredits || 0), 0);
|
||||||
console.log("Total brut (avec doublons potentiels):", totalBrut);
|
console.log("Total brut (avec doublons potentiels):", totalBrut);
|
||||||
|
|
||||||
|
// Ajouter des logs détaillés pour comprendre le problème
|
||||||
|
console.log("=== DIAGNOSTIC DÉTAILLÉ ===");
|
||||||
|
|
||||||
|
// Analyser les doublons
|
||||||
|
const duplicateDetails = Array.from(userCounts.entries())
|
||||||
|
.filter(([, count]) => count > 1)
|
||||||
|
.map(([userId, count]) => {
|
||||||
|
const userBalances = balances.filter(b => String(b.user) === userId);
|
||||||
|
const totalCredits = userBalances.reduce((sum, b) => sum + (b.tokenCredits || 0), 0);
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
count,
|
||||||
|
totalCredits,
|
||||||
|
balances: userBalances.map(b => ({
|
||||||
|
credits: b.tokenCredits,
|
||||||
|
createdAt: b.createdAt,
|
||||||
|
updatedAt: b.updatedAt
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Détails des doublons:", duplicateDetails);
|
||||||
|
|
||||||
// NOUVEAU : Identifier les utilisateurs fantômes
|
// 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)];
|
||||||
|
|
||||||
@@ -85,46 +105,63 @@ 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);
|
||||||
console.log("Crédits des vrais utilisateurs:", totalBrut - phantomCredits);
|
console.log("Crédits des vrais utilisateurs:", totalBrut - phantomCredits);
|
||||||
|
|
||||||
// Calculer les utilisateurs actifs (5 dernières minutes)
|
// Analyser les utilisateurs fantômes
|
||||||
const fiveMinutesAgo = new Date();
|
const phantomDetails = uniquePhantomUsers.map(userId => {
|
||||||
fiveMinutesAgo.setMinutes(fiveMinutesAgo.getMinutes() - 5);
|
const userBalances = balances.filter(b => String(b.user) === userId);
|
||||||
|
const totalCredits = userBalances.reduce((sum, b) => sum + (b.tokenCredits || 0), 0);
|
||||||
|
return { userId, totalCredits, count: userBalances.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Détails des utilisateurs fantômes:", phantomDetails);
|
||||||
|
|
||||||
|
// Calculer les utilisateurs actifs (30 derniers jours)
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
const activeUsers = users.filter((user) => {
|
const activeUsers = users.filter((user) => {
|
||||||
const lastActivity = new Date(user.updatedAt || user.createdAt);
|
const lastActivity = new Date(user.updatedAt || user.createdAt);
|
||||||
return lastActivity >= fiveMinutesAgo;
|
return lastActivity >= thirtyDaysAgo;
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
// CORRECTION : Créer une map des crédits par utilisateur en évitant les doublons
|
// CORRECTION AMÉLIORÉE : Créer une map des crédits par utilisateur
|
||||||
const creditsMap = new Map<string, number>();
|
const creditsMap = new Map<string, number>();
|
||||||
|
|
||||||
// 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);
|
||||||
if (!balancesByUser.has(userId)) {
|
// Ignorer les utilisateurs fantômes (qui n'existent plus)
|
||||||
balancesByUser.set(userId, []);
|
if (users.some(user => user._id === balanceUserId)) {
|
||||||
|
if (!balancesByUser.has(balanceUserId)) {
|
||||||
|
balancesByUser.set(balanceUserId, []);
|
||||||
|
}
|
||||||
|
balancesByUser.get(balanceUserId)!.push(balance);
|
||||||
}
|
}
|
||||||
balancesByUser.get(userId)!.push(balance);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pour chaque utilisateur, prendre seulement la dernière entrée
|
// Pour chaque utilisateur, calculer les crédits selon votre logique métier
|
||||||
balancesByUser.forEach((userBalances, userId) => {
|
balancesByUser.forEach((userBalances, userId) => {
|
||||||
if (userBalances.length > 0) {
|
if (userBalances.length > 0) {
|
||||||
// Trier par date de mise à jour (plus récent en premier)
|
// OPTION A: Prendre la balance la plus récente
|
||||||
const sortedBalances = userBalances.sort((a, b) => {
|
const sortedBalances = userBalances.sort((a, b) => {
|
||||||
const aDate = new Date((a.updatedAt as string) || (a.createdAt as string) || 0);
|
const aDate = new Date((a.updatedAt as string) || (a.createdAt as string) || 0);
|
||||||
const bDate = new Date((b.updatedAt as string) || (b.createdAt as string) || 0);
|
const bDate = new Date((b.updatedAt as string) || (b.createdAt as string) || 0);
|
||||||
return bDate.getTime() - aDate.getTime();
|
return bDate.getTime() - aDate.getTime();
|
||||||
});
|
});
|
||||||
|
creditsMap.set(userId, sortedBalances[0].tokenCredits || 0);
|
||||||
|
|
||||||
// Prendre la plus récente
|
// OPTION B: Sommer toutes les balances (si c'est votre logique)
|
||||||
const latestBalance = sortedBalances[0];
|
// const totalCredits = userBalances.reduce((sum, balance) => sum + (balance.tokenCredits || 0), 0);
|
||||||
creditsMap.set(userId, latestBalance.tokenCredits || 0);
|
// creditsMap.set(userId, totalCredits);
|
||||||
|
|
||||||
|
// OPTION C: Prendre la balance avec le plus de crédits
|
||||||
|
// const maxCredits = Math.max(...userBalances.map(b => b.tokenCredits || 0));
|
||||||
|
// creditsMap.set(userId, maxCredits);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,17 +186,19 @@ export function UsageAnalytics() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Calculer les conversations par utilisateur
|
// 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;
|
||||||
@@ -167,12 +206,16 @@ export function UsageAnalytics() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// CORRECTION : Calculer le total des crédits depuis la map corrigée
|
// Calculer le total des crédits depuis la map corrigée (sans doublons ni fantômes)
|
||||||
const totalCreditsUsed = Array.from(creditsMap.values()).reduce(
|
const totalCreditsUsed = Array.from(creditsMap.values()).reduce(
|
||||||
(sum, credits) => sum + credits,
|
(sum, credits) => sum + credits,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("=== RÉSULTATS CORRIGÉS ===");
|
||||||
|
console.log("Crédits totaux (sans doublons ni fantômes):", totalCreditsUsed);
|
||||||
|
console.log("Utilisateurs avec crédits:", creditsMap.size);
|
||||||
|
|
||||||
// Tous les utilisateurs triés par tokens puis conversations
|
// Tous les utilisateurs triés par tokens puis conversations
|
||||||
const allUsers = Array.from(userStats.entries())
|
const allUsers = Array.from(userStats.entries())
|
||||||
.map(([userId, stats]) => ({
|
.map(([userId, stats]) => ({
|
||||||
@@ -242,7 +285,7 @@ export function UsageAnalytics() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{stats.totalUsers}</div>
|
<div className="text-2xl font-bold">{stats.totalUsers}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{stats.activeUsers} actifs cette semaine
|
{stats.activeUsers} actifs ce mois
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -282,13 +325,26 @@ export function UsageAnalytics() {
|
|||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Crédits totaux
|
Crédits totaux
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Euro className="h-4 w-4 text-green-600" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{stats.totalCreditsUsed.toLocaleString()}
|
{stats.totalCreditsUsed.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">crédits disponibles</p>
|
<p className="text-xs text-muted-foreground">crédits disponibles</p>
|
||||||
|
|
||||||
|
{/* Conversion en euros */}
|
||||||
|
<div className="mt-2 p-2 bg-green-50 rounded-lg border border-green-200">
|
||||||
|
<div className="text-sm font-medium text-green-800">
|
||||||
|
Valeur: {convertCreditsToEuros(stats.totalCreditsUsed).formatted.eur}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-green-600">
|
||||||
|
({convertCreditsToEuros(stats.totalCreditsUsed).formatted.usd} USD)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
56
components/dashboard/user-management.tsx
Normal file
56
components/dashboard/user-management.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { UserPlus, UserMinus, Users } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import CreateUser from "./create-user";
|
||||||
|
import DeleteUser from "./delete-user";
|
||||||
|
|
||||||
|
export default function UserManagement() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-6 w-6" />
|
||||||
|
Gestion des Utilisateurs
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="create-user">
|
||||||
|
<AccordionTrigger className="text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5 text-green-600" />
|
||||||
|
<span>Créer un nouvel utilisateur</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="pt-4">
|
||||||
|
<CreateUser />
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="delete-user">
|
||||||
|
<AccordionTrigger className="text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserMinus className="h-5 w-5 text-red-600" />
|
||||||
|
<span>Supprimer un utilisateur</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="pt-4">
|
||||||
|
<DeleteUser />
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -20,136 +19,165 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
BarChart3,
|
LogOut,
|
||||||
Activity,
|
User,
|
||||||
|
Mail,
|
||||||
|
GraduationCap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import type { User as SupabaseUser } from "@supabase/supabase-js";
|
||||||
|
|
||||||
interface NavigationItem {
|
const topLevelNavigation = [
|
||||||
name: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
badge?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigation: NavigationItem[] = [
|
|
||||||
{
|
|
||||||
name: "Vue d'ensemble",
|
|
||||||
href: "/",
|
|
||||||
icon: LayoutDashboard,
|
|
||||||
badge: null,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Analytics",
|
name: "Analytics",
|
||||||
href: "/analytics",
|
href: "/",
|
||||||
icon: BarChart3,
|
icon: LayoutDashboard,
|
||||||
badge: "Nouveau",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const dataNavigation: NavigationItem[] = [
|
const navigationGroups = [
|
||||||
{ name: "Utilisateurs", href: "/users", icon: Users, badge: null },
|
{
|
||||||
|
name: "Données",
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
name: "Conversations",
|
name: "Conversations",
|
||||||
href: "/conversations",
|
href: "/conversations",
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
badge: null,
|
|
||||||
},
|
},
|
||||||
{ name: "Messages", href: "/messages", icon: FileText, badge: null },
|
{
|
||||||
|
name: "Utilisateurs",
|
||||||
|
href: "/users",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Messages",
|
||||||
|
href: "/messages",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Transactions",
|
name: "Transactions",
|
||||||
href: "/transactions",
|
href: "/transactions",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
badge: null,
|
},
|
||||||
|
{
|
||||||
|
name: "Groupes",
|
||||||
|
href: "/referents",
|
||||||
|
icon: GraduationCap,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Système",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "Collections",
|
||||||
|
href: "/collections",
|
||||||
|
icon: Database,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Agents",
|
||||||
|
href: "/agents",
|
||||||
|
icon: Bot,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rôles",
|
||||||
|
href: "/roles",
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Paramètres",
|
||||||
|
href: "/settings",
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const systemNavigation: NavigationItem[] = [
|
|
||||||
{ name: "Agents", href: "/agents", icon: Bot, badge: null },
|
|
||||||
{ name: "Rôles", href: "/roles", icon: Shield, badge: null },
|
|
||||||
{ name: "Collections", href: "/collections", icon: Database, badge: null },
|
|
||||||
{ name: "Paramètres", href: "/settings", icon: Settings, badge: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [user, setUser] = useState<SupabaseUser | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
const NavSection = ({
|
useEffect(() => {
|
||||||
title,
|
const getUser = async () => {
|
||||||
items,
|
try {
|
||||||
showTitle = true,
|
const {
|
||||||
}: {
|
data: { user },
|
||||||
title: string;
|
} = await supabase.auth.getUser();
|
||||||
items: NavigationItem[];
|
setUser(user);
|
||||||
showTitle?: boolean;
|
} catch (error) {
|
||||||
}) => (
|
console.error(
|
||||||
<div className="space-y-2">
|
"Erreur lors de la récupération de l'utilisateur:",
|
||||||
{!collapsed && showTitle && (
|
error
|
||||||
<h3 className="px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
)}
|
|
||||||
{items.map((item) => {
|
|
||||||
const isActive = pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link key={item.name} href={item.href}>
|
|
||||||
<Button
|
|
||||||
variant={isActive ? "secondary" : "ghost"}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start h-9 px-3",
|
|
||||||
collapsed && "px-2 justify-center",
|
|
||||||
isActive && "bg-secondary font-medium"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className={cn("h-4 w-4", collapsed ? "" : "mr-3")} />
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<span>{item.name}</span>
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getUser();
|
||||||
|
|
||||||
|
// Écouter les changements d'authentification
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||||
|
setUser(session?.user || null);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [supabase.auth]);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
router.push("/login");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors de la déconnexion:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ne pas afficher la sidebar si l'utilisateur n'est pas connecté ou en cours de chargement
|
||||||
|
if (loading || !user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-screen bg-background border-r border-border transition-all duration-300 ease-in-out",
|
"flex flex-col h-screen bg-white border-r border-gray-200 transition-all duration-300",
|
||||||
collapsed ? "w-16" : "w-64"
|
collapsed ? "w-16" : "w-64"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center">
|
<div className="relative w-8 h-8">
|
||||||
<Image
|
<Image
|
||||||
src="/img/logo.png"
|
src="/img/logo.png"
|
||||||
alt="Cercle GPT Logo"
|
alt="Logo"
|
||||||
width={32}
|
fill
|
||||||
height={32}
|
className="object-contain"
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-sm font-semibold">Cercle GPT</h1>
|
<h1 className="text-lg font-semibold text-gray-900">
|
||||||
<p className="text-xs text-muted-foreground">Admin Dashboard</p>
|
Cercle GPT
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-gray-500">Admin Dashboard</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="sm"
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
className="h-8 w-8"
|
className="p-1.5 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -160,32 +188,112 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 p-3 space-y-6 overflow-y-auto">
|
<nav className="flex-1 p-4 space-y-6 overflow-y-auto">
|
||||||
<NavSection title="Dashboard" items={navigation} showTitle={false} />
|
{/* Navigation de niveau supérieur */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{topLevelNavigation.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-gray-900 text-white"
|
||||||
|
: "text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||||
|
{!collapsed && <span>{item.name}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!collapsed && <Separator />}
|
{/* Séparateur */}
|
||||||
|
<div className="border-t border-gray-200"></div>
|
||||||
|
|
||||||
<NavSection title="Données" items={dataNavigation} />
|
{/* Groupes de navigation */}
|
||||||
|
{navigationGroups.map((group) => (
|
||||||
|
<div key={group.name}>
|
||||||
|
{/* Titre du groupe */}
|
||||||
|
{!collapsed && (
|
||||||
|
<h3 className="px-3 mb-2 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
|
{group.name}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
{!collapsed && <Separator />}
|
{/* Séparateur visuel quand collapsed */}
|
||||||
|
{collapsed && (
|
||||||
|
<div className="mb-2 mx-auto w-8 h-px bg-gray-300"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
<NavSection title="Système" items={systemNavigation} />
|
{/* Items du groupe */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-gray-900 text-white"
|
||||||
|
: "text-gray-700 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 flex-shrink-0" />
|
||||||
|
{!collapsed && <span>{item.name}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Section utilisateur connecté */}
|
||||||
|
<div className="p-4 border-t border-gray-200 space-y-3 flex-shrink-0 bg-white">
|
||||||
|
{/* Informations utilisateur */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center space-x-3 px-3 py-2 bg-gray-50 rounded-md",
|
||||||
|
collapsed && "justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center">
|
||||||
|
<User className="h-4 w-4 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="p-3 border-t border-border">
|
|
||||||
<div className="flex items-center space-x-3 p-2 rounded-lg bg-muted/50">
|
|
||||||
<div className="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
|
||||||
<Activity className="h-4 w-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs font-medium">Système en ligne</p>
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
<p className="text-xs text-muted-foreground">Tout fonctionne</p>
|
Administrateur
|
||||||
</div>
|
</p>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Mail className="h-3 w-3 text-gray-400" />
|
||||||
|
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bouton de déconnexion */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center space-x-2 text-sm font-medium border-gray-200 hover:bg-gray-50",
|
||||||
|
collapsed && "justify-center px-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
{!collapsed && <span>Déconnexion</span>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
components/ui/accordion.tsx
Normal file
58
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Accordion = AccordionPrimitive.Root;
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn("border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AccordionItem.displayName = "AccordionItem";
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
));
|
||||||
|
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
));
|
||||||
|
|
||||||
|
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
46
components/ui/alert.tsx
Normal file
46
components/ui/alert.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
|
export { Alert, AlertDescription }
|
||||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -7,6 +7,7 @@ interface MetricCardProps {
|
|||||||
title: string;
|
title: string;
|
||||||
value: number | string;
|
value: number | string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
description?: string;
|
||||||
trend?: {
|
trend?: {
|
||||||
value: number;
|
value: number;
|
||||||
isPositive: boolean;
|
isPositive: boolean;
|
||||||
@@ -18,6 +19,7 @@ export function MetricCard({
|
|||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
|
description,
|
||||||
trend,
|
trend,
|
||||||
className
|
className
|
||||||
}: MetricCardProps) {
|
}: MetricCardProps) {
|
||||||
@@ -33,6 +35,11 @@ export function MetricCard({
|
|||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{typeof value === 'number' ? formatNumber(value) : value}
|
{typeof value === 'number' ? formatNumber(value) : value}
|
||||||
</div>
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{trend && (
|
{trend && (
|
||||||
<Badge
|
<Badge
|
||||||
variant={trend.isPositive ? "default" : "destructive"}
|
variant={trend.isPositive ? "default" : "destructive"}
|
||||||
|
|||||||
160
components/ui/select.tsx
Normal file
160
components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
27
debug-analytics.js
Normal file
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);
|
||||||
73
hooks/useCollection.old.ts
Normal file
73
hooks/useCollection.old.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
interface UseCollectionOptions {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectionResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCollection<T = Record<string, unknown>>(
|
||||||
|
collectionName: string,
|
||||||
|
options: UseCollectionOptions = {}
|
||||||
|
) {
|
||||||
|
const [data, setData] = useState<T[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
|
||||||
|
const { page = 1, limit = 20, filter = {}, search } = options;
|
||||||
|
|
||||||
|
// Mémoriser la chaîne JSON du filtre pour éviter les re-renders inutiles
|
||||||
|
const filterString = useMemo(() => JSON.stringify(filter), [filter]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
limit: limit.toString(),
|
||||||
|
filter: filterString,
|
||||||
|
});
|
||||||
|
if (search) {
|
||||||
|
params.append("search", search);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/collections/${collectionName}?${params}`
|
||||||
|
);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Erreur lors du chargement de ${collectionName}`);
|
||||||
|
|
||||||
|
const result: CollectionResponse<T> = await response.json();
|
||||||
|
setData(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
setTotalPages(result.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [collectionName, page, limit, filterString]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
return fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
return { data, loading, error, total, totalPages, refetch };
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
|
||||||
interface UseCollectionOptions {
|
interface UseCollectionOptions {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
filter?: Record<string, unknown>;
|
filter?: Record<string, unknown>;
|
||||||
|
search?: string;
|
||||||
|
referent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CollectionResponse<T> {
|
interface CollectionResponse<T> {
|
||||||
@@ -26,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 = {} } = 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]);
|
||||||
@@ -37,22 +39,31 @@ export function useCollection<T = Record<string, unknown>>(
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
limit: limit.toString(),
|
limit: limit.toString(),
|
||||||
filter: filterString
|
filter: filterString,
|
||||||
});
|
});
|
||||||
|
if (search) {
|
||||||
|
params.append("search", search);
|
||||||
|
}
|
||||||
|
if (referent) {
|
||||||
|
params.append("referent", referent);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/collections/${collectionName}?${params}`);
|
const response = await fetch(
|
||||||
if (!response.ok) throw new Error(`Erreur lors du chargement de ${collectionName}`);
|
`/api/collections/${collectionName}?${params}`
|
||||||
|
);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Erreur lors du chargement de ${collectionName}`);
|
||||||
|
|
||||||
const result: CollectionResponse<T> = await response.json();
|
const result: CollectionResponse<T> = await response.json();
|
||||||
setData(result.data);
|
setData(result.data);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
setTotalPages(result.totalPages);
|
setTotalPages(result.totalPages);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Erreur inconnue');
|
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [collectionName, page, limit, filterString]);
|
}, [collectionName, page, limit, filterString, search, referent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|||||||
73
hooks/useCollection.ts.bak
Normal file
73
hooks/useCollection.ts.bak
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
interface UseCollectionOptions {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollectionResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCollection<T = Record<string, unknown>>(
|
||||||
|
collectionName: string,
|
||||||
|
options: UseCollectionOptions = {}
|
||||||
|
) {
|
||||||
|
const [data, setData] = useState<T[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
|
||||||
|
const { page = 1, limit = 20, filter = {}, search } = options;
|
||||||
|
|
||||||
|
// Mémoriser la chaîne JSON du filtre pour éviter les re-renders inutiles
|
||||||
|
const filterString = useMemo(() => JSON.stringify(filter), [filter]);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
limit: limit.toString(),
|
||||||
|
filter: filterString,
|
||||||
|
});
|
||||||
|
if (search) {
|
||||||
|
params.append("search", search);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/collections/${collectionName}?${params}`
|
||||||
|
);
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`Erreur lors du chargement de ${collectionName}`);
|
||||||
|
|
||||||
|
const result: CollectionResponse<T> = await response.json();
|
||||||
|
setData(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
setTotalPages(result.totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [collectionName, page, limit, filterString]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
return fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
return { data, loading, error, total, totalPages, refetch };
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ interface DailyToken {
|
|||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DailyConnection {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ModelDistribution {
|
interface ModelDistribution {
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number;
|
||||||
@@ -14,6 +19,7 @@ interface ModelDistribution {
|
|||||||
|
|
||||||
interface StatsData {
|
interface StatsData {
|
||||||
dailyTokens: DailyToken[];
|
dailyTokens: DailyToken[];
|
||||||
|
dailyConnections: DailyConnection[];
|
||||||
modelDistribution: ModelDistribution[];
|
modelDistribution: ModelDistribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
8
lib/supabase/client.ts
Normal file
8
lib/supabase/client.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
|
||||||
|
export function createClient() {
|
||||||
|
return createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
)
|
||||||
|
}
|
||||||
29
lib/supabase/server.ts
Normal file
29
lib/supabase/server.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createServerClient as createSupabaseServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
|
||||||
|
export async function createServerClient() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
|
||||||
|
return createSupabaseServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet: Array<{ name: string; value: string; options?: Record<string, unknown> }>) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// The `setAll` method was called from a Server Component.
|
||||||
|
// This can be ignored if you have middleware refreshing
|
||||||
|
// user sessions.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
49
lib/utils/currency.ts
Normal file
49
lib/utils/currency.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Fonction de conversion des crédits en euros
|
||||||
|
export function convertCreditsToEuros(credits: number): {
|
||||||
|
usd: number;
|
||||||
|
eur: number;
|
||||||
|
formatted: {
|
||||||
|
usd: string;
|
||||||
|
eur: string;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
// Donc : 1 million de crédits = 1 USD
|
||||||
|
const usdAmount = credits / 1_000_000;
|
||||||
|
|
||||||
|
// Taux de change USD → EUR (vous pouvez ajuster ce taux)
|
||||||
|
// Taux approximatif actuel : 1 USD ≈ 0.92 EUR
|
||||||
|
const USD_TO_EUR_RATE = 0.92;
|
||||||
|
const eurAmount = usdAmount * USD_TO_EUR_RATE;
|
||||||
|
|
||||||
|
return {
|
||||||
|
usd: usdAmount,
|
||||||
|
eur: eurAmount,
|
||||||
|
formatted: {
|
||||||
|
usd: `$${usdAmount.toFixed(2)}`,
|
||||||
|
eur: `€${eurAmount.toFixed(2)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour formater les crédits avec conversion
|
||||||
|
export function formatCreditsWithCurrency(credits: number): string {
|
||||||
|
const conversion = convertCreditsToEuros(credits);
|
||||||
|
return `${credits.toLocaleString()} crédits (${conversion.formatted.eur})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour obtenir le taux de change en temps réel (optionnel)
|
||||||
|
export async function getCurrentExchangeRate(): Promise<number> {
|
||||||
|
try {
|
||||||
|
// Vous pouvez utiliser une API gratuite comme exchangerate-api.com
|
||||||
|
const response = await fetch(
|
||||||
|
"https://api.exchangerate-api.com/v4/latest/USD"
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.rates.EUR || 0.92; // Fallback au taux fixe
|
||||||
|
} catch {
|
||||||
|
console.warn(
|
||||||
|
"Impossible de récupérer le taux de change, utilisation du taux fixe"
|
||||||
|
);
|
||||||
|
return 0.92; // Taux fixe de fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
65
middleware.ts
Normal file
65
middleware.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
let supabaseResponse = NextResponse.next({
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet: Array<{ name: string; value: string; options?: Record<string, unknown> }>) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
|
||||||
|
supabaseResponse = NextResponse.next({
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
supabaseResponse.cookies.set(name, value, options)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rafraîchir la session
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
// Si l'utilisateur est sur la page de login
|
||||||
|
if (request.nextUrl.pathname === '/login') {
|
||||||
|
if (user) {
|
||||||
|
// Utilisateur connecté, rediriger vers le dashboard
|
||||||
|
const redirectUrl = new URL('/', request.url)
|
||||||
|
return NextResponse.redirect(redirectUrl)
|
||||||
|
}
|
||||||
|
// Utilisateur non connecté, autoriser l'accès à la page de login
|
||||||
|
return supabaseResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pour toutes les autres pages, vérifier l'authentification
|
||||||
|
if (!user) {
|
||||||
|
// Utilisateur non connecté, rediriger vers login
|
||||||
|
const redirectUrl = new URL('/login', request.url)
|
||||||
|
return NextResponse.redirect(redirectUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return supabaseResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
* - public folder
|
||||||
|
*/
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|public|img).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
457
package-lock.json
generated
457
package-lock.json
generated
@@ -8,23 +8,33 @@
|
|||||||
"name": "admin-dashboard",
|
"name": "admin-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@supabase/ssr": "^0.7.0",
|
||||||
|
"@supabase/supabase-js": "^2.58.0",
|
||||||
|
"@tanstack/react-query": "^5.90.11",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/mongodb": "^4.0.6",
|
"@types/mongodb": "^4.0.6",
|
||||||
|
"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",
|
||||||
@@ -1024,12 +1034,49 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/number": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-accordion": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collapsible": "1.1.12",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
@@ -1053,6 +1100,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-collapsible": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
@@ -1245,6 +1322,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-label": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-navigation-menu": {
|
"node_modules/@radix-ui/react-navigation-menu": {
|
||||||
"version": "1.2.14",
|
"version": "1.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
|
||||||
@@ -1439,6 +1539,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-select": {
|
||||||
|
"version": "2.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||||
|
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.1",
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-separator": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||||
@@ -1761,6 +1904,115 @@
|
|||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.72.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.72.0.tgz",
|
||||||
|
"integrity": "sha512-4+bnUrtTDK1YD0/FCx2YtMiQH5FGu9Jlf4IQi5kcqRwRwqp2ey39V61nHNdH86jm3DIzz0aZKiWfTW8qXk1swQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-SXBx6Jvp+MOBekeKFu+G11YLYPeVeGQl23eYyAG9+Ro0pQ1aIP0UZNIBxHKNHqxzR0L0n6gysNr2KT3841NATw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/node-fetch": {
|
||||||
|
"version": "2.6.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
|
||||||
|
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/node-fetch/node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/node-fetch/node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/node-fetch/node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "1.21.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.4.tgz",
|
||||||
|
"integrity": "sha512-TxZCIjxk6/dP9abAi89VQbWWMBbybpGWyvmIzTd79OeravM13OjR/YEYeyUOPcM1C3QyvXkvPZhUfItvmhY1IQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.15.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.5.tgz",
|
||||||
|
"integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.13",
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/ssr": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.43.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.12.2.tgz",
|
||||||
|
"integrity": "sha512-SiySHxi3q7gia7NBYpsYRu8gyI0NhFwSORMxbZIxJ/zAVkN6QpwDRan158CJ+UdzD4WB/rQMAGRqIJQP+7ccAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "^2.6.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.58.0.tgz",
|
||||||
|
"integrity": "sha512-Tm1RmQpoAKdQr4/8wiayGti/no+If7RtveVZjHR8zbO7hhQjmPW2Ok5ZBPf1MGkt5c+9R85AVMsTfSaqAP1sUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.72.0",
|
||||||
|
"@supabase/functions-js": "2.5.0",
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"@supabase/postgrest-js": "1.21.4",
|
||||||
|
"@supabase/realtime-js": "2.15.5",
|
||||||
|
"@supabase/storage-js": "2.12.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -2046,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",
|
||||||
@@ -2057,6 +2335,12 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
@@ -2154,12 +2438,17 @@
|
|||||||
"version": "20.19.19",
|
"version": "20.19.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz",
|
||||||
"integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==",
|
"integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/phoenix": {
|
||||||
|
"version": "1.6.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||||
|
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz",
|
||||||
@@ -2203,6 +2492,15 @@
|
|||||||
"@types/webidl-conversions": "*"
|
"@types/webidl-conversions": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.45.0",
|
"version": "8.45.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz",
|
||||||
@@ -2785,6 +3083,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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",
|
||||||
@@ -3067,6 +3374,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -3180,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",
|
||||||
@@ -3234,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",
|
||||||
@@ -3261,6 +3599,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"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",
|
||||||
@@ -3561,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",
|
||||||
@@ -4372,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",
|
||||||
@@ -6775,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",
|
||||||
@@ -7280,7 +7672,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
@@ -7529,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",
|
||||||
@@ -7539,6 +7948,48 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"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",
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -9,23 +9,33 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@supabase/ssr": "^0.7.0",
|
||||||
|
"@supabase/supabase-js": "^2.58.0",
|
||||||
|
"@tanstack/react-query": "^5.90.11",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/mongodb": "^4.0.6",
|
"@types/mongodb": "^4.0.6",
|
||||||
|
"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