feat: rol manager, arreglos de reportes (cancelacion/afluencias), credito unico, cliente enkontrol, vendedor en bitacora y actualizar afluencia existente

This commit is contained in:
2026-09-18 15:25:34 +00:00
parent 9630893974
commit 4965a4488a
35 changed files with 303 additions and 90 deletions

View File

@@ -85,7 +85,7 @@ export default function App() {
)
}
if (vista === 'configuraciones' && usuario?.rol === 'admin') {
if (vista === 'configuraciones' && (usuario?.rol === 'admin' || usuario?.rol === 'manager')) {
return (
<div className="flex min-h-screen flex-col">
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />

View File

@@ -23,7 +23,7 @@ export function Navbar({ usuario, onConfiguraciones }: Props) {
}
}, [])
const esAdmin = usuario?.rol === 'admin'
const esAdmin = usuario?.rol === 'admin' || usuario?.rol === 'manager'
return (
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">

View File

@@ -22,12 +22,18 @@ function crearStorageStub(): Storage {
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
/** Sesión simulada; rol 'admin' para las pruebas de administrador */
function sesionDe(rol: string) {
return { token: 'jwt-prueba', usuario: { nombre: 'Prueba', rol, asignacion: null } }
}
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('localStorage', crearStorageStub())
localStorage.setItem('frasa_auth', JSON.stringify(sesionDe('admin')))
// Mockear las llamadas iniciales que los tabs pueden hacer
fetchMock.mockImplementation((input) => {
const url = String(input)
@@ -63,6 +69,14 @@ describe('ConfigPage', () => {
expect(screen.getByText('Restaurar valores por defecto')).toBeTruthy()
})
it('el rol manager NO ve "Roles y permisos" (no edita permisos)', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionDe('manager')))
render(<ConfigPage onVolver={() => {}} />)
expect(screen.queryByRole('button', { name: 'Roles y permisos' })).toBeNull()
expect(screen.getByRole('button', { name: 'Usuarios' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Auditoría' })).toBeTruthy()
})
it('al hacer clic en "Volver", ejecuta el callback', () => {
const volver = vi.fn()
render(<ConfigPage onVolver={volver} />)

View File

@@ -12,18 +12,27 @@ interface Props {
type Tab = 'roles' | 'usuarios' | 'sistema' | 'asistente' | 'auditoria'
const TABS_BASE: { key: Tab; label: string }[] = [
{ key: 'roles', label: 'Roles y permisos' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'sistema', label: 'Sistema' },
]
export function ConfigPage({ onVolver }: Props) {
const [tab, setTab] = useState<Tab>('roles')
const esAdmin = getSesion()?.usuario.rol === 'admin'
const tabs = esAdmin
? [...TABS_BASE, { key: 'asistente' as const, label: 'Asistente IA' }, { key: 'auditoria' as const, label: 'Auditoría' }]
: TABS_BASE
const [tabElegida, setTab] = useState<Tab>('roles')
const rol = getSesion()?.usuario.rol
const esAdmin = rol === 'admin'
// Roles y permisos: SOLO administrador (el manager no edita permisos ni crea usuarios).
const tabs: { key: Tab; label: string }[] = esAdmin
? [
{ key: 'roles', label: 'Roles y permisos' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'sistema', label: 'Sistema' },
{ key: 'asistente', label: 'Asistente IA' },
{ key: 'auditoria', label: 'Auditoría' },
]
: [
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'sistema', label: 'Sistema' },
{ key: 'asistente', label: 'Asistente IA' },
{ key: 'auditoria', label: 'Auditoría' },
]
// Con un rol sin acceso a la tab pedida, se abre la primera disponible
const tab: Tab = tabs.some((t) => t.key === tabElegida) ? tabElegida : tabs[0]!.key
return (
<main className="w-full flex-1 px-6 py-12 sm:px-6 lg:px-8 sm:py-16">
@@ -63,11 +72,11 @@ export function ConfigPage({ onVolver }: Props) {
{/* Contenido del tab activo */}
<section className="mt-6">
{tab === 'roles' && <RolesPermisos />}
{tab === 'usuarios' && <Usuarios />}
{tab === 'roles' && (esAdmin ? <RolesPermisos /> : null)}
{tab === 'usuarios' && <Usuarios puedeCrear={esAdmin} puedeEditarRoles={esAdmin} />}
{tab === 'sistema' && <Sistema />}
{tab === 'asistente' && esAdmin && <AsistenteIA />}
{tab === 'auditoria' && esAdmin && <AuditoriaLog />}
{tab === 'asistente' && <AsistenteIA />}
{tab === 'auditoria' && <AuditoriaLog />}
</section>
</main>
)

View File

@@ -12,6 +12,7 @@ const ROLES = [
'director_ventas',
'escrituracion',
'direccion_general',
'manager',
'admin',
] as const
@@ -30,6 +31,7 @@ const ROL_LABELS: Record<string, string> = {
director_ventas: 'Director ventas',
escrituracion: 'Escrituración',
direccion_general: 'Dirección general',
manager: 'Manager',
admin: 'Admin',
}
@@ -42,7 +44,17 @@ const ASIGNACION_LABELS: Record<string, string> = {
'': 'Sin asignación',
}
export function Usuarios() {
/**
* El rol manager puede ver la lista y ajustar asignación/activo, pero NO crear
* usuarios ni cambiar roles (permisos) — eso queda para el administrador.
*/
export function Usuarios({
puedeCrear = true,
puedeEditarRoles = true,
}: {
puedeCrear?: boolean
puedeEditarRoles?: boolean
} = {}) {
const [usuarios, setUsuarios] = useState<UsuarioConfig[]>([])
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
@@ -176,7 +188,8 @@ export function Usuarios() {
<p role="alert" className="text-sm text-red-600">{error}</p>
)}
{/* Alta de usuario */}
{/* Alta de usuario (solo administrador) */}
{puedeCrear && (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
@@ -278,6 +291,7 @@ export function Usuarios() {
</form>
)}
</div>
)}
{/* Búsqueda */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -319,15 +333,19 @@ export function Usuarios() {
<td className="whitespace-nowrap px-4 py-3 text-[#111827] dark:text-[#E2E8F0]">{u.nombre}</td>
<td className="whitespace-nowrap px-4 py-3 text-[#64748B] dark:text-[#94A3B8]">{u.username}</td>
<td className="px-4 py-3">
<select
value={u.rol}
onChange={(e) => void cambiarRol(u.id, e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
>
{ROLES.map((r) => (
<option key={r} value={r}>{ROL_LABELS[r]}</option>
))}
</select>
{puedeEditarRoles ? (
<select
value={u.rol}
onChange={(e) => void cambiarRol(u.id, e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
>
{ROLES.map((r) => (
<option key={r} value={r}>{ROL_LABELS[r]}</option>
))}
</select>
) : (
<span className="text-xs text-[#374151] dark:text-[#CBD5E1]">{ROL_LABELS[u.rol] ?? u.rol}</span>
)}
</td>
<td className="px-4 py-3">
<select

View File

@@ -219,7 +219,7 @@ export function CapturaPage({ onVolver }: Props) {
onGuardar={handleGuardar}
guardando={guardando}
filtroEstatus={filtroEstatus}
puedeEditarFechas={rol === 'admin'}
puedeEditarFechas={rol === 'admin' || rol === 'manager'}
/>
)}
</section>

View File

@@ -284,7 +284,7 @@ export function SoportePage({ onVolver }: Props) {
{/* Listado de solicitudes */}
<section aria-label="Mis solicitudes" className="mt-12 max-w-3xl">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
{sesion?.usuario.rol === 'admin' ? 'Todas las solicitudes' : 'Mis solicitudes'}
{sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager' ? 'Todas las solicitudes' : 'Mis solicitudes'}
</h2>
{cargando ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>