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,
ChevronRight,
@@ -24,12 +22,23 @@ import {
Target,
Cake,
Boxes,
ShieldCheck,
Palette,
ClipboardList,
ClipboardPlus,
Banknote,
Percent,
FolderClock,
Star,
Pencil,
ChevronUp,
ChevronDown,
} 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, ROLE_LABELS } from '../lib/auth';
import { useTheme } from '../lib/theme';
import { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
interface LayoutProps {
@@ -52,16 +61,20 @@ const menuGroups = [
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' },
@@ -71,6 +84,7 @@ const menuGroups = [
label: 'Analítica',
items: [
{ label: 'Reportes', icon: BarChart3, href: '/reportes' },
{ label: 'Comisiones', icon: Percent, href: '/reportes?tab=comisiones' },
{ label: 'Configuración', icon: Settings, href: '/configuracion' },
],
},
@@ -80,12 +94,6 @@ const menuGroups = [
{ label: 'Cumpleañeros', icon: Cake, href: '/cumpleanos' },
],
},
{
label: 'Administración',
items: [
{ label: 'Usuarios', icon: ShieldCheck, href: '/usuarios' },
],
},
{
label: 'WACRM',
items: [
@@ -99,7 +107,6 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout } = useAuth();
const { toggleTheme } = useTheme();
const [sidebarOpen, setSidebarOpen] = useState(false);
const visibleGroups = useMemo(
@@ -110,6 +117,27 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
[canSee, user]
);
// Accesos rápidos personalizables (localStorage por usuario)
const quick = useQuickLinks();
const dockItems = useMemo(
() => menuGroups.flatMap((g) => g.items).filter((it) => canSee(it.href)),
[canSee]
);
const quickItems = useMemo(
() =>
quick.links
.filter((href) => canSee(href))
.map((href) => {
for (const g of menuGroups) {
const found = g.items.find((it) => it.href === href);
if (found) return found;
}
return null;
})
.filter((it): it is (typeof menuGroups)[number]['items'][number] => it !== null),
[quick.links, canSee]
);
const initials = (user?.name || 'US')
.split(' ')
.filter(Boolean)
@@ -188,6 +216,62 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
</div>
<nav className="flex-1 overflow-y-auto py-5 px-3">
{quickItems.length > 0 && quick.mode === 'menu' && (
<div className="mb-6">
<div className="flex items-center justify-between px-3 mb-2">
<p className="text-[10px] uppercase tracking-wider text-white/40 font-heading">Accesos rápidos</p>
<span className="flex items-center gap-1">
<QuickLinksModePicker direction="down" align="right" />
<button
type="button"
onClick={() => quick.setEditing(!quick.editing)}
className="text-white/40 hover:text-white transition-colors"
title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'}
>
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
</button>
</span>
</div>
<ul className="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}
onClick={() => setSidebarOpen(false)}
className={`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'
: 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
}`}
>
<Icon size={18} />
<span>{item.label}</span>
</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="text-white/40 hover:text-white disabled:opacity-30 transition-colors" title="Subir">
<ChevronUp size={14} />
</button>
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
className="text-white/40 hover:text-white disabled:opacity-30 transition-colors" title="Bajar">
<ChevronDown size={14} />
</button>
<button type="button" onClick={() => quick.remove(item.href)}
className="text-white/40 hover:text-rose-400 transition-colors" title="Quitar">
<X size={14} />
</button>
</span>
)}
</li>
);
})}
</ul>
</div>
)}
{visibleGroups.map((group) => (
<div key={group.label} className="mb-6">
<p className="px-3 text-[10px] uppercase tracking-wider text-white/40 mb-2 font-heading">{group.label}</p>
@@ -195,12 +279,13 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
const pinned = quick.isPinned(item.href);
return (
<li key={item.label}>
<li key={item.label} className="group flex items-center gap-1">
<Link
to={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center space-x-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${
className={`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'
: 'text-white/70 hover:bg-theme-surface/10 hover:text-white'
@@ -210,6 +295,16 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
<span>{item.label}</span>
{active && <ChevronRight size={14} className="ml-auto" />}
</Link>
<button
type="button"
onClick={() => quick.toggle(item.href)}
className={`shrink-0 transition-all ${
pinned ? 'text-amber-400' : 'text-white/30 opacity-0 group-hover:opacity-100 hover:text-amber-400'
}`}
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
>
<Star size={15} fill={pinned ? 'currentColor' : 'none'} />
</button>
</li>
);
})}
@@ -312,43 +407,21 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
<Target size={14} className="mr-1.5" /> Definir meta
</Button>
)}
<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 paciente, cita..."
className="bg-transparent border-none text-sm ml-2 focus:outline-none w-48 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 HomeNest"
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(--surface)', color: 'var(--text-heading)', borderColor: 'var(--border)' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--accent-bg)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--surface)'}
>
<Palette size={14} />
HomeNest
</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>
</div>
</div>
</header>
<main className="flex-1 w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<main className={`flex-1 w-full 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} />
<Modal
isOpen={goalModalOpen}
onClose={() => setGoalModalOpen(false)}