Permisos: marketing (export completo, agenda/citas, cumpleanos, visitas, WACRM), gerencia gestiona usuarios y roles, recepcion edita expedientes, cosmetologas ven agenda, fix del sidebar que no dejaba seleccionar Leads
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FC, MutableRefObject, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -256,13 +256,47 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
const toggleGroup = (label: string) =>
|
||||
setOpenGroups((prev) => (prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label]));
|
||||
|
||||
const SidebarContent = () => (
|
||||
// El menú se renderiza como función (no como componente) a propósito: como
|
||||
// componente definido dentro del layout, cada render crea un tipo nuevo y
|
||||
// React remonta el subárbol entero, perdiendo la posición de scroll (los
|
||||
// últimos items, p. ej. Leads WACRM, quedaban inalcanzables).
|
||||
const navDesktopRef = useRef<HTMLElement | null>(null);
|
||||
const navMobileRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// 1) El grupo que contiene la ruta activa siempre queda abierto
|
||||
useEffect(() => {
|
||||
const current = `${location.pathname}${location.search}`;
|
||||
const abiertos = menuTree
|
||||
.filter((e) => e.children?.some((c) => {
|
||||
const base = c.href.split('?')[0];
|
||||
return current === c.href || (current.startsWith(base) && (!c.href.includes('?') || current.includes(c.href.split('?')[1])));
|
||||
}))
|
||||
.map((e) => e.label);
|
||||
if (!abiertos.length) return;
|
||||
setOpenGroups((prev) => {
|
||||
const faltan = abiertos.filter((l) => !prev.includes(l));
|
||||
return faltan.length ? [...prev, ...faltan] : prev;
|
||||
});
|
||||
}, [location.pathname, location.search]);
|
||||
|
||||
// 2) Con el grupo ya abierto (re-render), deja visible el item activo.
|
||||
// Va en un efecto aparte a propósito: en el mismo tick el item todavía no
|
||||
// está en el DOM y no habría nada que desplazar.
|
||||
useEffect(() => {
|
||||
for (const nav of [navDesktopRef.current, navMobileRef.current]) {
|
||||
if (!nav || nav.scrollHeight <= nav.clientHeight) continue;
|
||||
const el = nav.querySelector<HTMLElement>('[data-active="1"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [location.pathname, location.search, openGroups, visibleLeaves]);
|
||||
|
||||
const renderSidebar = ({ navRef = null }: { navRef?: MutableRefObject<HTMLElement | null> | null } = {}) => (
|
||||
<>
|
||||
{/* Sucursal, como en el sistema legacy */}
|
||||
<p className="text-center text-sm font-medium text-white py-3 shrink-0" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
|
||||
Rosarito
|
||||
</p>
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto py-2">
|
||||
{quickItems.length > 0 && quick.mode === 'menu' && (
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between px-4 py-2" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
|
||||
@@ -405,6 +439,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
<Link
|
||||
to={child.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
data-active={active ? '1' : undefined}
|
||||
className="flex-1 flex items-center pl-11 pr-4 py-2 text-[13px] transition-colors"
|
||||
style={{
|
||||
backgroundColor: active ? 'rgba(0,0,0,0.25)' : 'transparent',
|
||||
@@ -533,7 +568,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
className="hidden lg:flex flex-col w-[230px] fixed top-[50px] bottom-0 left-0 z-20"
|
||||
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
||||
>
|
||||
<SidebarContent />
|
||||
{renderSidebar({ navRef: navDesktopRef })}
|
||||
</aside>
|
||||
|
||||
{/* Drawer móvil */}
|
||||
@@ -546,7 +581,7 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
||||
}`}
|
||||
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
||||
>
|
||||
<SidebarContent />
|
||||
{renderSidebar({ navRef: navMobileRef })}
|
||||
</aside>
|
||||
|
||||
{/* Contenido */}
|
||||
|
||||
@@ -335,7 +335,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
|
||||
<div className="flex flex-1">
|
||||
{/* Sidebar workspace */}
|
||||
<aside className="hidden lg:flex flex-col w-72 border-r p-5 gap-6" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
||||
<aside className="hidden lg:flex flex-col w-72 border-r p-5 gap-6 overflow-y-auto max-h-screen sticky top-0" style={{ backgroundColor: 'var(--surface)', borderColor: 'var(--border)' }}>
|
||||
<div className="rounded-2xl p-4 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
|
||||
@@ -505,7 +505,7 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40 lg:hidden" style={{ backgroundColor: 'rgba(62,44,28,0.4)' }} onClick={() => setMenuOpen(false)} aria-hidden="true" />
|
||||
<aside className="fixed inset-y-0 left-0 w-80 z-50 flex flex-col lg:hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||
<aside className="fixed inset-y-0 left-0 w-80 z-50 flex flex-col overflow-y-auto lg:hidden" style={{ backgroundColor: 'var(--surface)' }}>
|
||||
<div className="flex items-center justify-between px-5 h-16 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<img src={LOGO_HOMENEST} alt="SKEEN" className="h-8 w-auto object-contain" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setMenuOpen(false)} aria-label="Cerrar menú">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FC, MutableRefObject, ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -227,7 +227,31 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
return location.pathname === href || location.pathname.startsWith(`${href}/`);
|
||||
};
|
||||
|
||||
const SidebarContent = ({ collapsed = false, showToggle = true }: { collapsed?: boolean; showToggle?: boolean }) => (
|
||||
// El menú lateral se renderiza como función (no como componente) a propósito:
|
||||
// definirlo como componente dentro del layout crea un tipo nuevo en cada
|
||||
// render, React desmonta y vuelve a montar todo el subárbol y se pierde la
|
||||
// posición de scroll, dejando inalcanzables los últimos items (WACRM).
|
||||
const navDesktopRef = useRef<HTMLElement | null>(null);
|
||||
const navMobileRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Al cambiar de ruta, deja visible el item activo (p. ej. Mensajes -> Leads).
|
||||
useEffect(() => {
|
||||
const activo = `${location.pathname}${location.search}`;
|
||||
const menuKey = visibleGroups.reduce((n, g) => n + g.items.length, 0);
|
||||
for (const nav of [navDesktopRef.current, navMobileRef.current]) {
|
||||
if (!nav || nav.scrollHeight <= nav.clientHeight) continue;
|
||||
const el = nav.querySelector<HTMLElement>('[data-active="1"]');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
void activo;
|
||||
void menuKey;
|
||||
}, [location.pathname, location.search, visibleGroups]);
|
||||
|
||||
const renderSidebar = ({ collapsed = false, showToggle = true, navRef = null }: {
|
||||
collapsed?: boolean;
|
||||
showToggle?: boolean;
|
||||
navRef?: MutableRefObject<HTMLElement | null> | null;
|
||||
}) => (
|
||||
<>
|
||||
<div className={`h-20 flex items-center border-b border-white/10 shrink-0 ${collapsed ? 'justify-center px-2' : 'px-5 lg:px-6'}`}>
|
||||
<img src={LOGO_SKEEN} alt="SKEEN" className={collapsed ? 'h-6 w-auto object-contain' : 'h-7 w-auto object-contain'} />
|
||||
@@ -244,7 +268,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto py-5 px-3">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto py-5 px-3">
|
||||
{quickItems.length > 0 && quick.mode === 'menu' && collapsed && (
|
||||
<div className="mb-5" title="Accesos rápidos">
|
||||
<div className="mx-3 mb-2 border-t border-white/10" />
|
||||
@@ -346,6 +370,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
data-active={active ? '1' : undefined}
|
||||
className={`${collapsed ? 'relative flex items-center justify-center w-10 h-10 rounded-xl' : 'flex-1 flex items-center space-x-3 px-3 py-2.5 rounded-xl'} text-sm font-medium transition-all ${
|
||||
active
|
||||
? 'bg-theme-accent text-theme-inverse shadow-soft'
|
||||
@@ -426,7 +451,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
className={`hidden lg:flex flex-col fixed h-full z-20 transition-all duration-200 ${collapsed ? 'w-16' : 'w-64'}`}
|
||||
style={{ backgroundColor: 'var(--sidebar-bg)' }}
|
||||
>
|
||||
<SidebarContent collapsed={collapsed} />
|
||||
{renderSidebar({ collapsed, showToggle: true, navRef: navDesktopRef })}
|
||||
</aside>
|
||||
|
||||
{sidebarOpen && (
|
||||
@@ -445,7 +470,7 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
<X size={22} />
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarContent />
|
||||
{renderSidebar({ navRef: navMobileRef })}
|
||||
</aside>
|
||||
|
||||
<div className={`flex-1 min-h-screen flex flex-col w-full transition-all duration-200 ${collapsed ? 'lg:ml-16' : 'lg:ml-64'}`}>
|
||||
|
||||
@@ -94,6 +94,7 @@ const UsuariosPanel: FC = () => {
|
||||
setSubmitting(true);
|
||||
if (editing) {
|
||||
await odooApi.updateFrontendUser(editing.id, {
|
||||
login: form.login.trim().toLowerCase(),
|
||||
name: form.name.trim(),
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
@@ -281,9 +282,14 @@ const UsuariosPanel: FC = () => {
|
||||
label="Usuario (login)"
|
||||
value={form.login}
|
||||
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
||||
disabled={!!editing}
|
||||
placeholder="ej. recepcion1"
|
||||
/>
|
||||
{editing && (
|
||||
<p className="text-xs text-theme-muted">
|
||||
El usuario es con el que se inicia sesión (sólo minúsculas, números, punto, guion y guion bajo).
|
||||
Cambiarlo queda registrado en Auditoría.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Nombre"
|
||||
value={form.name}
|
||||
|
||||
@@ -15,7 +15,8 @@ const ROLE_RANK: Record<FrontendRole, number> = {
|
||||
psicologa: 50,
|
||||
farmacia: 50,
|
||||
gerencia: 50,
|
||||
marketing: 10,
|
||||
// marketing pasó a 50: puede agendar citas y escribir en el WACRM
|
||||
marketing: 50,
|
||||
lectura: 10,
|
||||
};
|
||||
|
||||
@@ -24,7 +25,7 @@ const ROLE_RANK: Record<FrontendRole, number> = {
|
||||
// /configuracion (solo admin del sistema), /mis-ventas, /cumpleanos y /wacrm/*.
|
||||
const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/' : ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura'],
|
||||
'/agenda': ['admin', 'recepcion', 'medico', 'gerencia'],
|
||||
'/agenda': ['admin', 'recepcion', 'medico', 'gerencia', 'cosmetologia', 'marketing'],
|
||||
'/mis-ventas': ['medico'],
|
||||
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
'/pos': ['admin', 'recepcion', 'gerencia'],
|
||||
@@ -47,10 +48,13 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/configuracion': ['admin'],
|
||||
'/cumpleanos': ['admin', 'recepcion'],
|
||||
'/wacrm/messages': ['admin', 'recepcion'],
|
||||
'/wacrm/leads': ['admin', 'recepcion'],
|
||||
'/configuracion': ['admin', 'gerencia'],
|
||||
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
// El chat se lista explícitamente para dejar fuera a marketing (antes, al no
|
||||
// estar listado, lo veía cualquier rol).
|
||||
'/chat': ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'lectura'],
|
||||
};
|
||||
|
||||
interface AuthContextValue {
|
||||
|
||||
@@ -105,9 +105,14 @@ const Agenda: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { openPatient } = usePatientDrawer();
|
||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor)
|
||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
|
||||
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
|
||||
// restringida del médico), pero arranca en su propia columna si tiene
|
||||
// empleado vinculado.
|
||||
const isMedico = user?.role === 'medico';
|
||||
const isCosmetologa = user?.role === 'cosmetologia';
|
||||
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
|
||||
const propiaColumnaId = isCosmetologa && user?.employee_id ? user.employee_id : null;
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
@@ -120,6 +125,7 @@ const Agenda: FC = () => {
|
||||
const [stateFilter, setStateFilter] = useState('');
|
||||
const [doctorFilter, setDoctorFilter] = useState(() => {
|
||||
if (medicoEmployeeId) return String(medicoEmployeeId);
|
||||
if (propiaColumnaId) return String(propiaColumnaId);
|
||||
const saved = readAgendaPrefs().doctorFilter;
|
||||
return typeof saved === 'string' ? saved : '';
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft, Percent, Check, Calculator } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
@@ -11,6 +11,7 @@ import AuditoriaPanel from '../components/AuditoriaPanel';
|
||||
import ImpactoModal from '../components/ImpactoModal';
|
||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||
import { odooApi, type Doctor } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'clinica', label: 'Clínica' },
|
||||
@@ -45,8 +46,17 @@ const defaultSettings: ClinicSettings = {
|
||||
const STORAGE_KEY = 'skeen_clinic_settings';
|
||||
|
||||
const Configuracion: FC = () => {
|
||||
const { user } = useAuth();
|
||||
// Gerencia gestiona usuarios y roles; la sección "Clínica" (tipo de cambio y
|
||||
// comisiones) queda para administración del sistema.
|
||||
const esAdmin = user?.role === 'admin';
|
||||
const tabsVisibles = useMemo(
|
||||
() => (esAdmin ? TABS : TABS.filter((t) => t.key === 'usuarios' || t.key === 'roles')),
|
||||
[esAdmin],
|
||||
);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const tab = searchParams.get('tab') || 'clinica';
|
||||
const tabPedida = searchParams.get('tab') || 'clinica';
|
||||
const tab = tabsVisibles.some((t) => t.key === tabPedida) ? tabPedida : tabsVisibles[0].key;
|
||||
const setTab = (t: string) => setSearchParams({ tab: t });
|
||||
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
|
||||
const sub = searchParams.get('sub');
|
||||
@@ -85,6 +95,7 @@ const Configuracion: FC = () => {
|
||||
const [tcSaving, setTcSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!esAdmin) return;
|
||||
odooApi.getExchangeRate()
|
||||
.then((res) => {
|
||||
if (res.current) {
|
||||
@@ -123,6 +134,7 @@ const Configuracion: FC = () => {
|
||||
const [impactoOpen, setImpactoOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!esAdmin) return;
|
||||
odooApi.getCommissionDefault()
|
||||
.then((res) => setCommissionDefault(String(res.commission_pct ?? 0)))
|
||||
.catch(() => {/* odoo no disponible, ignorar */});
|
||||
@@ -184,7 +196,7 @@ const Configuracion: FC = () => {
|
||||
|
||||
{/* Tabs de secciones */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-6">
|
||||
{TABS.map((t) => (
|
||||
{tabsVisibles.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
|
||||
@@ -133,6 +133,7 @@ const Pacientes: FC = () => {
|
||||
const [entryDetail, setEntryDetail] = useState<
|
||||
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null
|
||||
>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
|
||||
const [onlyVip, setOnlyVip] = useState(false);
|
||||
const [onlyRecent, setOnlyRecent] = useState(false);
|
||||
@@ -415,13 +416,30 @@ const Pacientes: FC = () => {
|
||||
].sort((a, b) => b.date.localeCompare(a.date))
|
||||
: [];
|
||||
|
||||
// Exportación COMPLETA: pide al servidor todos los pacientes del filtro
|
||||
// (no sólo la página visible). Hoy son ~7.2k filas.
|
||||
const exportPatients = async () => {
|
||||
try {
|
||||
setExporting(true);
|
||||
const res = await odooApi.exportPatients({
|
||||
search: search || undefined,
|
||||
incomplete: onlyIncomplete ? 1 : undefined,
|
||||
vip: onlyVip ? 1 : undefined,
|
||||
recent: onlyRecent ? 1 : (onlyStale ? 0 : undefined),
|
||||
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
|
||||
});
|
||||
if (res.status !== 'success') {
|
||||
toast.error('Error al exportar');
|
||||
return;
|
||||
}
|
||||
const todos = res.patients;
|
||||
await exportToExcel({
|
||||
filename: `pacientes-skeen-${new Date().toISOString().split('T')[0]}`,
|
||||
sheetName: 'Pacientes',
|
||||
title: 'Pacientes SKEEN',
|
||||
subtitle: `${patients.length} pacientes de la vista actual`,
|
||||
subtitle: res.truncado
|
||||
? `${res.total} de ${res.total_disponible} pacientes (límite ${res.limite})`
|
||||
: `${res.total} pacientes (exportación completa)`,
|
||||
columns: [
|
||||
{ header: 'Expediente', key: 'patient_id' },
|
||||
{ header: 'Nombre', key: 'name' },
|
||||
@@ -436,24 +454,26 @@ const Pacientes: FC = () => {
|
||||
{ header: 'Fuente', key: 'source' },
|
||||
{ header: 'VIP', key: 'vip' },
|
||||
],
|
||||
rows: patients.map((p) => ({
|
||||
rows: todos.map((p) => ({
|
||||
patient_id: p.patient_id, name: p.name, phone: p.phone, email: p.email,
|
||||
birth_date: p.birth_date, gender: p.gender || '', last_visit: p.last_visit,
|
||||
total_visits: p.total_visits, wallet_points: p.wallet_points,
|
||||
total_spent: p.total_spent, source: p.source, vip: p.is_vip ? 'Sí' : 'No',
|
||||
})),
|
||||
});
|
||||
toast.success(`Excel descargado (${patients.length} pacientes de la vista actual)`);
|
||||
toast.success(`Excel descargado (${res.total} pacientes)`);
|
||||
} catch (err) {
|
||||
toast.error('Error al exportar');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Pacientes" subtitle="Directorio de pacientes">
|
||||
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
|
||||
<Button variant="outline" onClick={exportPatients} disabled={patients.length === 0}>
|
||||
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
|
||||
<Download size={16} className="mr-2" />
|
||||
Exportar CSV
|
||||
</Button>
|
||||
|
||||
@@ -1120,7 +1120,7 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateFrontendUser(id: number, payload: Partial<{ name: string; role: FrontendRole; active: boolean; password: string; must_change_password: boolean; allowed_menus: string[] | null }>): Promise<{ status: string; user: FrontendUser; message?: string }> {
|
||||
async updateFrontendUser(id: number, payload: { name?: string; login?: string; role?: FrontendRole; active?: boolean; password?: string; must_change_password?: boolean; allowed_menus?: string[] | null }): Promise<{ status: string; user: FrontendUser; message?: string }> {
|
||||
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`, payload);
|
||||
return data;
|
||||
},
|
||||
@@ -1335,6 +1335,12 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Exportación completa: TODOS los pacientes del filtro, sin paginar. */
|
||||
async exportPatients(params?: { search?: string; incomplete?: number; vip?: number; recent?: number; doctor_id?: number }): Promise<{ status: string; patients: Patient[]; total: number; total_disponible: number; truncado: boolean; limite: number }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/export`, { params: params || {} });
|
||||
return data;
|
||||
},
|
||||
|
||||
async getPatient(id: number): Promise<{ status: string; patient: Patient }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}`);
|
||||
return data;
|
||||
|
||||
@@ -114,6 +114,27 @@ def current_user(env):
|
||||
return user
|
||||
|
||||
|
||||
def forbid_roles(*roles):
|
||||
"""Decorator: niega el acceso a una lista explícita de roles.
|
||||
|
||||
Se usa para cerrar operaciones de dinero a roles con rango de escritura
|
||||
que no deben tocarlas (p. ej. marketing, que comparte rango con recepción
|
||||
para poder agendar, pero no debe cobrar).
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
user = current_user(request.env)
|
||||
if user and user.role in roles:
|
||||
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
return func(self, *args, **kw)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_role(min_role='lectura'):
|
||||
"""Decorator: exige token válido y un rol con rango >= min_role.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import base64
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from .auth import sign_token, current_user, require_role, verify_token
|
||||
from .auth import sign_token, current_user, require_role, forbid_roles, verify_token
|
||||
from ..roles import ROLE_SELECTION, ROLE_KEYS, MENU_ROLES_KEY, get_menu_roles
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ class SkeenFrontendController(http.Controller):
|
||||
AGENDA_START = 8.0 # 8:00 AM
|
||||
AGENDA_END = 19.0 # 7:00 PM
|
||||
|
||||
# Tope de seguridad de la exportación completa de pacientes (hoy ~7.2k)
|
||||
EXPORT_MAX = 100000
|
||||
|
||||
def _valid_agenda_time(self, value):
|
||||
"""Valida hora de inicio dentro del horario de agenda (8:00-19:00)."""
|
||||
try:
|
||||
@@ -56,6 +59,28 @@ class SkeenFrontendController(http.Controller):
|
||||
"""Usuario del frontend que hizo la petición (o None)."""
|
||||
return getattr(request, 'frontend_user', None)
|
||||
|
||||
def _puede_gestionar_usuarios(self):
|
||||
"""Admin del sistema o gerencia: gestión de usuarios y roles.
|
||||
|
||||
El rango de roles no distingue gerencia de recepción (ambos 50), así que
|
||||
el permiso se comprueba por rol explícito, como en el resto del API.
|
||||
"""
|
||||
u = self._fu_user()
|
||||
return bool(u and u.role in ('admin', 'gerencia'))
|
||||
|
||||
def _puede_gestionar_roles(self):
|
||||
"""Editar la matriz de permisos: admin o gerencia."""
|
||||
u = self._fu_user()
|
||||
return bool(u and u.role in ('admin', 'gerencia'))
|
||||
|
||||
def _puede_asignar_admin(self):
|
||||
"""Repartir el rol Administrador queda reservado al propio admin.
|
||||
|
||||
Gerencia gestiona usuarios y la matriz, pero no puede crear más admins.
|
||||
"""
|
||||
u = self._fu_user()
|
||||
return bool(u and u.role == 'admin')
|
||||
|
||||
def _auditar(self, module, entity, field, old_value=None, new_value=None,
|
||||
entity_label='', notes=''):
|
||||
"""Registra un cambio de configuración en skeen.config.audit.
|
||||
@@ -108,9 +133,13 @@ class SkeenFrontendController(http.Controller):
|
||||
return request.env.ref('base.user_admin', raise_if_not_found=False) or User.browse()
|
||||
|
||||
def _frontend_user_dict(self, user):
|
||||
"""to_public_dict + datos del empleado vinculado (rol medico)."""
|
||||
"""to_public_dict + datos del empleado vinculado.
|
||||
|
||||
Aplica a los roles clínicos (médico y cosmetología): la agenda usa
|
||||
employee_id para preseleccionar la columna propia.
|
||||
"""
|
||||
d = user.to_public_dict()
|
||||
if user.role == 'medico':
|
||||
if user.role in ('medico', 'cosmetologia'):
|
||||
emp = self._linked_employee(user)
|
||||
d['employee_id'] = emp.id if emp else None
|
||||
d['employee_name'] = emp.name if emp else None
|
||||
@@ -163,18 +192,22 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'success'})
|
||||
|
||||
@http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('admin')
|
||||
@require_role('recepcion')
|
||||
def auth_users_list(self, **kw):
|
||||
try:
|
||||
if not self._puede_gestionar_usuarios():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
users = request.env['skeen.frontend.user'].sudo().search([], order='name, login')
|
||||
return json_response({'status': 'success', 'users': [u.to_public_dict() | {'active': u.active} for u in users]})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/auth/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('admin')
|
||||
@require_role('recepcion')
|
||||
def auth_users_create(self, **kw):
|
||||
try:
|
||||
if not self._puede_gestionar_usuarios():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
data = _parse_json_body()
|
||||
login = (data.get('login') or '').strip().lower()
|
||||
name = (data.get('name') or '').strip()
|
||||
@@ -186,6 +219,8 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': 'La contraseña debe tener al menos 8 caracteres'}, 400)
|
||||
if role not in ROLE_KEYS:
|
||||
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
|
||||
if role == 'admin' and not self._puede_asignar_admin():
|
||||
return json_response({'status': 'error', 'message': 'Solo un administrador puede crear administradores'}, 403)
|
||||
User = request.env['skeen.frontend.user'].sudo()
|
||||
if User.search([('login', '=', login)], limit=1):
|
||||
return json_response({'status': 'error', 'message': 'El usuario ya existe'}, 409)
|
||||
@@ -198,19 +233,44 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('admin')
|
||||
@require_role('recepcion')
|
||||
def auth_users_update(self, user_id, **kw):
|
||||
try:
|
||||
if not self._puede_gestionar_usuarios():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
user = request.env['skeen.frontend.user'].sudo().browse(user_id)
|
||||
if not user.exists():
|
||||
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
vals = {}
|
||||
if 'name' in data:
|
||||
vals['name'] = data['name']
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return json_response({'status': 'error', 'message': 'El nombre no puede estar vacío'}, 400)
|
||||
vals['name'] = name
|
||||
if 'login' in data:
|
||||
login = (data.get('login') or '').strip().lower()
|
||||
if not login:
|
||||
return json_response({'status': 'error', 'message': 'El usuario no puede estar vacío'}, 400)
|
||||
if not re.match(r'^[a-z0-9._-]{3,}$', login):
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': 'El usuario debe tener al menos 3 caracteres (letras, números, punto, guion o guion bajo)',
|
||||
}, 400)
|
||||
duplicado = request.env['skeen.frontend.user'].sudo().search(
|
||||
[('login', '=', login), ('id', '!=', user_id)], limit=1)
|
||||
if duplicado:
|
||||
return json_response({'status': 'error', 'message': 'Ese usuario ya existe'}, 409)
|
||||
vals['login'] = login
|
||||
if 'role' in data:
|
||||
if data['role'] not in ROLE_KEYS:
|
||||
return json_response({'status': 'error', 'message': 'Rol inválido'}, 400)
|
||||
if not self._puede_gestionar_roles():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso para cambiar el rol'}, 403)
|
||||
if data['role'] == 'admin' and not self._puede_asignar_admin():
|
||||
return json_response({'status': 'error', 'message': 'Solo un administrador puede asignar el rol Administrador'}, 403)
|
||||
if user.role == 'admin' and data['role'] != 'admin' and not self._puede_asignar_admin():
|
||||
return json_response({'status': 'error', 'message': 'Solo un administrador puede quitar el rol Administrador'}, 403)
|
||||
vals['role'] = data['role']
|
||||
if 'active' in data:
|
||||
vals['active'] = bool(data['active'])
|
||||
@@ -241,9 +301,11 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/auth/users/<int:user_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
|
||||
@require_role('admin')
|
||||
@require_role('recepcion')
|
||||
def auth_users_delete(self, user_id, **kw):
|
||||
try:
|
||||
if not self._puede_gestionar_usuarios():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
user = request.env['skeen.frontend.user'].sudo().browse(user_id)
|
||||
if not user.exists():
|
||||
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
|
||||
@@ -273,9 +335,11 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/roles/permissions', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('admin')
|
||||
@require_role('recepcion')
|
||||
def roles_permissions_put(self, **kw):
|
||||
try:
|
||||
if not self._puede_gestionar_roles():
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
data = _parse_json_body()
|
||||
matrix = data.get('matrix')
|
||||
if not isinstance(matrix, dict):
|
||||
@@ -1313,6 +1377,48 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/export', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def export_patients(self, **kw):
|
||||
"""Todos los pacientes activos que cumplen los filtros, SIN paginar.
|
||||
|
||||
Pensado para exportaciones (marketing y quien tenga permiso de lectura):
|
||||
devuelve ~7k filas de una sola vez (~3 MB de JSON), que es manejable.
|
||||
`limit` sólo existe como salvaguarda ante un catálogo runaway; por
|
||||
defecto no hay tope práctico (EXPORT_MAX).
|
||||
"""
|
||||
try:
|
||||
domain = [('is_patient', '=', True)]
|
||||
if kw.get('search'):
|
||||
search = kw['search']
|
||||
domain += ['|', '|', ('name', 'ilike', search), ('phone', 'ilike', search), ('email', 'ilike', search)]
|
||||
if kw.get('incomplete') == '1':
|
||||
domain += [('expediente_completion', '<', 100)]
|
||||
if kw.get('vip') == '1':
|
||||
domain.append(('is_vip', '=', True))
|
||||
if kw.get('doctor_id'):
|
||||
domain.append(('primary_doctor_id', '=', int(kw['doctor_id'])))
|
||||
if kw.get('recent') == '1':
|
||||
seis = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
|
||||
domain.append(('last_visit', '>=', seis))
|
||||
elif kw.get('recent') == '0':
|
||||
seis = (date.today() - timedelta(days=183)).strftime('%Y-%m-%d')
|
||||
domain += ['|', ('last_visit', '=', False), ('last_visit', '<', seis)]
|
||||
Pacientes = request.env['res.partner'].sudo()
|
||||
total = Pacientes.search_count(domain)
|
||||
tope = int(kw.get('limit') or self.EXPORT_MAX)
|
||||
patients = Pacientes.search(domain, limit=tope, order='name asc')
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'patients': [self._patient_to_dict(p) for p in patients],
|
||||
'total': len(patients),
|
||||
'total_disponible': total,
|
||||
'truncado': len(patients) < total,
|
||||
'limite': tope,
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def create_patient(self, **kw):
|
||||
@@ -1409,7 +1515,35 @@ class SkeenFrontendController(http.Controller):
|
||||
vals = {k: data[k] for k in allowed if k in data}
|
||||
if data.get('photo'):
|
||||
vals['image_1920'] = data['photo']
|
||||
# Campos sensibles que este endpoint nunca toca (el dinero no se
|
||||
# edita por aquí: monedero y saldos se gestionan en sus módulos).
|
||||
if vals:
|
||||
previos = {}
|
||||
for k in vals:
|
||||
v = getattr(patient, k, None)
|
||||
previos[k] = (v.name if hasattr(v, 'name') else v)
|
||||
patient.write(vals)
|
||||
cambios = []
|
||||
for k, nuevo in vals.items():
|
||||
anterior = previos.get(k)
|
||||
nuevo_cmp = getattr(patient, k, None)
|
||||
nuevo_cmp = (nuevo_cmp.name if hasattr(nuevo_cmp, 'name') else nuevo_cmp)
|
||||
if str(anterior or '') != str(nuevo_cmp or ''):
|
||||
cambios.append(k)
|
||||
self._auditar('pacientes', f'paciente:{patient.id}', k, anterior, nuevo_cmp,
|
||||
entity_label=patient.name,
|
||||
notes='Edición de expediente')
|
||||
if cambios:
|
||||
try:
|
||||
autor = self._frontend_res_user()
|
||||
patient.sudo().message_post(
|
||||
body='Expediente editado desde el frontend: ' + ', '.join(cambios),
|
||||
author_id=autor.id if autor else None,
|
||||
message_type='notification',
|
||||
)
|
||||
except Exception:
|
||||
# El rastro principal es la auditoría; el chatter es extra
|
||||
pass
|
||||
return json_response({'status': 'success', 'patient': self._patient_to_dict(patient)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
@@ -1586,6 +1720,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def pay_patient_adeudo(self, patient_id, **kw):
|
||||
"""Registra un pago del adeudo (parcial o total) y devuelve el recibo.
|
||||
|
||||
@@ -3169,6 +3304,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/payments', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def create_payment(self, **kw):
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
@@ -3185,6 +3321,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/payments/<int:payment_id>/confirm', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def confirm_payment(self, payment_id, **kw):
|
||||
try:
|
||||
payment = request.env['skeen.pago'].sudo().browse(payment_id)
|
||||
@@ -3247,6 +3384,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/sales', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def create_sale(self, **kw):
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
@@ -3287,6 +3425,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/pay', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def pay_sale(self, sale_id, **kw):
|
||||
try:
|
||||
sale = request.env['skeen.venta'].sudo().browse(sale_id)
|
||||
@@ -3319,6 +3458,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/refund', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def refund_sale(self, sale_id, **kw):
|
||||
try:
|
||||
sale = request.env['skeen.venta'].sudo().browse(sale_id)
|
||||
@@ -3334,6 +3474,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/cancel', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def cancel_sale(self, sale_id, **kw):
|
||||
"""Cancelación de venta con nota de crédito (motivo obligatorio).
|
||||
|
||||
@@ -3480,6 +3621,7 @@ class SkeenFrontendController(http.Controller):
|
||||
# ============================================================
|
||||
@http.route('/skeen/frontend/v1/pos/checkout', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def pos_checkout(self, **kw):
|
||||
"""Crea la venta confirmada, aplica canje de puntos y/o registra el pago.
|
||||
|
||||
@@ -3672,6 +3814,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/cash_closings', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def create_cash_closing(self, **kw):
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
@@ -3689,6 +3832,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/cash_closings/<int:closing_id>/close', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def close_cash_closing(self, closing_id, **kw):
|
||||
try:
|
||||
closing = request.env['skeen.corte.caja'].sudo().browse(closing_id)
|
||||
@@ -5094,6 +5238,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def inventory_create(self, **kw):
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
@@ -5119,6 +5264,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>', type='http', auth='none', methods=['PUT'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def inventory_update(self, item_id, **kw):
|
||||
try:
|
||||
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
|
||||
@@ -5150,6 +5296,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventory/items/<int:item_id>/moves', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def inventory_move_create(self, item_id, **kw):
|
||||
try:
|
||||
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
|
||||
@@ -5212,6 +5359,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventario/compras', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def inventario_compra(self, **kw):
|
||||
"""Registrar compra de consumible (sube stock; actualiza costo si viene)"""
|
||||
try:
|
||||
@@ -5300,6 +5448,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventario/bajas', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def inventario_baja(self, **kw):
|
||||
"""Registrar baja/merma de consumible (baja stock)"""
|
||||
try:
|
||||
@@ -5376,6 +5525,7 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def create_conteo(self, **kw):
|
||||
"""Registra un conteo físico y aplica los ajustes por diferencia.
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class SkeenConfigAudit(models.Model):
|
||||
('horarios', 'Horarios'),
|
||||
('roles', 'Roles y permisos'),
|
||||
('usuarios', 'Usuarios'),
|
||||
('pacientes', 'Expedientes de pacientes'),
|
||||
('catalogos', 'Catálogos'),
|
||||
('clinica', 'Clínica'),
|
||||
], string='Módulo', required=True, index=True)
|
||||
|
||||
@@ -36,7 +36,9 @@ ROLE_RANKS = {
|
||||
'psicologa': 50,
|
||||
'farmacia': 50,
|
||||
'gerencia': 50,
|
||||
'marketing': 10,
|
||||
# marketing pasó a 50: puede agendar citas y escribir en el WACRM, igual
|
||||
# que recepción (los permisos de ESCRITURA dependen del rango).
|
||||
'marketing': 50,
|
||||
'lectura': 10,
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ MENU_ROLES_KEY = 'skeen.menu_roles'
|
||||
# /cumpleanos y /wacrm/*.
|
||||
DEFAULT_MENU_ROLES = {
|
||||
'/' : ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura'],
|
||||
'/agenda': ['admin', 'recepcion', 'medico', 'gerencia'],
|
||||
'/agenda': ['admin', 'recepcion', 'medico', 'gerencia', 'cosmetologia', 'marketing'],
|
||||
'/mis-ventas': ['medico'],
|
||||
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
'/pos': ['admin', 'recepcion', 'gerencia'],
|
||||
@@ -71,24 +73,43 @@ DEFAULT_MENU_ROLES = {
|
||||
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/configuracion': ['admin'],
|
||||
'/cumpleanos': ['admin', 'recepcion'],
|
||||
'/wacrm/messages': ['admin', 'recepcion'],
|
||||
'/wacrm/leads': ['admin', 'recepcion'],
|
||||
'/configuracion': ['admin', 'gerencia'],
|
||||
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
'/wacrm/messages': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
'/wacrm/leads': ['admin', 'recepcion', 'gerencia', 'marketing'],
|
||||
# El chat interno se lista explícitamente para dejar fuera a marketing
|
||||
# (antes, al no estar listado, lo veía todo el mundo).
|
||||
'/chat': ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'lectura'],
|
||||
}
|
||||
|
||||
# Marketing NO hereda los menús de 'lectura': sólo estos (mínimo privilegio).
|
||||
MARKETING_MENUS = (
|
||||
'/', '/agenda', '/pacientes', '/cumpleanos', '/visitas',
|
||||
'/wacrm/messages', '/wacrm/leads',
|
||||
)
|
||||
|
||||
# Roles nuevos que por default ven lo mismo que 'lectura'.
|
||||
READ_ONLY_NEW_ROLES = ('enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'marketing')
|
||||
|
||||
|
||||
def default_menu_roles():
|
||||
"""Matriz por default: MENU_ROLES + roles nuevos donde esté 'lectura'."""
|
||||
"""Matriz por default: MENU_ROLES + roles nuevos donde esté 'lectura'.
|
||||
|
||||
Marketing es la excepción: no hereda lo de 'lectura', sólo entra en sus
|
||||
menús explícitos (agenda, pacientes, cumpleaños, visitas y WACRM).
|
||||
"""
|
||||
matrix = {}
|
||||
for href, roles in DEFAULT_MENU_ROLES.items():
|
||||
r = list(roles)
|
||||
if 'lectura' in roles:
|
||||
r.extend(nr for nr in READ_ONLY_NEW_ROLES if nr not in r)
|
||||
matrix[href] = r
|
||||
for href in matrix:
|
||||
matrix[href] = [r for r in matrix[href] if r != 'marketing']
|
||||
for href in MARKETING_MENUS:
|
||||
matrix.setdefault(href, [])
|
||||
if 'marketing' not in matrix[href]:
|
||||
matrix[href].append('marketing')
|
||||
return matrix
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user