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 {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { SidebarProvider, SidebarTrigger, SidebarInset } from "@/components/ui/sidebar"
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { AppSidebar } from "@/components/app-sidebar"
|
import { SettingsProvider } from "@/components/settings";
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
import { AuthProvider } from "@/hooks/use-auth";
|
||||||
|
import { MainLayout } from "@/components/layouts/main-layout";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
|
|
@ -18,7 +18,7 @@ const geistMono = Geist_Mono({
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Dolibarr proyectos",
|
title: "Dolibarr proyectos",
|
||||||
description: "Generated by create next app",
|
description: "Panel de gestión de proyectos de Dolibarr",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -35,19 +35,13 @@ export default function RootLayout({
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<SidebarProvider>
|
<SettingsProvider>
|
||||||
<AppSidebar />
|
<AuthProvider>
|
||||||
|
<MainLayout>{children}</MainLayout>
|
||||||
<SidebarInset>
|
</AuthProvider>
|
||||||
<main>
|
</SettingsProvider>
|
||||||
<SidebarTrigger />
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { getUser } from "@/lib/usersService";
|
import { getUser } from "@/lib/usersService";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,6 +25,7 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { UserProfileDialog } from "@/components/user-profile";
|
import { UserProfileDialog } from "@/components/user-profile";
|
||||||
|
import { SettingsDialog } from "@/components/settings";
|
||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
|
|
@ -31,7 +34,8 @@ import {
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
GanttChart
|
GanttChart,
|
||||||
|
Loader2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// Datos del menú
|
// Datos del menú
|
||||||
|
|
@ -59,6 +63,7 @@ const items = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
const { logout, user: authUser } = useAuth();
|
||||||
const [user, setUser] = useState({
|
const [user, setUser] = useState({
|
||||||
name: "Cargando...",
|
name: "Cargando...",
|
||||||
email: "",
|
email: "",
|
||||||
|
|
@ -66,10 +71,23 @@ export function AppSidebar() {
|
||||||
initials: "..."
|
initials: "..."
|
||||||
});
|
});
|
||||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadUser() {
|
async function loadUser() {
|
||||||
try {
|
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();
|
const realUser = await getUser();
|
||||||
setUser({
|
setUser({
|
||||||
name: `${realUser.firstname} ${realUser.lastname}`,
|
name: `${realUser.firstname} ${realUser.lastname}`,
|
||||||
|
|
@ -79,11 +97,25 @@ export function AppSidebar() {
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load user:", 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();
|
loadUser();
|
||||||
}, []);
|
}, [authUser]);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Logout error:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoggingOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar>
|
<Sidebar>
|
||||||
|
|
@ -91,8 +123,8 @@ export function AppSidebar() {
|
||||||
{/* Header del Sidebar */}
|
{/* Header del Sidebar */}
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<div className="px-4 py-6">
|
<div className="px-4 py-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Dolibarr</h2>
|
<h2 className="text-xl font-semibold text-foreground">Dolibarr</h2>
|
||||||
<p className="text-sm text-gray-500">Gestión de Proyectos</p>
|
<p className="text-sm text-muted-foreground">Gestión de Proyectos</p>
|
||||||
</div>
|
</div>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
|
|
@ -134,7 +166,7 @@ export function AppSidebar() {
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">{user.name}</span>
|
<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>
|
||||||
<ChevronUp className="ml-auto size-4" />
|
<ChevronUp className="ml-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|
@ -155,7 +187,7 @@ export function AppSidebar() {
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">{user.name}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
@ -164,14 +196,27 @@ export function AppSidebar() {
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>Perfil</span>
|
<span>Perfil</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={() => setIsSettingsOpen(true)}>
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<span>Ajustes</span>
|
<span>Ajustes</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-red-600">
|
<DropdownMenuItem
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
className="text-destructive focus:text-destructive"
|
||||||
<span>Cerrar sesión</span>
|
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>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
@ -184,6 +229,12 @@ export function AppSidebar() {
|
||||||
open={isProfileOpen}
|
open={isProfileOpen}
|
||||||
onOpenChange={setIsProfileOpen}
|
onOpenChange={setIsProfileOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Dialog de ajustes */}
|
||||||
|
<SettingsDialog
|
||||||
|
open={isSettingsOpen}
|
||||||
|
onOpenChange={setIsSettingsOpen}
|
||||||
|
/>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Search, LayoutGrid, List, Plus } from 'lucide-react';
|
import { Search, LayoutGrid, List, Plus } from 'lucide-react';
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
interface DashboardHeaderProps {
|
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>
|
<p className="text-sm text-muted-foreground mt-1">Gestiona y visualiza todos tus proyectos</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ThemeToggle />
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,26 @@ import { ProjectFormSheet } from '@/components/project-form/project-form-sheet';
|
||||||
import { getProjects } from '@/lib/projectsService';
|
import { getProjects } from '@/lib/projectsService';
|
||||||
import { Project } from '@/types/project';
|
import { Project } from '@/types/project';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { useAppSettings } from '@/hooks/use-settings';
|
||||||
|
|
||||||
export default function ProjectDashboard() {
|
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 [searchTerm, setSearchTerm] = useState('');
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -52,11 +63,24 @@ export default function ProjectDashboard() {
|
||||||
setProjects(prevProjects => prevProjects.filter(p => p.id !== projectId));
|
setProjects(prevProjects => prevProjects.filter(p => p.id !== projectId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredProjects = projects.filter(project =>
|
// Handler para cambio manual de vista (desde el header)
|
||||||
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const handleViewModeChange = (mode: 'grid' | 'list') => {
|
||||||
project.client.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
setViewModeOverride(mode);
|
||||||
project.ref.toLowerCase().includes(searchTerm.toLowerCase())
|
};
|
||||||
);
|
|
||||||
|
// 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -65,7 +89,7 @@ export default function ProjectDashboard() {
|
||||||
searchTerm=""
|
searchTerm=""
|
||||||
onSearchChange={() => {}}
|
onSearchChange={() => {}}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={handleViewModeChange}
|
||||||
onCreateProject={() => {}}
|
onCreateProject={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -113,7 +137,7 @@ export default function ProjectDashboard() {
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSearchChange={setSearchTerm}
|
onSearchChange={setSearchTerm}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={handleViewModeChange}
|
||||||
onCreateProject={() => setIsCreateSheetOpen(true)}
|
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 */}
|
{/* Header del proyecto */}
|
||||||
<ProjectHeader
|
<ProjectHeader
|
||||||
project={project}
|
project={project}
|
||||||
|
tasks={tasks}
|
||||||
onProjectUpdated={handleProjectUpdated}
|
onProjectUpdated={handleProjectUpdated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
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 { ProjectFormSheet } from "@/components/project-form/project-form-sheet";
|
||||||
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
|
import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
|
||||||
import { deleteProject } from "@/lib/projectsService";
|
import { deleteProject } from "@/lib/projectsService";
|
||||||
|
import { exportProjectDetailToPDF } from "@/lib/exportPdf";
|
||||||
import { Project } from "@/types/project";
|
import { Project } from "@/types/project";
|
||||||
|
import { Task } from "@/types/task";
|
||||||
|
|
||||||
interface ProjectHeaderProps {
|
interface ProjectHeaderProps {
|
||||||
project: Project;
|
project: Project;
|
||||||
|
tasks?: Task[];
|
||||||
onProjectUpdated?: (project: Project) => void;
|
onProjectUpdated?: (project: Project) => void;
|
||||||
onProjectDeleted?: () => void;
|
onProjectDeleted?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +39,7 @@ function getInitials(name: string): string {
|
||||||
.slice(0, 2);
|
.slice(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: ProjectHeaderProps) {
|
export function ProjectHeader({ project, tasks = [], onProjectUpdated, onProjectDeleted }: ProjectHeaderProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const initials = getInitials(project.name);
|
const initials = getInitials(project.name);
|
||||||
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
const [isEditSheetOpen, setIsEditSheetOpen] = useState(false);
|
||||||
|
|
@ -47,6 +50,10 @@ export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: P
|
||||||
navigator.clipboard.writeText(project.ref);
|
navigator.clipboard.writeText(project.ref);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportPDF = () => {
|
||||||
|
exportProjectDetailToPDF(project, tasks);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEditSuccess = (updatedProject: Project) => {
|
const handleEditSuccess = (updatedProject: Project) => {
|
||||||
onProjectUpdated?.(updatedProject);
|
onProjectUpdated?.(updatedProject);
|
||||||
};
|
};
|
||||||
|
|
@ -115,6 +122,11 @@ export function ProjectHeader({ project, onProjectUpdated, onProjectDeleted }: P
|
||||||
|
|
||||||
{/* Acciones */}
|
{/* Acciones */}
|
||||||
<div className="flex items-center gap-2">
|
<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()}>
|
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Volver
|
Volver
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"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 { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -20,7 +20,20 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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 {
|
interface DataTableToolbarProps {
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
|
|
@ -29,6 +42,10 @@ interface DataTableToolbarProps {
|
||||||
onStatusFilterChange: (value: string) => void;
|
onStatusFilterChange: (value: string) => void;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
|
columns?: ColumnVisibilityInfo[];
|
||||||
|
onExport?: (projects: Project[]) => void;
|
||||||
|
getSelectedProjects?: () => Project[];
|
||||||
|
getAllVisibleProjects?: () => Project[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTableToolbar({
|
export function DataTableToolbar({
|
||||||
|
|
@ -38,9 +55,26 @@ export function DataTableToolbar({
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
|
columns,
|
||||||
|
onExport,
|
||||||
|
getSelectedProjects,
|
||||||
|
getAllVisibleProjects,
|
||||||
}: DataTableToolbarProps) {
|
}: DataTableToolbarProps) {
|
||||||
const isFiltered = searchValue !== "" || statusFilter !== "all";
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<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">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
|
@ -93,51 +127,51 @@ export function DataTableToolbar({
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Opciones de vista */}
|
{/* Opciones de vista - columnas visibles */}
|
||||||
<DropdownMenu>
|
{columns && columns.length > 0 && (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button variant="outline" size="sm" className="h-9">
|
<DropdownMenuTrigger asChild>
|
||||||
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
<Button variant="outline" size="sm" className="h-9">
|
||||||
Vista
|
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
Vista
|
||||||
</DropdownMenuTrigger>
|
</Button>
|
||||||
<DropdownMenuContent align="end" className="w-[180px]">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuLabel>Columnas visibles</DropdownMenuLabel>
|
<DropdownMenuContent align="end" className="w-[180px]">
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuLabel>Columnas visibles</DropdownMenuLabel>
|
||||||
<DropdownMenuCheckboxItem checked>
|
<DropdownMenuSeparator />
|
||||||
Referencia
|
{columns
|
||||||
</DropdownMenuCheckboxItem>
|
.filter((col) => col.canHide)
|
||||||
<DropdownMenuCheckboxItem checked>
|
.map((column) => (
|
||||||
Nombre
|
<DropdownMenuCheckboxItem
|
||||||
</DropdownMenuCheckboxItem>
|
key={column.id}
|
||||||
<DropdownMenuCheckboxItem checked>
|
checked={column.isVisible}
|
||||||
Cliente
|
onCheckedChange={(checked) => {
|
||||||
</DropdownMenuCheckboxItem>
|
column.toggleVisibility(checked);
|
||||||
<DropdownMenuCheckboxItem checked>
|
}}
|
||||||
Estado
|
onSelect={(e) => e.preventDefault()}
|
||||||
</DropdownMenuCheckboxItem>
|
>
|
||||||
<DropdownMenuCheckboxItem checked>
|
{COLUMN_NAMES[column.id] || column.id}
|
||||||
Progreso
|
</DropdownMenuCheckboxItem>
|
||||||
</DropdownMenuCheckboxItem>
|
))}
|
||||||
<DropdownMenuCheckboxItem checked>
|
</DropdownMenuContent>
|
||||||
Presupuesto
|
</DropdownMenu>
|
||||||
</DropdownMenuCheckboxItem>
|
)}
|
||||||
<DropdownMenuCheckboxItem checked>
|
|
||||||
Fechas
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{/* Exportar */}
|
{/* 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" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Exportar
|
Exportar
|
||||||
</Button>
|
{selectedCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2 px-1.5 py-0 text-xs">
|
||||||
{/* Nuevo proyecto */}
|
{selectedCount}
|
||||||
<Button size="sm" className="h-9">
|
</Badge>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
)}
|
||||||
Nuevo
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,26 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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> {
|
interface DataTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
data: TData[];
|
data: TData[];
|
||||||
searchKey?: string;
|
searchKey?: string;
|
||||||
searchValue?: string;
|
searchValue?: string;
|
||||||
|
onTableChange?: (info: TableInfo<TData>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
export function DataTable<TData, TValue>({
|
||||||
|
|
@ -44,6 +59,7 @@ export function DataTable<TData, TValue>({
|
||||||
data,
|
data,
|
||||||
searchKey,
|
searchKey,
|
||||||
searchValue,
|
searchValue,
|
||||||
|
onTableChange,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
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
|
// Aplicar filtro de búsqueda externo
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (searchKey && searchValue !== undefined) {
|
if (searchKey && searchValue !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
import { FolderKanban, RefreshCw, Plus } from "lucide-react";
|
import { FolderKanban, RefreshCw, Plus } from "lucide-react";
|
||||||
|
|
||||||
import { Project } from "@/types/project";
|
import { Project } from "@/types/project";
|
||||||
import { getProjects, deleteProject } from "@/lib/projectsService";
|
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 { DataTableToolbar } from "./data-table-toolbar";
|
||||||
import { DataTableSkeleton } from "./data-table-skeleton";
|
import { DataTableSkeleton } from "./data-table-skeleton";
|
||||||
import { EmptyState } from "./empty-state";
|
import { EmptyState } from "./empty-state";
|
||||||
|
|
@ -23,6 +24,9 @@ export function ProjectsTable() {
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
|
||||||
|
// Info de la tabla
|
||||||
|
const [tableInfo, setTableInfo] = useState<TableInfo<Project> | null>(null);
|
||||||
|
|
||||||
// Estados para crear/editar/eliminar
|
// Estados para crear/editar/eliminar
|
||||||
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
|
||||||
const [isEditSheetOpen, setIsEditSheetOpen] = 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
|
// Crear columnas con callbacks
|
||||||
const columns = useMemo(() => createProjectColumns({
|
const columns = useMemo(() => createProjectColumns({
|
||||||
onEdit: handleEditProject,
|
onEdit: handleEditProject,
|
||||||
|
|
@ -121,9 +136,6 @@ export function ProjectsTable() {
|
||||||
setStatusFilter("all");
|
setStatusFilter("all");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Contar seleccionados (placeholder - el DataTable lo maneja internamente)
|
|
||||||
const selectedCount = 0;
|
|
||||||
|
|
||||||
// Estado de error
|
// Estado de error
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -174,8 +186,12 @@ export function ProjectsTable() {
|
||||||
onSearchChange={setSearchValue}
|
onSearchChange={setSearchValue}
|
||||||
statusFilter={statusFilter}
|
statusFilter={statusFilter}
|
||||||
onStatusFilterChange={setStatusFilter}
|
onStatusFilterChange={setStatusFilter}
|
||||||
selectedCount={selectedCount}
|
selectedCount={tableInfo?.selectedCount ?? 0}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
|
columns={tableInfo?.columns}
|
||||||
|
onExport={handleExport}
|
||||||
|
getSelectedProjects={tableInfo?.getSelectedRows}
|
||||||
|
getAllVisibleProjects={tableInfo?.getAllVisibleRows}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setIsCreateSheetOpen(true)} size="sm">
|
<Button onClick={() => setIsCreateSheetOpen(true)} size="sm">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
|
@ -187,6 +203,7 @@ export function ProjectsTable() {
|
||||||
data={filteredProjects}
|
data={filteredProjects}
|
||||||
searchKey="name"
|
searchKey="name"
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
|
onTableChange={handleTableChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sheet de creación */}
|
{/* 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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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">
|
<DialogHeader className="pb-2">
|
||||||
<DialogTitle className="text-xl">Mi Perfil</DialogTitle>
|
<DialogTitle className="text-xl">Mi Perfil</DialogTitle>
|
||||||
</DialogHeader>
|
</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",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jspdf": "^4.1.0",
|
||||||
|
"jspdf-autotable": "^5.0.7",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
"next": "16.0.7",
|
"next": "16.0.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|
@ -35,6 +37,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/jspdf": "^1.3.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
@ -250,6 +253,15 @@
|
||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.2",
|
"version": "7.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||||
|
|
@ -3555,6 +3567,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.25",
|
"version": "20.19.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
|
||||||
|
|
@ -3565,6 +3584,19 @@
|
||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
|
|
@ -3585,6 +3617,13 @@
|
||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
|
@ -4498,6 +4537,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.3",
|
"version": "2.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.3.tgz",
|
||||||
|
|
@ -4666,6 +4715,26 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
|
@ -4789,6 +4858,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -4804,6 +4885,16 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
|
|
@ -5113,6 +5204,16 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|
@ -5847,6 +5948,17 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||||
|
|
@ -5856,6 +5968,12 @@
|
||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
|
|
@ -6281,6 +6399,20 @@
|
||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|
@ -6352,6 +6484,12 @@
|
||||||
"node": ">=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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
|
|
@ -6884,6 +7022,32 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/jsx-ast-utils": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
|
"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"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -7756,6 +7926,13 @@
|
||||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|
@ -8010,6 +8187,16 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.0",
|
"version": "19.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
|
|
@ -8218,6 +8405,13 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
|
|
@ -8295,6 +8489,16 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/run-parallel": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
|
@ -8611,6 +8815,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||||
|
|
@ -8938,6 +9162,16 @@
|
||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
|
|
@ -9362,6 +9596,16 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/victory-vendor": {
|
||||||
"version": "37.3.6",
|
"version": "37.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"jspdf": "^4.1.0",
|
||||||
|
"jspdf-autotable": "^5.0.7",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
"next": "16.0.7",
|
"next": "16.0.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|
@ -40,6 +42,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/jspdf": "^1.3.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue