modifs complete

This commit is contained in:
Biqoz
2025-11-27 13:58:47 +01:00
parent 5b8b3c84c9
commit 73f97919ac
12 changed files with 931 additions and 524 deletions

View File

@@ -8,7 +8,9 @@
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)",
"Bash(npm install:*)"
"Bash(npm install:*)",
"Bash(node debug-search.js:*)",
"Bash(node update-referent.js:*)"
],
"deny": [],
"ask": []

View File

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

View File

@@ -55,29 +55,76 @@ export async function GET(
if (collection === "users") {
const email = searchParams.get("email");
const id = searchParams.get("id");
const search = searchParams.get("search"); // ✅ AJOUTER cette ligne
const search = searchParams.get("search");
const referent = searchParams.get("referent");
if (email) {
filter.email = email.toLowerCase();
} else if (id) {
// Vérifier si l'ID est un ObjectId valide
if (ObjectId.isValid(id)) {
filter._id = new ObjectId(id);
} else {
// Si l'ID n'est pas valide, retourner une erreur
return NextResponse.json(
{ error: "ID utilisateur invalide" },
{ status: 400 }
);
}
} else if (search) {
// ✅ AJOUTER ce bloc
// Recherche partielle sur nom et email
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();

View File

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

View File

@@ -3,6 +3,7 @@ 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() {
@@ -79,8 +80,11 @@ export default function SettingsPage() {
Gestion des Crédits
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-6">
<AddCreditsSingleUser />
<div className="border-t pt-6">
<AddCredits />
</div>
</CardContent>
</Card>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useCollection } from "@/hooks/useCollection";
import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
@@ -13,15 +13,17 @@ import {
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
ChevronLeft,
ChevronRight,
Users,
ChevronDown,
Search,
MessageSquare,
Calendar,
X,
User,
Bot,
Loader2,
} from "lucide-react";
import { formatDate } from "@/lib/utils";
import {
@@ -30,532 +32,396 @@ import {
LibreChatMessage,
} from "@/lib/types";
// Hook debounce personnalisé
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// Types pour les messages étendus
interface ExtendedMessage extends LibreChatMessage {
content?: Array<{ type: string; text: string }> | string;
message?: Record<string, unknown>;
parts?: Array<string | { text: string }>;
metadata?: { text?: string };
[key: string]: unknown;
}
// 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 response = await fetch(`/api/collections/${collection}?${searchParams}`);
if (!response.ok) throw new Error(`Erreur lors du chargement de ${collection}`);
return response.json();
}
export function ConversationsTable() {
const [page, setPage] = useState(1);
const [selectedConversationId, setSelectedConversationId] = useState<
string | null
>(null);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState("");
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const limit = 10;
const limit = 20;
const debouncedSearch = useDebounce(searchInput, 250);
// Charger toutes les conversations pour le groupement côté client
// Reset page quand la recherche change
useEffect(() => {
setPage(1);
}, [debouncedSearch]);
// Query conversations avec TanStack Query
const {
data: conversations = [],
total = 0,
loading,
} = useCollection<LibreChatConversation>("conversations", {
limit: 1000,
page: 1, // Remplacer skip par page
data: conversationsData,
isLoading: conversationsLoading,
isFetching: conversationsFetching,
} = useQuery({
queryKey: ["conversations", page, limit, debouncedSearch],
queryFn: () =>
fetchCollection<LibreChatConversation>("conversations", {
page,
limit,
search: debouncedSearch,
}),
placeholderData: keepPreviousData,
staleTime: 30000, // 30 secondes
});
const { data: users = [] } = useCollection<LibreChatUser>("users", {
limit: 1000,
// Query users (cache long car ça change rarement)
const { data: usersData } = useQuery({
queryKey: ["users", "all"],
queryFn: () => fetchCollection<LibreChatUser>("users", { limit: 1000 }),
staleTime: 1000 * 60 * 5, // 5 minutes
});
// Charger les messages seulement si une conversation est sélectionnée
const { data: messages = [] } = useCollection<LibreChatMessage>("messages", {
limit: 1000,
filter: selectedConversationId
? { conversationId: selectedConversationId }
: {},
// Query messages de la conversation sélectionnée
const { data: messagesData, isLoading: messagesLoading } = useQuery({
queryKey: ["messages", selectedConversationId],
queryFn: () =>
fetchCollection<LibreChatMessage>("messages", {
limit: 500,
filter: JSON.stringify({ conversationId: selectedConversationId }),
}),
enabled: !!selectedConversationId,
staleTime: 30000,
});
const userMap = new Map(users.map((user) => [user._id, user]));
const conversations = conversationsData?.data ?? [];
const total = conversationsData?.total ?? 0;
const totalPages = conversationsData?.totalPages ?? 0;
const users = usersData?.data ?? [];
const messages = messagesData?.data ?? [];
const getUserDisplayName = (userId: string): string => {
if (userId === "unknown") return "Utilisateur inconnu";
// Map des users pour lookup rapide
const userMap = useMemo(() => new Map(users.map((u) => [u._id, u])), [users]);
const getUserInfo = useCallback(
(userId: string) => {
const user = userMap.get(userId);
if (user) {
return (
user.name ||
user.username ||
user.email ||
`Utilisateur ${userId.slice(-8)}`
return {
name: user?.name || user?.username || `User ${userId.slice(-6)}`,
email: user?.email || null,
};
},
[userMap]
);
}
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;
};
// Extraction du contenu des messages
const getMessageContent = useCallback((message: LibreChatMessage): string => {
const msg = message as ExtendedMessage;
// 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);
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();
}
// 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 clearSearch = () => {
setSearchInput("");
searchInputRef.current?.focus();
};
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
const groupedConversations = conversations.reduce((acc, conversation) => {
const userId = conversation.user || "unknown";
if (!acc[userId]) {
acc[userId] = [];
}
acc[userId].push(conversation);
return acc;
}, {} as Record<string, LibreChatConversation[]>);
// Pagination des groupes d'utilisateurs
const totalPages = Math.ceil(
Object.keys(groupedConversations).length / limit
);
const skip = (page - 1) * limit;
const userIds = Object.keys(groupedConversations).slice(skip, skip + limit);
const isSearching = searchInput !== debouncedSearch || conversationsFetching;
return (
<div className="space-y-6">
<div className="space-y-4">
<Card>
<CardHeader>
<CardHeader className="pb-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Conversations par utilisateur
<MessageSquare className="h-5 w-5" />
Conversations
<Badge variant="secondary" className="ml-2">
{total}
</Badge>
{conversationsFetching && !conversationsLoading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</CardTitle>
<p className="text-sm text-muted-foreground">
{Object.keys(groupedConversations).length} utilisateurs {" "}
{conversations.length} conversations au total
{/* 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" />
</button>
) : null}
</div>
</div>
</div>
{debouncedSearch && (
<p className="text-sm text-muted-foreground mt-2">
{total} résultat{total > 1 ? "s" : ""} pour &quot;{debouncedSearch}&quot;
</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>
{conversationsLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : conversations.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{debouncedSearch ? (
<>
<Search className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p>Aucun résultat pour &quot;{debouncedSearch}&quot;</p>
<Button variant="link" onClick={clearSearch} className="mt-2">
Effacer la recherche
</Button>
</>
) : (
<p>Aucune conversation</p>
)}
</div>
<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">
) : (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead className="w-52">Utilisateur</TableHead>
<TableHead>Titre</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Modèle</TableHead>
<TableHead>Messages</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Créée le</TableHead>
<TableHead className="w-24">Endpoint</TableHead>
<TableHead className="w-20 text-center">Msgs</TableHead>
<TableHead className="w-24">Statut</TableHead>
<TableHead className="w-28">Date</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;
{conversations.map((conv) => {
const userInfo = getUserInfo(String(conv.user));
const msgCount = conv.messages?.length || 0;
const isSelected = selectedConversationId === conv.conversationId;
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"
<Fragment key={conv._id}>
<TableRow
className={`cursor-pointer hover:bg-muted/50 transition-colors ${isSelected ? "bg-muted/50" : ""}`}
onClick={() =>
handleShowMessages(
conversation.conversationId,
userId
setSelectedConversationId(
isSelected ? null : conv.conversationId
)
}
>
{messageCount}
<TableCell>
<div className="flex items-center gap-2">
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${
isSelected ? "rotate-0" : "-rotate-90"
}`}
/>
<div className="flex flex-col">
<span className="font-medium truncate max-w-[180px]">
{userInfo.name}
</span>
{userInfo.email && (
<span className="text-xs text-muted-foreground truncate max-w-[180px]">
{userInfo.email}
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<span className="truncate block max-w-xs" title={String(conv.title)}>
{String(conv.title) || "Sans titre"}
</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">
{String(conv.endpoint).slice(0, 10)}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary" className="text-xs">
{msgCount}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={getStatusVariant(status)}
variant={conv.isArchived ? "outline" : "default"}
className="text-xs"
>
{getStatusLabel(status)}
{conv.isArchived ? "Archivée" : "Active"}
</Badge>
</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{formatDate(conversation.createdAt)}
{formatDate(conv.updatedAt)}
</span>
</TableCell>
</TableRow>
{/* Messages inline sous la row */}
{isSelected && (
<TableRow key={`${conv._id}-messages`}>
<TableCell colSpan={6} className="p-0 border-b">
<div className="bg-slate-50 p-4">
{messagesLoading ? (
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Chargement des messages...</span>
</div>
) : messages.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">
Aucun message dans cette conversation
</p>
) : (
<div className="space-y-3 max-h-[400px] overflow-y-auto">
{messages
.sort(
(a, b) =>
new Date(a.createdAt).getTime() -
new Date(b.createdAt).getTime()
)
.map((msg) => {
const content = getMessageContent(msg);
const isUser = msg.isCreatedByUser;
return (
<div
key={msg._id}
className={`flex gap-3 p-3 rounded-lg border-l-4 ${
isUser
? "bg-blue-50 border-l-blue-500"
: "bg-white border-l-gray-300"
}`}
>
<div className="flex-shrink-0 mt-0.5">
{isUser ? (
<User className="h-4 w-4 text-blue-600" />
) : (
<Bot className="h-4 w-4 text-gray-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-medium">
{isUser ? "Utilisateur" : "Assistant"}
</span>
<span className="text-xs text-muted-foreground">
{formatDate(msg.createdAt)}
</span>
{msg.tokenCount > 0 && (
<span className="text-xs text-muted-foreground">
({msg.tokenCount} tokens)
</span>
)}
</div>
<div className="text-sm whitespace-pre-wrap break-words">
{content}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
{/* Section des messages pour cet utilisateur */}
{selectedConversationId && selectedUserId === userId && (
<div className="mt-6 border-t pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Messages de la conversation
</h3>
<Button
variant="outline"
size="sm"
onClick={handleCloseMessages}
>
<X className="h-4 w-4" />
Fermer
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Conversation ID: {selectedConversationId}
</p>
<div className="space-y-4 max-h-96 overflow-y-auto border rounded-lg p-4 bg-gray-50">
{messages.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
Aucun message trouvé pour cette conversation
</p>
) : (
messages
.sort(
(a, b) =>
new Date(a.createdAt).getTime() -
new Date(b.createdAt).getTime()
)
.map((message) => {
const content = getMessageContent(message);
return (
<div
key={message._id}
className={`flex gap-3 p-4 rounded-lg ${
message.isCreatedByUser
? "bg-blue-50 border-l-4 border-l-blue-500"
: "bg-white border-l-4 border-l-gray-500"
}`}
>
<div className="flex-shrink-0">
{message.isCreatedByUser ? (
<User className="h-5 w-5 text-blue-600" />
) : (
<Bot className="h-5 w-5 text-gray-600" />
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Badge
variant={
message.isCreatedByUser
? "default"
: "secondary"
}
className="text-xs"
>
{message.isCreatedByUser
? "Utilisateur"
: "Assistant"}
</Badge>
<span className="text-xs text-muted-foreground">
{formatDate(message.createdAt)}
</span>
{message.tokenCount > 0 && (
<Badge
variant="outline"
className="text-xs"
>
{message.tokenCount} tokens
</Badge>
)}
<Badge
variant="outline"
className="text-xs font-mono"
>
{message._id.slice(-8)}
</Badge>
</div>
<div className="text-sm whitespace-pre-wrap">
{content}
</div>
{message.error && (
<Badge
variant="destructive"
className="text-xs"
>
Erreur
</Badge>
)}
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
);
})}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {page} sur {totalPages} {total} conversations au total
Page {page} / {totalPages}
</p>
<div className="flex items-center gap-2">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Précédent
Préc.
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Suivant
Suiv.
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -14,7 +14,7 @@ import {
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { LibreChatUser, LibreChatBalance } from "@/lib/types";
@@ -22,18 +22,20 @@ import { LibreChatUser, LibreChatBalance } from "@/lib/types";
const REFERENT_COLORS: Record<string, string> = {
"Emmanuel WATHELE": "#3B82F6", // Bleu
"IHECS": "#10B981", // Vert
"Patrice De La Broise": "#F59E0B", // Orange
};
export function UsersTable() {
const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState(""); // Ce que l'utilisateur tape
const [activeSearch, setActiveSearch] = useState(""); // Ce qui est réellement recherché
const [activeReferent, setActiveReferent] = useState<string | undefined>(undefined);
const limit = 20;
// Réinitialiser la page à 1 quand une nouvelle recherche est lancée
// Réinitialiser la page à 1 quand une nouvelle recherche ou filtre est lancé
useEffect(() => {
setPage(1);
}, [activeSearch]);
}, [activeSearch, activeReferent]);
const {
data: users = [],
@@ -43,8 +45,21 @@ export function UsersTable() {
page,
limit,
search: activeSearch,
referent: activeReferent,
});
const handleReferentClick = (referent: string) => {
if (activeReferent === referent) {
setActiveReferent(undefined); // Toggle off
} else {
setActiveReferent(referent);
}
};
const clearReferentFilter = () => {
setActiveReferent(undefined);
};
// Fonction pour lancer la recherche
const handleSearch = () => {
setActiveSearch(searchInput);
@@ -105,7 +120,22 @@ export function UsersTable() {
<CardTitle>
Liste des utilisateurs ({total})
</CardTitle>
<div className="flex gap-2 w-full sm:w-auto">
<div className="flex gap-2 w-full sm:w-auto items-center">
{activeReferent && (
<Badge
variant="secondary"
className="flex items-center gap-1 px-3 py-1"
style={{ backgroundColor: REFERENT_COLORS[activeReferent] || "#6B7280", color: "white" }}
>
{activeReferent}
<button
onClick={clearReferentFilter}
className="ml-1 hover:bg-white/20 rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
)}
<div className="relative flex-1 sm:w-80">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -166,9 +196,17 @@ export function UsersTable() {
</TableCell>
<TableCell>
{user.referent ? (
<span className="text-sm truncate block max-w-[120px]" title={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}
</span>
</button>
) : (
<span className="text-sm text-gray-400">-</span>
)}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ interface UseCollectionOptions {
limit?: number;
filter?: Record<string, unknown>;
search?: string;
referent?: string;
}
interface CollectionResponse<T> {
@@ -27,7 +28,7 @@ export function useCollection<T = Record<string, unknown>>(
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const { page = 1, limit = 20, filter = {}, search } = options;
const { page = 1, limit = 20, filter = {}, search, referent } = options;
// Mémoriser la chaîne JSON du filtre pour éviter les re-renders inutiles
const filterString = useMemo(() => JSON.stringify(filter), [filter]);
@@ -43,6 +44,9 @@ export function useCollection<T = Record<string, unknown>>(
if (search) {
params.append("search", search);
}
if (referent) {
params.append("referent", referent);
}
const response = await fetch(
`/api/collections/${collectionName}?${params}`
@@ -59,7 +63,7 @@ export function useCollection<T = Record<string, unknown>>(
} finally {
setLoading(false);
}
}, [collectionName, page, limit, filterString, search]);
}, [collectionName, page, limit, filterString, search, referent]);
useEffect(() => {
fetchData();

27
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.58.0",
"@tanstack/react-query": "^5.90.11",
"@types/bcryptjs": "^2.4.6",
"@types/mongodb": "^4.0.6",
"bcryptjs": "^3.0.2",
@@ -2297,6 +2298,32 @@
"tailwindcss": "4.1.14"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",

View File

@@ -21,6 +21,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.58.0",
"@tanstack/react-query": "^5.90.11",
"@types/bcryptjs": "^2.4.6",
"@types/mongodb": "^4.0.6",
"bcryptjs": "^3.0.2",