add section tabs

This commit is contained in:
Anibal Angulo
2025-11-06 12:15:00 -06:00
parent 90f32b2508
commit 86e5c955c5
7 changed files with 751 additions and 454 deletions

View File

View File

@@ -15,6 +15,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.543.0", "lucide-react": "^0.543.0",
@@ -1932,6 +1933,58 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",

View File

@@ -17,6 +17,7 @@
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.543.0", "lucide-react": "^0.543.0",

View File

@@ -1,23 +1,27 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from "react";
import { useFileStore } from '@/stores/fileStore' import { useFileStore } from "@/stores/fileStore";
import { api } from '@/services/api' import { api } from "@/services/api";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow TableRow,
} from '@/components/ui/table' } from "@/components/ui/table";
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from "@/components/ui/checkbox";
import { FileUpload } from './FileUpload' import { FileUpload } from "./FileUpload";
import { DeleteConfirmDialog } from './DeleteConfirmDialog' import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
import { PDFPreviewModal } from './PDFPreviewModal' import { PDFPreviewModal } from "./PDFPreviewModal";
import { CollectionVerifier } from './CollectionVerifier' import { CollectionVerifier } from "./CollectionVerifier";
import { ChunkViewerModal } from './ChunkViewerModal' import { ChunkViewerModal } from "./ChunkViewerModal";
import { ChunkingConfigModalLandingAI, type LandingAIConfig } from './ChunkingConfigModalLandingAI' import {
ChunkingConfigModalLandingAI,
type LandingAIConfig,
} from "./ChunkingConfigModalLandingAI";
import { import {
Upload, Upload,
Download, Download,
@@ -26,11 +30,11 @@ import {
FileText, FileText,
Eye, Eye,
MessageSquare, MessageSquare,
Scissors Scissors,
} from 'lucide-react' } from "lucide-react";
interface DashboardProps { interface DashboardProps {
onProcessingChange?: (isProcessing: boolean) => void onProcessingChange?: (isProcessing: boolean) => void;
} }
export function Dashboard({ onProcessingChange }: DashboardProps = {}) { export function Dashboard({ onProcessingChange }: DashboardProps = {}) {
@@ -43,451 +47,516 @@ export function Dashboard({ onProcessingChange }: DashboardProps = {}) {
selectedFiles, selectedFiles,
toggleFileSelection, toggleFileSelection,
selectAllFiles, selectAllFiles,
clearSelection clearSelection,
} = useFileStore() } = useFileStore();
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState("");
const [uploadDialogOpen, setUploadDialogOpen] = useState(false) const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [fileToDelete, setFileToDelete] = useState<string | null>(null) const [fileToDelete, setFileToDelete] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false);
const [downloading, setDownloading] = useState(false) const [downloading, setDownloading] = useState(false);
// Estados para el modal de preview de PDF // Estados para el modal de preview de PDF
const [previewModalOpen, setPreviewModalOpen] = useState(false) const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewFileUrl, setPreviewFileUrl] = useState<string | null>(null) const [previewFileUrl, setPreviewFileUrl] = useState<string | null>(null);
const [previewFileName, setPreviewFileName] = useState('') const [previewFileName, setPreviewFileName] = useState("");
const [previewFileTema, setPreviewFileTema] = useState<string | undefined>(undefined) const [previewFileTema, setPreviewFileTema] = useState<string | undefined>(
const [loadingPreview, setLoadingPreview] = useState(false) undefined,
);
const [loadingPreview, setLoadingPreview] = useState(false);
// Estados para el modal de chunks // Estados para el modal de chunks
const [chunkViewerOpen, setChunkViewerOpen] = useState(false) const [chunkViewerOpen, setChunkViewerOpen] = useState(false);
const [chunkFileName, setChunkFileName] = useState('') const [chunkFileName, setChunkFileName] = useState("");
const [chunkFileTema, setChunkFileTema] = useState('') const [chunkFileTema, setChunkFileTema] = useState("");
// Estados para chunking // Estados para chunking
const [chunkingConfigOpen, setChunkingConfigOpen] = useState(false) const [chunkingConfigOpen, setChunkingConfigOpen] = useState(false);
const [chunkingFileName, setChunkingFileName] = useState('') const [chunkingFileName, setChunkingFileName] = useState("");
const [chunkingFileTema, setChunkingFileTema] = useState('') const [chunkingFileTema, setChunkingFileTema] = useState("");
const [chunkingCollectionName, setChunkingCollectionName] = useState('') const [chunkingCollectionName, setChunkingCollectionName] = useState("");
const [processing, setProcessing] = useState(false) const [processing, setProcessing] = useState(false);
useEffect(() => { useEffect(() => {
loadFiles() loadFiles();
}, [selectedTema]) }, [selectedTema]);
const loadFiles = async () => { const loadFiles = async () => {
try { try {
setLoading(true) setLoading(true);
const response = await api.getFiles(selectedTema || undefined) const response = await api.getFiles(selectedTema || undefined);
setFiles(response.files) setFiles(response.files);
} catch (error) { } catch (error) {
console.error('Error loading files:', error) console.error("Error loading files:", error);
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
const handleUploadSuccess = () => { const handleUploadSuccess = () => {
loadFiles() loadFiles();
} };
// Eliminar archivo individual // Eliminar archivo individual
const handleDeleteSingle = async (filename: string) => { const handleDeleteSingle = async (filename: string) => {
setFileToDelete(filename) setFileToDelete(filename);
setDeleteDialogOpen(true) setDeleteDialogOpen(true);
} };
// Eliminar archivos seleccionados // Eliminar archivos seleccionados
const handleDeleteMultiple = () => { const handleDeleteMultiple = () => {
if (selectedFiles.size === 0) return if (selectedFiles.size === 0) return;
setFileToDelete(null) setFileToDelete(null);
setDeleteDialogOpen(true) setDeleteDialogOpen(true);
} };
// Confirmar eliminación // Confirmar eliminación
const confirmDelete = async () => { const confirmDelete = async () => {
if (!fileToDelete && selectedFiles.size === 0) return if (!fileToDelete && selectedFiles.size === 0) return;
setDeleting(true) setDeleting(true);
try { try {
if (fileToDelete) { if (fileToDelete) {
// Eliminar archivo individual // Eliminar archivo individual
await api.deleteFile(fileToDelete, selectedTema || undefined) await api.deleteFile(fileToDelete, selectedTema || undefined);
} else { } else {
// Eliminar archivos seleccionados // Eliminar archivos seleccionados
const filesToDelete = Array.from(selectedFiles) const filesToDelete = Array.from(selectedFiles);
await api.deleteFiles(filesToDelete, selectedTema || undefined) await api.deleteFiles(filesToDelete, selectedTema || undefined);
clearSelection() clearSelection();
} }
// Recargar archivos // Recargar archivos
await loadFiles() await loadFiles();
setDeleteDialogOpen(false) setDeleteDialogOpen(false);
setFileToDelete(null) setFileToDelete(null);
} catch (error) { } catch (error) {
console.error('Error deleting files:', error) console.error("Error deleting files:", error);
} finally { } finally {
setDeleting(false) setDeleting(false);
} }
} };
// Descargar archivo individual // Descargar archivo individual
const handleDownloadSingle = async (filename: string) => { const handleDownloadSingle = async (filename: string) => {
try { try {
setDownloading(true) setDownloading(true);
await api.downloadFile(filename, selectedTema || undefined) await api.downloadFile(filename, selectedTema || undefined);
} catch (error) { } catch (error) {
console.error('Error downloading file:', error) console.error("Error downloading file:", error);
} finally { } finally {
setDownloading(false) setDownloading(false);
} }
} };
// Descargar archivos seleccionados // Descargar archivos seleccionados
const handleDownloadMultiple = async () => { const handleDownloadMultiple = async () => {
if (selectedFiles.size === 0) return if (selectedFiles.size === 0) return;
try { try {
setDownloading(true) setDownloading(true);
const filesToDownload = Array.from(selectedFiles) const filesToDownload = Array.from(selectedFiles);
const zipName = selectedTema ? `${selectedTema}_archivos` : 'archivos_seleccionados' const zipName = selectedTema
await api.downloadMultipleFiles(filesToDownload, selectedTema || undefined, zipName) ? `${selectedTema}_archivos`
: "archivos_seleccionados";
await api.downloadMultipleFiles(
filesToDownload,
selectedTema || undefined,
zipName,
);
} catch (error) { } catch (error) {
console.error('Error downloading files:', error) console.error("Error downloading files:", error);
} finally { } finally {
setDownloading(false) setDownloading(false);
} }
} };
// Abrir preview de PDF // Abrir preview de PDF
const handlePreviewFile = async (filename: string, tema?: string) => { const handlePreviewFile = async (filename: string, tema?: string) => {
// Solo permitir preview de archivos PDF // Solo permitir preview de archivos PDF
if (!filename.toLowerCase().endsWith('.pdf')) { if (!filename.toLowerCase().endsWith(".pdf")) {
console.log('Solo se pueden previsualizar archivos PDF') console.log("Solo se pueden previsualizar archivos PDF");
return return;
} }
try { try {
setLoadingPreview(true) setLoadingPreview(true);
setPreviewFileName(filename) setPreviewFileName(filename);
setPreviewFileTema(tema) setPreviewFileTema(tema);
// Obtener la URL temporal (SAS) para el archivo // Obtener la URL temporal (SAS) para el archivo
const url = await api.getPreviewUrl(filename, tema) const url = await api.getPreviewUrl(filename, tema);
setPreviewFileUrl(url) setPreviewFileUrl(url);
setPreviewModalOpen(true) setPreviewModalOpen(true);
} catch (error) { } catch (error) {
console.error('Error obteniendo URL de preview:', error) console.error("Error obteniendo URL de preview:", error);
alert('Error al cargar la vista previa del archivo') alert("Error al cargar la vista previa del archivo");
} finally { } finally {
setLoadingPreview(false) setLoadingPreview(false);
} }
} };
// Manejar descarga desde el modal de preview // Manejar descarga desde el modal de preview
const handleDownloadFromPreview = async () => { const handleDownloadFromPreview = async () => {
if (previewFileName) { if (previewFileName) {
await handleDownloadSingle(previewFileName) await handleDownloadSingle(previewFileName);
} }
} };
// Abrir modal de chunks // Abrir modal de chunks
const handleViewChunks = (filename: string, tema: string) => { const handleViewChunks = (filename: string, tema: string) => {
if (!tema) { if (!tema) {
alert('No hay tema seleccionado. Por favor selecciona un tema primero.') alert("No hay tema seleccionado. Por favor selecciona un tema primero.");
return return;
} }
setChunkFileName(filename) setChunkFileName(filename);
setChunkFileTema(tema) setChunkFileTema(tema);
setChunkViewerOpen(true) setChunkViewerOpen(true);
} };
// Handlers para chunking // Handlers para chunking
const handleStartChunking = (filename: string, tema: string) => { const handleStartChunking = (filename: string, tema: string) => {
if (!tema) { if (!tema) {
alert('No hay tema seleccionado. Por favor selecciona un tema primero.') alert("No hay tema seleccionado. Por favor selecciona un tema primero.");
return return;
} }
setChunkingFileName(filename) setChunkingFileName(filename);
setChunkingFileTema(tema) setChunkingFileTema(tema);
setChunkingCollectionName(tema) // Usar el tema como nombre de colección setChunkingCollectionName(tema); // Usar el tema como nombre de colección
setChunkingConfigOpen(true) setChunkingConfigOpen(true);
} };
const handleProcessWithLandingAI = async (config: LandingAIConfig) => { const handleProcessWithLandingAI = async (config: LandingAIConfig) => {
setProcessing(true) setProcessing(true);
onProcessingChange?.(true) onProcessingChange?.(true);
setChunkingConfigOpen(false) setChunkingConfigOpen(false);
try { try {
const result = await api.processWithLandingAI(config) const result = await api.processWithLandingAI(config);
// Mensaje detallado // Mensaje detallado
let message = `Completado\n\n` let message = `Completado\n\n`;
message += `• Modo: ${result.mode === 'quick' ? 'Rápido' : 'Con Extracción'}\n` message += `• Modo: ${result.mode === "quick" ? "Rápido" : "Con Extracción"}\n`;
message += `• Chunks procesados: ${result.total_chunks}\n` message += `• Chunks procesados: ${result.total_chunks}\n`;
message += `• Chunks agregados: ${result.chunks_added}\n` message += `• Chunks agregados: ${result.chunks_added}\n`;
message += `• Colección: ${result.collection_name}\n` message += `• Colección: ${result.collection_name}\n`;
message += `• Tiempo: ${result.processing_time_seconds}s\n` message += `• Tiempo: ${result.processing_time_seconds}s\n`;
if (result.schema_used) { if (result.schema_used) {
message += `• Schema usado: ${result.schema_used}\n` message += `• Schema usado: ${result.schema_used}\n`;
} }
if (result.extracted_data) { if (result.extracted_data) {
message += `\nDatos extraídos disponibles en metadata` message += `\nDatos extraídos disponibles en metadata`;
} }
alert(message) alert(message);
// Recargar archivos // Recargar archivos
loadFiles() loadFiles();
} catch (error: any) { } catch (error: any) {
console.error('Error processing with LandingAI:', error) console.error("Error processing with LandingAI:", error);
alert(`❌ Error: ${error.message}`) alert(`❌ Error: ${error.message}`);
} finally { } finally {
setProcessing(false) setProcessing(false);
onProcessingChange?.(false) onProcessingChange?.(false);
} }
} };
const filteredFiles = files.filter(file => const filteredFiles = files.filter((file) =>
file.name.toLowerCase().includes(searchTerm.toLowerCase()) file.name.toLowerCase().includes(searchTerm.toLowerCase()),
) );
const totalFiles = files.length;
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes' if (bytes === 0) return "0 Bytes";
const k = 1024 const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'] const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
} };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-ES', { return new Date(dateString).toLocaleDateString("es-ES", {
year: 'numeric', year: "numeric",
month: 'short', month: "short",
day: 'numeric', day: "numeric",
hour: '2-digit', hour: "2-digit",
minute: '2-digit' minute: "2-digit",
}) });
} };
// Preparar datos para el modal de confirmación // Preparar datos para el modal de confirmación
const getDeleteDialogProps = () => { const getDeleteDialogProps = () => {
if (fileToDelete) { if (fileToDelete) {
return { return {
title: 'Eliminar archivo', title: "Eliminar archivo",
description: `¿Estás seguro de que quieres eliminar "${fileToDelete}"? Esta acción no se puede deshacer.`, description: `¿Estás seguro de que quieres eliminar "${fileToDelete}"? Esta acción no se puede deshacer.`,
fileList: [fileToDelete] fileList: [fileToDelete],
} };
} else { } else {
const filesToDelete = Array.from(selectedFiles) const filesToDelete = Array.from(selectedFiles);
return { return {
title: `Eliminar ${filesToDelete.length} archivos`, title: `Eliminar ${filesToDelete.length} archivos`,
description: `¿Estás seguro de que quieres eliminar ${filesToDelete.length} archivo${filesToDelete.length !== 1 ? 's' : ''}? Esta acción no se puede deshacer.`, description: `¿Estás seguro de que quieres eliminar ${filesToDelete.length} archivo${filesToDelete.length !== 1 ? "s" : ""}? Esta acción no se puede deshacer.`,
fileList: filesToDelete fileList: filesToDelete,
} };
} }
} };
return ( return (
<div className="flex flex-col h-full bg-white"> <div className="flex flex-col h-full bg-white">
{/* Processing Banner */} <div className="border-b border-gray-200 px-6 py-4">
{processing && ( <div className="flex flex-wrap items-center justify-between gap-4">
<div className="bg-blue-50 border-b border-blue-200 px-6 py-3">
<div className="flex items-center justify-center gap-3">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<p className="text-sm font-medium text-blue-900">
Procesando archivo con LandingAI... Por favor no navegues ni realices otras acciones.
</p>
</div>
</div>
)}
{/* Header */}
<div className="border-b border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div> <div>
<h2 className="text-2xl font-semibold text-gray-900"> <h2 className="text-2xl font-semibold text-gray-900">
{selectedTema ? `Tema: ${selectedTema}` : 'Todos los archivos'} {selectedTema
? `Tema actual: ${selectedTema}`
: "Todos los archivos"}
</h2> </h2>
<p className="text-sm text-gray-600 mt-1"> <p className="mt-1 text-sm text-gray-600">
{filteredFiles.length} archivo{filteredFiles.length !== 1 ? 's' : ''} {totalFiles} archivo{totalFiles !== 1 ? "s" : ""}
</p> </p>
</div> </div>
<div className="flex gap-2">
<Button onClick={() => setUploadDialogOpen(true)} disabled={processing}>
<Upload className="w-4 h-4 mr-2" />
Subir archivo
</Button>
</div>
</div> </div>
</div>
{/* Search and Actions */} <Tabs defaultValue="files" className="flex flex-col flex-1">
<div className="flex items-center gap-4"> <div className="border-b border-gray-200 px-6 py-2">
<div className="relative flex-1 max-w-sm"> <TabsList className="flex h-10 items-center gap-2 bg-transparent p-0 justify-start">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" /> <TabsTrigger
<Input value="dashboard"
placeholder="Buscar archivos..." className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-black data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:ring-0 data-[state=active]:shadow-black/30"
value={searchTerm} >
onChange={(e) => setSearchTerm(e.target.value)} Dashboard
className="pl-10" </TabsTrigger>
disabled={processing} <TabsTrigger
/> value="files"
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-black data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:ring-0 data-[state=active]:shadow-black/30"
>
Files
</TabsTrigger>
<TabsTrigger
value="chat"
className="rounded-md px-4 py-2 text-sm font-medium text-gray-600 transition data-[state=active]:bg-black data-[state=active]:text-white data-[state=active]:shadow-lg data-[state=active]:ring-0 data-[state=active]:shadow-black/30"
>
Chat
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="dashboard" className="mt-0 flex-1">
<div className="p-6 text-sm text-gray-500">
Este panel se llenará con métricas generales próximamente.
</div> </div>
</TabsContent>
{selectedFiles.size > 0 && ( <TabsContent value="files" className="mt-0 flex flex-1 flex-col">
<div className="flex gap-2"> {processing && (
<Button <div className="bg-blue-50 border-b border-blue-200 px-6 py-3">
variant="outline" <div className="flex items-center justify-center gap-3">
size="sm" <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
onClick={handleDownloadMultiple} <p className="text-sm font-medium text-blue-900">
disabled={downloading || processing} Procesando archivo con LandingAI... Por favor no navegues ni
> realices otras acciones.
<Download className="w-4 h-4 mr-2" /> </p>
{downloading ? 'Descargando...' : `Descargar (${selectedFiles.size})`} </div>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteMultiple}
disabled={processing}
>
<Trash2 className="w-4 h-4 mr-2" />
Eliminar ({selectedFiles.size})
</Button>
</div> </div>
)} )}
</div>
</div>
{/* Table */} <div className="border-b border-gray-200 px-6 py-4">
<div className="flex-1 overflow-auto"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
{loading ? ( <div className="relative w-full md:max-w-sm">
<div className="flex items-center justify-center h-64"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<p className="text-gray-500">Cargando archivos...</p> <Input
</div> placeholder="Buscar archivos..."
) : filteredFiles.length === 0 ? ( value={searchTerm}
<div className="flex flex-col items-center justify-center h-64"> onChange={(e) => setSearchTerm(e.target.value)}
<FileText className="w-12 h-12 text-gray-400 mb-4" /> className="pl-10"
<p className="text-gray-500"> disabled={processing}
{searchTerm ? 'No se encontraron archivos' : 'No hay archivos en este tema'} />
</p> </div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedFiles.size === filteredFiles.length && filteredFiles.length > 0}
onCheckedChange={(checked: boolean) => {
if (checked) {
selectAllFiles()
} else {
clearSelection()
}
}}
/>
</TableHead>
<TableHead>Nombre</TableHead>
<TableHead>Tamaño</TableHead>
<TableHead>Fecha</TableHead>
<TableHead>Tema</TableHead>
<TableHead className="w-32">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredFiles.map((file) => {
const isPDF = file.name.toLowerCase().endsWith('.pdf')
return ( <div className="flex w-full items-center justify-between gap-2 md:w-auto md:justify-end">
<TableRow key={file.full_path}> {selectedFiles.size > 0 && (
<TableCell> <>
<Button
variant="outline"
size="sm"
onClick={handleDownloadMultiple}
disabled={downloading || processing}
>
<Download className="w-4 h-4 mr-2" />
{downloading
? "Descargando..."
: `Descargar (${selectedFiles.size})`}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDeleteMultiple}
disabled={processing}
>
<Trash2 className="w-4 h-4 mr-2" />
Eliminar ({selectedFiles.size})
</Button>
</>
)}
<Button
onClick={() => setUploadDialogOpen(true)}
disabled={processing}
>
<Upload className="w-4 h-4 mr-2" />
Subir archivo
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-64">
<p className="text-gray-500">Cargando archivos...</p>
</div>
) : filteredFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64">
<FileText className="w-12 h-12 text-gray-400 mb-4" />
<p className="text-gray-500">
{searchTerm
? "No se encontraron archivos"
: "No hay archivos en este tema"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox <Checkbox
checked={selectedFiles.has(file.name)} checked={
onCheckedChange={() => toggleFileSelection(file.name)} selectedFiles.size === filteredFiles.length &&
filteredFiles.length > 0
}
onCheckedChange={(checked: boolean) => {
if (checked) {
selectAllFiles();
} else {
clearSelection();
}
}}
/> />
</TableCell> </TableHead>
<TableCell className="font-medium"> <TableHead>Nombre</TableHead>
{isPDF ? ( <TableHead>Tamaño</TableHead>
<button <TableHead>Fecha</TableHead>
onClick={() => handlePreviewFile(file.name, file.tema || undefined)} <TableHead>Tema</TableHead>
className="text-blue-600 hover:text-blue-800 hover:underline text-left transition-colors" <TableHead className="w-32">Acciones</TableHead>
disabled={loadingPreview}
>
{file.name}
</button>
) : (
<span>{file.name}</span>
)}
</TableCell>
<TableCell>{formatFileSize(file.size)}</TableCell>
<TableCell>{formatDate(file.last_modified)}</TableCell>
<TableCell>
<span className="px-2 py-1 bg-gray-100 rounded-md text-sm">
{file.tema || 'General'}
</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadSingle(file.name)}
disabled={downloading}
title="Descargar archivo"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Procesar con chunking"
onClick={() => handleStartChunking(file.name, file.tema)}
>
<Scissors className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Ver chunks"
onClick={() => handleViewChunks(file.name, file.tema)}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Generar preguntas"
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSingle(file.name)}
title="Eliminar archivo"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow> </TableRow>
) </TableHeader>
})} <TableBody>
</TableBody> {filteredFiles.map((file) => {
</Table> const isPDF = file.name.toLowerCase().endsWith(".pdf");
)}
</div>
return (
<TableRow key={file.full_path}>
<TableCell>
<Checkbox
checked={selectedFiles.has(file.name)}
onCheckedChange={() =>
toggleFileSelection(file.name)
}
/>
</TableCell>
<TableCell className="font-medium">
{isPDF ? (
<button
onClick={() =>
handlePreviewFile(
file.name,
file.tema || undefined,
)
}
className="text-blue-600 hover:text-blue-800 hover:underline text-left transition-colors"
disabled={loadingPreview}
>
{file.name}
</button>
) : (
<span>{file.name}</span>
)}
</TableCell>
<TableCell>{formatFileSize(file.size)}</TableCell>
<TableCell>{formatDate(file.last_modified)}</TableCell>
<TableCell>
<span className="px-2 py-1 bg-gray-100 rounded-md text-sm">
{file.tema || "General"}
</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadSingle(file.name)}
disabled={downloading}
title="Descargar archivo"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Procesar con chunking"
onClick={() =>
handleStartChunking(file.name, file.tema)
}
>
<Scissors className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Ver chunks"
onClick={() =>
handleViewChunks(file.name, file.tema)
}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Generar preguntas"
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSingle(file.name)}
title="Eliminar archivo"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</TabsContent>
<TabsContent value="chat" className="mt-0 flex-1">
<div className="p-6 text-sm text-gray-500">
El chat estará disponible próximamente.
</div>
</TabsContent>
</Tabs>
{/* Upload Dialog */} {/* Upload Dialog */}
<FileUpload <FileUpload
open={uploadDialogOpen} open={uploadDialogOpen}
onOpenChange={setUploadDialogOpen} onOpenChange={setUploadDialogOpen}
onSuccess={handleUploadSuccess} onSuccess={handleUploadSuccess}
/> />
@@ -514,7 +583,7 @@ export function Dashboard({ onProcessingChange }: DashboardProps = {}) {
<CollectionVerifier <CollectionVerifier
tema={selectedTema} tema={selectedTema}
onVerified={(exists) => { onVerified={(exists) => {
console.log(`Collection ${selectedTema} exists: ${exists}`) console.log(`Collection ${selectedTema} exists: ${exists}`);
}} }}
/> />
@@ -536,5 +605,5 @@ export function Dashboard({ onProcessingChange }: DashboardProps = {}) {
onProcess={handleProcessWithLandingAI} onProcess={handleProcessWithLandingAI}
/> />
</div> </div>
) );
} }

