253 lines
7.8 KiB
TypeScript
253 lines
7.8 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { usuariosApi } from '@/api/usuarios';
|
|
import { rolesApi } from '@/api/roles';
|
|
import type { ActualizarUsuarioBody, Usuario } from '@/types';
|
|
import { PageHeader } from '@/components/PageHeader';
|
|
import { DataTable, type Column } from '@/components/DataTable';
|
|
import { Modal } from '@/components/Modal';
|
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
|
import { ErrorAlert } from '@/components/ErrorAlert';
|
|
import {
|
|
Field,
|
|
Input,
|
|
Select,
|
|
Checkbox,
|
|
btnPrimary,
|
|
btnSecondary,
|
|
btnDanger,
|
|
} from '@/components/form/Field';
|
|
|
|
export function UsersPage() {
|
|
const qc = useQueryClient();
|
|
const [editUser, setEditUser] = useState<Usuario | null>(null);
|
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
const [formError, setFormError] = useState('');
|
|
|
|
const usuarios = useQuery({ queryKey: ['usuarios'], queryFn: usuariosApi.list });
|
|
const pendientes = useQuery({
|
|
queryKey: ['usuarios-pendientes'],
|
|
queryFn: usuariosApi.pendientes,
|
|
});
|
|
const roles = useQuery({ queryKey: ['roles'], queryFn: rolesApi.list });
|
|
|
|
const aceptar = useMutation({
|
|
mutationFn: (id: number) => usuariosApi.aceptar(id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['usuarios'] });
|
|
qc.invalidateQueries({ queryKey: ['usuarios-pendientes'] });
|
|
qc.invalidateQueries({ queryKey: ['dashboard-stats'] });
|
|
},
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: ({ id, body }: { id: number; body: ActualizarUsuarioBody }) =>
|
|
usuariosApi.update(id, body),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['usuarios'] });
|
|
qc.invalidateQueries({ queryKey: ['usuarios-pendientes'] });
|
|
setEditUser(null);
|
|
},
|
|
onError: (e: Error) => setFormError(e.message),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (id: number) => usuariosApi.delete(id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['usuarios'] });
|
|
setDeleteId(null);
|
|
},
|
|
});
|
|
|
|
const columns: Column<Usuario>[] = [
|
|
{ key: 'id', header: 'ID', render: (u) => u.id },
|
|
{ key: 'email', header: 'Email', render: (u) => u.username },
|
|
{ key: 'nombre', header: 'Nombre', render: (u) => u.nombre },
|
|
{
|
|
key: 'rol',
|
|
header: 'Rol',
|
|
render: (u) => u.rol?.name ?? u.roles ?? '—',
|
|
},
|
|
{
|
|
key: 'aprobado',
|
|
header: 'Aprobado',
|
|
render: (u) => (u.aprobado ? 'Sí' : 'No'),
|
|
},
|
|
{
|
|
key: 'visible',
|
|
header: 'Visible',
|
|
render: (u) => (u.visible ? 'Sí' : 'No'),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: '',
|
|
render: (u) => (
|
|
<div className="flex gap-2">
|
|
<button type="button" className={btnSecondary} onClick={() => setEditUser(u)}>
|
|
Editar
|
|
</button>
|
|
<button type="button" className={btnDanger} onClick={() => setDeleteId(u.id)}>
|
|
Eliminar
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
title="Usuarios"
|
|
description="Gestión de cuentas y aprobaciones"
|
|
/>
|
|
|
|
{pendientes.data && pendientes.data.length > 0 && (
|
|
<section className="mb-8 rounded-xl border border-amber-500/30 bg-amber-950/20 p-5">
|
|
<h2 className="mb-3 font-semibold text-amber-200">
|
|
Pendientes de aprobación ({pendientes.data.length})
|
|
</h2>
|
|
<ul className="space-y-2">
|
|
{pendientes.data.map((u) => (
|
|
<li
|
|
key={u.id}
|
|
className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-surface-raised/80 px-4 py-3"
|
|
>
|
|
<span>
|
|
<strong>{u.nombre}</strong> — {u.username}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className={btnPrimary}
|
|
disabled={aceptar.isPending}
|
|
onClick={() => aceptar.mutate(u.id)}
|
|
>
|
|
Aprobar
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={usuarios.data ?? []}
|
|
keyExtractor={(u) => u.id}
|
|
loading={usuarios.isLoading}
|
|
/>
|
|
|
|
{editUser && (
|
|
<UserFormModal
|
|
user={editUser}
|
|
roles={roles.data ?? []}
|
|
error={formError}
|
|
loading={update.isPending}
|
|
onClose={() => {
|
|
setEditUser(null);
|
|
setFormError('');
|
|
}}
|
|
onSave={(body) => update.mutate({ id: editUser.id, body })}
|
|
/>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={deleteId != null}
|
|
title="Eliminar usuario"
|
|
message="¿Confirma eliminar este usuario? Esta acción no se puede deshacer."
|
|
onConfirm={() => deleteId != null && remove.mutate(deleteId)}
|
|
onCancel={() => setDeleteId(null)}
|
|
loading={remove.isPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserFormModal({
|
|
user,
|
|
roles,
|
|
error,
|
|
loading,
|
|
onClose,
|
|
onSave,
|
|
}: {
|
|
user: Usuario;
|
|
roles: { id: number; name: string }[];
|
|
error: string;
|
|
loading: boolean;
|
|
onClose: () => void;
|
|
onSave: (body: ActualizarUsuarioBody) => void;
|
|
}) {
|
|
const [nombre, setNombre] = useState(user.nombre);
|
|
const [ci, setCi] = useState(String(user.ci));
|
|
const [celular, setCelular] = useState(String(user.celular));
|
|
const [aprobado, setAprobado] = useState(user.aprobado);
|
|
const [visible, setVisible] = useState(user.visible);
|
|
const [rolId, setRolId] = useState(String(user.rol?.id ?? ''));
|
|
const [password, setPassword] = useState('');
|
|
|
|
return (
|
|
<Modal open title={`Editar: ${user.username}`} onClose={onClose}>
|
|
{error && <div className="mb-4"><ErrorAlert message={error} /></div>}
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const body: ActualizarUsuarioBody = {
|
|
nombre,
|
|
ci: Number(ci),
|
|
celular: Number(celular),
|
|
aprobado,
|
|
visible,
|
|
rol: rolId ? Number(rolId) : undefined,
|
|
};
|
|
if (password) body.password = password;
|
|
onSave(body);
|
|
}}
|
|
>
|
|
<Field label="Nombre">
|
|
<Input value={nombre} onChange={(e) => setNombre(e.target.value)} required />
|
|
</Field>
|
|
<Field label="CI">
|
|
<Input type="number" value={ci} onChange={(e) => setCi(e.target.value)} required />
|
|
</Field>
|
|
<Field label="Celular">
|
|
<Input
|
|
type="number"
|
|
value={celular}
|
|
onChange={(e) => setCelular(e.target.value)}
|
|
required
|
|
/>
|
|
</Field>
|
|
<Field label="Rol (tabla roles)">
|
|
<Select value={rolId} onChange={(e) => setRolId(e.target.value)}>
|
|
<option value="">— Sin cambio —</option>
|
|
{roles.map((r) => (
|
|
<option key={r.id} value={r.id}>
|
|
{r.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
<Field label="Nueva contraseña (opcional)">
|
|
<Input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
autoComplete="new-password"
|
|
/>
|
|
</Field>
|
|
<Checkbox label="Aprobado" checked={aprobado} onChange={(e) => setAprobado(e.target.checked)} />
|
|
<Checkbox label="Visible" checked={visible} onChange={(e) => setVisible(e.target.checked)} />
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<button type="button" className={btnSecondary} onClick={onClose}>
|
|
Cancelar
|
|
</button>
|
|
<button type="submit" className={btnPrimary} disabled={loading}>
|
|
Guardar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|