feat: add user profile dialog and related services for user data management
This commit is contained in:
parent
ae66248df9
commit
ec7d26e559
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { UserProfileDialog } from "./user-profile-dialog";
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue