modifs complete
This commit is contained in:
@@ -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";
|
||||
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<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
|
||||
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 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 (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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);
|
||||
<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">
|
||||
<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>
|
||||
|
||||
return (
|
||||
<div key={userId} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{userId === "unknown" ? "unknown" : userId.slice(-8)}
|
||||
</Badge>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold">{userName}</span>
|
||||
{userEmail && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{userEmail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{conversations.length} conversation
|
||||
{conversations.length > 1 ? "s" : ""}
|
||||
</Badge>
|
||||
{activeConversations > 0 && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
{activeConversations} actives
|
||||
</Badge>
|
||||
)}
|
||||
{archivedConversations > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{archivedConversations} archivées
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{totalMessages} message{totalMessages > 1 ? "s" : ""}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Dernière:{" "}
|
||||
{formatDate(
|
||||
new Date(
|
||||
Math.max(
|
||||
...conversations.map((c) =>
|
||||
new Date(c.updatedAt).getTime()
|
||||
)
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Titre</TableHead>
|
||||
<TableHead>Endpoint</TableHead>
|
||||
<TableHead>Modèle</TableHead>
|
||||
<TableHead>Messages</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Créée le</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{conversations
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() -
|
||||
new Date(a.updatedAt).getTime()
|
||||
)
|
||||
.map((conversation) => {
|
||||
const status = getStatus(conversation);
|
||||
const messageCount =
|
||||
conversation.messages?.length || 0;
|
||||
|
||||
return (
|
||||
<TableRow key={conversation._id}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">
|
||||
{String(conversation._id).slice(-8)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="max-w-xs truncate block">
|
||||
{String(conversation.title) || "Sans titre"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{String(conversation.endpoint).slice(0, 20)}
|
||||
{String(conversation.endpoint).length > 20
|
||||
? "..."
|
||||
: ""}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{String(conversation.model)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
onClick={() =>
|
||||
handleShowMessages(
|
||||
conversation.conversationId,
|
||||
userId
|
||||
)
|
||||
}
|
||||
>
|
||||
{messageCount}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={getStatusVariant(status)}
|
||||
className="text-xs"
|
||||
>
|
||||
{getStatusLabel(status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(conversation.createdAt)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Section des messages pour cet utilisateur */}
|
||||
{selectedConversationId && selectedUserId === userId && (
|
||||
<div className="mt-6 border-t pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Messages de la conversation
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCloseMessages}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Fermer
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Conversation ID: {selectedConversationId}
|
||||
</p>
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto border rounded-lg p-4 bg-gray-50">
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Aucun message trouvé pour cette conversation
|
||||
</p>
|
||||
) : (
|
||||
messages
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() -
|
||||
new Date(b.createdAt).getTime()
|
||||
)
|
||||
.map((message) => {
|
||||
const content = getMessageContent(message);
|
||||
return (
|
||||
<div
|
||||
key={message._id}
|
||||
className={`flex gap-3 p-4 rounded-lg ${
|
||||
message.isCreatedByUser
|
||||
? "bg-blue-50 border-l-4 border-l-blue-500"
|
||||
: "bg-white border-l-4 border-l-gray-500"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{message.isCreatedByUser ? (
|
||||
<User className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<Bot className="h-5 w-5 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
message.isCreatedByUser
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{message.isCreatedByUser
|
||||
? "Utilisateur"
|
||||
: "Assistant"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(message.createdAt)}
|
||||
</span>
|
||||
{message.tokenCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{message.tokenCount} tokens
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{message._id.slice(-8)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap">
|
||||
{content}
|
||||
</div>
|
||||
{message.error && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
>
|
||||
Erreur
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page} sur {totalPages} • {total} conversations au total
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Précédent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* 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 "{debouncedSearch}"
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{conversationsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{debouncedSearch ? (
|
||||
<>
|
||||
<Search className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p>Aucun résultat pour "{debouncedSearch}"</p>
|
||||
<Button variant="link" onClick={clearSearch} className="mt-2">
|
||||
Effacer la recherche
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<p>Aucune conversation</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-52">Utilisateur</TableHead>
|
||||
<TableHead>Titre</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.map((conv) => {
|
||||
const userInfo = getUserInfo(String(conv.user));
|
||||
const msgCount = conv.messages?.length || 0;
|
||||
const isSelected = selectedConversationId === conv.conversationId;
|
||||
|
||||
return (
|
||||
<Fragment key={conv._id}>
|
||||
<TableRow
|
||||
className={`cursor-pointer hover:bg-muted/50 transition-colors ${isSelected ? "bg-muted/50" : ""}`}
|
||||
onClick={() =>
|
||||
setSelectedConversationId(
|
||||
isSelected ? null : conv.conversationId
|
||||
)
|
||||
}
|
||||
>
|
||||
<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={conv.isArchived ? "outline" : "default"}
|
||||
className="text-xs"
|
||||
>
|
||||
{conv.isArchived ? "Archivée" : "Active"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{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>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page} / {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Préc.
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Suiv.
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<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>
|
||||
)}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user