From f1d19bd2579972e9ccb95f3815530cee6d6dc6fb Mon Sep 17 00:00:00 2001 From: Levi Planelles Date: Fri, 6 Feb 2026 20:51:04 +0100 Subject: [PATCH] feat: add login page and authentication context - Implemented a new login page with form validation and error handling. - Created an authentication context to manage user login state and redirection. - Added middleware for route protection based on authentication status. - Developed settings dialog for user preferences including theme and appearance settings. - Introduced PDF export functionality for projects and tasks. - Created reusable UI components for alerts, cards, and settings. --- app/globals.css | 31 ++ app/layout.tsx | 26 +- app/login/layout.tsx | 11 + app/login/page.tsx | 177 +++++++++++ components/app-sidebar.tsx | 75 ++++- components/dashboard/dashboard-header.tsx | 2 - components/dashboard/project-dashboard.tsx | 40 ++- components/layouts/index.ts | 1 + components/layouts/main-layout.tsx | 64 ++++ .../project-detail/project-detail-view.tsx | 1 + components/project-detail/project-header.tsx | 16 +- components/projects/data-table-toolbar.tsx | 120 ++++--- components/projects/data-table.tsx | 43 +++ components/projects/projects-table.tsx | 29 +- components/settings/index.ts | 2 + components/settings/settings-dialog.tsx | 294 ++++++++++++++++++ components/settings/settings-provider.tsx | 50 +++ components/ui/alert.tsx | 59 ++++ .../user-profile/user-profile-dialog.tsx | 2 +- hooks/use-auth.tsx | 143 +++++++++ hooks/use-settings.ts | 106 +++++++ lib/authService.ts | 248 +++++++++++++++ lib/exportPdf.ts | 280 +++++++++++++++++ middleware.ts | 50 +++ package-lock.json | 244 +++++++++++++++ package.json | 3 + 26 files changed, 2027 insertions(+), 90 deletions(-) create mode 100644 app/login/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 components/layouts/index.ts create mode 100644 components/layouts/main-layout.tsx create mode 100644 components/settings/index.ts create mode 100644 components/settings/settings-dialog.tsx create mode 100644 components/settings/settings-provider.tsx create mode 100644 components/ui/alert.tsx create mode 100644 hooks/use-auth.tsx create mode 100644 hooks/use-settings.ts create mode 100644 lib/authService.ts create mode 100644 lib/exportPdf.ts create mode 100644 middleware.ts diff --git a/app/globals.css b/app/globals.css index 394035e..2d871a5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -150,4 +150,35 @@ body { @apply bg-background text-foreground; } + + /* Deshabilitar animaciones cuando se prefiere */ + .no-animations *, + .no-animations *::before, + .no-animations *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + /* Modo compacto */ + .compact-mode { + --spacing-scale: 0.75; + } + + .compact-mode .p-4 { + padding: 0.75rem; + } + + .compact-mode .p-6 { + padding: 1rem; + } + + .compact-mode .gap-4 { + gap: 0.75rem; + } + + .compact-mode .gap-6 { + gap: 1rem; + } } diff --git a/app/layout.tsx b/app/layout.tsx index 74369bf..06e010e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,10 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar" -import { AppSidebar } from "@/components/app-sidebar" -import { ThemeProvider } from "@/components/theme-provider" - +import { ThemeProvider } from "@/components/theme-provider"; +import { SettingsProvider } from "@/components/settings"; +import { AuthProvider } from "@/hooks/use-auth"; +import { MainLayout } from "@/components/layouts/main-layout"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -18,7 +18,7 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "Dolibarr proyectos", - description: "Generated by create next app", + description: "Panel de gestión de proyectos de Dolibarr", }; export default function RootLayout({ @@ -35,19 +35,13 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - - - - -
- - {children} -
-
-
+ + + {children} + + ); } - diff --git a/app/login/layout.tsx b/app/login/layout.tsx new file mode 100644 index 0000000..89c806c --- /dev/null +++ b/app/login/layout.tsx @@ -0,0 +1,11 @@ +/** + * Layout para la página de login + * Sin sidebar ni elementos de navegación + */ +export default function LoginLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..e611360 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { FolderKanban, Loader2, Eye, EyeOff } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +export default function LoginPage() { + const { login, isLoading: authLoading } = useAuth(); + const [formData, setFormData] = useState({ + login: "", + password: "", + }); + const [showPassword, setShowPassword] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + // Limpiar error al escribir + if (error) setError(null); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsSubmitting(true); + + // Validación básica + if (!formData.login.trim()) { + setError("El usuario es requerido"); + setIsSubmitting(false); + return; + } + + if (!formData.password) { + setError("La contraseña es requerida"); + setIsSubmitting(false); + return; + } + + try { + const result = await login({ + login: formData.login.trim(), + password: formData.password, + }); + + if (!result.success) { + setError(result.error || "Error al iniciar sesión"); + } + // Si success, el AuthProvider redirigirá automáticamente + } catch { + setError("Error inesperado. Por favor, intenta de nuevo."); + } finally { + setIsSubmitting(false); + } + }; + + const isLoading = isSubmitting || authLoading; + + return ( +
+ + {/* Header */} + +
+ +
+
+ + Dolibarr Proyectos + + + Inicia sesión para acceder al panel de gestión + +
+
+ + {/* Form */} +
+ + {/* Error Alert */} + {error && ( + + {error} + + )} + + {/* Usuario */} +
+ + +
+ + {/* Contraseña */} +
+ +
+ + +
+
+
+ + + + +

+ Usa tus credenciales de Dolibarr para acceder +

+
+
+
+
+ ); +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 8d55076..f2b0c3e 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -1,5 +1,7 @@ "use client"; + import { useEffect, useState } from "react"; +import { useAuth } from "@/hooks/use-auth"; import { getUser } from "@/lib/usersService"; import { @@ -23,6 +25,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { UserProfileDialog } from "@/components/user-profile"; +import { SettingsDialog } from "@/components/settings"; import { Home, FolderKanban, @@ -31,7 +34,8 @@ import { ChevronUp, LogOut, User, - GanttChart + GanttChart, + Loader2 } from "lucide-react"; // Datos del menú @@ -59,6 +63,7 @@ const items = [ ]; export function AppSidebar() { + const { logout, user: authUser } = useAuth(); const [user, setUser] = useState({ name: "Cargando...", email: "", @@ -66,10 +71,23 @@ export function AppSidebar() { initials: "..." }); const [isProfileOpen, setIsProfileOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); useEffect(() => { async function loadUser() { try { + // Si tenemos usuario del auth, usarlo primero + if (authUser) { + setUser({ + name: `${authUser.firstname} ${authUser.lastname}`, + email: authUser.email, + avatar: "/avatar.jpg", + initials: `${authUser.firstname.charAt(0)}${authUser.lastname.charAt(0)}` + }); + } + + // Luego intentar cargar datos más completos const realUser = await getUser(); setUser({ name: `${realUser.firstname} ${realUser.lastname}`, @@ -79,11 +97,25 @@ export function AppSidebar() { }); } catch (error) { console.error("Failed to load user:", error); - setUser(prev => ({ ...prev, name: "Usuario desconocido" })); + // Si falla y tenemos authUser, mantener esos datos + if (!authUser) { + setUser(prev => ({ ...prev, name: "Usuario" })); + } } } loadUser(); - }, []); + }, [authUser]); + + const handleLogout = async () => { + setIsLoggingOut(true); + try { + await logout(); + } catch (error) { + console.error("Logout error:", error); + } finally { + setIsLoggingOut(false); + } + }; return ( @@ -91,8 +123,8 @@ export function AppSidebar() { {/* Header del Sidebar */}
-

Dolibarr

-

Gestión de Proyectos

+

Dolibarr

+

Gestión de Proyectos

@@ -134,7 +166,7 @@ export function AppSidebar() {
{user.name} - {user.email} + {user.email}
@@ -155,7 +187,7 @@ export function AppSidebar() {
{user.name} - {user.email} + {user.email}
@@ -164,14 +196,27 @@ export function AppSidebar() { Perfil - + setIsSettingsOpen(true)}> Ajustes - - - Cerrar sesión + + {isLoggingOut ? ( + <> + + Cerrando sesión... + + ) : ( + <> + + Cerrar sesión + + )} @@ -184,6 +229,12 @@ export function AppSidebar() { open={isProfileOpen} onOpenChange={setIsProfileOpen} /> + + {/* Dialog de ajustes */} +
); -} \ No newline at end of file +} diff --git a/components/dashboard/dashboard-header.tsx b/components/dashboard/dashboard-header.tsx index c8e7c27..050f782 100644 --- a/components/dashboard/dashboard-header.tsx +++ b/components/dashboard/dashboard-header.tsx @@ -1,7 +1,6 @@ "use client"; import { Search, LayoutGrid, List, Plus } from 'lucide-react'; -import { ThemeToggle } from '@/components/theme-toggle'; import { Button } from '@/components/ui/button'; interface DashboardHeaderProps { @@ -28,7 +27,6 @@ export default function DashboardHeader({

Gestiona y visualiza todos tus proyectos

-
('grid'); + const { settings } = useAppSettings(); + // viewModeOverride: null significa usar el de settings, valor significa override manual + const [viewModeOverride, setViewModeOverride] = useState<'grid' | 'list' | null>(null); const [searchTerm, setSearchTerm] = useState(''); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false); + // El viewMode efectivo: usa override si existe, sino el de settings + const viewMode = viewModeOverride ?? settings.defaultView; + + // Cuando cambia defaultView en settings, resetear el override + useEffect(() => { + setViewModeOverride(null); + }, [settings.defaultView]); + useEffect(() => { async function loadProjects() { try { @@ -52,11 +63,24 @@ export default function ProjectDashboard() { setProjects(prevProjects => prevProjects.filter(p => p.id !== projectId)); }; - const filteredProjects = projects.filter(project => - project.name.toLowerCase().includes(searchTerm.toLowerCase()) || - project.client.toLowerCase().includes(searchTerm.toLowerCase()) || - project.ref.toLowerCase().includes(searchTerm.toLowerCase()) - ); + // Handler para cambio manual de vista (desde el header) + const handleViewModeChange = (mode: 'grid' | 'list') => { + setViewModeOverride(mode); + }; + + // Filtrar proyectos según búsqueda y configuración de mostrar completados + const filteredProjects = projects.filter(project => { + // Filtro de búsqueda + const matchesSearch = + project.name.toLowerCase().includes(searchTerm.toLowerCase()) || + project.client.toLowerCase().includes(searchTerm.toLowerCase()) || + project.ref.toLowerCase().includes(searchTerm.toLowerCase()); + + // Filtro de proyectos completados (status '2' = cerrado) + const showProject = settings.showCompletedProjects || project.status !== '2'; + + return matchesSearch && showProject; + }); if (loading) { return ( @@ -65,7 +89,7 @@ export default function ProjectDashboard() { searchTerm="" onSearchChange={() => {}} viewMode={viewMode} - onViewModeChange={setViewMode} + onViewModeChange={handleViewModeChange} onCreateProject={() => {}} /> @@ -113,7 +137,7 @@ export default function ProjectDashboard() { searchTerm={searchTerm} onSearchChange={setSearchTerm} viewMode={viewMode} - onViewModeChange={setViewMode} + onViewModeChange={handleViewModeChange} onCreateProject={() => setIsCreateSheetOpen(true)} /> diff --git a/components/layouts/index.ts b/components/layouts/index.ts new file mode 100644 index 0000000..84c5b92 --- /dev/null +++ b/components/layouts/index.ts @@ -0,0 +1 @@ +export { MainLayout } from "./main-layout"; diff --git a/components/layouts/main-layout.tsx b/components/layouts/main-layout.tsx new file mode 100644 index 0000000..1561366 --- /dev/null +++ b/components/layouts/main-layout.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar"; +import { AppSidebar } from "@/components/app-sidebar"; +import { useAuth } from "@/hooks/use-auth"; +import { Loader2 } from "lucide-react"; + +// Rutas públicas que no muestran el sidebar +const PUBLIC_ROUTES = ["/login"]; + +interface MainLayoutProps { + children: React.ReactNode; +} + +export function MainLayout({ children }: MainLayoutProps) { + const pathname = usePathname(); + const { isLoading, isAuthenticated } = useAuth(); + + const isPublicRoute = PUBLIC_ROUTES.includes(pathname); + + // Mostrar loading mientras se verifica la autenticación + if (isLoading) { + return ( +
+
+ +

Cargando...

+
+
+ ); + } + + // Rutas públicas: sin sidebar + if (isPublicRoute) { + return <>{children}; + } + + // Si no está autenticado y no es ruta pública, el AuthProvider redirigirá + // Pero mostramos loading por si acaso + if (!isAuthenticated) { + return ( +
+
+ +

Redirigiendo...

+
+
+ ); + } + + // Rutas privadas: con sidebar + return ( + + + +
+ + {children} +
+
+
+ ); +} diff --git a/components/project-detail/project-detail-view.tsx b/components/project-detail/project-detail-view.tsx index 395a9b1..3fc91ae 100644 --- a/components/project-detail/project-detail-view.tsx +++ b/components/project-detail/project-detail-view.tsx @@ -135,6 +135,7 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView {/* Header del proyecto */} diff --git a/components/project-detail/project-header.tsx b/components/project-detail/project-header.tsx index a08bc06..a2c8ecb 100644 --- a/components/project-detail/project-header.tsx +++ b/components/project-detail/project-header.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy } from "lucide-react"; +import { ArrowLeft, MoreHorizontal, Pencil, Trash2, Share2, Copy, Download } from "lucide-react"; import { useRouter } from "next/navigation"; import Link from "next/link"; @@ -18,10 +18,13 @@ import { ProjectStatusBadge } from "@/components/ui/project-status-badge"; import { ProjectFormSheet } from "@/components/project-form/project-form-sheet"; import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog"; import { deleteProject } from "@/lib/projectsService"; +import { exportProjectDetailToPDF } from "@/lib/exportPdf"; import { Project } from "@/types/project"; +import { Task } from "@/types/task"; interface ProjectHeaderProps { project: Project; + tasks?: Task[]; onProjectUpdated?: (project: Project) => void; onProjectDeleted?: () => void; } @@ -36,7 +39,7 @@ function getInitials(name: string): string { .slice(0, 2); } -export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: ProjectHeaderProps) { +export function ProjectHeader({ project, tasks = [], onProjectUpdated, onProjectDeleted }: ProjectHeaderProps) { const router = useRouter(); const initials = getInitials(project.name); const [isEditSheetOpen, setIsEditSheetOpen] = useState(false); @@ -47,6 +50,10 @@ export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: P navigator.clipboard.writeText(project.ref); }; + const handleExportPDF = () => { + exportProjectDetailToPDF(project, tasks); + }; + const handleEditSuccess = (updatedProject: Project) => { onProjectUpdated?.(updatedProject); }; @@ -115,6 +122,11 @@ export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: P {/* Acciones */}
+ + - - - Columnas visibles - - - Referencia - - - Nombre - - - Cliente - - - Estado - - - Progreso - - - Presupuesto - - - Fechas - - - + {/* Opciones de vista - columnas visibles */} + {columns && columns.length > 0 && ( + + + + + + Columnas visibles + + {columns + .filter((col) => col.canHide) + .map((column) => ( + { + column.toggleVisibility(checked); + }} + onSelect={(e) => e.preventDefault()} + > + {COLUMN_NAMES[column.id] || column.id} + + ))} + + + )} {/* Exportar */} - - - {/* Nuevo proyecto */} -
diff --git a/components/projects/data-table.tsx b/components/projects/data-table.tsx index c32060f..a9a0396 100644 --- a/components/projects/data-table.tsx +++ b/components/projects/data-table.tsx @@ -32,11 +32,26 @@ import { SelectValue, } from "@/components/ui/select"; +export interface ColumnVisibilityInfo { + id: string; + isVisible: boolean; + canHide: boolean; + toggleVisibility: (visible: boolean) => void; +} + +export interface TableInfo { + selectedCount: number; + getSelectedRows: () => TData[]; + getAllVisibleRows: () => TData[]; + columns: ColumnVisibilityInfo[]; +} + interface DataTableProps { columns: ColumnDef[]; data: TData[]; searchKey?: string; searchValue?: string; + onTableChange?: (info: TableInfo) => void; } export function DataTable({ @@ -44,6 +59,7 @@ export function DataTable({ data, searchKey, searchValue, + onTableChange, }: DataTableProps) { const [sorting, setSorting] = React.useState([]); const [columnFilters, setColumnFilters] = React.useState([]); @@ -69,6 +85,33 @@ export function DataTable({ }, }); + // Notificar cambios al componente padre + React.useEffect(() => { + if (onTableChange) { + const columnsInfo: ColumnVisibilityInfo[] = table + .getAllColumns() + .filter((column) => typeof column.accessorFn !== "undefined") + .map((column) => ({ + id: column.id, + isVisible: column.getIsVisible(), + canHide: column.getCanHide(), + toggleVisibility: (visible: boolean) => { + setColumnVisibility((prev) => ({ + ...prev, + [column.id]: visible, + })); + }, + })); + + onTableChange({ + selectedCount: table.getFilteredSelectedRowModel().rows.length, + getSelectedRows: () => table.getFilteredSelectedRowModel().rows.map((row) => row.original), + getAllVisibleRows: () => table.getFilteredRowModel().rows.map((row) => row.original), + columns: columnsInfo, + }); + } + }, [table, onTableChange, columnVisibility, rowSelection]); + // Aplicar filtro de búsqueda externo React.useEffect(() => { if (searchKey && searchValue !== undefined) { diff --git a/components/projects/projects-table.tsx b/components/projects/projects-table.tsx index 0a3bd30..8405f1c 100644 --- a/components/projects/projects-table.tsx +++ b/components/projects/projects-table.tsx @@ -1,11 +1,12 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { FolderKanban, RefreshCw, Plus } from "lucide-react"; import { Project } from "@/types/project"; import { getProjects, deleteProject } from "@/lib/projectsService"; -import { DataTable } from "./data-table"; +import { exportProjectsToPDF } from "@/lib/exportPdf"; +import { DataTable, TableInfo } from "./data-table"; import { DataTableToolbar } from "./data-table-toolbar"; import { DataTableSkeleton } from "./data-table-skeleton"; import { EmptyState } from "./empty-state"; @@ -23,6 +24,9 @@ export function ProjectsTable() { const [searchValue, setSearchValue] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); + // Info de la tabla + const [tableInfo, setTableInfo] = useState | null>(null); + // Estados para crear/editar/eliminar const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false); const [isEditSheetOpen, setIsEditSheetOpen] = useState(false); @@ -86,6 +90,17 @@ export function ProjectsTable() { } }; + // Handler para exportar PDF + const handleExport = useCallback((projectsToExport: Project[]) => { + if (projectsToExport.length === 0) return; + exportProjectsToPDF(projectsToExport); + }, []); + + // Handler para cuando la tabla cambia + const handleTableChange = useCallback((info: TableInfo) => { + setTableInfo(info); + }, []); + // Crear columnas con callbacks const columns = useMemo(() => createProjectColumns({ onEdit: handleEditProject, @@ -121,9 +136,6 @@ export function ProjectsTable() { setStatusFilter("all"); }; - // Contar seleccionados (placeholder - el DataTable lo maneja internamente) - const selectedCount = 0; - // Estado de error if (error) { return ( @@ -174,8 +186,12 @@ export function ProjectsTable() { onSearchChange={setSearchValue} statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} - selectedCount={selectedCount} + selectedCount={tableInfo?.selectedCount ?? 0} onClearFilters={handleClearFilters} + columns={tableInfo?.columns} + onExport={handleExport} + getSelectedProjects={tableInfo?.getSelectedRows} + getAllVisibleProjects={tableInfo?.getAllVisibleRows} /> + ); +} + +// Componente de item de configuración con switch +function SettingItem({ + icon: Icon, + label, + description, + checked, + onCheckedChange, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + description?: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( +
+
+
+ +
+
+ + {description && ( +

{description}

+ )} +
+
+ +
+ ); +} + +// Componente de item de configuración con select +function SettingSelect({ + icon: Icon, + label, + description, + value, + onValueChange, + options, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + description?: string; + value: string; + onValueChange: (value: string) => void; + options: { value: string; label: string }[]; +}) { + return ( +
+
+
+ +
+
+ + {description && ( +

{description}

+ )} +
+
+ +
+ ); +} + +export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) { + const { theme, setTheme } = useTheme(); + const { settings, updateSettings, isLoaded } = useAppSettings(); + const [mounted, setMounted] = useState(false); + + // Evitar hydration mismatch + useEffect(() => { + setMounted(true); + }, []); + + // Sincronizar tema con next-themes + const handleThemeChange = (newTheme: ThemeMode) => { + setTheme(newTheme); + updateSettings({ theme: newTheme }); + }; + + if (!mounted || !isLoaded) { + return null; + } + + return ( + + + + Ajustes + + Personaliza la apariencia y comportamiento de la aplicación + + + +
+ {/* Sección: Tema */} + + +
+ +

+ Tema +

+
+ +
+ handleThemeChange("light")} + /> + handleThemeChange("dark")} + /> + handleThemeChange("system")} + /> +
+
+
+ + {/* Sección: Apariencia */} + + +
+ +

