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:
parent
ec7d26e559
commit
f1d19bd257
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { MainLayout } from "./main-layout";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -135,6 +135,7 @@ export function ProjectDetailView({ project: initialProject }: ProjectDetailView
|
|||
{/* Header del proyecto */}
|
||||
<ProjectHeader
|
||||
project={project}
|
||||
tasks={tasks}
|
||||
onProjectUpdated={handleProjectUpdated}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export { SettingsDialog } from "./settings-dialog";
|
||||
export { SettingsProvider } from "./settings-provider";
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)$).*)",
|
||||
],
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue