feat: add user profile dialog and related services for user data management

This commit is contained in:
Levi Planelles 2026-02-06 19:24:39 +01:00
parent ae66248df9
commit ec7d26e559
7 changed files with 709 additions and 7 deletions

View File

@ -22,6 +22,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} 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 { import {
Home, Home,
FolderKanban, FolderKanban,
@ -64,6 +65,7 @@ export function AppSidebar() {
avatar: "/avatar.jpg", avatar: "/avatar.jpg",
initials: "..." initials: "..."
}); });
const [isProfileOpen, setIsProfileOpen] = useState(false);
useEffect(() => { useEffect(() => {
async function loadUser() { async function loadUser() {
@ -158,11 +160,9 @@ export function AppSidebar() {
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem> <DropdownMenuItem onClick={() => setIsProfileOpen(true)}>
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
<a href="/perfil"> <span>Perfil</span>
<span>Perfil</span>
</a>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem> <DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
@ -178,6 +178,12 @@ export function AppSidebar() {
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarFooter> </SidebarFooter>
{/* Dialog de perfil de usuario */}
<UserProfileDialog
open={isProfileOpen}
onOpenChange={setIsProfileOpen}
/>
</Sidebar> </Sidebar>
); );
} }

View File

@ -95,7 +95,7 @@ export function DataTable<TData, TValue>({
</TableHead> </TableHead>
))} ))}
</TableRow> </TableRow>
))} ))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (

122
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1 @@
export { UserProfileDialog } from "./user-profile-dialog";

View File

@ -0,0 +1,311 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
Mail,
Phone,
Smartphone,
MapPin,
Briefcase,
Shield,
ShieldCheck,
Clock,
Globe,
Calendar,
User,
Building,
RefreshCw,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { getCurrentUserProfile } from "@/lib/usersService";
import { UserProfile, formatLastLogin } from "@/types/user";
interface UserProfileDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// Componente para mostrar un campo de información
function InfoField({
icon: Icon,
label,
value,
className = "",
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string | null | undefined;
className?: string;
}) {
if (!value) return null;
return (
<div className={`flex items-start gap-3 ${className}`}>
<div className="flex-shrink-0 p-2 bg-muted rounded-md">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-medium truncate">{value}</p>
</div>
</div>
);
}
// Skeleton para estado de carga
function ProfileSkeleton() {
return (
<div className="space-y-6">
{/* Header skeleton */}
<div className="flex items-center gap-4">
<Skeleton className="h-20 w-20 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
</div>
</div>
<Separator />
{/* Content skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="h-8 w-8 rounded-md" />
<div className="space-y-1 flex-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-32" />
</div>
</div>
))}
</div>
</div>
);
}
// Estado de error
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="p-3 rounded-full bg-destructive/10 mb-4">
<User className="h-8 w-8 text-destructive" />
</div>
<h3 className="text-lg font-semibold mb-1">Error al cargar el perfil</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
No se pudo cargar la información del usuario. Por favor, intenta de nuevo.
</p>
<Button onClick={onRetry} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Reintentar
</Button>
</div>
);
}
export function UserProfileDialog({ open, onOpenChange }: UserProfileDialogProps) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchProfile = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await getCurrentUserProfile();
setProfile(data);
} catch (err) {
console.error("Error fetching profile:", err);
setError("No se pudo cargar el perfil");
} finally {
setIsLoading(false);
}
}, []);
// Cargar perfil cuando se abre el dialog
useEffect(() => {
if (open && !profile) {
fetchProfile();
}
}, [open, profile, fetchProfile]);
// Formatear dirección completa
const formatAddress = (p: UserProfile): string | null => {
const parts = [p.address, p.postalCode, p.city, p.country].filter(Boolean);
return parts.length > 0 ? parts.join(", ") : null;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle className="text-xl">Mi Perfil</DialogTitle>
</DialogHeader>
{isLoading && <ProfileSkeleton />}
{error && !isLoading && <ErrorState onRetry={fetchProfile} />}
{profile && !isLoading && !error && (
<div className="space-y-6">
{/* Header con avatar y nombre */}
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20 border-2 border-border">
{profile.photo ? (
<AvatarImage src={profile.photo} alt={profile.fullName} />
) : null}
<AvatarFallback className="bg-gradient-to-br from-blue-500 to-purple-600 text-white text-2xl font-semibold">
{profile.initials}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold truncate">{profile.fullName}</h3>
<p className="text-sm text-muted-foreground">@{profile.login}</p>
<div className="flex flex-wrap items-center gap-2 mt-2">
{profile.isAdmin && (
<Badge variant="default" className="bg-gradient-to-r from-amber-500 to-orange-500">
<ShieldCheck className="h-3 w-3 mr-1" />
Administrador
</Badge>
)}
<Badge variant={profile.isActive ? "default" : "secondary"}>
{profile.isActive ? "Activo" : "Inactivo"}
</Badge>
{profile.isEmployee && (
<Badge variant="outline">Empleado</Badge>
)}
</div>
</div>
</div>
<Separator />
{/* Información de contacto */}
<Card>
<CardContent className="pt-4 pb-4">
<h4 className="text-sm font-semibold text-muted-foreground mb-4 uppercase tracking-wide">
Contacto
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoField
icon={Mail}
label="Email"
value={profile.email}
/>
<InfoField
icon={Mail}
label="Email personal"
value={profile.personalEmail}
/>
<InfoField
icon={Phone}
label="Teléfono"
value={profile.phone}
/>
<InfoField
icon={Smartphone}
label="Móvil"
value={profile.mobile}
/>
<InfoField
icon={MapPin}
label="Dirección"
value={formatAddress(profile)}
className="sm:col-span-2"
/>
</div>
</CardContent>
</Card>
{/* Información laboral */}
{(profile.job || profile.establishment || profile.employmentStartDate) && (
<Card>
<CardContent className="pt-4 pb-4">
<h4 className="text-sm font-semibold text-muted-foreground mb-4 uppercase tracking-wide">
Información Laboral
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoField
icon={Briefcase}
label="Puesto"
value={profile.job}
/>
<InfoField
icon={Building}
label="Establecimiento"
value={profile.establishment}
/>
{profile.employmentStartDate && (
<InfoField
icon={Calendar}
label="Fecha de alta"
value={profile.employmentStartDate.toLocaleDateString('es-ES', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
/>
)}
</div>
</CardContent>
</Card>
)}
{/* Actividad y sesión */}
<Card>
<CardContent className="pt-4 pb-4">
<h4 className="text-sm font-semibold text-muted-foreground mb-4 uppercase tracking-wide">
Actividad
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoField
icon={Clock}
label="Último acceso"
value={formatLastLogin(profile.lastLogin)}
/>
{profile.lastLoginIp && (
<InfoField
icon={Globe}
label="IP último acceso"
value={profile.lastLoginIp}
/>
)}
{profile.language && (
<InfoField
icon={Globe}
label="Idioma"
value={profile.language}
/>
)}
<InfoField
icon={Shield}
label="ID de usuario"
value={`#${profile.id}`}
/>
</div>
</CardContent>
</Card>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,6 +1,42 @@
import { dolibarrFetch } from "./dolibarrClient"; import { dolibarrFetch } from "./dolibarrClient";
import { DolibarrUser, UserProfile, mapDolibarrUser } from "@/types/user";
export async function getUser(id?: string) { /**
* Obtiene los datos crudos de un usuario de Dolibarr
*/
export async function getDolibarrUser(id?: string): Promise<DolibarrUser> {
const userId = id || process.env.NEXT_PUBLIC_DOLIBARR_USER_ID || "1"; const userId = id || process.env.NEXT_PUBLIC_DOLIBARR_USER_ID || "1";
return dolibarrFetch(`users/${userId}`); return dolibarrFetch(`users/${userId}`);
} }
/**
* Obtiene el perfil de usuario mapeado para la UI
*/
export async function getUserProfile(id?: string): Promise<UserProfile> {
const raw = await getDolibarrUser(id);
return mapDolibarrUser(raw);
}
/**
* Obtiene datos básicos de usuario (para sidebar, compatibilidad hacia atrás)
*/
export async function getUser(id?: string): Promise<{
firstname: string;
lastname: string;
email: string;
}> {
const raw = await getDolibarrUser(id);
return {
firstname: raw.firstname || raw.login || 'Usuario',
lastname: raw.lastname || '',
email: raw.email || '',
};
}
/**
* Obtiene el perfil del usuario autenticado actualmente
* Usa el ID configurado en las variables de entorno
*/
export async function getCurrentUserProfile(): Promise<UserProfile> {
return getUserProfile();
}

226
types/user.ts Normal file
View File

@ -0,0 +1,226 @@
/**
* Tipos para usuarios de Dolibarr
*/
// Respuesta cruda de la API de Dolibarr para usuarios
export interface DolibarrUser {
id: string;
ref: string;
login: string;
entity: string;
// Información personal
firstname: string | null;
lastname: string;
gender: string | null;
birth: string;
civility_id: string | null;
civility_code: string | null;
// Contacto
email: string | null;
email_oauth2: string | null;
personal_email: string | null;
office_phone: string | null;
office_fax: string | null;
user_mobile: string | null;
personal_mobile: string | null;
// Dirección
address: string | null;
zip: string | null;
town: string | null;
country_id: string;
country_code: string;
state_id: string;
// Trabajo
employee: string;
job: string | null;
salary: string | null;
salaryextra: string | null;
weeklyhours: string | null;
thm: string | null; // taux horaire moyen
tjm: string | null; // taux journalier moyen
dateemployment: string;
dateemploymentend: string;
ref_employee: string | null;
fk_establishment: string;
label_establishment: string | null;
// Estado y permisos
status: string;
statut: string;
admin: string;
// Empresa/Tercero asociado
socid: string | null;
// Sesión y actividad
datelastlogin: number | null;
datepreviouslogin: string;
iplastlogin: string | null;
ippreviouslogin: string | null;
datestartvalidity: string;
dateendvalidity: string;
// Otros
photo: string | null;
lang: string | null;
color: string | null;
signature: string | null;
national_registration_number: string | null;
// Metadatos
date_creation: string | null;
date_modification: string | null;
datec: string;
datem: number | null;
// Social
socialnetworks: Record<string, string> | string[];
// Configuración
rights: DolibarrUserRights;
conf: Record<string, unknown>;
array_options: Record<string, unknown> | unknown[];
}
export interface DolibarrUserRights {
user?: {
user?: Record<string, unknown>;
self?: Record<string, unknown>;
user_advance?: Record<string, unknown>;
self_advance?: Record<string, unknown>;
group_advance?: Record<string, unknown>;
};
[key: string]: unknown;
}
// Tipo UI para mostrar en la aplicación
export interface UserProfile {
id: string;
login: string;
// Nombre completo
firstname: string;
lastname: string;
fullName: string;
initials: string;
// Contacto
email: string | null;
personalEmail: string | null;
phone: string | null;
mobile: string | null;
// Ubicación
address: string | null;
city: string | null;
postalCode: string | null;
country: string | null;
// Trabajo
job: string | null;
isEmployee: boolean;
employmentStartDate: Date | null;
employmentEndDate: Date | null;
establishment: string | null;
// Estado
isActive: boolean;
isAdmin: boolean;
// Actividad
lastLogin: Date | null;
lastLoginIp: string | null;
// Otros
photo: string | null;
language: string | null;
// Datos crudos para campos adicionales
raw: DolibarrUser;
}
/**
* Mapea respuesta de Dolibarr a tipo UI
*/
export function mapDolibarrUser(raw: DolibarrUser): UserProfile {
const firstname = raw.firstname || '';
const lastname = raw.lastname || '';
const fullName = [firstname, lastname].filter(Boolean).join(' ') || raw.login;
// Generar iniciales
const initials = firstname && lastname
? `${firstname.charAt(0)}${lastname.charAt(0)}`.toUpperCase()
: fullName.substring(0, 2).toUpperCase();
return {
id: raw.id,
login: raw.login,
firstname,
lastname,
fullName,
initials,
email: raw.email,
personalEmail: raw.personal_email,
phone: raw.office_phone,
mobile: raw.user_mobile || raw.personal_mobile,
address: raw.address,
city: raw.town,
postalCode: raw.zip,
country: raw.country_code || null,
job: raw.job,
isEmployee: raw.employee === '1',
employmentStartDate: raw.dateemployment ? new Date(raw.dateemployment) : null,
employmentEndDate: raw.dateemploymentend ? new Date(raw.dateemploymentend) : null,
establishment: raw.label_establishment,
isActive: raw.status === '1',
isAdmin: raw.admin === '1',
lastLogin: raw.datelastlogin ? new Date(raw.datelastlogin * 1000) : null,
lastLoginIp: raw.iplastlogin,
photo: raw.photo,
language: raw.lang,
raw,
};
}
/**
* Helper para obtener nombre a mostrar
*/
export function getDisplayName(user: UserProfile): string {
return user.fullName || user.login;
}
/**
* Helper para formatear fecha de último login
*/
export function formatLastLogin(date: Date | null): string {
if (!date) return 'Nunca';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Hace un momento';
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins !== 1 ? 's' : ''}`;
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours !== 1 ? 's' : ''}`;
if (diffDays < 7) return `Hace ${diffDays} día${diffDays !== 1 ? 's' : ''}`;
return date.toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}