From 73f97919ac8b649247d3161f1ef077ccad334e99 Mon Sep 17 00:00:00 2001 From: Biqoz Date: Thu, 27 Nov 2025 13:58:47 +0100 Subject: [PATCH] modifs complete --- .claude/settings.local.json | 4 +- app/api/add-credits-single/route.ts | 117 +++ app/api/collections/[collection]/route.ts | 57 +- app/layout.tsx | 15 +- app/settings/page.tsx | 8 +- .../collections/conversations-table.tsx | 866 ++++++++---------- components/collections/users-table.tsx | 54 +- .../dashboard/add-credits-single-user.tsx | 274 ++++++ components/providers/query-provider.tsx | 24 + hooks/useCollection.ts | 8 +- package-lock.json | 27 + package.json | 1 + 12 files changed, 931 insertions(+), 524 deletions(-) create mode 100644 app/api/add-credits-single/route.ts create mode 100644 components/dashboard/add-credits-single-user.tsx create mode 100644 components/providers/query-provider.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 166617c..5297503 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/app/api/add-credits-single/route.ts b/app/api/add-credits-single/route.ts new file mode 100644 index 0000000..312d179 --- /dev/null +++ b/app/api/add-credits-single/route.ts @@ -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 } + ); + } +} diff --git a/app/api/collections/[collection]/route.ts b/app/api/collections/[collection]/route.ts index 797a3c0..7a5217e 100644 --- a/app/api/collections/[collection]/route.ts +++ b/app/api/collections/[collection]/route.ts @@ -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(); diff --git a/app/layout.tsx b/app/layout.tsx index 1c2be10..7f743c3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ -
- -
-
{children}
-
-
+ +
+ +
+
{children}
+
+
+
); diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 993d9aa..cba6d5a 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -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 - - + + +
+ +
diff --git a/components/collections/conversations-table.tsx b/components/collections/conversations-table.tsx index ea2cbc4..19a7f73 100644 --- a/components/collections/conversations-table.tsx +++ b/components/collections/conversations-table.tsx @@ -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(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(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; parts?: Array; - metadata?: { text?: string }; [key: string]: unknown; } +// Fetcher générique +async function fetchCollection( + collection: string, + params: Record +): 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(null); + const [searchInput, setSearchInput] = useState(""); + const [selectedConversationId, setSelectedConversationId] = useState(null); + const searchInputRef = useRef(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("conversations", { - limit: 1000, - page: 1, // Remplacer skip par page + data: conversationsData, + isLoading: conversationsLoading, + isFetching: conversationsFetching, + } = useQuery({ + queryKey: ["conversations", page, limit, debouncedSearch], + queryFn: () => + fetchCollection("conversations", { + page, + limit, + search: debouncedSearch, + }), + placeholderData: keepPreviousData, + staleTime: 30000, // 30 secondes }); - const { data: users = [] } = useCollection("users", { - limit: 1000, + // Query users (cache long car ça change rarement) + const { data: usersData } = useQuery({ + queryKey: ["users", "all"], + queryFn: () => fetchCollection("users", { limit: 1000 }), + staleTime: 1000 * 60 * 5, // 5 minutes }); - // Charger les messages seulement si une conversation est sélectionnée - const { data: messages = [] } = useCollection("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("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"; - const user = userMap.get(userId); - if (user) { - return ( - user.name || - user.username || - user.email || - `Utilisateur ${userId.slice(-8)}` - ); - } - return `Utilisateur ${userId.slice(-8)}`; - }; + // Map des users pour lookup rapide + const userMap = useMemo(() => new Map(users.map((u) => [u._id, u])), [users]); - const getUserEmail = (userId: string): string | null => { - if (userId === "unknown") return null; - const user = userMap.get(userId); - return user?.email || null; - }; - - // Fonction améliorée pour extraire le contenu du message - const getMessageContent = (message: LibreChatMessage): string => { - // Fonction helper pour nettoyer le texte - const cleanText = (text: string): string => { - return text.trim().replace(/\n\s*\n/g, "\n"); - }; - - // 1. Vérifier le tableau content (structure LibreChat) - const messageObj = message as ExtendedMessage; - if (messageObj.content && Array.isArray(messageObj.content)) { - for (const contentItem of messageObj.content) { - if ( - contentItem && - typeof contentItem === "object" && - contentItem.text - ) { - return cleanText(contentItem.text); - } - } - } - - // 2. Essayer le champ text principal - if ( - message.text && - typeof message.text === "string" && - message.text.trim() - ) { - return cleanText(message.text); - } - - // 3. Essayer le champ content (format legacy string) - if ( - messageObj.content && - typeof messageObj.content === "string" && - messageObj.content.trim() - ) { - return cleanText(messageObj.content); - } - - // 4. Vérifier s'il y a des propriétés imbriquées - if (message.message && typeof message.message === "object") { - const nestedMessage = message.message as Record; - if (nestedMessage.content && typeof nestedMessage.content === "string") { - return cleanText(nestedMessage.content); - } - if (nestedMessage.text && typeof nestedMessage.text === "string") { - return cleanText(nestedMessage.text); - } - } - - // 5. Vérifier les propriétés spécifiques à LibreChat - // Parfois le contenu est dans une propriété 'parts' - if ( - messageObj.parts && - Array.isArray(messageObj.parts) && - messageObj.parts.length > 0 - ) { - const firstPart = messageObj.parts[0]; - if (typeof firstPart === "string") { - return cleanText(firstPart); - } - if (firstPart && typeof firstPart === "object" && firstPart.text) { - return cleanText(firstPart.text); - } - } - - // 6. Vérifier si c'est un message avec des métadonnées - if (messageObj.metadata && messageObj.metadata.text) { - return cleanText(messageObj.metadata.text); - } - - // 7. Vérifier les propriétés alternatives - const alternativeFields = ["body", "messageText", "textContent", "data"]; - for (const field of alternativeFields) { - const value = messageObj[field]; - if (value && typeof value === "string" && value.trim()) { - return cleanText(value); - } - } - - // Debug: afficher la structure du message si aucun contenu n'est trouvé - console.log("Message sans contenu trouvé:", { - messageId: message.messageId, - isCreatedByUser: message.isCreatedByUser, - keys: Object.keys(messageObj), - content: messageObj.content, - text: messageObj.text, - }); - - return "Contenu non disponible"; - }; - - const handleShowMessages = (conversationId: string, userId: string) => { - if ( - selectedConversationId === conversationId && - selectedUserId === userId - ) { - setSelectedConversationId(null); - setSelectedUserId(null); - } else { - setSelectedConversationId(conversationId); - setSelectedUserId(userId); - } - }; - - const handleCloseMessages = () => { - setSelectedConversationId(null); - setSelectedUserId(null); - }; - - const getStatus = (conversation: LibreChatConversation) => { - if (conversation.isArchived) return "archived"; - return "active"; - }; - - const getStatusLabel = (status: string) => { - switch (status) { - case "archived": - return "Archivée"; - case "active": - return "Active"; - default: - return "Inconnue"; - } - }; - - const getStatusVariant = (status: string) => { - switch (status) { - case "archived": - return "outline" as const; - case "active": - return "default" as const; - default: - return "secondary" as const; - } - }; - - if (loading) { - return ( - - -
Chargement des conversations...
-
-
- ); - } - - // Grouper les conversations par utilisateur - const groupedConversations = conversations.reduce((acc, conversation) => { - const userId = conversation.user || "unknown"; - if (!acc[userId]) { - acc[userId] = []; - } - acc[userId].push(conversation); - return acc; - }, {} as Record); - - // Pagination des groupes d'utilisateurs - const totalPages = Math.ceil( - Object.keys(groupedConversations).length / limit + const getUserInfo = useCallback( + (userId: string) => { + const user = userMap.get(userId); + return { + name: user?.name || user?.username || `User ${userId.slice(-6)}`, + email: user?.email || null, + }; + }, + [userMap] ); - const skip = (page - 1) * limit; - const userIds = Object.keys(groupedConversations).slice(skip, skip + limit); + + // 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 ( -
+
- - - - Conversations par utilisateur - -

