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,
|
||||
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)}
|
||||
|
||||
Reference in New Issue
Block a user