View File

@@ -5,11 +5,13 @@ import { Menu } from 'lucide-react'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
import { Dashboard } from './Dashboard' import { Dashboard } from './Dashboard'
import { SchemaManagement } from '@/pages/SchemaManagement' import { SchemaManagement } from '@/pages/SchemaManagement'
import { cn } from '@/lib/utils'
type View = 'dashboard' | 'schemas' type View = 'dashboard' | 'schemas'
export function Layout() { export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false) const [sidebarOpen, setSidebarOpen] = useState(false)
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [currentView, setCurrentView] = useState<View>('dashboard') const [currentView, setCurrentView] = useState<View>('dashboard')
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
@@ -33,19 +35,37 @@ export function Layout() {
return ( return (
<div className="h-screen flex bg-gray-50"> <div className="h-screen flex bg-gray-50">
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<div className="hidden md:flex md:w-64 md:flex-col"> <div
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} /> className={cn(
'hidden md:flex md:flex-col transition-all duration-300',
isSidebarCollapsed ? 'md:w-20' : 'md:w-64'
)}
>
<Sidebar
onNavigateToSchemas={handleNavigateToSchemas}
disabled={isProcessing}
collapsed={isSidebarCollapsed}
onToggleCollapse={() => setIsSidebarCollapsed((prev) => !prev)}
/>
</div> </div>
{/* Mobile Sidebar */} {/* Mobile Sidebar */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}> <Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden fixed top-4 left-4 z-40" disabled={isProcessing}> <Button
variant="ghost"
size="icon"
className="md:hidden fixed top-4 left-4 z-40"
disabled={isProcessing}
>
<Menu className="h-6 w-6" /> <Menu className="h-6 w-6" />
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="left" className="w-64 p-0"> <SheetContent side="left" className="w-64 p-0">
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} /> <Sidebar
onNavigateToSchemas={handleNavigateToSchemas}
disabled={isProcessing}
/>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -66,4 +86,4 @@ export function Layout() {
</div> </div>
</div> </div>
) )
} }