- {Object.keys(groupedConversations).length} utilisateurs •{" "} - {conversations.length} conversations au total -

-
- -
- {userIds.map((userId) => { - const conversations = groupedConversations[userId]; - const totalMessages = conversations.reduce( - (sum, conv) => sum + (conv.messages?.length || 0), - 0 - ); - const activeConversations = conversations.filter( - (conv) => !conv.isArchived - ).length; - const archivedConversations = conversations.filter( - (conv) => conv.isArchived - ).length; - const userName = getUserDisplayName(userId); - const userEmail = getUserEmail(userId); + +
+ + + Conversations + + {total} + + {conversationsFetching && !conversationsLoading && ( + + )} + - return ( -
-
-
- - {userId === "unknown" ? "unknown" : userId.slice(-8)} - -
- {userName} - {userEmail && ( - - {userEmail} - - )} -
- - {conversations.length} conversation - {conversations.length > 1 ? "s" : ""} - - {activeConversations > 0 && ( - - {activeConversations} actives - - )} - {archivedConversations > 0 && ( - - {archivedConversations} archivées - - )} -
-
-
- - {totalMessages} message{totalMessages > 1 ? "s" : ""} -
-
- - Dernière:{" "} - {formatDate( - new Date( - Math.max( - ...conversations.map((c) => - new Date(c.updatedAt).getTime() - ) - ) - ) - )} -
-
-
- -
- - - - ID - Titre - Endpoint - Modèle - Messages - Statut - Créée le - - - - {conversations - .sort( - (a, b) => - new Date(b.updatedAt).getTime() - - new Date(a.updatedAt).getTime() - ) - .map((conversation) => { - const status = getStatus(conversation); - const messageCount = - conversation.messages?.length || 0; - - return ( - - - - {String(conversation._id).slice(-8)} - - - - - {String(conversation.title) || "Sans titre"} - - - - - {String(conversation.endpoint).slice(0, 20)} - {String(conversation.endpoint).length > 20 - ? "..." - : ""} - - - - - {String(conversation.model)} - - - - - handleShowMessages( - conversation.conversationId, - userId - ) - } - > - {messageCount} - - - - - {getStatusLabel(status)} - - - - - {formatDate(conversation.createdAt)} - - - - ); - })} - -
-
- - {/* Section des messages pour cet utilisateur */} - {selectedConversationId && selectedUserId === userId && ( -
-
-

- - Messages de la conversation -

- -
-

- Conversation ID: {selectedConversationId} -

-
- {messages.length === 0 ? ( -

- Aucun message trouvé pour cette conversation -

- ) : ( - messages - .sort( - (a, b) => - new Date(a.createdAt).getTime() - - new Date(b.createdAt).getTime() - ) - .map((message) => { - const content = getMessageContent(message); - return ( -
-
- {message.isCreatedByUser ? ( - - ) : ( - - )} -
-
-
- - {message.isCreatedByUser - ? "Utilisateur" - : "Assistant"} - - - {formatDate(message.createdAt)} - - {message.tokenCount > 0 && ( - - {message.tokenCount} tokens - - )} - - {message._id.slice(-8)} - -
-
- {content} -
- {message.error && ( - - Erreur - - )} -
-
- ); - }) - )} -
-
- )} -
- ); - })} -
- - {totalPages > 1 && ( -
-

- Page {page} sur {totalPages} • {total} conversations au total -

-
- - + {/* Barre de recherche */} +
+ + setSearchInput(e.target.value)} + className="pl-10 pr-10" + /> +
+ {isSearching ? ( + + ) : searchInput ? ( + + ) : null}
+
+ + {debouncedSearch && ( +

+ {total} résultat{total > 1 ? "s" : ""} pour "{debouncedSearch}" +

+ )} + + + + {conversationsLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : conversations.length === 0 ? ( +
+ {debouncedSearch ? ( + <> + +

Aucun résultat pour "{debouncedSearch}"

+ + + ) : ( +

Aucune conversation

+ )} +
+ ) : ( + <> +
+ + + + Utilisateur + Titre + Endpoint + Msgs + Statut + Date + + + + {conversations.map((conv) => { + const userInfo = getUserInfo(String(conv.user)); + const msgCount = conv.messages?.length || 0; + const isSelected = selectedConversationId === conv.conversationId; + + return ( + + + setSelectedConversationId( + isSelected ? null : conv.conversationId + ) + } + > + +
+ +
+ + {userInfo.name} + + {userInfo.email && ( + + {userInfo.email} + + )} +
+
+
+ + + {String(conv.title) || "Sans titre"} + + + + + {String(conv.endpoint).slice(0, 10)} + + + + + {msgCount} + + + + + {conv.isArchived ? "Archivée" : "Active"} + + + + + {formatDate(conv.updatedAt)} + + +
+ + {/* Messages inline sous la row */} + {isSelected && ( + + +
+ {messagesLoading ? ( +
+ + Chargement des messages... +
+ ) : messages.length === 0 ? ( +

+ Aucun message dans cette conversation +

+ ) : ( +
+ {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 ( +
+
+ {isUser ? ( + + ) : ( + + )} +
+
+
+ + {isUser ? "Utilisateur" : "Assistant"} + + + {formatDate(msg.createdAt)} + + {msg.tokenCount > 0 && ( + + ({msg.tokenCount} tokens) + + )} +
+
+ {content} +
+
+
+ ); + })} +
+ )} +
+
+
+ )} +
+ ); + })} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} / {totalPages} +

