feat(docker): añadir Dockerfile para contenerizar la aplicación

This commit is contained in:
unknown
2026-06-11 16:05:10 -04:00
parent 74a67fbab2
commit 2a58693d31
70 changed files with 3826 additions and 874 deletions

252
src/pages/Users.tsx Normal file
View File

@@ -0,0 +1,252 @@
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>
);
}