/* Live dashboard mockup with animated chart + AI typing */
const useTicker = (interval = 1500) => {
const [tick, setTick] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), interval);
return () => clearInterval(id);
}, [interval]);
return tick;
};
const AreaChart = () => {
const tick = useTicker(2200);
const points = React.useMemo(() => {
const n = 28;
let v = 50 + Math.random() * 20;
const out = [];
for (let i = 0; i < n; i++) {
v += (Math.random() - 0.45) * 16;
v = Math.max(20, Math.min(96, v));
out.push(v);
}
return out;
}, [tick]);
const points2 = React.useMemo(() => {
return points.map(p => Math.max(15, Math.min(80, p - 12 - Math.random() * 12)));
}, [points]);
const W = 600, H = 130, P = 4;
const path = (arr) => {
return arr.map((y, i) => {
const x = P + (i / (arr.length - 1)) * (W - P*2);
const yy = H - P - (y / 100) * (H - P*2);
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${yy.toFixed(1)}`;
}).join(' ');
};
const area = (arr) => {
return path(arr) + ` L ${W - P} ${H - P} L ${P} ${H - P} Z`;
};
return (
);
};
const TypingAI = () => {
const messages = [
{ p: 'Detecté un patrón en las citas de mañana. ', hl: '3 clientes recurrentes podrían cambiar al horario AM.', s: ' Sugiero contactarlos hoy.' },
{ p: '', hl: 'Inventario crítico:', s: ' 4 productos bajo el umbral. Generé orden automática a proveedor.' },
{ p: 'El restaurante de Sur tiene ', hl: '+18% de ventas los jueves.', s: ' Activo promo automatizada para replicar en otras sucursales.' },
];
const [idx, setIdx] = React.useState(0);
const [shown, setShown] = React.useState('');
const [phase, setPhase] = React.useState(0); // 0: prefix, 1: hl, 2: suffix, 3: hold
React.useEffect(() => {
const m = messages[idx];
let target = '';
if (phase === 0) target = m.p;
else if (phase === 1) target = m.p + m.hl;
else if (phase === 2) target = m.p + m.hl + m.s;
if (phase === 3) {
const id = setTimeout(() => {
setIdx(i => (i + 1) % messages.length);
setShown('');
setPhase(0);
}, 4200);
return () => clearTimeout(id);
}
if (shown.length < target.length) {
const id = setTimeout(() => setShown(target.slice(0, shown.length + 1)), 22 + Math.random() * 18);
return () => clearTimeout(id);
} else {
setPhase(p => p + 1);
}
}, [shown, phase, idx]);
// render with highlighting
const m = messages[idx];
const pLen = m.p.length;
const hlLen = m.hl.length;
const visible = shown;
const partP = visible.slice(0, Math.min(pLen, visible.length));
const partHL = visible.slice(pLen, Math.min(pLen + hlLen, visible.length));
const partS = visible.slice(pLen + hlLen);
return (
{partP}{partHL}{partS}
);
};
const useCount = (target, duration = 1600) => {
const [v, setV] = React.useState(0);
const ref = React.useRef(null);
React.useEffect(() => {
let started = false;
const obs = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting && !started) {
started = true;
const start = performance.now();
const tick = (now) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
setV(Math.round(target * eased));
if (t < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
});
}, { threshold: 0.4 });
if (ref.current) obs.observe(ref.current);
return () => obs.disconnect();
}, [target, duration]);
return [v, ref];
};
const Counter = ({ target, suffix = '', prefix = '' }) => {
const [v, ref] = useCount(target);
return {prefix}{v.toLocaleString('es-MX')}{suffix};
};
const KPI = ({ label, value, delta, down }) => {
const tick = useTicker(2400);
const display = React.useMemo(() => {
const variation = (Math.sin(tick * 1.3) * 0.04) + 1;
if (typeof value === 'number') return Math.round(value * variation).toLocaleString('es-MX');
return value;
}, [tick, value]);
return (
{label}
{display}
{down ? '↓' : '↑'} {delta}
);
};
const Icon = ({ name, size = 16 }) => {
const paths = {
home: <>>,
activity: ,
users: <>>,
box: <>>,
settings: <>>,
chart: <>>,
bell: <>>,
sparkles: <>>,
};
return (
);
};
// Section content components
const AIPopup = () => {
const [open, setOpen] = React.useState(false);
return (
<>
{open && (
setOpen(false)}
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 99,
borderRadius: 8,
}}
/>
)}
{open && (
Asistente IA
)}
>
);
};
const ResumenView = () => (
Flujo de pacientes · 28 días
Atendidos
Programados
MR
María Rodríguez · Consulta general
15:00
Confirmada
JL
Javier López · Seguimiento
15:30
Recordatorio enviado
AC
Ana Carrillo · Primera vez
16:00
Falta confirmar
);
const PacientesView = () => (
Paciente
Última visita
Estado
Acciones
{[
{ name: 'María Rodríguez', last: '15 abr 2026', status: 'Activo' },
{ name: 'Javier López', last: '22 abr 2026', status: 'Activo' },
{ name: 'Ana Carrillo', last: '29 abr 2026', status: 'Pendiente' },
{ name: 'Carlos Méndez', last: '10 abr 2026', status: 'Inactivo' },
{ name: 'Laura Vega', last: '18 abr 2026', status: 'Activo' },
].map((p, i) => (
{p.name}
{p.last}
{p.status}
))}
);
const CitasView = () => (
{[
{ name: 'María Rodríguez · Consulta general', time: '15:00', status: 'Confirmada' },
{ name: 'Javier López · Seguimiento', time: '15:30', status: 'info', statusText: 'Recordatorio enviado' },
{ name: 'Ana Carrillo · Primera vez', time: '16:00', status: 'warn', statusText: 'Falta confirmar' },
{ name: 'Carlos Méndez · Cardiología', time: '16:30', status: 'Confirmada' },
{ name: 'Laura Vega · Dermatología', time: '17:00', status: 'Confirmada' },
{ name: 'Roberto Sánchez · Laboratorio', time: '17:30', status: 'info', statusText: 'En espera' },
].map((c, i) => (
{c.name.split(' ').map(n => n[0]).join('')}
{c.time}
{c.statusText || c.status}
))}
);
const InventarioView = () => (
Producto
Categoría
Stock
Estado
{[
{ name: 'Paracetamol 500mg', cat: 'Medicamentos', stock: 145, status: 'OK' },
{ name: 'Ibuprofeno 400mg', cat: 'Medicamentos', stock: 89, status: 'OK' },
{ name: 'Guantes quirúrgicos', cat: 'Suministros', stock: 23, status: 'warn', statusText: 'Bajo' },
{ name: 'Jeringas 5ml', cat: 'Suministros', stock: 156, status: 'OK' },
{ name: 'Alcohol etílico', cat: 'Limpieza', stock: 8, status: 'warn', statusText: 'Crítico' },
].map((p, i) => (
{p.name}
{p.cat}
{p.stock}
{p.statusText || p.status}
))}
);
const AsistenteView = () => (
Sugerencia 14:25
Detecté un patrón en las citas de mañana. 3 clientes recurrentes podrían cambiar al horario AM. Sugiero contactarlos hoy.
Alerta 14:18
Inventario crítico: 4 productos bajo el umbral. Generé orden automática a proveedor.
Oportunidad 13:52
El restaurante de Sur tiene +18% de ventas los jueves. Activo promo automatizada para replicar en otras sucursales.
);
const ReportesView = () => (
Ingresos por servicio · 6 meses
Servicio
Ingresos
Citas
Tendencia
{[
{ name: 'Consulta general', income: '$45,200', citas: 234, trend: '↑ +12%' },
{ name: 'Cardiología', income: '$38,100', citas: 156, trend: '↑ +8%' },
{ name: 'Dermatología', income: '$29,800', citas: 189, trend: '↑ +15%' },
{ name: 'Laboratorio', income: '$22,400', citas: 312, trend: '↓ -3%' },
{ name: 'Vacunación', income: '$18,600', citas: 445, trend: '↑ +22%' },
].map((s, i) => (
{s.name}
{s.income}
{s.citas}
{s.trend}
))}
);
const AlertasView = () => (
{[
{ type: 'critical', title: 'Stock crítico - Alcohol etílico', desc: '8 unidades restantes. Umbral mínimo: 20', time: 'Hace 5 min' },
{ type: 'warning', title: 'No-show elevado - Dr. Méndez', desc: '18% no-show vs promedio 12%. Revisar recordatorios', time: 'Hace 23 min' },
{ type: 'info', title: 'Nueva integración - HubSpot', desc: 'Sincronización completada exitosamente', time: 'Hace 1h' },
].map((a, i) => (
))}
);
const AjustesView = () => (
Notificaciones
Integraciones
● Conectado
● Conectado
);
const Dashboard = () => {
const [activeView, setActiveView] = React.useState('resumen');
const views = {
resumen: { title: 'Resumen de hoy', component: ResumenView },
pacientes: { title: 'Pacientes', component: PacientesView },
citas: { title: 'Citas', component: CitasView },
inventario: { title: 'Inventario', component: InventarioView },
asistente: { title: 'Asistente IA', component: AsistenteView },
reportes: { title: 'Reportes', component: ReportesView },
alertas: { title: 'Alertas', component: AlertasView },
ajustes: { title: 'Ajustes', component: AjustesView },
};
const currentView = views[activeView];
return (
app.martzlab.com/clinica-noria
{currentView.title}
Martes · 29 abr · 14:32
);
};
window.Dashboard = Dashboard;
window.Counter = Counter;
window.Icon = Icon;