+
+ + +
+
+ )} + )} +
); } diff --git a/components/collections/users-table.tsx b/components/collections/users-table.tsx index 57fd305..b3d7eaf 100644 --- a/components/collections/users-table.tsx +++ b/components/collections/users-table.tsx @@ -14,26 +14,28 @@ 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"; // Couleurs prédéfinies pour les référents const REFERENT_COLORS: Record = { - "Emmanuel WATHELE": "#3B82F6", // Bleu - "IHECS": "#10B981", // Vert + "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(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() { Liste des utilisateurs ({total}) -
+
+ {activeReferent && ( + + {activeReferent} + + + )}
{user.referent ? ( - + ) : ( - )} diff --git a/components/dashboard/add-credits-single-user.tsx b/components/dashboard/add-credits-single-user.tsx new file mode 100644 index 0000000..d131c91 --- /dev/null +++ b/components/dashboard/add-credits-single-user.tsx @@ -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([]); + const [selectedUser, setSelectedUser] = useState(null); + const [userBalance, setUserBalance] = useState(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 ( + + + + + Ajouter des Crédits à un Utilisateur + + + Rechercher un utilisateur et lui ajouter 3 millions de tokens + + + + {/* Barre de recherche */} +
+ setSearchTerm(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchUsers()} + /> + +
+ + {/* Résultats de recherche */} + {searchResults.length > 0 && ( +
+ {searchResults.map((user) => ( +
selectUser(user)} + > +
+ +
+

{user.name || user.username}

+

{user.email}

+
+
+ {user.role} +
+ ))} +
+ )} + + {/* Utilisateur sélectionné */} + {selectedUser && ( +
+

+ + Utilisateur sélectionné +

+ +
+
+ ID: +

+ {selectedUser._id} +

+
+
+ Nom: +

+ {selectedUser.name || selectedUser.username} +

+
+
+ Email: +

{selectedUser.email}

+
+
+ Rôle: + + {selectedUser.role} + +
+ {selectedUser.referent && ( +
+ Référent: +

{selectedUser.referent}

+
+ )} +
+ Crédits actuels: + {loadingBalance ? ( +

Chargement...

+ ) : ( +

+ {userBalance?.tokenCredits.toLocaleString() || "0"} +

+ )} +
+
+ + {/* Boutons d'action */} +
+ + +
+
+ )} + + {/* Message de succès */} + {success && ( +
+

+ + Crédits ajoutés avec succès ! +

+

+ Nouveau solde:{" "} + + {success.newBalance.toLocaleString()} + {" "} + tokens +

+
+ )} +
+
+ ); +} diff --git a/components/providers/query-provider.tsx b/components/providers/query-provider.tsx new file mode 100644 index 0000000..4bc1b8b --- /dev/null +++ b/components/providers/query-provider.tsx @@ -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 ( + {children} + ); +} diff --git a/hooks/useCollection.ts b/hooks/useCollection.ts index e14a45b..0b328b4 100644 --- a/hooks/useCollection.ts +++ b/hooks/useCollection.ts @@ -7,6 +7,7 @@ interface UseCollectionOptions { limit?: number; filter?: Record; search?: string; + referent?: string; } interface CollectionResponse { @@ -27,7 +28,7 @@ export function useCollection>( 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>( 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>( } finally { setLoading(false); } - }, [collectionName, page, limit, filterString, search]); + }, [collectionName, page, limit, filterString, search, referent]); useEffect(() => { fetchData(); diff --git a/package-lock.json b/package-lock.json index 76ef222..b54520b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fc0853a..bcc597c 100644 --- a/package.json +++ b/package.json @@ -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",