Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- Módulo Visitas completo (auto desde agenda, insumos con descargo de inventario, fotos antes/después y documentos, receta imprimible) - Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con puntos monedero, ticket imprimible) - WACRM: leads automáticos desde WhatsApp, asignación de conversaciones y leads a agentes, conversión lead→paciente, ficha del paciente en chat - Pacientes: completitud de expediente, alertas clínicas, historial unificado con detalle, foto, WhatsApp, estado de cuenta, filtros rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería) - Agenda: vistas por médico y por hora, filtros rápidos (libres, primera vez, check-in, no-show), modal de acciones, bloqueos por médico, drag&drop para mover citas - Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos, comisiones, pagos, devoluciones, top clientes, horas, paquetes, vendedores, concentrado, recomendaciones, KPIs) con exportación Excel - Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo treeview, selector de tema; accesos rápidos personalizables con 3 presentaciones; búsqueda global; notificaciones reales - Configuración: secciones (clínica, usuarios con permisos por sección, recetas, catálogos de diagnósticos y procedimientos) - Inventario: alertas de caducidad y sugerencia de compra, cron diario que descuenta artículos caducados, compras/bajas - Consultas Médicas, página Expedientes, importadores delta (citas/visitas legacy idempotentes), depuración de duplicados - Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias Odoo corregidas (noupdate, company_id), rollback en validaciones
This commit is contained in:
@@ -7,15 +7,13 @@ import {
|
||||
Users,
|
||||
Stethoscope,
|
||||
Briefcase,
|
||||
Package,
|
||||
Wallet,
|
||||
CreditCard,
|
||||
ShoppingCart,
|
||||
Receipt,
|
||||
Store,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Bell,
|
||||
Search,
|
||||
Menu,
|
||||
X,
|
||||
ChevronDown,
|
||||
@@ -24,13 +22,23 @@ import {
|
||||
Target,
|
||||
Cake,
|
||||
Boxes,
|
||||
ShieldCheck,
|
||||
ClipboardList,
|
||||
ClipboardPlus,
|
||||
Banknote,
|
||||
Percent,
|
||||
FolderClock,
|
||||
Sparkles,
|
||||
Palette,
|
||||
Star,
|
||||
Pencil,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
import ThemePicker from './ThemePicker';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useQuickLinks } from '../lib/quicklinks';
|
||||
import { odooApi } from '../services/odoo';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -53,16 +61,20 @@ const menuSections = [
|
||||
label: 'Clínica',
|
||||
items: [
|
||||
{ label: 'Pacientes', icon: Users, href: '/pacientes' },
|
||||
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
||||
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
|
||||
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||
{ label: 'Productos', icon: Package, href: '/productos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Operaciones',
|
||||
items: [
|
||||
{ label: 'Punto de Venta', icon: Store, href: '/pos' },
|
||||
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
|
||||
{ label: 'Pagos', icon: CreditCard, href: '/pagos' },
|
||||
{ label: 'Adeudos', icon: Banknote, href: '/reportes?tab=adeudos' },
|
||||
{ label: 'Monedero', icon: Wallet, href: '/monedero' },
|
||||
{ label: 'Inventario', icon: Boxes, href: '/inventario' },
|
||||
{ label: 'Cortes de Caja', icon: Receipt, href: '/cortes' },
|
||||
@@ -72,9 +84,9 @@ const menuSections = [
|
||||
label: 'Analítica y más',
|
||||
items: [
|
||||
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
|
||||
{ label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' },
|
||||
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
|
||||
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
|
||||
{ label: 'Usuarios', icon: ShieldCheck, href: '/usuarios' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -90,7 +102,6 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, canSee, hasRole, logout } = useAuth();
|
||||
const { toggleTheme } = useTheme();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||
|
||||
@@ -102,6 +113,32 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
[canSee, user]
|
||||
);
|
||||
|
||||
// Accesos rápidos personalizables (localStorage por usuario)
|
||||
const quick = useQuickLinks();
|
||||
const dockItems = useMemo(() => {
|
||||
const all = [...topNavItems, ...menuSections.flatMap((s) => s.items)];
|
||||
const seen = new Set<string>();
|
||||
return all.filter((it) => {
|
||||
if (seen.has(it.href) || !canSee(it.href)) return false;
|
||||
seen.add(it.href);
|
||||
return true;
|
||||
});
|
||||
}, [canSee]);
|
||||
const quickItems = useMemo(
|
||||
() =>
|
||||
quick.links
|
||||
.filter((href) => canSee(href))
|
||||
.map((href) => {
|
||||
for (const s of menuSections) {
|
||||
const found = s.items.find((it) => it.href === href);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((it): it is (typeof menuSections)[number]['items'][number] => it !== null),
|
||||
[quick.links, canSee]
|
||||
);
|
||||
|
||||
const initials = (user?.name || 'US')
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
@@ -259,33 +296,9 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="hidden md:flex items-center rounded-full px-3 py-1.5 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<Search size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-40 placeholder:text-theme-muted/60"
|
||||
style={{ color: 'var(--text-heading)' }}
|
||||
/>
|
||||
</div>
|
||||
<button className="p-2 rounded-full relative transition" style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--accent)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
title="Cambiar a SKEEN"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition border"
|
||||
style={{ backgroundColor: 'var(--bg)', color: 'var(--text-heading)', borderColor: 'var(--border)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--bg)'}
|
||||
>
|
||||
<Palette size={14} />
|
||||
SKEEN
|
||||
</button>
|
||||
<GlobalSearch className="hidden md:block" />
|
||||
<NotificationBell buttonClassName="p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading" />
|
||||
<ThemePicker />
|
||||
<div className="hidden sm:flex items-center gap-2 pl-2 border-l" style={{ borderColor: 'var(--border)' }}>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold" style={{ backgroundColor: 'var(--success)', color: 'var(--success-text)' }}>
|
||||
{initials || 'US'}
|
||||
@@ -335,6 +348,76 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2">
|
||||
{quickItems.length > 0 && quick.mode === 'menu' && (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<div className="w-full flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm font-medium flex items-center gap-2" style={{ color: 'var(--text-heading)' }}>
|
||||
<Star size={14} style={{ color: 'var(--accent)' }} />
|
||||
Accesos rápidos
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<QuickLinksModePicker direction="down" align="right" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => quick.setEditing(!quick.editing)}
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'}
|
||||
>
|
||||
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="px-2 pb-2 space-y-1">
|
||||
{quickItems.map((item, idx) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<li key={item.href} className="flex items-center gap-1">
|
||||
<Link
|
||||
to={item.href}
|
||||
className="flex-1 flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--text-inverse)' : 'var(--text-muted)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--accent-bg)';
|
||||
e.currentTarget.style.color = 'var(--text-heading)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--text-muted)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.label}
|
||||
</Link>
|
||||
{quick.editing && (
|
||||
<span className="flex items-center gap-0.5 shrink-0">
|
||||
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
|
||||
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title="Subir">
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
|
||||
className="disabled:opacity-30" style={{ color: 'var(--text-muted)' }} title="Bajar">
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => quick.remove(item.href)}
|
||||
style={{ color: 'var(--text-muted)' }} title="Quitar">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{visibleSections.map((section) => (
|
||||
<div key={section.label} className="rounded-xl border overflow-hidden" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<button
|
||||
@@ -350,11 +433,12 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
const pinned = quick.isPinned(item.href);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<li key={item.href} className="group flex items-center gap-1">
|
||||
<Link
|
||||
to={item.href}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
|
||||
className="flex-1 flex items-center gap-3 px-3 py-2 rounded-xl text-sm transition"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--text-inverse)' : 'var(--text-muted)',
|
||||
@@ -375,6 +459,15 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
<Icon size={16} />
|
||||
{item.label}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => quick.toggle(item.href)}
|
||||
className={`shrink-0 transition-all ${pinned ? '' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
style={{ color: pinned ? 'var(--accent)' : 'var(--text-muted)' }}
|
||||
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
|
||||
>
|
||||
<Star size={14} fill={pinned ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -385,11 +478,13 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<main className={`flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6 sm:py-8 ${quick.mode === 'right' ? 'lg:pr-20' : ''} ${quick.mode === 'bottom' ? 'pb-20' : ''}`}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<QuickLinksDock items={dockItems} />
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{menuOpen && (
|
||||
<>
|
||||
@@ -402,6 +497,32 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{quickItems.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pt-1 text-[10px] uppercase tracking-wider font-medium flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
|
||||
<Star size={11} style={{ color: 'var(--accent)' }} /> Accesos rápidos
|
||||
</p>
|
||||
{quickItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={`quick-${item.href}`}
|
||||
to={item.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition"
|
||||
style={{
|
||||
backgroundColor: active ? 'var(--accent)' : 'var(--bg)',
|
||||
color: active ? 'var(--text-inverse)' : 'var(--text-heading)',
|
||||
}}
|
||||
>
|
||||
<Icon size={18} /> {item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="border-b my-2" style={{ borderColor: 'var(--border)' }} />
|
||||
</>
|
||||
)}
|
||||
{topNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
|
||||
Reference in New Issue
Block a user