Reports module: - types/reports.types.ts: Complete type definitions for definitions, executions, schedules - api/reports.api.ts: API clients for all report operations - hooks/useReports.ts: Custom hooks (useReportDefinitions, useReportExecutions, etc.) - ReportsPage.tsx: Main reports management with tabs - QuickReportsPage.tsx: Trial Balance and General Ledger quick reports Settings module: - types/settings.types.ts: Types for company, users, profile, audit logs - api/settings.api.ts: API clients for settings operations - hooks/useSettings.ts: Custom hooks (useCompany, useUsers, useProfile, etc.) - SettingsPage.tsx: Settings hub with navigation cards - UsersSettingsPage.tsx: User management page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
449 lines
15 KiB
TypeScript
449 lines
15 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Users as UsersIcon,
|
|
Plus,
|
|
MoreVertical,
|
|
Eye,
|
|
Edit2,
|
|
Trash2,
|
|
Key,
|
|
Mail,
|
|
RefreshCw,
|
|
Search,
|
|
UserCheck,
|
|
UserX,
|
|
Shield,
|
|
} from 'lucide-react';
|
|
import { Button } from '@components/atoms/Button';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
|
import { DataTable, type Column } from '@components/organisms/DataTable';
|
|
import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown';
|
|
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
|
import { ConfirmModal } from '@components/organisms/Modal';
|
|
import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState';
|
|
import { useUsers } from '@features/settings/hooks';
|
|
import type { User, UserRole } from '@features/settings/types';
|
|
import { USER_ROLE_LABELS } from '@features/settings/types';
|
|
import { formatDate } from '@utils/formatters';
|
|
|
|
const roleColors: Record<UserRole, string> = {
|
|
admin: 'bg-red-100 text-red-700',
|
|
manager: 'bg-blue-100 text-blue-700',
|
|
user: 'bg-green-100 text-green-700',
|
|
viewer: 'bg-gray-100 text-gray-700',
|
|
};
|
|
|
|
export function UsersSettingsPage() {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedRole, setSelectedRole] = useState<UserRole | ''>('');
|
|
const [showActiveOnly, setShowActiveOnly] = useState<boolean | undefined>(undefined);
|
|
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
|
const [userToToggle, setUserToToggle] = useState<User | null>(null);
|
|
const [userToResetPassword, setUserToResetPassword] = useState<User | null>(null);
|
|
|
|
const {
|
|
users,
|
|
total,
|
|
page,
|
|
totalPages,
|
|
isLoading,
|
|
error,
|
|
setFilters,
|
|
refresh,
|
|
remove,
|
|
toggleActive,
|
|
resetPassword,
|
|
resendInvitation,
|
|
} = useUsers({
|
|
initialFilters: {
|
|
search: searchTerm,
|
|
role: selectedRole || undefined,
|
|
isActive: showActiveOnly,
|
|
},
|
|
});
|
|
|
|
const handleSearch = (value: string) => {
|
|
setSearchTerm(value);
|
|
setFilters({ search: value });
|
|
};
|
|
|
|
const handleRoleFilter = (role: UserRole | '') => {
|
|
setSelectedRole(role);
|
|
setFilters({ role: role || undefined });
|
|
};
|
|
|
|
const handleActiveFilter = (active: boolean | undefined) => {
|
|
setShowActiveOnly(active);
|
|
setFilters({ isActive: active });
|
|
};
|
|
|
|
const columns: Column<User>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'Usuario',
|
|
render: (user) => (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
|
|
{user.avatarUrl ? (
|
|
<img src={user.avatarUrl} alt={user.name} className="h-10 w-10 rounded-full object-cover" />
|
|
) : (
|
|
<span className="text-sm font-medium text-blue-600">
|
|
{user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="font-medium text-gray-900">{user.name}</div>
|
|
<div className="text-sm text-gray-500">{user.email}</div>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'role',
|
|
header: 'Rol',
|
|
render: (user) => (
|
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${roleColors[user.role]}`}>
|
|
<Shield className="h-3 w-3" />
|
|
{USER_ROLE_LABELS[user.role]}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'isActive',
|
|
header: 'Estado',
|
|
render: (user) => (
|
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${
|
|
user.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{user.isActive ? (
|
|
<>
|
|
<UserCheck className="h-3 w-3" />
|
|
Activo
|
|
</>
|
|
) : (
|
|
<>
|
|
<UserX className="h-3 w-3" />
|
|
Inactivo
|
|
</>
|
|
)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'lastLoginAt',
|
|
header: 'Ultimo acceso',
|
|
render: (user) => (
|
|
<span className="text-sm text-gray-600">
|
|
{user.lastLoginAt ? formatDate(user.lastLoginAt, 'short') : 'Nunca'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
header: 'Creado',
|
|
render: (user) => (
|
|
<span className="text-sm text-gray-600">
|
|
{formatDate(user.createdAt, 'short')}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (user) => {
|
|
const items: DropdownItem[] = [
|
|
{
|
|
key: 'view',
|
|
label: 'Ver detalle',
|
|
icon: <Eye className="h-4 w-4" />,
|
|
onClick: () => console.log('View', user.id),
|
|
},
|
|
{
|
|
key: 'edit',
|
|
label: 'Editar',
|
|
icon: <Edit2 className="h-4 w-4" />,
|
|
onClick: () => console.log('Edit', user.id),
|
|
},
|
|
{
|
|
key: 'toggle',
|
|
label: user.isActive ? 'Desactivar' : 'Activar',
|
|
icon: user.isActive ? <UserX className="h-4 w-4" /> : <UserCheck className="h-4 w-4" />,
|
|
onClick: () => setUserToToggle(user),
|
|
},
|
|
{
|
|
key: 'resetPassword',
|
|
label: 'Restablecer contrasena',
|
|
icon: <Key className="h-4 w-4" />,
|
|
onClick: () => setUserToResetPassword(user),
|
|
},
|
|
{
|
|
key: 'resendInvitation',
|
|
label: 'Reenviar invitacion',
|
|
icon: <Mail className="h-4 w-4" />,
|
|
onClick: () => resendInvitation(user.id),
|
|
},
|
|
{
|
|
key: 'delete',
|
|
label: 'Eliminar',
|
|
icon: <Trash2 className="h-4 w-4" />,
|
|
danger: true,
|
|
onClick: () => setUserToDelete(user),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Dropdown
|
|
trigger={
|
|
<button className="rounded p-1 hover:bg-gray-100">
|
|
<MoreVertical className="h-4 w-4 text-gray-500" />
|
|
</button>
|
|
}
|
|
items={items}
|
|
align="right"
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
const handleDeleteUser = async () => {
|
|
if (userToDelete) {
|
|
await remove(userToDelete.id);
|
|
setUserToDelete(null);
|
|
}
|
|
};
|
|
|
|
const handleToggleUser = async () => {
|
|
if (userToToggle) {
|
|
await toggleActive(userToToggle.id);
|
|
setUserToToggle(null);
|
|
}
|
|
};
|
|
|
|
const handleResetPassword = async () => {
|
|
if (userToResetPassword) {
|
|
// Generate a temporary password or show a form
|
|
const tempPassword = 'TempPass123!';
|
|
await resetPassword(userToResetPassword.id, tempPassword);
|
|
setUserToResetPassword(null);
|
|
alert(`Contrasena restablecida. Nueva contrasena temporal: ${tempPassword}`);
|
|
}
|
|
};
|
|
|
|
// Stats
|
|
const activeUsers = users.filter(u => u.isActive).length;
|
|
const adminCount = users.filter(u => u.role === 'admin').length;
|
|
const managerCount = users.filter(u => u.role === 'manager').length;
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-6">
|
|
<ErrorEmptyState onRetry={refresh} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-6">
|
|
<Breadcrumbs items={[
|
|
{ label: 'Configuracion', href: '/settings' },
|
|
{ label: 'Usuarios' },
|
|
]} />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Usuarios</h1>
|
|
<p className="text-sm text-gray-500">
|
|
Gestiona los usuarios de tu empresa
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={refresh} disabled={isLoading}>
|
|
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
Actualizar
|
|
</Button>
|
|
<Button>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Nuevo usuario
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Stats */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
|
<UsersIcon className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Total usuarios</div>
|
|
<div className="text-xl font-bold text-blue-600">{total}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleActiveFilter(true)}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
|
<UserCheck className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Activos</div>
|
|
<div className="text-xl font-bold text-green-600">{activeUsers}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleRoleFilter('admin')}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
|
|
<Shield className="h-5 w-5 text-red-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Administradores</div>
|
|
<div className="text-xl font-bold text-red-600">{adminCount}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => handleRoleFilter('manager')}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
|
|
<UsersIcon className="h-5 w-5 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<div className="text-sm text-gray-500">Gerentes</div>
|
|
<div className="text-xl font-bold text-purple-600">{managerCount}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Lista de Usuarios</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="relative flex-1 min-w-[200px]">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar usuarios..."
|
|
value={searchTerm}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
className="w-full rounded-md border border-gray-300 py-2 pl-10 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={selectedRole}
|
|
onChange={(e) => handleRoleFilter(e.target.value as UserRole | '')}
|
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option value="">Todos los roles</option>
|
|
{Object.entries(USER_ROLE_LABELS).map(([value, label]) => (
|
|
<option key={value} value={value}>{label}</option>
|
|
))}
|
|
</select>
|
|
|
|
<select
|
|
value={showActiveOnly === undefined ? '' : showActiveOnly.toString()}
|
|
onChange={(e) => handleActiveFilter(e.target.value === '' ? undefined : e.target.value === 'true')}
|
|
className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
>
|
|
<option value="">Todos los estados</option>
|
|
<option value="true">Activos</option>
|
|
<option value="false">Inactivos</option>
|
|
</select>
|
|
|
|
{(searchTerm || selectedRole || showActiveOnly !== undefined) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSearchTerm('');
|
|
setSelectedRole('');
|
|
setShowActiveOnly(undefined);
|
|
setFilters({});
|
|
}}
|
|
>
|
|
Limpiar filtros
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
{users.length === 0 && !isLoading ? (
|
|
<NoDataEmptyState entityName="usuarios" />
|
|
) : (
|
|
<DataTable
|
|
data={users}
|
|
columns={columns}
|
|
isLoading={isLoading}
|
|
pagination={{
|
|
page,
|
|
totalPages,
|
|
total,
|
|
limit: 20,
|
|
onPageChange: (p) => setFilters({ page: p }),
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Delete User Modal */}
|
|
<ConfirmModal
|
|
isOpen={!!userToDelete}
|
|
onClose={() => setUserToDelete(null)}
|
|
onConfirm={handleDeleteUser}
|
|
title="Eliminar usuario"
|
|
message={`¿Eliminar al usuario "${userToDelete?.name}"? Esta accion no se puede deshacer.`}
|
|
variant="danger"
|
|
confirmText="Eliminar"
|
|
/>
|
|
|
|
{/* Toggle User Modal */}
|
|
<ConfirmModal
|
|
isOpen={!!userToToggle}
|
|
onClose={() => setUserToToggle(null)}
|
|
onConfirm={handleToggleUser}
|
|
title={userToToggle?.isActive ? 'Desactivar usuario' : 'Activar usuario'}
|
|
message={userToToggle?.isActive
|
|
? `¿Desactivar al usuario "${userToToggle?.name}"? No podra acceder al sistema.`
|
|
: `¿Activar al usuario "${userToToggle?.name}"? Podra acceder al sistema nuevamente.`
|
|
}
|
|
variant={userToToggle?.isActive ? 'warning' : 'success'}
|
|
confirmText={userToToggle?.isActive ? 'Desactivar' : 'Activar'}
|
|
/>
|
|
|
|
{/* Reset Password Modal */}
|
|
<ConfirmModal
|
|
isOpen={!!userToResetPassword}
|
|
onClose={() => setUserToResetPassword(null)}
|
|
onConfirm={handleResetPassword}
|
|
title="Restablecer contrasena"
|
|
message={`¿Restablecer la contrasena de "${userToResetPassword?.name}"? Se generara una contrasena temporal.`}
|
|
variant="warning"
|
|
confirmText="Restablecer"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default UsersSettingsPage;
|