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:
2026-08-13 23:30:08 +00:00
parent a718592291
commit 01f6007e30
109 changed files with 19067 additions and 931 deletions

View File

@@ -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);