add section tabs
This commit is contained in:
0
backend/app/routers/datarooms.py
Normal file
0
backend/app/routers/datarooms.py
Normal file
53
frontend/package-lock.json
generated
53
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
30
frontend/src/components/ui/tooltip.tsx
Normal file
30
frontend/src/components/ui/tooltip.tsx
Normal 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 }
|
||||||
Reference in New Issue
Block a user