Files
Dashboard/components/collections/conversations-table.tsx
2025-11-27 14:23:08 +01:00

492 lines
20 KiB
TypeScript

"use client";
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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
ChevronLeft,
ChevronRight,
ChevronDown,
Search,
MessageSquare,
X,
User,
Bot,
Loader2,
} from "lucide-react";
import { formatDate } from "@/lib/utils";
import {
LibreChatConversation,
LibreChatUser,
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;
parts?: Array<string | { text: string }>;
[key: string]: unknown;
}
// Type pour les groupes d'utilisateurs
interface UserGroup {
userId: string;
conversations: LibreChatConversation[];
totalMessages: number;
}
// Fetcher générique
async function fetchCollection<T>(
collection: string,
params: Record<string, string | number>
): Promise<{ data: T[]; total: number; totalPages: number }> {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
searchParams.set(key, String(value));
}
});
const url = `/api/collections/${collection}?${searchParams}`;
console.log("[fetchCollection]", collection, "URL:", url);
const response = await fetch(url);
if (!response.ok) throw new Error(`Erreur lors du chargement de ${collection}`);
const data = await response.json();
console.log("[fetchCollection]", collection, "Results:", data.data?.length, "items");
return data;
}
export function ConversationsTable() {
const [page, setPage] = useState(1);
const [searchInput, setSearchInput] = useState("");
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set());
const [expandedConversation, setExpandedConversation] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const usersPerPage = 10; // Nombre de groupes d'utilisateurs par page
const debouncedSearch = useDebounce(searchInput, 250);
// Reset page et expanded states quand la recherche change
useEffect(() => {
setPage(1);
setExpandedUsers(new Set());
setExpandedConversation(null);
}, [debouncedSearch]);
// Query conversations avec TanStack Query - limite élevée pour groupement client
const {
data: conversationsData,
isLoading: conversationsLoading,
isFetching: conversationsFetching,
} = useQuery({
queryKey: ["conversations", debouncedSearch],
queryFn: () =>
fetchCollection<LibreChatConversation>("conversations", {
page: 1,
limit: 1000, // Charger beaucoup pour grouper côté client
search: debouncedSearch,
}),
placeholderData: keepPreviousData,
staleTime: 30000, // 30 secondes
});
// 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
});
// Query messages de la conversation sélectionnée
const { data: messagesData, isLoading: messagesLoading } = useQuery({
queryKey: ["messages", expandedConversation],
queryFn: () =>
fetchCollection<LibreChatMessage>("messages", {
limit: 500,
filter: JSON.stringify({ conversationId: expandedConversation }),
}),
enabled: !!expandedConversation,
staleTime: 30000,
});
const conversations = conversationsData?.data ?? [];
const total = conversationsData?.total ?? 0;
const users = usersData?.data ?? [];
const messages = messagesData?.data ?? [];
// Map des users pour lookup rapide
const userMap = useMemo(() => new Map(users.map((u) => [u._id, u])), [users]);
// Grouper les conversations par utilisateur
const groupedByUser = useMemo((): UserGroup[] => {
const groups: Record<string, LibreChatConversation[]> = {};
conversations.forEach((conv) => {
const uId = String(conv.user);
if (!groups[uId]) groups[uId] = [];
groups[uId].push(conv);
});
return Object.entries(groups)
.map(([userId, convs]) => ({
userId,
conversations: convs.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
),
totalMessages: convs.reduce((sum, c) => sum + (c.messages?.length || 0), 0),
}))
.sort((a, b) => {
// Trier par date de dernière conversation
const aDate = new Date(a.conversations[0]?.updatedAt || 0).getTime();
const bDate = new Date(b.conversations[0]?.updatedAt || 0).getTime();
return bDate - aDate;
});
}, [conversations]);
// Pagination sur les groupes d'utilisateurs
const totalUserGroups = groupedByUser.length;
const totalPages = Math.ceil(totalUserGroups / usersPerPage);
const paginatedGroups = groupedByUser.slice(
(page - 1) * usersPerPage,
page * usersPerPage
);
// Toggle user group expansion
const toggleUserExpanded = useCallback((userId: string) => {
setExpandedUsers((prev) => {
const next = new Set(prev);
if (next.has(userId)) {
next.delete(userId);
// Fermer aussi la conversation si elle appartient à cet utilisateur
setExpandedConversation(null);
} else {
next.add(userId);
}
return next;
});
}, []);
// Toggle conversation expansion
const toggleConversationExpanded = useCallback((conversationId: string) => {
console.log("[Conversations] Toggle conversation:", conversationId);
setExpandedConversation((prev) =>
prev === conversationId ? null : conversationId
);
}, []);
const getUserInfo = useCallback(
(userId: string) => {
const user = userMap.get(userId);
return {
name: user?.name || user?.username || `User ${userId.slice(-6)}`,
email: user?.email || null,
};
},
[userMap]
);
// Extraction du contenu des messages
const getMessageContent = useCallback((message: LibreChatMessage): string => {
const msg = message as ExtendedMessage;
if (msg.content && Array.isArray(msg.content)) {
for (const item of msg.content) {
if (item?.text) return item.text.trim();
}
}
if (message.text?.trim()) return message.text.trim();
if (typeof msg.content === "string" && msg.content.trim()) return msg.content.trim();
if (msg.parts?.[0]) {
const part = msg.parts[0];
if (typeof part === "string") return part.trim();
if (part?.text) return part.text.trim();
}
return "Contenu non disponible";
}, []);
const clearSearch = () => {
setSearchInput("");
searchInputRef.current?.focus();
};
const isSearching = searchInput !== debouncedSearch || conversationsFetching;
return (
<div className="space-y-4">
<Card>
<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>
{/* Barre de recherche */}
<div className="relative w-full sm:w-96">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Rechercher par nom ou email..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : searchInput ? (
<button
onClick={clearSearch}
className="text-muted-foreground hover:text-foreground transition-colors"
type="button"
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
</div>
</div>
{debouncedSearch && (
<p className="text-sm text-muted-foreground mt-2">
{total} résultat{total > 1 ? "s" : ""} pour &quot;{debouncedSearch}&quot;
</p>
)}
</CardHeader>
<CardContent>
{conversationsLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : conversations.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{debouncedSearch ? (
<>
<Search className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p>Aucun résultat pour &quot;{debouncedSearch}&quot;</p>
<Button variant="link" onClick={clearSearch} className="mt-2">
Effacer la recherche
</Button>
</>
) : (
<p>Aucune conversation</p>
)}
</div>
) : (
<>
{/* Liste des groupes d'utilisateurs */}
<div className="space-y-2">
{paginatedGroups.map((group) => {
const userInfo = getUserInfo(group.userId);
const isUserExpanded = expandedUsers.has(group.userId);
return (
<div key={group.userId} className="border rounded-lg overflow-hidden">
{/* Header du groupe utilisateur */}
<button
onClick={() => toggleUserExpanded(group.userId)}
className="w-full flex items-center gap-3 p-4 bg-muted/30 hover:bg-muted/50 transition-colors text-left"
>
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform flex-shrink-0 ${
isUserExpanded ? "rotate-0" : "-rotate-90"
}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold">{userInfo.name}</span>
{userInfo.email && (
<span className="text-sm text-muted-foreground">
({userInfo.email})
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant="secondary">
{group.conversations.length} conversation{group.conversations.length > 1 ? "s" : ""}
</Badge>
<Badge variant="outline" className="text-xs">
{group.totalMessages} msg{group.totalMessages > 1 ? "s" : ""}
</Badge>
</div>
</button>
{/* Liste des conversations de l'utilisateur */}
{isUserExpanded && (
<div className="border-t">
{group.conversations.map((conv) => {
const msgCount = conv.messages?.length || 0;
const isConvExpanded = expandedConversation === conv.conversationId;
return (
<Fragment key={conv._id}>
<button
onClick={() => toggleConversationExpanded(conv.conversationId)}
className={`w-full flex items-center gap-3 p-3 pl-10 hover:bg-muted/30 transition-colors text-left border-b last:border-b-0 ${
isConvExpanded ? "bg-muted/20" : ""
}`}
>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform flex-shrink-0 ${
isConvExpanded ? "rotate-0" : "-rotate-90"
}`}
/>
<div className="flex-1 min-w-0">
<span className="truncate block text-sm font-medium">
{String(conv.title) || "Sans titre"}
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge variant="outline" className="text-xs font-mono">
{String(conv.endpoint).slice(0, 8)}
</Badge>
<Badge variant="secondary" className="text-xs">
{msgCount} msg{msgCount > 1 ? "s" : ""}
</Badge>
<Badge
variant={conv.isArchived ? "outline" : "default"}
className="text-xs"
>
{conv.isArchived ? "Archivée" : "Active"}
</Badge>
<span className="text-xs text-muted-foreground">
{formatDate(conv.updatedAt)}
</span>
</div>
</button>
{/* Messages de la conversation */}
{isConvExpanded && (
<div className="bg-slate-50 p-4 pl-14 border-b">
{messagesLoading ? (
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Chargement des messages...</span>
</div>
) : messages.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">
Aucun message dans cette conversation
</p>
) : (
<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>
)}
</Fragment>
);
})}
</div>
)}
</div>
);
})}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {page} / {totalPages} ({totalUserGroups} utilisateurs)
</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>
);
}