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.
This commit is contained in:
Levi Planelles 2026-02-06 20:51:04 +01:00
parent ec7d26e559
commit f1d19bd257
26 changed files with 2027 additions and 90 deletions

View File

@ -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;
}
}

View File

@ -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
>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<main>
<SidebarTrigger />
{children}
</main>
</SidebarInset>
</SidebarProvider>
<SettingsProvider>
<AuthProvider>
<MainLayout>{children}</MainLayout>
</AuthProvider>
</SettingsProvider>
</ThemeProvider>
</body>
</html>
);
}

11
app/login/layout.tsx Normal file
View File

@ -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}</>;
}

177
app/login/page.tsx Normal file
View File

@ -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<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 p-4">
<Card className="w-full max-w-md shadow-xl border-0 bg-card/80 backdrop-blur">
{/* Header */}
<CardHeader className="space-y-4 text-center pb-2">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 shadow-lg">
<FolderKanban className="h-7 w-7 text-white" />
</div>
<div className="space-y-1">
<CardTitle className="text-2xl font-bold tracking-tight">
Dolibarr Proyectos
</CardTitle>
<CardDescription className="text-muted-foreground">
Inicia sesión para acceder al panel de gestión
</CardDescription>
</div>
</CardHeader>
{/* Form */}
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4 pt-4">
{/* Error Alert */}
{error && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Usuario */}
<div className="space-y-2">
<Label htmlFor="login">Usuario</Label>
<Input
id="login"
name="login"
type="text"
placeholder="Tu nombre de usuario"
value={formData.login}
onChange={handleChange}
disabled={isLoading}
autoComplete="username"
autoFocus
className="h-11"
/>
</div>
{/* Contraseña */}
<div className="space-y-2">
<Label htmlFor="password">Contraseña</Label>
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="Tu contraseña"
value={formData.password}
onChange={handleChange}
disabled={isLoading}
autoComplete="current-password"
className="h-11 pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-11 w-11 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
disabled={isLoading}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4 pt-2">
<Button
type="submit"
className="w-full h-11 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Iniciando sesión...
</>
) : (
"Iniciar sesión"
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
Usa tus credenciales de Dolibarr para acceder
</p>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@ -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 (
<Sidebar>
@ -91,8 +123,8 @@ export function AppSidebar() {
{/* Header del Sidebar */}
<SidebarGroup>
<div className="px-4 py-6">
<h2 className="text-xl font-semibold text-gray-900">Dolibarr</h2>
<p className="text-sm text-gray-500">Gestión de Proyectos</p>
<h2 className="text-xl font-semibold text-foreground">Dolibarr</h2>
<p className="text-sm text-muted-foreground">Gestión de Proyectos</p>
</div>
</SidebarGroup>
@ -134,7 +166,7 @@ export function AppSidebar() {
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs text-gray-500">{user.email}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
<ChevronUp className="ml-auto size-4" />
</SidebarMenuButton>
@ -155,7 +187,7 @@ export function AppSidebar() {
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs text-gray-500">{user.email}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
@ -164,14 +196,27 @@ export function AppSidebar() {
<User className="mr-2 h-4 w-4" />
<span>Perfil</span>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsSettingsOpen(true)}>
<Settings className="mr-2 h-4 w-4" />
<span>Ajustes</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
<LogOut className="mr-2 h-4 w-4" />
<span>Cerrar sesión</span>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={handleLogout}
disabled={isLoggingOut}
>
{isLoggingOut ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span>Cerrando sesión...</span>
</>
) : (
<>
<LogOut className="mr-2 h-4 w-4" />
<span>Cerrar sesión</span>
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -184,6 +229,12 @@ export function AppSidebar() {
open={isProfileOpen}
onOpenChange={setIsProfileOpen}
/>
{/* Dialog de ajustes */}
<SettingsDialog
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
/>
</Sidebar>
);
}
}

View File

@ -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({
<p className="text-sm text-muted-foreground mt-1">Gestiona y visualiza todos tus proyectos</p>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<input

View File

@ -9,15 +9,26 @@ import { ProjectFormSheet } from '@/components/project-form/project-form-sheet';
import { getProjects } from '@/lib/projectsService';
import { Project } from '@/types/project';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppSettings } from '@/hooks/use-settings';
export default function ProjectDashboard() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('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<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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)}
/>

View File

@ -0,0 +1 @@
export { MainLayout } from "./main-layout";

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Cargando...</p>
</div>
</div>
);
}
// 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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Redirigiendo...</p>
</div>
</div>
);
}
// Rutas privadas: con sidebar
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<main>
<SidebarTrigger />
{children}
</main>
</SidebarInset>
</SidebarProvider>
);
}

View File

