first commit
This commit is contained in:
55
components/collections/agents-table.tsx
Normal file
55
components/collections/agents-table.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { CollectionTable } from "@/components/collections/collection-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Agent } from "@/lib/types";
|
||||
|
||||
export function AgentsTable() {
|
||||
const columns = [
|
||||
{
|
||||
key: "_id",
|
||||
label: "ID",
|
||||
render: (value: unknown) => (
|
||||
<span className="font-mono text-xs">{String(value).slice(-8)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: "Nom",
|
||||
render: (value: unknown) => (
|
||||
<span className="font-semibold">{String(value)}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "Description",
|
||||
render: (value: unknown) => (
|
||||
<span className="max-w-xs truncate">{String(value) || '-'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "category",
|
||||
label: "Catégorie",
|
||||
render: (value: unknown) => (
|
||||
<Badge variant="outline">{String(value)}</Badge>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: "isActive",
|
||||
label: "Statut",
|
||||
render: (value: unknown) => (
|
||||
<Badge variant={value ? 'default' : 'destructive'}>
|
||||
{value ? 'Actif' : 'Inactif'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<CollectionTable<Agent>
|
||||
collectionName="agents"
|
||||
title="Liste des agents"
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
components/collections/collection-selector.tsx
Normal file
107
components/collections/collection-selector.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CollectionTable } from "@/components/collections/collection-table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CollectionItem } from "@/lib/types";
|
||||
|
||||
const COLLECTIONS = [
|
||||
"accessroles",
|
||||
"aclentries",
|
||||
"actions",
|
||||
"agentcategories",
|
||||
"agents",
|
||||
"assistants",
|
||||
"balances",
|
||||
"banners",
|
||||
"conversations",
|
||||
"conversationtags",
|
||||
"files",
|
||||
"groups",
|
||||
"keys",
|
||||
"memoryentries",
|
||||
"messages",
|
||||
"pluginauths",
|
||||
"presets",
|
||||
"projects",
|
||||
"promptgroups",
|
||||
"prompts",
|
||||
"roles",
|
||||
"sessions",
|
||||
"sharedlinks",
|
||||
"tokens",
|
||||
"toolcalls",
|
||||
"transactions",
|
||||
"users",
|
||||
];
|
||||
|
||||
export function CollectionSelector() {
|
||||
const [selectedCollection, setSelectedCollection] = useState<string>("users");
|
||||
|
||||
// Colonnes génériques pour toutes les collections
|
||||
const genericColumns = [
|
||||
{
|
||||
key: "_id",
|
||||
label: "ID",
|
||||
render: (value: unknown) => (
|
||||
<span className="font-mono text-xs">{String(value).slice(-8)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: "Nom",
|
||||
render: (value: unknown) => String(value) || "-",
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: "Email",
|
||||
render: (value: unknown) => String(value) || "-",
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "Créé le",
|
||||
render: (value: unknown) => {
|
||||
if (!value) return "-";
|
||||
try {
|
||||
return new Date(String(value)).toLocaleDateString("fr-FR");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sélectionner une collection</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||||
{COLLECTIONS.map((collection) => (
|
||||
<Button
|
||||
key={collection}
|
||||
variant={
|
||||
selectedCollection === collection ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCollection(collection)}
|
||||
className="text-xs"
|
||||
>
|
||||
{collection}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CollectionTable<CollectionItem>
|
||||
collectionName={selectedCollection}
|
||||
title={`Collection: ${selectedCollection}`}
|
||||
columns={genericColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
components/collections/collection-table.tsx
Normal file
123
components/collections/collection-table.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useCollection } from "@/hooks/useCollection";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CollectionTableProps<T = Record<string, unknown>> {
|
||||
collectionName: string;
|
||||
title: string;
|
||||
columns: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
render?: (value: unknown, item: T) => React.ReactNode;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function CollectionTable<T extends Record<string, unknown>>({
|
||||
collectionName,
|
||||
title,
|
||||
columns
|
||||
}: CollectionTableProps<T>) {
|
||||
const [page, setPage] = useState(1);
|
||||
const { data, loading, error, total, totalPages } = useCollection<T>(
|
||||
collectionName,
|
||||
{ page, limit: 20 }
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Erreur: {error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title} ({total} éléments)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.key}>{column.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item, index) => (
|
||||
<TableRow key={(item as { _id?: string })._id || index}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key}>
|
||||
{column.render
|
||||
? column.render(item[column.key], item)
|
||||
: String(item[column.key] || '-')
|
||||
}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page} sur {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édent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
561
components/collections/conversations-table.tsx
Normal file
561
components/collections/conversations-table.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCollection } from "@/hooks/useCollection";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Users,
|
||||
MessageSquare,
|
||||
Calendar,
|
||||
X,
|
||||
User,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import {
|
||||
LibreChatConversation,
|
||||
LibreChatUser,
|
||||
LibreChatMessage,
|
||||
} from "@/lib/types";
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function ConversationsTable() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
|
||||
const limit = 10;
|
||||
|
||||
// Charger toutes les conversations pour le groupement côté client
|
||||
const {
|
||||
data: conversations = [],
|
||||
total = 0,
|
||||
loading,
|
||||
} = useCollection<LibreChatConversation>("conversations", {
|
||||
limit: 1000,
|
||||
page: 1, // Remplacer skip par page
|
||||
});
|
||||
|
||||
const { data: users = [] } = useCollection<LibreChatUser>("users", {
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
// Charger les messages seulement si une conversation est sélectionnée
|
||||
const { data: messages = [] } = useCollection<LibreChatMessage>("messages", {
|
||||
limit: 1000,
|
||||
filter: selectedConversationId
|
||||
? { conversationId: selectedConversationId }
|
||||
: {},
|
||||
});
|
||||
|
||||
const userMap = new Map(users.map((user) => [user._id, user]));
|
||||
|
||||
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)}`;
|
||||
};
|
||||
|
||||
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 skip = (page - 1) * limit;
|
||||
const userIds = Object.keys(groupedConversations).slice(skip, skip + limit);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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);
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
components/collections/messages-table.tsx
Normal file
304
components/collections/messages-table.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useCollection } from "@/hooks/useCollection";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChevronLeft, ChevronRight, User, Bot } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import {
|
||||
LibreChatMessage,
|
||||
LibreChatUser,
|
||||
LibreChatConversation,
|
||||
} from "@/lib/types";
|
||||
|
||||
// Définir des interfaces pour les types de contenu
|
||||
interface MessageContentItem {
|
||||
text?: string;
|
||||
content?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
text?: string;
|
||||
content?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface MessageWithParts extends LibreChatMessage {
|
||||
parts?: MessagePart[];
|
||||
content?: MessageContentItem[] | string;
|
||||
}
|
||||
|
||||
export function MessagesTable() {
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 20;
|
||||
|
||||
// Charger les messages
|
||||
const {
|
||||
data: messages = [],
|
||||
total = 0,
|
||||
loading: messagesLoading,
|
||||
} = useCollection<LibreChatMessage>("messages", {
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
// Charger les utilisateurs pour les noms
|
||||
const { data: users = [] } = useCollection<LibreChatUser>("users", {
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
// Charger les conversations pour les titres
|
||||
const { data: conversations = [] } = useCollection<LibreChatConversation>(
|
||||
"conversations",
|
||||
{
|
||||
limit: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
// Créer des maps pour les lookups
|
||||
const userMap = useMemo(() => {
|
||||
return new Map(users.map((user) => [user._id, user]));
|
||||
}, [users]);
|
||||
|
||||
const conversationMap = useMemo(() => {
|
||||
return new Map(
|
||||
conversations.map((conv) => [conv.conversationId || conv._id, conv])
|
||||
);
|
||||
}, [conversations]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setPage((prev) => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
setPage((prev) => Math.min(totalPages, prev + 1));
|
||||
};
|
||||
|
||||
// Fonction pour extraire le contenu du message
|
||||
const getMessageContent = (message: LibreChatMessage): string => {
|
||||
try {
|
||||
// Vérifier le champ text principal
|
||||
if (message.text && typeof message.text === "string") {
|
||||
return message.text.trim();
|
||||
}
|
||||
|
||||
// Traiter le message comme ayant potentiellement des parties
|
||||
const messageWithParts = message as MessageWithParts;
|
||||
|
||||
// Vérifier le champ content (peut être un array ou string)
|
||||
if (messageWithParts.content) {
|
||||
if (typeof messageWithParts.content === "string") {
|
||||
return messageWithParts.content.trim();
|
||||
}
|
||||
if (Array.isArray(messageWithParts.content)) {
|
||||
// Extraire le texte des objets content
|
||||
const textContent = messageWithParts.content
|
||||
.map((item: MessageContentItem | string) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item && typeof item === "object" && item.text)
|
||||
return item.text;
|
||||
if (item && typeof item === "object" && item.content)
|
||||
return item.content;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (textContent.trim()) return textContent.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier les propriétés alternatives
|
||||
if (messageWithParts.parts && Array.isArray(messageWithParts.parts)) {
|
||||
const textContent = messageWithParts.parts
|
||||
.map((part: MessagePart) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (part && typeof part === "object" && part.text) return part.text;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (textContent.trim()) return textContent.trim();
|
||||
}
|
||||
|
||||
return "Contenu non disponible";
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'extraction du contenu:", error);
|
||||
return "Erreur de lecture du contenu";
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour obtenir le nom d'utilisateur
|
||||
const getUserName = (userId: string): string => {
|
||||
if (!userId || userId === "undefined") return "Utilisateur inconnu";
|
||||
const user = userMap.get(userId);
|
||||
return user?.name || user?.email || `Utilisateur ${userId.slice(-8)}`;
|
||||
};
|
||||
|
||||
// Fonction pour obtenir le titre de la conversation
|
||||
const getConversationTitle = (conversationId: string): string => {
|
||||
if (!conversationId || conversationId === "undefined")
|
||||
return "Conversation inconnue";
|
||||
|
||||
const conversation = conversationMap.get(conversationId);
|
||||
if (conversation && conversation.title) {
|
||||
return conversation.title;
|
||||
}
|
||||
return `Conversation ${conversationId.slice(-6)}`;
|
||||
};
|
||||
|
||||
if (messagesLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Messages récents ({total})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Conversation</TableHead>
|
||||
<TableHead>Utilisateur</TableHead>
|
||||
<TableHead>Rôle</TableHead>
|
||||
<TableHead>Contenu</TableHead>
|
||||
<TableHead>Tokens</TableHead>
|
||||
<TableHead>Créé le</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{messages.map((message) => {
|
||||
const content = getMessageContent(message);
|
||||
const userName = getUserName(message.user);
|
||||
const conversationTitle = getConversationTitle(
|
||||
message.conversationId
|
||||
);
|
||||
const isUser = message.isCreatedByUser;
|
||||
|
||||
return (
|
||||
<TableRow key={message._id}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">
|
||||
{message._id.slice(-8)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-xs">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{message.conversationId?.slice(-8) || "N/A"}
|
||||
</span>
|
||||
<div className="text-sm truncate">
|
||||
{conversationTitle}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-xs">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{message.user?.slice(-8) || "N/A"}
|
||||
</span>
|
||||
<div className="text-sm truncate">{userName}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={isUser ? "default" : "secondary"}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="h-3 w-3" />
|
||||
) : (
|
||||
<Bot className="h-3 w-3" />
|
||||
)}
|
||||
{isUser ? "Utilisateur" : "Assistant"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-md">
|
||||
<p className="text-sm truncate">
|
||||
{content.length > 100
|
||||
? `${content.substring(0, 100)}...`
|
||||
: content}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{message.tokenCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{message.tokenCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(new Date(message.createdAt))}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between space-x-2 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {page} sur {totalPages} ({total} éléments au total)
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Précédent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
53
components/collections/roles-table.tsx
Normal file
53
components/collections/roles-table.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { CollectionTable } from "@/components/collections/collection-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AccessRole } from "@/lib/types";
|
||||
|
||||
export function RolesTable() {
|
||||
const columns = [
|
||||
{
|
||||
key: "_id",
|
||||
label: "ID",
|
||||
render: (value: unknown) => (
|
||||
<span className="font-mono text-xs">{String(value).slice(-8)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: "Nom du rôle",
|
||||
render: (value: unknown) => (
|
||||
<span className="font-semibold">{String(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "permissions",
|
||||
label: "Permissions",
|
||||
render: (value: unknown) => {
|
||||
if (!Array.isArray(value)) return "-";
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.slice(0, 3).map((permission, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{String(permission)}
|
||||
</Badge>
|
||||
))}
|
||||
{value.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{value.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<CollectionTable<AccessRole>
|
||||
collectionName="accessroles"
|
||||
title="Liste des rôles"
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
213
components/collections/transactions-table.tsx
Normal file
213
components/collections/transactions-table.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useCollection } from "@/hooks/useCollection";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { formatDate, formatCurrency } from "@/lib/utils";
|
||||
import { LibreChatTransaction, LibreChatUser } from "@/lib/types";
|
||||
|
||||
// Interface étendue pour les transactions avec description optionnelle
|
||||
interface TransactionWithDescription extends LibreChatTransaction {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function TransactionsTable() {
|
||||
const { data: transactions, loading } =
|
||||
useCollection<LibreChatTransaction>("transactions");
|
||||
const { data: users } = useCollection<LibreChatUser>("users");
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
// Créer une map pour les lookups rapides des utilisateurs
|
||||
const usersMap = useMemo(() => {
|
||||
if (!users) return new Map();
|
||||
return new Map(users.map((user) => [user._id, user]));
|
||||
}, [users]);
|
||||
|
||||
const totalPages = Math.ceil((transactions?.length || 0) / itemsPerPage);
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
|
||||
};
|
||||
|
||||
// Fonction pour obtenir le nom d'utilisateur
|
||||
const getUserName = (userId: string): string => {
|
||||
if (!userId || userId === "undefined") return "Utilisateur inconnu";
|
||||
const user = usersMap.get(userId);
|
||||
return user?.name || user?.email || `Utilisateur ${userId.slice(-8)}`;
|
||||
};
|
||||
|
||||
// Fonction pour formater le montant en euros
|
||||
const formatAmount = (rawAmount: number): string => {
|
||||
// Convertir les tokens en euros (exemple: 1000 tokens = 1 euro)
|
||||
const euros = rawAmount / 1000;
|
||||
return formatCurrency(euros);
|
||||
};
|
||||
|
||||
// Fonction pour obtenir la description
|
||||
const getDescription = (transaction: LibreChatTransaction): string => {
|
||||
const transactionWithDesc = transaction as TransactionWithDescription;
|
||||
|
||||
if (transactionWithDesc.description &&
|
||||
typeof transactionWithDesc.description === 'string' &&
|
||||
transactionWithDesc.description !== "undefined") {
|
||||
return transactionWithDesc.description;
|
||||
}
|
||||
|
||||
// Générer une description basée sur le type et le montant
|
||||
const amount = Math.abs(Number(transaction.rawAmount) || 0);
|
||||
if (amount > 0) {
|
||||
return `Consommation de ${amount.toLocaleString()} tokens`;
|
||||
}
|
||||
|
||||
return "Transaction sans description";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transactions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Transactions récentes ({transactions?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Utilisateur</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Montant</TableHead>
|
||||
<TableHead>Tokens</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions
|
||||
?.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
)
|
||||
.map((transaction) => {
|
||||
const userName = getUserName(transaction.user);
|
||||
const description = getDescription(transaction);
|
||||
const tokenAmount = Math.abs(
|
||||
Number(transaction.rawAmount) || 0
|
||||
);
|
||||
const isCredit = Number(transaction.rawAmount) > 0;
|
||||
|
||||
return (
|
||||
<TableRow key={transaction._id}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">
|
||||
{transaction._id.slice(-8)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-xs">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{transaction.user?.slice(-8) || "N/A"}
|
||||
</span>
|
||||
<div className="text-sm truncate">{userName}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={isCredit ? "default" : "destructive"}>
|
||||
{isCredit ? "Crédit" : "Débit"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-semibold">
|
||||
{formatAmount(transaction.rawAmount)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{tokenAmount > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{tokenAmount.toLocaleString()} tokens
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="max-w-xs truncate block text-sm">
|
||||
{description}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(new Date(transaction.createdAt))}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between space-x-2 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {currentPage} sur {totalPages} ({transactions?.length || 0}{" "}
|
||||
éléments au total)
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Précédent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
170
components/collections/users-table.tsx
Normal file
170
components/collections/users-table.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useCollection } from "@/hooks/useCollection";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { LibreChatUser, LibreChatBalance } from "@/lib/types";
|
||||
|
||||
|
||||
export function UsersTable() {
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 20;
|
||||
|
||||
// Charger les utilisateurs
|
||||
const {
|
||||
data: users = [],
|
||||
total = 0,
|
||||
loading: usersLoading,
|
||||
} = useCollection<LibreChatUser>("users", {
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
// Charger tous les balances pour associer les crédits
|
||||
const { data: balances = [] } = useCollection<LibreChatBalance>("balances", {
|
||||
limit: 1000, // Charger tous les balances
|
||||
});
|
||||
|
||||
// Créer une map des crédits par utilisateur
|
||||
const creditsMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
balances.forEach((balance) => {
|
||||
map.set(balance.user, balance.tokenCredits || 0);
|
||||
});
|
||||
return map;
|
||||
}, [balances]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const handlePrevPage = () => {
|
||||
setPage((prev) => Math.max(1, prev - 1));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
setPage((prev) => Math.min(totalPages, prev + 1));
|
||||
};
|
||||
|
||||
if (usersLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des utilisateurs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liste des utilisateurs ({total})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Nom</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Rôle</TableHead>
|
||||
<TableHead>Crédits</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Créé le</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => {
|
||||
const userCredits = creditsMap.get(user._id) || 0;
|
||||
const isActive = new Date(user.updatedAt || user.createdAt) >
|
||||
new Date(Date.now() - 5 * 60 * 1000); // 5 minutes en millisecondes
|
||||
|
||||
return (
|
||||
<TableRow key={user._id}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">
|
||||
{user._id.slice(-8)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{user.email}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.role === 'ADMIN' ? 'default' : 'secondary'}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-semibold">
|
||||
{userCredits.toLocaleString()} crédits
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={isActive ? 'default' : 'destructive'}>
|
||||
{isActive ? 'Actif' : 'Inactif'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(new Date(user.createdAt))}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between space-x-2 py-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {page} sur {totalPages} ({total} éléments au total)
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Précédent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user