+ Apariencia +

+
+ + updateSettings({ compactMode: checked })} + /> + + + + updateSettings({ showAnimations: checked })} + /> +
+
+ + {/* Sección: Proyectos */} + + +
+ +

+ Proyectos +

+
+ + updateSettings({ defaultView: value as DefaultView })} + options={[ + { value: "grid", label: "Cuadrícula" }, + { value: "list", label: "Lista" }, + ]} + /> + + + + updateSettings({ showCompletedProjects: checked })} + /> +
+
+
+
+
+ ); +} diff --git a/components/settings/settings-provider.tsx b/components/settings/settings-provider.tsx new file mode 100644 index 0000000..5dbe5f2 --- /dev/null +++ b/components/settings/settings-provider.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAppSettings } from "@/hooks/use-settings"; + +interface SettingsProviderProps { + children: React.ReactNode; +} + +/** + * Provider que aplica las configuraciones de la app al DOM + * - Modo compacto (clase en body) + * - Deshabilitar animaciones (clase en body) + */ +export function SettingsProvider({ children }: SettingsProviderProps) { + const { settings, isLoaded } = useAppSettings(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + // Aplicar clases al body según configuraciones + useEffect(() => { + if (!mounted || !isLoaded) return; + + const body = document.body; + + // Modo compacto + if (settings.compactMode) { + body.classList.add("compact-mode"); + } else { + body.classList.remove("compact-mode"); + } + + // Animaciones + if (!settings.showAnimations) { + body.classList.add("no-animations"); + } else { + body.classList.remove("no-animations"); + } + + // Cleanup + return () => { + body.classList.remove("compact-mode", "no-animations"); + }; + }, [mounted, isLoaded, settings.compactMode, settings.showAnimations]); + + return <>{children}; +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..41fa7e0 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/user-profile/user-profile-dialog.tsx b/components/user-profile/user-profile-dialog.tsx index 74f1498..ded1bc6 100644 --- a/components/user-profile/user-profile-dialog.tsx +++ b/components/user-profile/user-profile-dialog.tsx @@ -153,7 +153,7 @@ export function UserProfileDialog({ open, onOpenChange }: UserProfileDialogProps return ( - + Mi Perfil diff --git a/hooks/use-auth.tsx b/hooks/use-auth.tsx new file mode 100644 index 0000000..9223896 --- /dev/null +++ b/hooks/use-auth.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + ReactNode, +} from "react"; +import { useRouter, usePathname } from "next/navigation"; +import { + AuthUser, + AuthState, + LoginCredentials, + login as authLogin, + logout as authLogout, + getAuthState, + verifyToken, +} from "@/lib/authService"; + +// Rutas públicas que no requieren autenticación +const PUBLIC_ROUTES = ["/login"]; + +interface AuthContextType { + user: AuthUser | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (credentials: LoginCredentials) => Promise<{ success: boolean; error?: string }>; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const router = useRouter(); + const pathname = usePathname(); + const [authState, setAuthState] = useState({ + isAuthenticated: false, + user: null, + token: null, + }); + const [isLoading, setIsLoading] = useState(true); + + // Verificar autenticación al cargar + useEffect(() => { + async function checkAuth() { + setIsLoading(true); + + // Obtener estado guardado + const storedState = getAuthState(); + + if (storedState.isAuthenticated) { + // Verificar que el token sigue siendo válido + const isValid = await verifyToken(); + + if (isValid) { + setAuthState(storedState); + } else { + // Token inválido, limpiar + setAuthState({ + isAuthenticated: false, + user: null, + token: null, + }); + } + } + + setIsLoading(false); + } + + checkAuth(); + }, []); + + // Redirección basada en autenticación + useEffect(() => { + if (isLoading) return; + + const isPublicRoute = PUBLIC_ROUTES.includes(pathname); + + if (!authState.isAuthenticated && !isPublicRoute) { + // No autenticado y en ruta privada -> redirigir a login + router.push("/login"); + } else if (authState.isAuthenticated && pathname === "/login") { + // Autenticado y en login -> redirigir a home + router.push("/"); + } + }, [authState.isAuthenticated, isLoading, pathname, router]); + + // Login + const login = useCallback(async (credentials: LoginCredentials) => { + const result = await authLogin(credentials); + + if (result.success) { + setAuthState({ + isAuthenticated: true, + user: result.user || null, + token: result.token || null, + }); + return { success: true }; + } + + return { success: false, error: result.error }; + }, []); + + // Logout + const logout = useCallback(async () => { + await authLogout(); + setAuthState({ + isAuthenticated: false, + user: null, + token: null, + }); + router.push("/login"); + }, [router]); + + const value: AuthContextType = { + user: authState.user, + isAuthenticated: authState.isAuthenticated, + isLoading, + login, + logout, + }; + + return {children}; +} + +/** + * Hook para acceder al contexto de autenticación + */ +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + + return context; +} diff --git a/hooks/use-settings.ts b/hooks/use-settings.ts new file mode 100644 index 0000000..65ff820 --- /dev/null +++ b/hooks/use-settings.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useState, useEffect, useCallback, useSyncExternalStore } from "react"; + +// Tipos para las configuraciones +export type ThemeMode = "light" | "dark" | "system"; +export type DefaultView = "grid" | "list"; + +export interface AppSettings { + // Apariencia + theme: ThemeMode; + compactMode: boolean; + showAnimations: boolean; + + // Proyectos + defaultView: DefaultView; + showCompletedProjects: boolean; +} + +// Valores por defecto +export const DEFAULT_SETTINGS: AppSettings = { + theme: "system", + compactMode: false, + showAnimations: true, + defaultView: "grid", + showCompletedProjects: true, +}; + +// Clave para localStorage +const SETTINGS_KEY = "app-settings"; + +// Store global para sincronizar entre componentes +let globalSettings: AppSettings = DEFAULT_SETTINGS; +const listeners = new Set<() => void>(); + +// Notificar a todos los listeners cuando cambian las settings +function emitChange() { + listeners.forEach((listener) => listener()); +} + +// Helper para obtener settings de localStorage +function getStoredSettings(): AppSettings { + if (typeof window === "undefined") return DEFAULT_SETTINGS; + try { + const stored = localStorage.getItem(SETTINGS_KEY); + if (stored) { + return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }; + } + } catch (error) { + console.error("Error loading settings:", error); + } + return DEFAULT_SETTINGS; +} + +// Inicializar settings globales +if (typeof window !== "undefined") { + globalSettings = getStoredSettings(); +} + +// Subscribe function para useSyncExternalStore +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +// Snapshot function para useSyncExternalStore +function getSnapshot() { + return globalSettings; +} + +// Server snapshot +function getServerSnapshot() { + return DEFAULT_SETTINGS; +} + +/** + * Hook para gestionar configuraciones de la aplicación + * Usa useSyncExternalStore para sincronizar entre componentes + */ +export function useAppSettings() { + const settings = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + + // Actualizar configuraciones + const updateSettings = useCallback((updates: Partial) => { + globalSettings = { ...globalSettings, ...updates }; + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(globalSettings)); + } catch (error) { + console.error("Error saving settings:", error); + } + emitChange(); + }, []); + + // Resetear a valores por defecto + const resetSettings = useCallback(() => { + globalSettings = DEFAULT_SETTINGS; + try { + localStorage.removeItem(SETTINGS_KEY); + } catch (error) { + console.error("Error resetting settings:", error); + } + emitChange(); + }, []); + + return { settings, updateSettings, resetSettings, isLoaded: true }; +} diff --git a/lib/authService.ts b/lib/authService.ts new file mode 100644 index 0000000..1f459c9 --- /dev/null +++ b/lib/authService.ts @@ -0,0 +1,248 @@ +/** + * Servicio de autenticación para Dolibarr + * + * Maneja login, logout y gestión de tokens/sesiones + */ + +// Tipos para la autenticación +export interface AuthUser { + id: number; + login: string; + firstname: string; + lastname: string; + email: string; + admin: boolean; +} + +export interface LoginCredentials { + login: string; + password: string; +} + +export interface LoginResponse { + success: boolean; + token?: string; + user?: AuthUser; + error?: string; +} + +export interface AuthState { + isAuthenticated: boolean; + user: AuthUser | null; + token: string | null; +} + +// Constantes +const AUTH_TOKEN_KEY = "dolibarr_auth_token"; +const AUTH_USER_KEY = "dolibarr_auth_user"; + +/** + * Obtiene la URL base de la API de Dolibarr + */ +function getApiUrl(): string { + // Intentar obtener del entorno (cliente usa NEXT_PUBLIC_) + const url = typeof window !== "undefined" + ? process.env.NEXT_PUBLIC_API_URL + : process.env.DOLIBARR_API_URL || process.env.NEXT_PUBLIC_API_URL; + + if (!url) { + throw new Error("API URL not configured"); + } + + return url; +} + +/** + * Realiza login contra la API de Dolibarr + * Dolibarr usa el endpoint /login para autenticar y devuelve un token + */ +export async function login(credentials: LoginCredentials): Promise { + try { + const apiUrl = getApiUrl(); + + // Dolibarr API login endpoint + const response = await fetch(`${apiUrl}/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ + login: credentials.login, + password: credentials.password, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + + if (response.status === 401 || response.status === 403) { + return { + success: false, + error: "Credenciales incorrectas. Verifica tu usuario y contraseña.", + }; + } + + return { + success: false, + error: errorData.error || `Error del servidor: ${response.status}`, + }; + } + + const data = await response.json(); + + // Dolibarr devuelve el token directamente en success.token + const token = data.success?.token || data.token; + + if (!token) { + return { + success: false, + error: "No se recibió token de autenticación", + }; + } + + // Obtener información del usuario con el token + const userResponse = await fetch(`${apiUrl}/users/info?DOLAPIKEY=${token}`, { + headers: { + "Accept": "application/json", + }, + }); + + let user: AuthUser | undefined; + + if (userResponse.ok) { + const userData = await userResponse.json(); + user = { + id: parseInt(userData.id), + login: userData.login, + firstname: userData.firstname || "", + lastname: userData.lastname || "", + email: userData.email || "", + admin: userData.admin === "1" || userData.admin === 1, + }; + } + + // Guardar en localStorage + saveAuthData(token, user); + + return { + success: true, + token, + user, + }; + } catch (error) { + console.error("Login error:", error); + return { + success: false, + error: "Error de conexión. Verifica tu conexión a internet.", + }; + } +} + +/** + * Realiza logout - limpia datos locales + * Nota: Dolibarr no tiene endpoint de logout, los tokens expiran solos + */ +export async function logout(): Promise { + // Limpiar datos de autenticación + clearAuthData(); +} + +/** + * Guarda los datos de autenticación en localStorage y cookie + */ +function saveAuthData(token: string, user?: AuthUser): void { + if (typeof window === "undefined") return; + + localStorage.setItem(AUTH_TOKEN_KEY, token); + + if (user) { + localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user)); + } + + // También guardar en cookie para que el middleware pueda leerlo + document.cookie = `${AUTH_TOKEN_KEY}=${token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax`; +} + +/** + * Limpia los datos de autenticación + */ +function clearAuthData(): void { + if (typeof window === "undefined") return; + + localStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_USER_KEY); + + // Limpiar cookie + document.cookie = `${AUTH_TOKEN_KEY}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`; +} + +/** + * Obtiene el token guardado + */ +export function getStoredToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(AUTH_TOKEN_KEY); +} + +/** + * Obtiene el usuario guardado + */ +export function getStoredUser(): AuthUser | null { + if (typeof window === "undefined") return null; + + const userJson = localStorage.getItem(AUTH_USER_KEY); + if (!userJson) return null; + + try { + return JSON.parse(userJson); + } catch { + return null; + } +} + +/** + * Verifica si el token actual es válido + */ +export async function verifyToken(): Promise { + const token = getStoredToken(); + + if (!token) { + return false; + } + + try { + const apiUrl = getApiUrl(); + + // Verificar token haciendo una llamada simple a la API + const response = await fetch(`${apiUrl}/users/info?DOLAPIKEY=${token}`, { + headers: { + "Accept": "application/json", + }, + }); + + if (!response.ok) { + // Token inválido o expirado + clearAuthData(); + return false; + } + + return true; + } catch { + return false; + } +} + +/** + * Obtiene el estado de autenticación actual + */ +export function getAuthState(): AuthState { + const token = getStoredToken(); + const user = getStoredUser(); + + return { + isAuthenticated: !!token, + user, + token, + }; +} diff --git a/lib/exportPdf.ts b/lib/exportPdf.ts new file mode 100644 index 0000000..c6b4146 --- /dev/null +++ b/lib/exportPdf.ts @@ -0,0 +1,280 @@ +import { jsPDF } from "jspdf"; +import autoTable from "jspdf-autotable"; + +import { Project, STATUS_CONFIG } from "@/types/project"; +import { Task, TASK_STATUS_CONFIG, TASK_PRIORITY_CONFIG, TaskStatus, TaskPriority } from "@/types/task"; + +// Formateadores +function formatCurrency(amount: number): string { + return new Intl.NumberFormat("es-ES", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +function formatDate(dateString: string | null | undefined): string { + if (!dateString) return "-"; + try { + return new Date(dateString).toLocaleDateString("es-ES", { + day: "2-digit", + month: "short", + year: "numeric", + }); + } catch { + return "-"; + } +} + +function getStatusLabel(status: string): string { + return STATUS_CONFIG[status as keyof typeof STATUS_CONFIG]?.label || status; +} + +function getTaskStatusLabel(status: TaskStatus): string { + return TASK_STATUS_CONFIG[status]?.label || status; +} + +function getTaskPriorityLabel(priority: TaskPriority): string { + return TASK_PRIORITY_CONFIG[priority]?.label || priority; +} + +/** + * Exporta una lista de proyectos a PDF (vista resumen) + */ +export function exportProjectsToPDF(projects: Project[]): void { + const doc = new jsPDF(); + const pageWidth = doc.internal.pageSize.getWidth(); + + // Título + doc.setFontSize(20); + doc.setTextColor(59, 130, 246); // blue-500 + doc.text("Listado de Proyectos", pageWidth / 2, 20, { align: "center" }); + + // Fecha de generación + doc.setFontSize(10); + doc.setTextColor(100); + doc.text( + `Generado el ${new Date().toLocaleDateString("es-ES", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })}`, + pageWidth / 2, + 28, + { align: "center" } + ); + + // Resumen + doc.setFontSize(11); + doc.setTextColor(60); + doc.text(`Total de proyectos: ${projects.length}`, 14, 40); + + // Tabla de proyectos + const tableData = projects.map((project) => [ + project.ref, + project.name, + project.client, + getStatusLabel(project.status), + `${project.progress.toFixed(0)}%`, + formatCurrency(project.budget), + formatDate(project.startDate), + formatDate(project.endDate), + ]); + + autoTable(doc, { + startY: 48, + head: [ + ["Ref", "Nombre", "Cliente", "Estado", "Progreso", "Presupuesto", "Inicio", "Fin"], + ], + body: tableData, + styles: { + fontSize: 9, + cellPadding: 3, + }, + headStyles: { + fillColor: [59, 130, 246], // blue-500 + textColor: 255, + fontStyle: "bold", + }, + alternateRowStyles: { + fillColor: [248, 250, 252], // slate-50 + }, + columnStyles: { + 0: { cellWidth: 20 }, // Ref + 1: { cellWidth: 40 }, // Nombre + 2: { cellWidth: 30 }, // Cliente + 3: { cellWidth: 22 }, // Estado + 4: { cellWidth: 18 }, // Progreso + 5: { cellWidth: 25 }, // Presupuesto + 6: { cellWidth: 22 }, // Inicio + 7: { cellWidth: 22 }, // Fin + }, + }); + + // Guardar + const fileName = `proyectos_${new Date().toISOString().split("T")[0]}.pdf`; + doc.save(fileName); +} + +/** + * Exporta un proyecto con sus tareas a PDF (vista detallada) + */ +export function exportProjectDetailToPDF(project: Project, tasks: Task[]): void { + const doc = new jsPDF(); + const pageWidth = doc.internal.pageSize.getWidth(); + + // === HEADER === + doc.setFontSize(22); + doc.setTextColor(59, 130, 246); // blue-500 + doc.text(project.name, pageWidth / 2, 20, { align: "center" }); + + doc.setFontSize(12); + doc.setTextColor(100); + doc.text(`Referencia: ${project.ref}`, pageWidth / 2, 28, { align: "center" }); + + // Fecha de generación + doc.setFontSize(9); + doc.text( + `Generado el ${new Date().toLocaleDateString("es-ES", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })}`, + pageWidth / 2, + 35, + { align: "center" } + ); + + // === INFORMACIÓN DEL PROYECTO === + let yPos = 48; + + doc.setFontSize(14); + doc.setTextColor(30); + doc.text("Información del Proyecto", 14, yPos); + yPos += 8; + + // Tabla de información + const projectInfo = [ + ["Cliente", project.client || "-"], + ["Estado", getStatusLabel(project.status)], + ["Progreso", `${project.progress.toFixed(0)}%`], + ["Presupuesto", formatCurrency(project.budget)], + ["Fecha de inicio", formatDate(project.startDate)], + ["Fecha de fin", formatDate(project.endDate)], + ]; + + autoTable(doc, { + startY: yPos, + body: projectInfo, + styles: { + fontSize: 10, + cellPadding: 4, + }, + columnStyles: { + 0: { fontStyle: "bold", cellWidth: 45, textColor: [100, 100, 100] }, + 1: { cellWidth: 60 }, + }, + theme: "plain", + margin: { left: 14 }, + }); + + // Obtener posición después de la tabla + yPos = (doc as jsPDF & { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY || yPos + 50; + + // Descripción si existe + if (project.description) { + yPos += 10; + doc.setFontSize(14); + doc.setTextColor(30); + doc.text("Descripción", 14, yPos); + yPos += 6; + + doc.setFontSize(10); + doc.setTextColor(60); + const descriptionLines = doc.splitTextToSize(project.description, pageWidth - 28); + doc.text(descriptionLines, 14, yPos); + yPos += descriptionLines.length * 5 + 5; + } + + // === TAREAS === + if (tasks.length > 0) { + yPos += 10; + + // Verificar si necesitamos nueva página + if (yPos > 250) { + doc.addPage(); + yPos = 20; + } + + doc.setFontSize(14); + doc.setTextColor(30); + doc.text(`Tareas (${tasks.length})`, 14, yPos); + yPos += 8; + + // Tabla de tareas + const taskData = tasks.map((task) => [ + task.ref, + task.title, + getTaskStatusLabel(task.status), + getTaskPriorityLabel(task.priority), + `${task.progress}%`, + formatDate(task.startDate), + formatDate(task.endDate), + ]); + + autoTable(doc, { + startY: yPos, + head: [["Ref", "Tarea", "Estado", "Prioridad", "Progreso", "Inicio", "Fin"]], + body: taskData, + styles: { + fontSize: 8, + cellPadding: 3, + }, + headStyles: { + fillColor: [147, 51, 234], // purple-600 + textColor: 255, + fontStyle: "bold", + }, + alternateRowStyles: { + fillColor: [248, 250, 252], // slate-50 + }, + columnStyles: { + 0: { cellWidth: 18 }, // Ref + 1: { cellWidth: 50 }, // Tarea + 2: { cellWidth: 25 }, // Estado + 3: { cellWidth: 22 }, // Prioridad + 4: { cellWidth: 18 }, // Progreso + 5: { cellWidth: 22 }, // Inicio + 6: { cellWidth: 22 }, // Fin + }, + }); + } else { + yPos += 15; + doc.setFontSize(11); + doc.setTextColor(100); + doc.text("Este proyecto no tiene tareas asignadas.", 14, yPos); + } + + // === FOOTER === + const pageCount = doc.getNumberOfPages(); + for (let i = 1; i <= pageCount; i++) { + doc.setPage(i); + doc.setFontSize(8); + doc.setTextColor(150); + doc.text( + `Página ${i} de ${pageCount}`, + pageWidth / 2, + doc.internal.pageSize.getHeight() - 10, + { align: "center" } + ); + } + + // Guardar + const fileName = `proyecto_${project.ref}_${new Date().toISOString().split("T")[0]}.pdf`; + doc.save(fileName); +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..aaf242f --- /dev/null +++ b/middleware.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +// Rutas públicas que no requieren autenticación +const PUBLIC_ROUTES = ["/login"]; + +// Rutas que siempre deben ser accesibles (assets, API, etc.) +const ALWAYS_ALLOWED = ["/_next", "/api", "/favicon.ico", "/avatar.jpg"]; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Permitir siempre assets y APIs + if (ALWAYS_ALLOWED.some((route) => pathname.startsWith(route))) { + return NextResponse.next(); + } + + // Obtener token de las cookies (si existe) + const token = request.cookies.get("dolibarr_auth_token")?.value; + + // Verificar si es ruta pública + const isPublicRoute = PUBLIC_ROUTES.some((route) => pathname === route); + + if (!token && !isPublicRoute) { + // No hay token y la ruta es privada -> redirigir a login + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("redirect", pathname); + return NextResponse.redirect(loginUrl); + } + + if (token && pathname === "/login") { + // Hay token y está en login -> redirigir a home + return NextResponse.redirect(new URL("/", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + /* + * Match all request paths except: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public folder + */ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; diff --git a/package-lock.json b/package-lock.json index 9698279..19f1e42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,8 @@ "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jspdf": "^4.1.0", + "jspdf-autotable": "^5.0.7", "lucide-react": "^0.556.0", "next": "16.0.7", "next-themes": "^0.4.6", @@ -35,6 +37,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/jspdf": "^1.3.3", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -250,6 +253,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -3555,6 +3567,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jspdf": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/jspdf/-/jspdf-1.3.3.tgz", + "integrity": "sha512-DqwyAKpVuv+7DniCp2Deq1xGvfdnKSNgl9Agun2w6dFvR5UKamiv4VfYUgcypd8S9ojUyARFIlZqBrYrBMQlew==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", @@ -3565,6 +3584,19 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", @@ -3585,6 +3617,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -4498,6 +4537,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.3.tgz", @@ -4666,6 +4715,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4789,6 +4858,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4804,6 +4885,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5113,6 +5204,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5847,6 +5948,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -5856,6 +5968,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6281,6 +6399,20 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6352,6 +6484,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6884,6 +7022,32 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz", + "integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf-autotable": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.7.tgz", + "integrity": "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw==", + "license": "MIT", + "peerDependencies": { + "jspdf": "^2 || ^3 || ^4" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -7717,6 +7881,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7756,6 +7926,13 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8010,6 +8187,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -8218,6 +8405,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -8295,6 +8489,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8611,6 +8815,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8831,6 +9045,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -8938,6 +9162,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -9362,6 +9596,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", diff --git a/package.json b/package.json index 526f263..4d311d2 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jspdf": "^4.1.0", + "jspdf-autotable": "^5.0.7", "lucide-react": "^0.556.0", "next": "16.0.7", "next-themes": "^0.4.6", @@ -40,6 +42,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/jspdf": "^1.3.3", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19",