feat: rol manager, arreglos de reportes (cancelacion/afluencias), credito unico, cliente enkontrol, vendedor en bitacora y actualizar afluencia existente
This commit is contained in:
@@ -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')} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -219,7 +219,7 @@ export function CapturaPage({ onVolver }: Props) {
|
||||
onGuardar={handleGuardar}
|
||||
guardando={guardando}
|
||||
filtroEstatus={filtroEstatus}
|
||||
puedeEditarFechas={rol === 'admin'}
|
||||
puedeEditarFechas={rol === 'admin' || rol === 'manager'}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user