@ -135,6 +135,7 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView
{/* Header del proyecto */}
<ProjectHeader
project={project}
tasks={tasks}
onProjectUpdated={handleProjectUpdated}
/>

View File

@ -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 */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleExportPDF}>
<Download className="h-4 w-4 mr-2" />
Exportar PDF
</Button>
<Button variant="outline" size="sm" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Volver

View File

@ -1,6 +1,6 @@
"use client";
import { Search, X, SlidersHorizontal, Download, Plus } from "lucide-react";
import { Search, X, SlidersHorizontal, Download } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -20,7 +20,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ProjectStatus, STATUS_CONFIG } from "@/types/project";
import { ProjectStatus, STATUS_CONFIG, Project } from "@/types/project";
import { ColumnVisibilityInfo } from "./data-table";
// Mapeo de IDs de columna a nombres legibles
const COLUMN_NAMES: Record<string, string> = {
ref: "Referencia",
name: "Nombre",
client: "Cliente",
status: "Estado",
progress: "Progreso",
budget: "Presupuesto",
startDate: "Fecha inicio",
endDate: "Fecha fin",
};
interface DataTableToolbarProps {
searchValue: string;
@ -29,6 +42,10 @@ interface DataTableToolbarProps {
onStatusFilterChange: (value: string) => void;
selectedCount: number;
onClearFilters: () => void;
columns?: ColumnVisibilityInfo[];
onExport?: (projects: Project[]) => void;
getSelectedProjects?: () => Project[];
getAllVisibleProjects?: () => Project[];
}
export function DataTableToolbar({
@ -38,9 +55,26 @@ export function DataTableToolbar({
onStatusFilterChange,
selectedCount,
onClearFilters,
columns,
onExport,
getSelectedProjects,
getAllVisibleProjects,
}: DataTableToolbarProps) {
const isFiltered = searchValue !== "" || statusFilter !== "all";
// Obtener proyectos para exportar
const handleExport = () => {
if (!onExport) return;
if (selectedCount > 0 && getSelectedProjects) {
// Exportar solo los seleccionados
onExport(getSelectedProjects());
} else if (getAllVisibleProjects) {
// Exportar todos los visibles (filtrados)
onExport(getAllVisibleProjects());
}
};
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center gap-2">
@ -93,51 +127,51 @@ export function DataTableToolbar({
</Badge>
)}
{/* Opciones de vista */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
<SlidersHorizontal className="mr-2 h-4 w-4" />
Vista
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[180px]">
<DropdownMenuLabel>Columnas visibles</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked>
Referencia
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked>
Nombre
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked>
Cliente
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked>
Estado
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked>
Progreso
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked>
Presupuesto
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked>
Fechas
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Opciones de vista - columnas visibles */}
{columns && columns.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9">
<SlidersHorizontal className="mr-2 h-4 w-4" />
Vista
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[180px]">
<DropdownMenuLabel>Columnas visibles</DropdownMenuLabel>
<DropdownMenuSeparator />
{columns
.filter((col) => col.canHide)
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.isVisible}
onCheckedChange={(checked) => {
column.toggleVisibility(checked);
}}
onSelect={(e) => e.preventDefault()}
>
{COLUMN_NAMES[column.id] || column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Exportar */}
<Button variant="outline" size="sm" className="h-9">
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleExport}
disabled={!onExport}
>
<Download className="mr-2 h-4 w-4" />
Exportar
</Button>
{/* Nuevo proyecto */}
<Button size="sm" className="h-9">
<Plus className="mr-2 h-4 w-4" />
Nuevo
{selectedCount > 0 && (
<Badge variant="secondary" className="ml-2 px-1.5 py-0 text-xs">
{selectedCount}
</Badge>
)}
</Button>
</div>
</div>

View File

@ -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<TData> {
selectedCount: number;
getSelectedRows: () => TData[];
getAllVisibleRows: () => TData[];
columns: ColumnVisibilityInfo[];
}
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
searchValue?: string;
onTableChange?: (info: TableInfo<TData>) => void;
}
export function DataTable<TData, TValue>({
@ -44,6 +59,7 @@ export function DataTable<TData, TValue>({
data,
searchKey,
searchValue,
onTableChange,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
@ -69,6 +85,33 @@ export function DataTable<TData, TValue>({
},
});
// 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) {

View File

@ -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<TableInfo<Project> | 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<Project>) => {
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}
/>
<Button onClick={() => setIsCreateSheetOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-2" />
@ -187,6 +203,7 @@ export function ProjectsTable() {
data={filteredProjects}
searchKey="name"
searchValue={searchValue}
onTableChange={handleTableChange}
/>
{/* Sheet de creación */}

View File

@ -0,0 +1,2 @@
export { SettingsDialog } from "./settings-dialog";
export { SettingsProvider } from "./settings-provider";

View File

@ -0,0 +1,294 @@
"use client";
import { useState, useEffect } from "react";
import { useTheme } from "next-themes";
import {
Sun,
Moon,
Monitor,
Palette,
LayoutGrid,
List,
Eye,
EyeOff,
Check,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useAppSettings, type ThemeMode, type DefaultView } from "@/hooks/use-settings";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// Componente de opción de tema
function ThemeOption({
value,
label,
icon: Icon,
selected,
onClick,
}: {
value: ThemeMode;
label: string;
icon: React.ComponentType<{ className?: string }>;
selected: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all",
selected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<div
className={cn(
"p-3 rounded-full",
selected ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
<Icon className="h-5 w-5" />
</div>
<span className="text-sm font-medium">{label}</span>
{selected && (
<Check className="h-4 w-4 text-primary" />
)}
</button>
);
}
// 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 (
<div className="flex items-center justify-between py-3">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-md mt-0.5">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<Label className="text-sm font-medium">{label}</Label>
{description && (
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
)}
</div>
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
}
// 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 (
<div className="flex items-center justify-between py-3">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-md mt-0.5">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<Label className="text-sm font-medium">{label}</Label>
{description && (
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
)}
</div>
</div>
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle className="text-xl">Ajustes</DialogTitle>
<DialogDescription>
Personaliza la apariencia y comportamiento de la aplicación
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
{/* Sección: Tema */}
<Card>
<CardContent className="pt-4 pb-4">
<div className="flex items-center gap-2 mb-4">
<Palette className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Tema
</h4>
</div>
<div className="grid grid-cols-3 gap-3">
<ThemeOption
value="light"
label="Claro"
icon={Sun}
selected={theme === "light"}
onClick={() => handleThemeChange("light")}
/>
<ThemeOption
value="dark"
label="Oscuro"
icon={Moon}
selected={theme === "dark"}
onClick={() => handleThemeChange("dark")}
/>
<ThemeOption
value="system"
label="Sistema"
icon={Monitor}
selected={theme === "system"}
onClick={() => handleThemeChange("system")}
/>
</div>
</CardContent>
</Card>
{/* Sección: Apariencia */}
<Card>
<CardContent className="pt-4 pb-2">
<div className="flex items-center gap-2 mb-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Apariencia
</h4>
</div>
<SettingItem
icon={LayoutGrid}
label="Modo compacto"
description="Reduce el espaciado para mostrar más contenido"
checked={settings.compactMode}
onCheckedChange={(checked) => updateSettings({ compactMode: checked })}
/>
<Separator />
<SettingItem
icon={Eye}
label="Animaciones"
description="Habilitar transiciones y animaciones"
checked={settings.showAnimations}
onCheckedChange={(checked) => updateSettings({ showAnimations: checked })}
/>
</CardContent>
</Card>
{/* Sección: Proyectos */}
<Card>
<CardContent className="pt-4 pb-2">
<div className="flex items-center gap-2 mb-2">
<LayoutGrid className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Proyectos
</h4>
</div>
<SettingSelect
icon={List}
label="Vista por defecto"
description="Cómo mostrar los proyectos inicialmente"
value={settings.defaultView}
onValueChange={(value) => updateSettings({ defaultView: value as DefaultView })}
options={[
{ value: "grid", label: "Cuadrícula" },
{ value: "list", label: "Lista" },
]}
/>
<Separator />
<SettingItem
icon={settings.showCompletedProjects ? Eye : EyeOff}
label="Mostrar completados"
description="Mostrar proyectos cerrados en el dashboard"
checked={settings.showCompletedProjects}
onCheckedChange={(checked) => updateSettings({ showCompletedProjects: checked })}
/>
</CardContent>
</Card>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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}</>;
}

59
components/ui/alert.tsx Normal file
View File

@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -153,7 +153,7 @@ export function UserProfileDialog({ open, onOpenChange }: UserProfileDialogProps
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle className="text-xl">Mi Perfil</DialogTitle>
</DialogHeader>

143
hooks/use-auth.tsx Normal file
View File

@ -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<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const router = useRouter();
const pathname = usePathname();
const [authState, setAuthState] = useState<AuthState>({
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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
* 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;
}

106
hooks/use-settings.ts Normal file
View File

@ -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<AppSettings>) => {
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 };
}

248
lib/authService.ts Normal file
View File

@ -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<LoginResponse> {
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<void> {
// 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<boolean> {
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,
};
}

280
lib/exportPdf.ts Normal file
View File

@ -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);
}

50
middleware.ts Normal file
View File

@ -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)$).*)",
],
};

244
package-lock.json generated
View File

@ -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",

View File

@ -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",