From ec7d26e5590fcdeebb4eb01808eb9dd4b844d521 Mon Sep 17 00:00:00 2001 From: Levi Planelles Date: Fri, 6 Feb 2026 19:24:39 +0100 Subject: [PATCH] feat: add user profile dialog and related services for user data management --- components/app-sidebar.tsx | 14 +- components/projects/data-table.tsx | 2 +- components/ui/dialog.tsx | 122 +++++++ components/user-profile/index.ts | 1 + .../user-profile/user-profile-dialog.tsx | 311 ++++++++++++++++++ lib/usersService.ts | 40 ++- types/user.ts | 226 +++++++++++++ 7 files changed, 709 insertions(+), 7 deletions(-) create mode 100644 components/ui/dialog.tsx create mode 100644 components/user-profile/index.ts create mode 100644 components/user-profile/user-profile-dialog.tsx create mode 100644 types/user.ts diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index fd6788a..8d55076 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -22,6 +22,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { UserProfileDialog } from "@/components/user-profile"; import { Home, FolderKanban, @@ -64,6 +65,7 @@ export function AppSidebar() { avatar: "/avatar.jpg", initials: "..." }); + const [isProfileOpen, setIsProfileOpen] = useState(false); useEffect(() => { async function loadUser() { @@ -158,11 +160,9 @@ export function AppSidebar() { - + setIsProfileOpen(true)}> - - Perfil - + Perfil @@ -178,6 +178,12 @@ export function AppSidebar() { + + {/* Dialog de perfil de usuario */} + ); } \ No newline at end of file diff --git a/components/projects/data-table.tsx b/components/projects/data-table.tsx index 533ea76..c32060f 100644 --- a/components/projects/data-table.tsx +++ b/components/projects/data-table.tsx @@ -95,7 +95,7 @@ export function DataTable({ ))} - ))} + ))} {table.getRowModel().rows?.length ? ( diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..d0b4fbb --- /dev/null +++ b/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/components/user-profile/index.ts b/components/user-profile/index.ts new file mode 100644 index 0000000..9c3b14d --- /dev/null +++ b/components/user-profile/index.ts @@ -0,0 +1 @@ +export { UserProfileDialog } from "./user-profile-dialog"; diff --git a/components/user-profile/user-profile-dialog.tsx b/components/user-profile/user-profile-dialog.tsx new file mode 100644 index 0000000..74f1498 --- /dev/null +++ b/components/user-profile/user-profile-dialog.tsx @@ -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 ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ); +} + +// Skeleton para estado de carga +function ProfileSkeleton() { + return ( +
+ {/* Header skeleton */} +
+ +
+ + +
+ + +
+
+
+ + + + {/* Content skeleton */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ); +} + +// Estado de error +function ErrorState({ onRetry }: { onRetry: () => void }) { + return ( +
+
+ +
+

Error al cargar el perfil

+

+ No se pudo cargar la información del usuario. Por favor, intenta de nuevo. +

+ +
+ ); +} + +export function UserProfileDialog({ open, onOpenChange }: UserProfileDialogProps) { + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( + + + + Mi Perfil + + + {isLoading && } + + {error && !isLoading && } + + {profile && !isLoading && !error && ( +
+ {/* Header con avatar y nombre */} +
+ + {profile.photo ? ( + + ) : null} + + {profile.initials} + + + +
+

{profile.fullName}

+

@{profile.login}

+ +
+ {profile.isAdmin && ( + + + Administrador + + )} + + {profile.isActive ? "Activo" : "Inactivo"} + + {profile.isEmployee && ( + Empleado + )} +
+
+
+ + + + {/* Información de contacto */} + + +

+ Contacto +

+
+ + + + + +
+
+
+ + {/* Información laboral */} + {(profile.job || profile.establishment || profile.employmentStartDate) && ( + + +

+ Información Laboral +

+
+ + + {profile.employmentStartDate && ( + + )} +
+
+
+ )} + + {/* Actividad y sesión */} + + +

+ Actividad +

+
+ + {profile.lastLoginIp && ( + + )} + {profile.language && ( + + )} + +
+
+
+
+ )} +
+
+ ); +} diff --git a/lib/usersService.ts b/lib/usersService.ts index 5449245..457ea88 100644 --- a/lib/usersService.ts +++ b/lib/usersService.ts @@ -1,6 +1,42 @@ 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 { const userId = id || process.env.NEXT_PUBLIC_DOLIBARR_USER_ID || "1"; return dolibarrFetch(`users/${userId}`); -} \ No newline at end of file +} + +/** + * Obtiene el perfil de usuario mapeado para la UI + */ +export async function getUserProfile(id?: string): Promise { + 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 { + return getUserProfile(); +} diff --git a/types/user.ts b/types/user.ts new file mode 100644 index 0000000..6e1a243 --- /dev/null +++ b/types/user.ts @@ -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[]; + + // Configuración + rights: DolibarrUserRights; + conf: Record; + array_options: Record | unknown[]; +} + +export interface DolibarrUserRights { + user?: { + user?: Record; + self?: Record; + user_advance?: Record; + self_advance?: Record; + group_advance?: Record; + }; + [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', + }); +}