View File

@@ -1,172 +1,296 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type ReactElement } from "react";
import { useFileStore } from '@/stores/fileStore' import { useFileStore } from "@/stores/fileStore";
import { api } from '@/services/api' import { api } from "@/services/api";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { FolderIcon, FileText, Trash2, Database } from 'lucide-react' import {
FolderIcon,
FileText,
Trash2,
Database,
ChevronLeft,
ChevronRight,
RefreshCcw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface SidebarProps { interface SidebarProps {
onNavigateToSchemas?: () => void onNavigateToSchemas?: () => void;
disabled?: boolean disabled?: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
} }
export function Sidebar({ onNavigateToSchemas, disabled = false }: SidebarProps = {}) { export function Sidebar({
onNavigateToSchemas,
disabled = false,
collapsed = false,
onToggleCollapse,
}: SidebarProps = {}) {
const { const {
temas, temas,
selectedTema, selectedTema,
setTemas, setTemas,
setSelectedTema, setSelectedTema,
loading, loading,
setLoading setLoading,
} = useFileStore() } = useFileStore();
const [deletingTema, setDeletingTema] = useState<string | null>(null) const [deletingTema, setDeletingTema] = useState<string | null>(null);
const renderWithTooltip = (label: string, element: ReactElement) => {
if (!collapsed) {
return element;
}
return (
<Tooltip>
<TooltipTrigger asChild>{element}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
};
useEffect(() => { useEffect(() => {
loadTemas() loadTemas();
}, []) }, []);
const loadTemas = async () => { const loadTemas = async () => {
try { try {
setLoading(true) setLoading(true);
const response = await api.getTemas() const response = await api.getTemas();
setTemas(response.temas) setTemas(response.temas);
} catch (error) { } catch (error) {
console.error('Error loading temas:', error) console.error("Error loading temas:", error);
} finally { } finally {
setLoading(false) setLoading(false);
} }
} };
const handleTemaSelect = (tema: string | null) => { const handleTemaSelect = (tema: string | null) => {
setSelectedTema(tema) setSelectedTema(tema);
} };
const handleDeleteTema = async (tema: string, e: React.MouseEvent<HTMLButtonElement>) => { const handleDeleteTema = async (
e.stopPropagation() // Evitar que se seleccione el tema al hacer clic en el icono tema: string,
e: React.MouseEvent<HTMLButtonElement>,
) => {
e.stopPropagation(); // Evitar que se seleccione el tema al hacer clic en el icono
const confirmed = window.confirm( const confirmed = window.confirm(
`¿Estás seguro de que deseas eliminar el tema "${tema}"?\n\n` + `¿Estás seguro de que deseas eliminar el tema "${tema}"?\n\n` +
`Esto eliminará:\n` + `Esto eliminará:\n` +
`• Todos los archivos del tema en Azure Blob Storage\n` + `• Todos los archivos del tema en Azure Blob Storage\n` +
`• La colección "${tema}" en Qdrant (si existe)\n\n` + `• La colección "${tema}" en Qdrant (si existe)\n\n` +
`Esta acción no se puede deshacer.` `Esta acción no se puede deshacer.`,
) );
if (!confirmed) return if (!confirmed) return;
try { try {
setDeletingTema(tema) setDeletingTema(tema);
// 1. Eliminar todos los archivos del tema en Azure Blob Storage // 1. Eliminar todos los archivos del tema en Azure Blob Storage
await api.deleteTema(tema) await api.deleteTema(tema);
// 2. Intentar eliminar la colección en Qdrant (si existe) // 2. Intentar eliminar la colección en Qdrant (si existe)
try { try {
const collectionExists = await api.checkCollectionExists(tema) const collectionExists = await api.checkCollectionExists(tema);
if (collectionExists.exists) { if (collectionExists.exists) {
await api.deleteCollection(tema) await api.deleteCollection(tema);
console.log(`Colección "${tema}" eliminada de Qdrant`) console.log(`Colección "${tema}" eliminada de Qdrant`);
} }
} catch (error) { } catch (error) {
console.warn(`No se pudo eliminar la colección "${tema}" de Qdrant:`, error) console.warn(
`No se pudo eliminar la colección "${tema}" de Qdrant:`,
error,
);
// Continuar aunque falle la eliminación de la colección // Continuar aunque falle la eliminación de la colección
} }
// 3. Actualizar la lista de temas // 3. Actualizar la lista de temas
await loadTemas() await loadTemas();
// 4. Si el tema eliminado estaba seleccionado, deseleccionar // 4. Si el tema eliminado estaba seleccionado, deseleccionar
if (selectedTema === tema) { if (selectedTema === tema) {
setSelectedTema(null) setSelectedTema(null);
} }
} catch (error) { } catch (error) {
console.error(`Error eliminando tema "${tema}":`, error) console.error(`Error eliminando tema "${tema}":`, error);
alert(`Error al eliminar el tema: ${error instanceof Error ? error.message : 'Error desconocido'}`) alert(
`Error al eliminar el tema: ${error instanceof Error ? error.message : "Error desconocido"}`,
);
} finally { } finally {
setDeletingTema(null) setDeletingTema(null);
} }
} };
return ( return (
<div className="bg-white border-r border-gray-200 flex flex-col h-full"> <TooltipProvider delayDuration={100}>
{/* Header */} <div className="bg-white border-r border-gray-200 flex flex-col h-full transition-[width] duration-300">
<div className="p-6 border-b border-gray-200"> {/* Header */}
<h1 className="text-xl font-semibold text-gray-900 flex items-center gap-2"> <div
<FileText className="h-6 w-6" /> className={cn(
DoRa Luma "border-b border-gray-200 flex items-center gap-3",
</h1> collapsed ? "p-4" : "p-6",
</div> )}
>
{/* Temas List */} <div
<div className="flex-1 overflow-y-auto p-4"> className={cn(
<div className="space-y-1"> "flex items-center gap-2 text-gray-900 flex-1",
<h2 className="text-sm font-medium text-gray-500 mb-3">Collections</h2> collapsed ? "justify-center" : "justify-start",
)}
{/* Todos los archivos */}
<Button
variant={selectedTema === null ? "secondary" : "ghost"}
className="w-full justify-start"
onClick={() => handleTemaSelect(null)}
disabled={disabled}
> >
<FolderIcon className="mr-2 h-4 w-4" /> <FileText className="h-6 w-6" />
Todos los archivos {!collapsed && <h1 className="text-xl font-semibold">DoRa Luma</h1>}
</Button> </div>
{onToggleCollapse && (
<Button
variant="ghost"
size="icon"
className="text-gray-500 hover:text-gray-900"
onClick={onToggleCollapse}
disabled={disabled}
aria-label={
collapsed ? "Expandir barra lateral" : "Contraer barra lateral"
}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
)}
</div>
{/* Lista de temas */} {/* Temas List */}
{loading ? ( <div className={cn("flex-1 overflow-y-auto p-4", collapsed && "px-2")}>
<div className="text-sm text-gray-500 px-3 py-2">Cargando...</div> <div className="space-y-1">
) : ( <h2
temas.map((tema) => ( className={cn(
<div key={tema} className="relative group"> "text-sm font-medium text-gray-500 mb-3",
<Button collapsed && "text-xs text-center",
variant={selectedTema === tema ? "secondary" : "ghost"} )}
className="w-full justify-start pr-10" >
onClick={() => handleTemaSelect(tema)} {collapsed ? "Coll." : "Collections"}
disabled={deletingTema === tema || disabled} </h2>
>
<FolderIcon className="mr-2 h-4 w-4" /> {/* Todos los archivos */}
{tema} {renderWithTooltip(
</Button> "Todos los archivos",
<button <Button
onClick={(e) => handleDeleteTema(tema, e)} variant={selectedTema === null ? "secondary" : "ghost"}
disabled={deletingTema === tema || disabled} className={cn(
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50" "w-full justify-start",
title="Eliminar tema y colección" collapsed && "px-0 justify-center",
> )}
<Trash2 className="h-4 w-4 text-red-600" /> onClick={() => handleTemaSelect(null)}
</button> disabled={disabled}
>
<FolderIcon className={cn("h-4 w-4", !collapsed && "mr-2")} />
<span className={cn("truncate", collapsed && "sr-only")}>
Todos los archivos
</span>
</Button>,
)}
{/* Lista de temas */}
{loading ? (
<div className="text-sm text-gray-500 px-3 py-2 text-center">
{collapsed ? "..." : "Cargando..."}
</div> </div>
)) ) : (
temas.map((tema) => (
<div key={tema} className="relative group">
{renderWithTooltip(
tema,
<Button
variant={selectedTema === tema ? "secondary" : "ghost"}
className={cn(
"w-full justify-start",
collapsed ? "px-0 justify-center" : "pr-10",
)}
onClick={() => handleTemaSelect(tema)}
disabled={deletingTema === tema || disabled}
>
<FolderIcon
className={cn("h-4 w-4", !collapsed && "mr-2")}
/>
<span className={cn("truncate", collapsed && "sr-only")}>
{tema}
</span>
</Button>,
)}
{!collapsed && (
<button
onClick={(e) => handleDeleteTema(tema, e)}
disabled={deletingTema === tema || disabled}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
title="Eliminar tema y colección"
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
)}
</div>
))
)}
</div>
</div>
{/* Footer */}
<div
className={cn(
"p-4 border-t border-gray-200 space-y-2",
collapsed && "flex flex-col items-center gap-2",
)}
>
{onNavigateToSchemas &&
renderWithTooltip(
"Gestionar Schemas",
<Button
variant="default"
size="sm"
onClick={onNavigateToSchemas}
disabled={disabled}
className={cn(
"w-full justify-start",
collapsed && "px-0 justify-center",
)}
>
<Database className={cn("h-4 w-4", !collapsed && "mr-2")} />
<span className={cn(collapsed && "sr-only")}>
Gestionar Schemas
</span>
</Button>,
)}
{renderWithTooltip(
"Actualizar temas",
<Button
variant="outline"
size="sm"
onClick={loadTemas}
disabled={loading || disabled}
className={cn(
"w-full justify-start",
collapsed && "px-0 justify-center",
)}
>
<RefreshCcw className={cn("mr-2 h-4 w-4", collapsed && "mr-0")} />
<span className={cn(collapsed && "sr-only")}>
Actualizar temas
</span>
</Button>,
)} )}
</div> </div>
</div> </div>
</TooltipProvider>
{/* Footer */} );
<div className="p-4 border-t border-gray-200 space-y-2"> }
{onNavigateToSchemas && (
<Button
variant="default"
size="sm"
onClick={onNavigateToSchemas}
disabled={disabled}
className="w-full"
>
<Database className="mr-2 h-4 w-4" />
Gestionar Schemas
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={loadTemas}
disabled={loading || disabled}
className="w-full"
>
Actualizar temas
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }