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

1
.gitignore vendored
View File

@@ -62,3 +62,4 @@ datos/
# Git
.git/
visitas_legacy_*.json

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Alike&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Alike&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&family=Source+Sans+Pro:wght@400;600;700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SKEEN Derma Experts</title>
</head>

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@
"axios": "^1.18.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"exceljs": "^4.4.0",
"lucide-react": "^1.23.0",
"moment": "^2.30.1",
"postcss": "^8.5.16",

View File

@@ -2,12 +2,16 @@ import type { FC, ReactNode } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { ToastProvider } from './components/ui';
import { AuthProvider, useAuth } from './lib/auth';
import { QuickLinksProvider } from './lib/quicklinks';
import Dashboard from './pages/Dashboard';
import Agenda from './pages/Agenda';
import Pacientes from './pages/Pacientes';
import Pos from './pages/Pos';
import Visitas from './pages/Visitas';
import Consultas from './pages/Consultas';
import UltimasVisitas from './pages/UltimasVisitas';
import Servicios from './pages/Servicios';
import Medicos from './pages/Medicos';
import Productos from './pages/Productos';
import Monedero from './pages/Monedero';
import Pagos from './pages/Pagos';
import Ventas from './pages/Ventas';
@@ -16,7 +20,6 @@ import Reportes from './pages/Reportes';
import Configuracion from './pages/Configuracion';
import Cumpleanos from './pages/Cumpleanos';
import Inventario from './pages/Inventario';
import Usuarios from './pages/Usuarios';
import WacrmMessages from './pages/WacrmMessages';
import WacrmLeads from './pages/WacrmLeads';
import Login from './pages/Login';
@@ -46,30 +49,37 @@ const LoginRoute: FC = () => {
const App: FC = () => {
return (
<AuthProvider>
<Router>
<QuickLinksProvider>
<Router>
<ToastProvider />
<Routes>
<Route path="/login" element={<LoginRoute />} />
<Route path="/" element={<RequireAuth href="/"><Dashboard /></RequireAuth>} />
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />
<Route path="/visitas" element={<RequireAuth href="/visitas"><Visitas /></RequireAuth>} />
<Route path="/consultas" element={<RequireAuth href="/consultas"><Consultas /></RequireAuth>} />
<Route path="/expedientes" element={<RequireAuth href="/expedientes"><UltimasVisitas /></RequireAuth>} />
<Route path="/ultimas-visitas" element={<Navigate to="/expedientes" replace />} />
<Route path="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
<Route path="/productos" element={<RequireAuth href="/productos"><Productos /></RequireAuth>} />
<Route path="/productos" element={<Navigate to="/inventario" replace />} />
<Route path="/monedero" element={<RequireAuth href="/monedero"><Monedero /></RequireAuth>} />
<Route path="/pagos" element={<RequireAuth href="/pagos"><Pagos /></RequireAuth>} />
<Route path="/ventas" element={<RequireAuth href="/ventas"><Ventas /></RequireAuth>} />
<Route path="/cortes" element={<RequireAuth href="/cortes"><Cortes /></RequireAuth>} />
<Route path="/reportes" element={<RequireAuth href="/reportes"><Reportes /></RequireAuth>} />
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
<Route path="/usuarios" element={<RequireAuth href="/usuarios"><Usuarios /></RequireAuth>} />
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
</Router>
</QuickLinksProvider>
</AuthProvider>
);
};

View File

@@ -0,0 +1,220 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Pencil, Trash2, Search, BookMarked } from 'lucide-react';
import {
Button,
Input,
TextArea,
Modal,
EmptyState,
Skeleton,
MobileCard,
toast,
} from './ui';
import { odooApi, type Diagnostico, type Procedimiento } from '../services/odoo';
type CatalogoItem = Diagnostico | Procedimiento;
interface CatalogoPanelProps {
kind: 'diagnosticos' | 'procedimientos';
titulo: string;
}
const CatalogoPanel: FC<CatalogoPanelProps> = ({ kind, titulo }) => {
const [items, setItems] = useState<CatalogoItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<CatalogoItem | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({ name: '', categoria: '', descripcion: '' });
const api = kind === 'diagnosticos'
? { list: odooApi.getDiagnosticos, create: odooApi.createDiagnostico, update: odooApi.updateDiagnostico, remove: odooApi.deleteDiagnostico }
: { list: odooApi.getProcedimientos, create: odooApi.createProcedimiento, update: odooApi.updateProcedimiento, remove: odooApi.deleteProcedimiento };
const load = useCallback(async () => {
try {
setLoading(true);
const res = await api.list(search || undefined);
if (res.status === 'success') setItems(res.items);
} catch (err) {
toast.error(`Error al cargar ${titulo.toLowerCase()}`);
console.error(err);
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, kind]);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setForm({ name: '', categoria: '', descripcion: '' });
setModalOpen(true);
};
const openEdit = (item: CatalogoItem) => {
setEditing(item);
setForm({ name: item.name, categoria: item.categoria, descripcion: item.descripcion });
setModalOpen(true);
};
const submit = async () => {
if (!form.name.trim()) {
toast.error('El nombre es obligatorio');
return;
}
try {
setSubmitting(true);
if (editing) {
await api.update(editing.id, form);
toast.success('Actualizado');
} else {
await api.create(form);
toast.success('Creado');
}
setModalOpen(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar');
console.error(err);
} finally {
setSubmitting(false);
}
};
const eliminar = async (item: CatalogoItem) => {
if (!window.confirm(`¿Eliminar "${item.name}"?`)) return;
try {
await api.remove(item.id);
toast.success('Eliminado');
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al eliminar');
console.error(err);
}
};
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por nombre o categoría..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button onClick={openCreate} className="sm:ml-auto">
<Plus size={16} className="mr-2" />
Nuevo
</Button>
</div>
{loading ? (
<Skeleton count={4} className="h-12 w-full" />
) : items.length === 0 ? (
<EmptyState
title={`Sin ${titulo.toLowerCase()}`}
subtitle={`Agrega el primero al catálogo de ${titulo.toLowerCase()}.`}
actionLabel="Nuevo"
onAction={openCreate}
icon={<BookMarked size={28} />}
/>
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Categoría</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Descripción</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{items.map((item) => (
<tr key={item.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-medium text-theme-heading">{item.name}</td>
<td className="p-3 text-sm text-theme-muted">{item.categoria || '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
<span className="block max-w-md truncate">{item.descripcion}</span>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(item)} title="Editar">
<Pencil size={16} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => eliminar(item)} title="Eliminar">
<Trash2 size={16} className="text-rose-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{items.map((item) => (
<MobileCard
key={item.id}
title={item.name}
subtitle={item.categoria || undefined}
rows={[{ label: 'Descripción', value: item.descripcion.slice(0, 60) || '-' }]}
actions={
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(item)}><Pencil size={16} className="text-theme-muted" /></Button>
<Button variant="ghost" size="sm" onClick={() => eliminar(item)}><Trash2 size={16} className="text-rose-500" /></Button>
</div>
}
/>
))}
</div>
</>
)}
{/* Modal crear/editar */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar' : 'Nuevo'}
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Nombre *"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<Input
label="Categoría"
value={form.categoria}
onChange={(e) => setForm({ ...form, categoria: e.target.value })}
/>
<TextArea
label="Descripción"
value={form.descripcion}
onChange={(e) => setForm({ ...form, descripcion: e.target.value })}
/>
</div>
</Modal>
</>
);
};
export default CatalogoPanel;

View File

@@ -0,0 +1,569 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
Calendar,
Users,
Stethoscope,
FlaskConical,
Wallet,
ShoppingCart,
Store,
Receipt,
BarChart3,
Settings,
Menu,
X,
ChevronRight,
LogOut,
MessageSquare,
Target,
Boxes,
ClipboardList,
ClipboardPlus,
FolderClock,
Star,
Pencil,
ChevronUp,
ChevronDown,
DollarSign,
} 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 { useQuickLinks } from '../lib/quicklinks';
import { odooApi } from '../services/odoo';
interface LayoutProps {
children: ReactNode;
title: string;
subtitle?: string;
}
// Árbol de menús con las etiquetas y submenús del sistema legacy
interface MenuChild {
label: string;
href: string;
}
interface MenuEntry {
label: string;
icon: FC<{ size?: number | string; className?: string }>;
href?: string;
children?: MenuChild[];
}
const menuTree: MenuEntry[] = [
{ label: 'Inicio', icon: LayoutDashboard, href: '/' },
{ label: 'Agenda', icon: Calendar, href: '/agenda' },
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
{ label: 'Consultas Médicas', icon: ClipboardPlus, href: '/consultas' },
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
{ label: 'Cosmetología', icon: FlaskConical, href: '/servicios' },
{
label: 'Configuración', icon: Settings,
children: [
{ label: 'Clínica', href: '/configuracion?tab=clinica' },
{ label: 'Usuarios', href: '/configuracion?tab=usuarios' },
{ label: 'Recetas', href: '/configuracion?tab=recetas' },
{ label: 'Diagnósticos', href: '/configuracion?tab=catalogos&sub=diagnosticos' },
{ label: 'Procedimientos médicos', href: '/configuracion?tab=catalogos&sub=procedimientos' },
],
},
{
label: 'Reportes', icon: BarChart3,
children: [
{ label: 'Mov. diario', href: '/reportes?tab=diario' },
{ label: 'Cortes de caja', href: '/reportes?tab=cortes' },
{ label: 'Ingresos', href: '/reportes?tab=ingresos' },
{ label: 'Inventario', href: '/reportes?tab=inventario' },
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
{ label: 'Exportar correos', href: '/reportes?tab=exportar' },
],
},
{
label: 'Administrativo', icon: DollarSign,
children: [
{ label: 'Adeudos clientes', href: '/reportes?tab=adeudos' },
{ label: 'Pagos | Servicios', href: '/reportes?tab=pagos-servicios' },
{ label: 'Pagos | Clientes', href: '/reportes?tab=pagos-clientes' },
{ label: 'Devoluciones', href: '/reportes?tab=devoluciones' },
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
{ label: 'Top ventas', href: '/reportes?tab=top-clientes' },
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes' },
{ label: 'Cortes de caja', href: '/cortes' },
],
},
{
label: 'Monedero', icon: Wallet,
children: [
{ label: 'Monederos', href: '/monedero' },
{ label: 'Reporte puntos', href: '/monedero?tab=reporte' },
],
},
{
label: 'Comunicación', icon: MessageSquare,
children: [
{ label: 'Cumpleañeros', href: '/cumpleanos' },
{ label: 'Mensajes', href: '/wacrm/messages' },
],
},
{ label: 'CRM', icon: Target, href: '/wacrm/leads' },
{ label: 'Comprobantes del día', icon: Receipt, href: '/pagos' },
{
label: 'Consumibles', icon: Boxes,
children: [
{ label: 'Inventario actual', href: '/inventario' },
{ label: 'Compras', href: '/inventario?tab=compras' },
{ label: 'Bajas', href: '/inventario?tab=bajas' },
],
},
{ label: 'Ventas del día', icon: ShoppingCart, href: '/ventas' },
{ label: 'Punto de Venta', icon: Store, href: '/pos' },
{ label: 'Clientes', icon: Users, href: '/pacientes' },
];
// Hojas planas (para accesos rápidos y dock)
const menuLeaves = menuTree.flatMap((e) =>
e.children ? e.children.map((c) => ({ label: c.label, icon: e.icon, href: c.href })) : [{ label: e.label, icon: e.icon, href: e.href as string }]
);
export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
const location = useLocation();
const navigate = useNavigate();
const { user, canSee, hasRole, logout } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const visibleLeaves = useMemo(
() => menuLeaves.filter((it) => canSee(it.href)),
[canSee]
);
// Accesos rápidos personalizables (localStorage por usuario)
const quick = useQuickLinks();
const dockItems = visibleLeaves;
const quickItems = useMemo(
() =>
quick.links
.filter((href) => canSee(href))
.map((href) => menuLeaves.find((it) => it.href === href) ?? null)
.filter((it): it is (typeof menuLeaves)[number] => it !== null),
[quick.links, canSee]
);
const initials = (user?.name || 'US')
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]?.toUpperCase())
.join('');
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const [goal, setGoal] = useState<{ goal: number; current: number; pct: number } | null>(null);
const [goalModalOpen, setGoalModalOpen] = useState(false);
const [goalInput, setGoalInput] = useState('');
const [goalSaving, setGoalSaving] = useState(false);
useEffect(() => {
let alive = true;
const fetchGoal = () => {
odooApi.getSalesGoal()
.then((res) => {
if (alive && res.status === 'success') {
setGoal({ goal: res.goal, current: res.current, pct: res.pct });
}
})
.catch(() => {});
};
fetchGoal();
const id = setInterval(fetchGoal, 60000);
return () => { alive = false; clearInterval(id); };
}, []);
const openGoalModal = () => {
setGoalInput(goal?.goal ? String(goal.goal) : '');
setGoalModalOpen(true);
};
const saveGoal = async () => {
const v = parseFloat(goalInput);
if (Number.isNaN(v) || v < 0) return;
try {
setGoalSaving(true);
await odooApi.setSalesGoal(v);
setGoal((g) => (g ? { ...g, goal: v, pct: g.current && v ? (g.current / v) * 100 : 0 } : g));
setGoalModalOpen(false);
} catch {
// ignore
} finally {
setGoalSaving(false);
}
};
const fmtMoney = (n: number) =>
n >= 1000000 ? `$${(n / 1000000).toFixed(1)}M` : n >= 1000 ? `$${(n / 1000).toFixed(0)}k` : `$${n.toFixed(0)}`;
const pctClamped = Math.min(goal?.pct ?? 0, 100);
useEffect(() => {
if (!sidebarOpen) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow;
};
}, [sidebarOpen]);
const isActive = (href: string) => {
const [path, query] = href.split('?');
if (path === '/') {
if (location.pathname !== '/') return false;
} else if (location.pathname !== path && !location.pathname.startsWith(`${path}/`)) {
return false;
}
if (query) {
return query.split('&').every((p) => location.search.includes(p));
}
return true;
};
// Grupos de submenú abiertos (auto-abre el de la ruta activa)
const [openGroups, setOpenGroups] = useState<string[]>(() => {
const current = `${location.pathname}${location.search}`;
return menuTree
.filter((e) => e.children?.some((c) => current.startsWith(c.href.split('?')[0]) && (!c.href.includes('?') || current.includes(c.href.split('?')[1]))))
.map((e) => e.label);
});
const toggleGroup = (label: string) =>
setOpenGroups((prev) => (prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label]));
const SidebarContent = () => (
<>
{/* Sucursal, como en el sistema legacy */}
<p className="text-center text-sm font-medium text-white py-3 shrink-0" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
Rosarito
</p>
<nav className="flex-1 overflow-y-auto py-2">
{quickItems.length > 0 && quick.mode === 'menu' && (
<div className="mb-2">
<div className="flex items-center justify-between px-4 py-2" style={{ backgroundColor: 'var(--sidebar-group-bg)' }}>
<p className="text-[10px] uppercase tracking-wider font-medium" style={{ color: 'var(--sidebar-group-text)' }}>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="hover:text-white transition-colors"
style={{ color: 'var(--sidebar-group-text)' }}
title={quick.editing ? 'Terminar edición' : 'Editar accesos rápidos'}
>
{quick.editing ? <X size={13} /> : <Pencil size={12} />}
</button>
</span>
</div>
<ul>
{quickItems.map((item, idx) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href} className="flex items-center">
<Link
to={item.href}
onClick={() => setSidebarOpen(false)}
className="flex-1 flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
style={{
backgroundColor: active ? 'var(--sidebar-active-bg)' : 'transparent',
borderLeft: `3px solid ${active ? 'var(--sidebar-accent)' : 'transparent'}`,
color: active ? '#fff' : 'var(--sidebar-text)',
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<Icon size={16} />
<span>{item.label}</span>
</Link>
{quick.editing && (
<span className="flex items-center gap-0.5 shrink-0 pr-2">
<button type="button" onClick={() => quick.move(item.href, -1)} disabled={idx === 0}
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title="Subir">
<ChevronUp size={14} />
</button>
<button type="button" onClick={() => quick.move(item.href, 1)} disabled={idx === quickItems.length - 1}
className="hover:text-white disabled:opacity-30 transition-colors" style={{ color: 'var(--sidebar-text)' }} title="Bajar">
<ChevronDown size={14} />
</button>
<button type="button" onClick={() => quick.remove(item.href)}
className="hover:text-rose-400 transition-colors" style={{ color: 'var(--sidebar-text)' }} title="Quitar">
<X size={14} />
</button>
</span>
)}
</li>
);
})}
</ul>
</div>
)}
<ul>
{menuTree.map((entry) => {
const Icon = entry.icon;
// Hojas visibles de este entry
const leaves = entry.children
? entry.children.filter((c) => canSee(c.href))
: (entry.href && canSee(entry.href) ? [{ label: entry.label, href: entry.href }] : []);
if (leaves.length === 0) return null;
// Entry simple (sin hijos)
if (!entry.children) {
const active = isActive(entry.href as string);
const pinned = quick.isPinned(entry.href as string);
return (
<li key={entry.label} className="group flex items-center">
<Link
to={entry.href as string}
onClick={() => setSidebarOpen(false)}
className="flex-1 flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
style={{
backgroundColor: active ? 'var(--sidebar-active-bg)' : 'transparent',
borderLeft: `3px solid ${active ? 'var(--sidebar-accent)' : 'transparent'}`,
color: active ? '#fff' : 'var(--sidebar-text)',
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<Icon size={16} />
<span>{entry.label}</span>
{active && <ChevronRight size={13} className="ml-auto" />}
</Link>
<button
type="button"
onClick={() => quick.toggle(entry.href as string)}
className={`shrink-0 pr-2 transition-all ${
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
}`}
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
>
<Star size={14} fill={pinned ? 'currentColor' : 'none'} />
</button>
</li>
);
}
// Entry con submenú (treeview AdminLTE)
const open = openGroups.includes(entry.label);
const childActive = leaves.some((c) => isActive(c.href));
return (
<li key={entry.label}>
<button
type="button"
onClick={() => toggleGroup(entry.label)}
className="w-full flex items-center space-x-3 px-4 py-2.5 text-sm transition-colors"
style={{
backgroundColor: childActive ? 'var(--sidebar-active-bg)' : 'transparent',
borderLeft: `3px solid ${childActive ? 'var(--sidebar-accent)' : 'transparent'}`,
color: childActive ? '#fff' : 'var(--sidebar-text)',
}}
onMouseEnter={(e) => { if (!childActive) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
onMouseLeave={(e) => { if (!childActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<Icon size={16} />
<span>{entry.label}</span>
<ChevronDown size={14} className={`ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
{open && (
<ul style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}>
{leaves.map((child) => {
const active = isActive(child.href);
const pinned = quick.isPinned(child.href);
return (
<li key={child.href} className="group flex items-center">
<Link
to={child.href}
onClick={() => setSidebarOpen(false)}
className="flex-1 flex items-center pl-11 pr-4 py-2 text-[13px] transition-colors"
style={{
backgroundColor: active ? 'rgba(0,0,0,0.25)' : 'transparent',
color: active ? '#fff' : 'var(--sidebar-text)',
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.1)'; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<span className="inline-block w-1.5 h-1.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: active ? 'var(--sidebar-accent)' : 'var(--sidebar-text)' }} />
<span>{child.label}</span>
</Link>
<button
type="button"
onClick={() => quick.toggle(child.href)}
className={`shrink-0 pr-2 transition-all ${
pinned ? 'text-amber-400' : 'opacity-0 group-hover:opacity-100 hover:text-amber-400'
}`}
style={{ color: pinned ? undefined : 'var(--sidebar-text)' }}
title={pinned ? 'Quitar de accesos rápidos' : 'Fijar en accesos rápidos'}
>
<Star size={13} fill={pinned ? 'currentColor' : 'none'} />
</button>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
</nav>
<div className="p-3 shrink-0" style={{ borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<div className="flex items-center space-x-3">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold"
style={{ backgroundColor: 'var(--sidebar-accent)', color: '#3E454C' }}
>
{initials || 'US'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{user?.name || 'Usuario'}</p>
<p className="text-xs truncate" style={{ color: 'var(--sidebar-text)' }}>{user ? ROLE_LABELS[user.role] : ''}</p>
</div>
<button
onClick={handleLogout}
className="p-2 transition-colors hover:text-white"
style={{ color: 'var(--sidebar-text)' }}
title="Cerrar sesión"
aria-label="Cerrar sesión"
>
<LogOut size={17} />
</button>
</div>
</div>
</>
);
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--bg)', color: 'var(--text)' }}>
{/* Header fijo */}
<header
className="fixed top-0 left-0 right-0 z-30 h-[50px] flex items-stretch"
style={{ backgroundColor: 'var(--header-bg)' }}
>
<div className="hidden lg:flex w-[230px] shrink-0 items-center px-5">
<span className="text-[26px] font-bold tracking-tight" style={{ color: '#333333' }}>
SKEEN<sup className="text-xs font-semibold">®</sup>
</span>
</div>
<div className="flex-1 flex items-center justify-between px-3 sm:px-4 min-w-0">
<div className="flex items-center space-x-3 min-w-0">
<Button
variant="ghost"
size="sm"
className="lg:hidden text-white"
onClick={() => setSidebarOpen(true)}
aria-label="Abrir menú"
>
<Menu size={20} />
</Button>
<span className="lg:hidden text-lg font-bold truncate" style={{ color: '#333333' }}>SKEEN<sup className="text-[10px]">®</sup></span>
</div>
<div className="flex items-center space-x-2 sm:space-x-3 shrink-0">
{goal && goal.goal > 0 && (
<button
onClick={hasRole('admin') ? openGoalModal : undefined}
className={`hidden xl:flex flex-col w-44 text-left rounded-[2px] px-3 py-1 ${hasRole('admin') ? 'cursor-pointer' : 'cursor-default'}`}
style={{ backgroundColor: 'rgba(255,255,255,0.25)' }}
title={hasRole('admin') ? 'Click para editar la meta' : 'Meta de ventas del mes'}
>
<div className="flex items-center justify-between text-[11px] mb-0.5 text-white/80">
<span className="flex items-center gap-1"><Target size={11} /> Meta del mes</span>
<span className="font-bold text-white">{Math.round(goal.pct)}%</span>
</div>
<div className="w-full h-1.5 overflow-hidden" style={{ backgroundColor: 'rgba(0,0,0,0.15)' }}>
<div className="h-full transition-all" style={{ width: `${pctClamped}%`, backgroundColor: goal.pct >= 100 ? 'var(--success)' : '#fff' }} />
</div>
<div className="flex items-center justify-between text-[11px] mt-0.5 text-white/80">
<span className="font-semibold text-white">{fmtMoney(goal.current)}</span>
<span>de {fmtMoney(goal.goal)}</span>
</div>
</button>
)}
{goal && goal.goal <= 0 && hasRole('admin') && (
<Button variant="outline" size="sm" className="hidden md:inline-flex" onClick={openGoalModal}>
<Target size={14} className="mr-1.5" /> Definir meta
</Button>
)}
<GlobalSearch className="hidden md:block" />
<NotificationBell buttonClassName="p-2 rounded-[2px] relative transition text-white/90 hover:bg-black/10" />
<ThemePicker triggerClassName="hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-[2px] text-xs font-medium text-white/90 transition hover:bg-black/10" />
</div>
</div>
</header>
{/* Sidebar desktop */}
<aside
className="hidden lg:flex flex-col w-[230px] fixed top-[50px] bottom-0 left-0 z-20"
style={{ backgroundColor: 'var(--sidebar-bg)' }}
>
<SidebarContent />
</aside>
{/* Drawer móvil */}
{sidebarOpen && (
<div className="fixed inset-0 bg-black/40 z-30 lg:hidden" onClick={() => setSidebarOpen(false)} aria-hidden="true" />
)}
<aside
className={`fixed top-[50px] bottom-0 left-0 w-[230px] z-40 transform transition-transform duration-200 ease-in-out lg:hidden flex flex-col ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
style={{ backgroundColor: 'var(--sidebar-bg)' }}
>
<SidebarContent />
</aside>
{/* Contenido */}
<main
className={`lg:ml-[230px] pt-[50px] min-h-screen ${quick.mode === 'right' ? 'lg:pr-20' : ''} ${quick.mode === 'bottom' ? 'pb-20' : ''}`}
>
<div className="px-4 sm:px-6 py-5">
{children}
</div>
</main>
<QuickLinksDock items={dockItems} />
<Modal
isOpen={goalModalOpen}
onClose={() => setGoalModalOpen(false)}
title="Meta de ventas del mes"
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setGoalModalOpen(false)}>Cancelar</Button>
<Button onClick={saveGoal} loading={goalSaving}>Guardar meta</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>Define el objetivo de ventas (MXN) para el mes en curso. La barra del header muestra el avance en tiempo real.</p>
<Input label="Meta mensual (MXN)" type="number" min={0} step={1000} value={goalInput} onChange={(e) => setGoalInput(e.target.value)} placeholder="ej. 500000" />
</div>
</Modal>
</div>
);
};
export default ClasicoLayout;

View File

@@ -0,0 +1,160 @@
import type { FC } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, Users, Calendar, Briefcase } from 'lucide-react';
import { odooApi } from '../services/odoo';
interface Results {
patients: { id: number; name: string; phone: string }[];
appointments: { id: number; patient: string; service: string; date: string; time: string }[];
services: { id: number; name: string; price: number }[];
}
const EMPTY: Results = { patients: [], appointments: [], services: [] };
interface GlobalSearchProps {
placeholder?: string;
className?: string;
}
/** Búsqueda global del header: dropdown con autocompletado (pacientes, citas, servicios). */
export const GlobalSearch: FC<GlobalSearchProps> = ({ placeholder = 'Buscar paciente, cita, servicio...', className = '' }) => {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [results, setResults] = useState<Results>(EMPTY);
const [open, setOpen] = useState(false);
const [searching, setSearching] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
// Cerrar al hacer click fuera
useEffect(() => {
const close = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, []);
// Búsqueda con debounce
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setResults(EMPTY);
setSearching(false);
return;
}
setSearching(true);
const t = setTimeout(async () => {
try {
const res = await odooApi.globalSearch(q);
if (res.status === 'success') {
setResults({
patients: res.patients || [],
appointments: res.appointments || [],
services: res.services || [],
});
setOpen(true);
}
} catch {
setResults(EMPTY);
} finally {
setSearching(false);
}
}, 250);
return () => clearTimeout(t);
}, [query]);
const go = (path: string) => {
setOpen(false);
setQuery('');
navigate(path);
};
const hasResults = results.patients.length + results.appointments.length + results.services.length > 0;
const showDropdown = open && query.trim().length >= 2;
return (
<div ref={boxRef} className={`relative ${className}`}>
<div className="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"
value={query}
placeholder={placeholder}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => { if (hasResults) setOpen(true); }}
onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }}
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>
{showDropdown && (
<div className="absolute right-0 top-full mt-2 w-80 max-h-[70vh] overflow-y-auto rounded-2xl border border-theme-border bg-theme-surface shadow-card z-50">
{searching && (
<p className="px-4 py-3 text-xs text-theme-muted">Buscando...</p>
)}
{!searching && !hasResults && (
<p className="px-4 py-3 text-xs text-theme-muted">Sin resultados para "{query.trim()}"</p>
)}
{results.patients.length > 0 && (
<div className="p-1.5">
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">Pacientes</p>
{results.patients.map((p) => (
<button
key={`p-${p.id}`}
type="button"
onClick={() => go(`/pacientes?q=${encodeURIComponent(p.phone || p.name)}`)}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading hover:bg-theme-accent-bg transition-colors text-left"
>
<Users size={15} className="text-theme-muted shrink-0" />
<span className="truncate">{p.name}</span>
<span className="ml-auto text-xs text-theme-muted shrink-0">{p.phone}</span>
</button>
))}
</div>
)}
{results.appointments.length > 0 && (
<div className="p-1.5 border-t border-theme-border">
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">Citas próximas</p>
{results.appointments.map((a) => (
<button
key={`a-${a.id}`}
type="button"
onClick={() => go(`/agenda?date=${a.date}`)}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading hover:bg-theme-accent-bg transition-colors text-left"
>
<Calendar size={15} className="text-theme-muted shrink-0" />
<span className="truncate">{a.patient} · {a.service}</span>
<span className="ml-auto text-xs text-theme-muted shrink-0">{a.date.slice(5)} {a.time}</span>
</button>
))}
</div>
)}
{results.services.length > 0 && (
<div className="p-1.5 border-t border-theme-border">
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">Servicios</p>
{results.services.map((s) => (
<button
key={`s-${s.id}`}
type="button"
onClick={() => go(`/servicios?q=${encodeURIComponent(s.name)}`)}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading hover:bg-theme-accent-bg transition-colors text-left"
>
<Briefcase size={15} className="text-theme-muted shrink-0" />
<span className="truncate">{s.name}</span>
<span className="ml-auto text-xs text-theme-muted shrink-0">${s.price}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
};
export default GlobalSearch;

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

View File

@@ -1,6 +1,7 @@
import type { FC, ReactNode } from 'react';
import SkeenLayout from './SkeenLayout';
import HomeNestLayout from './HomeNestLayout';
import ClasicoLayout from './ClasicoLayout';
import { useTheme } from '../lib/theme';
interface LayoutProps {
@@ -14,6 +15,9 @@ const Layout: FC<LayoutProps> = (props) => {
if (theme === 'homenest') {
return <HomeNestLayout {...props} />;
}
if (theme === 'clasico') {
return <ClasicoLayout {...props} />;
}
return <SkeenLayout {...props} />;
};

View File

@@ -0,0 +1,122 @@
import type { FC } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, Boxes, CalendarX, Clock, Target, MessageSquare } from 'lucide-react';
import { odooApi, type AppNotification } from '../services/odoo';
const TIPO_ICON: Record<string, typeof Boxes> = {
stock_bajo: Boxes,
caducidades: CalendarX,
citas_pendientes_hoy: Clock,
leads_sin_asignar: Target,
conversaciones_sin_leer: MessageSquare,
};
const SEVERITY_CLASS: Record<string, string> = {
info: 'bg-theme-accent-bg text-theme-heading',
warning: 'bg-theme-warning text-theme-warning-text',
danger: 'bg-rose-100 text-rose-700',
};
interface NotificationBellProps {
buttonClassName?: string;
}
export const NotificationBell: FC<NotificationBellProps> = ({ buttonClassName }) => {
const navigate = useNavigate();
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
const ref = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
try {
const res = await odooApi.getNotifications();
if (res.status === 'success') setNotifications(res.notifications);
} catch {
// silencioso: la campanita no debe molestar si falla
} finally {
setLoading(false);
}
}, []);
// Polling 60s + al enfocar la ventana
useEffect(() => {
load();
const interval = setInterval(load, 60000);
const onFocus = () => load();
window.addEventListener('focus', onFocus);
return () => {
clearInterval(interval);
window.removeEventListener('focus', onFocus);
};
}, [load]);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
const total = notifications.length;
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
title="Notificaciones"
aria-label="Notificaciones"
className={buttonClassName || 'p-2 rounded-full relative transition text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading'}
>
<Bell size={18} />
{total > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-rose-500 text-white text-[10px] font-bold flex items-center justify-center">
{total}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full mt-2 z-50 w-80 rounded-2xl border border-theme-border bg-theme-surface shadow-card overflow-hidden">
<p className="px-4 py-2.5 text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Notificaciones
</p>
{loading && notifications.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-6">Cargando...</p>
) : notifications.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-6">Sin notificaciones</p>
) : (
<ul className="max-h-80 overflow-y-auto">
{notifications.map((n) => {
const Icon = TIPO_ICON[n.tipo] || Bell;
return (
<li key={n.tipo}>
<button
type="button"
onClick={() => { setOpen(false); navigate(n.link); }}
className="w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-theme-bg transition border-b border-theme-border last:border-0"
>
<span className={`mt-0.5 w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${SEVERITY_CLASS[n.severity] || SEVERITY_CLASS.info}`}>
<Icon size={14} />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-theme-heading truncate">{n.titulo}</span>
<span className="block text-xs text-theme-muted truncate">{n.detalle}</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
)}
</div>
);
};
export default NotificationBell;

View File

@@ -0,0 +1,185 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { FileText, X, Upload } from 'lucide-react';
import { Button, Modal, Skeleton, toast } from './ui';
import { odooApi, type PatientAdjunto } from '../services/odoo';
interface PatientAdjuntosProps {
patientId: number;
kind: 'expediente' | 'imagen';
editable?: boolean;
onChange?: () => void;
}
const MAX_BYTES = 10 * 1024 * 1024;
export const PatientAdjuntos: FC<PatientAdjuntosProps> = ({ patientId, kind, editable = true, onChange }) => {
const [adjuntos, setAdjuntos] = useState<PatientAdjunto[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [visor, setVisor] = useState<PatientAdjunto | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getPatientAdjuntos(patientId, kind);
if (res.status === 'success') setAdjuntos(res.adjuntos);
} catch (err) {
toast.error('Error al cargar archivos');
console.error(err);
} finally {
setLoading(false);
}
}, [patientId, kind]);
useEffect(() => {
load();
}, [load]);
const subir = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (file.size > MAX_BYTES) {
toast.error('El archivo excede 10 MB');
return;
}
const reader = new FileReader();
reader.onload = async () => {
const file_b64 = String(reader.result || '').split(',')[1] || '';
try {
setUploading(true);
const res = await odooApi.uploadPatientAdjunto(patientId, {
kind,
name: file.name,
file_b64,
mimetype: file.type || 'application/octet-stream',
});
if (res.status === 'success') {
toast.success('Archivo subido');
await load();
onChange?.();
}
} catch (err) {
toast.error('Error al subir el archivo');
console.error(err);
} finally {
setUploading(false);
}
};
reader.readAsDataURL(file);
};
const borrar = async (adjId: number) => {
if (!window.confirm('¿Eliminar este archivo?')) return;
try {
await odooApi.deletePatientAdjunto(adjId);
toast.success('Archivo eliminado');
await load();
onChange?.();
} catch (err) {
toast.error('Error al eliminar el archivo');
console.error(err);
}
};
const imagenes = adjuntos.filter((a) => a.mimetype.startsWith('image/'));
const docs = adjuntos.filter((a) => !a.mimetype.startsWith('image/'));
return (
<div>
{editable && (
<div className="mb-3">
<input
ref={fileRef}
type="file"
accept={kind === 'imagen' ? 'image/*' : undefined}
className="hidden"
onChange={subir}
/>
<Button variant="outline" size="sm" onClick={() => fileRef.current?.click()} loading={uploading}>
<Upload size={14} className="mr-1.5" />
{kind === 'imagen' ? 'Subir foto' : 'Escanear / Subir expediente'}
</Button>
</div>
)}
{loading ? (
<Skeleton count={2} className="h-16 w-full" />
) : adjuntos.length === 0 ? (
<p className="text-sm text-theme-muted">
{kind === 'imagen' ? 'Sin fotos en la galería.' : 'Sin expediente escaneado.'}
</p>
) : (
<>
{imagenes.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
{imagenes.map((a) => (
<div key={a.id} className="relative">
<button type="button" onClick={() => setVisor(a)} className="w-full">
<img
src={odooApi.patientAdjuntoUrl(a.url)}
alt={a.name}
className="w-full h-24 object-cover rounded-xl border border-theme-border"
/>
</button>
{editable && (
<button
type="button"
onClick={() => borrar(a.id)}
title="Eliminar"
className="absolute top-1 right-1 p-1 rounded-full bg-theme-surface/90 text-theme-muted hover:text-rose-600"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
)}
{docs.length > 0 && (
<ul className="space-y-1">
{docs.map((a) => (
<li key={a.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
<a
href={odooApi.patientAdjuntoUrl(a.url)}
target="_blank"
rel="noreferrer"
className="flex items-center text-theme-heading hover:underline min-w-0"
>
<FileText size={14} className="mr-1.5 text-theme-muted shrink-0" />
<span className="truncate">{a.name}</span>
</a>
{editable && (
<Button variant="ghost" size="sm" onClick={() => borrar(a.id)} title="Eliminar">
<X size={14} className="text-theme-muted" />
</Button>
)}
</li>
))}
</ul>
)}
</>
)}
{/* Visor de imagen */}
<Modal
isOpen={!!visor}
onClose={() => setVisor(null)}
title={visor?.name || 'Imagen'}
maxWidth="2xl"
>
{visor && (
<img
src={odooApi.patientAdjuntoUrl(visor.url)}
alt={visor.name}
className="w-full rounded-xl"
/>
)}
</Modal>
</div>
);
};
export default PatientAdjuntos;

View File

@@ -0,0 +1,145 @@
import type { FC } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { List, PanelRight, PanelBottom, Settings2 } from 'lucide-react';
import { useQuickLinks, QUICK_LINKS_MODE_LABELS, type QuickLinksMode } from '../lib/quicklinks';
interface DockItem {
label: string;
href: string;
icon: FC<{ size?: number | string; className?: string }>;
}
interface QuickLinksDockProps {
/** Items visibles del menú (para resolver icono/label de cada href fijado) */
items: DockItem[];
}
const MODE_ICONS: Record<QuickLinksMode, FC<{ size?: number | string; className?: string }>> = {
menu: List,
right: PanelRight,
bottom: PanelBottom,
};
/** Selector de presentación de los accesos rápidos (menú / derecha / abajo). */
export const QuickLinksModePicker: FC<{ direction?: 'up' | 'down'; align?: 'left' | 'right' }> = ({
direction = 'up',
align = 'right',
}) => {
const quick = useQuickLinks();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center justify-center w-8 h-8 rounded-xl text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading transition-colors"
title="Cambiar presentación de accesos rápidos"
>
<Settings2 size={15} />
</button>
{open && (
<div
className={`absolute z-50 w-56 rounded-2xl border border-theme-border bg-theme-surface shadow-card p-1.5 ${
direction === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'
} ${align === 'right' ? 'right-0' : 'left-0'}`}
>
<p className="px-2.5 py-1.5 text-[10px] uppercase tracking-wider text-theme-muted font-medium">
Mostrar accesos rápidos
</p>
{(Object.keys(MODE_ICONS) as QuickLinksMode[]).map((m) => {
const ModeIcon = MODE_ICONS[m];
const selected = quick.mode === m;
return (
<button
key={m}
type="button"
onClick={() => { quick.setMode(m); setOpen(false); }}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm transition-colors ${
selected ? 'bg-theme-accent-bg text-theme-heading font-medium' : 'text-theme-muted hover:bg-theme-bg hover:text-theme-heading'
}`}
>
<ModeIcon size={15} />
{QUICK_LINKS_MODE_LABELS[m]}
{selected && <span className="ml-auto text-brand-coral"></span>}
</button>
);
})}
</div>
)}
</div>
);
};
/**
* Barra flotante de accesos rápidos (iconos), a la derecha o abajo según
* la preferencia del usuario. En modo 'menu' no se muestra (la sección
* va dentro del sidebar de cada layout).
*/
export const QuickLinksDock: FC<QuickLinksDockProps> = ({ items }) => {
const location = useLocation();
const quick = useQuickLinks();
if (quick.mode === 'menu') return null;
const dockItems = quick.links
.map((href) => items.find((it) => it.href === href))
.filter((it): it is DockItem => it !== undefined);
const isActive = (href: string) =>
href === '/' ? location.pathname === '/' : location.pathname === href || location.pathname.startsWith(`${href}/`);
const isRight = quick.mode === 'right';
return (
<nav
aria-label="Accesos rápidos"
className={
isRight
? 'fixed right-0 top-1/2 -translate-y-1/2 z-40 hidden lg:flex flex-col items-center gap-1 py-2 px-1.5 mr-2 rounded-2xl border border-theme-border bg-theme-surface shadow-card'
: 'fixed bottom-3 left-1/2 -translate-x-1/2 z-40 flex items-center gap-1 px-2 py-1.5 rounded-2xl border border-theme-border bg-theme-surface shadow-card'
}
>
{dockItems.length === 0 && (
<span className={`text-[11px] text-theme-muted px-2 ${isRight ? 'py-2 [writing-mode:vertical-rl]' : ''}`}>
Fija accesos con la del menú
</span>
)}
{dockItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<Link
key={item.href}
to={item.href}
title={item.label}
aria-label={item.label}
className={`flex items-center justify-center w-10 h-10 rounded-xl transition-colors ${
active
? 'bg-theme-accent text-theme-inverse shadow-soft'
: 'text-theme-muted hover:bg-theme-accent-bg hover:text-theme-heading'
}`}
>
<Icon size={18} />
</Link>
);
})}
<div className={isRight ? 'border-t border-theme-border pt-1 mt-1' : 'border-l border-theme-border pl-1 ml-1'}>
<QuickLinksModePicker direction={isRight ? 'down' : 'up'} align="right" />
</div>
</nav>
);
};
export default QuickLinksDock;

View File

@@ -0,0 +1,139 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Printer } from 'lucide-react';
import { Button, Modal, Select, TextArea } from './ui';
import { odooApi, type Receta } from '../services/odoo';
interface ClinicSettings {
name: string;
phone: string;
address: string;
}
const clinicSettings = (): ClinicSettings => {
try {
const saved = localStorage.getItem('skeen_clinic_settings');
if (saved) return JSON.parse(saved);
} catch {
// ignore
}
return { name: 'SKEEN Derma Experts', phone: '', address: '' };
};
interface RecetaSectionProps {
value: string;
onChange: (v: string) => void;
onPrint: () => void;
}
/** Sección "Receta": plantilla + texto editable + imprimir (para modales de visita/consulta) */
export const RecetaSection: FC<RecetaSectionProps> = ({ value, onChange, onPrint }) => {
const [recetas, setRecetas] = useState<Receta[]>([]);
useEffect(() => {
odooApi.getRecetas()
.then((res) => { if (res.status === 'success') setRecetas(res.recetas); })
.catch(() => {});
}, []);
const aplicarPlantilla = (id: string) => {
if (!id) return;
const r = recetas.find((x) => String(x.id) === id);
if (r) onChange(r.contenido);
};
return (
<section className="border-t border-theme-border pt-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-theme-heading">Receta</h4>
<Button variant="outline" size="sm" onClick={onPrint} disabled={!value.trim()}>
<Printer size={14} className="mr-1.5" />
Imprimir receta
</Button>
</div>
<div className="space-y-3">
<Select
label="Usar plantilla"
options={[
{ value: '', label: 'Selecciona plantilla...' },
...recetas.map((r) => ({ value: String(r.id), label: r.categoria ? `${r.name} (${r.categoria})` : r.name })),
]}
value=""
onChange={(e) => aplicarPlantilla(e.target.value)}
/>
<TextArea
label="Contenido de la receta"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Medicamentos, dosis e indicaciones..."
/>
</div>
</section>
);
};
interface RecetaPrintProps {
isOpen: boolean;
onClose: () => void;
paciente: string;
doctor?: string | null;
contenido: string;
}
/** Vista de impresión de la receta médica (formato imprimible) */
export const RecetaPrint: FC<RecetaPrintProps> = ({ isOpen, onClose, paciente, doctor, contenido }) => {
const clinic = clinicSettings();
const fecha = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' });
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Vista de impresión — Receta médica"
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={onClose}>Cerrar</Button>
<Button onClick={() => window.print()}>
<Printer size={16} className="mr-2" />
Imprimir
</Button>
</>
}
>
<div className="print-receta bg-white text-[#222] p-6 rounded-xl border border-theme-border">
{/* Encabezado clínica */}
<div className="flex items-start justify-between border-b-2 border-[#1abc9c] pb-3 mb-4">
<div>
<p className="text-xl font-bold">{clinic.name}</p>
{clinic.address && <p className="text-xs text-[#555]">{clinic.address}</p>}
{clinic.phone && <p className="text-xs text-[#555]">Tel: {clinic.phone}</p>}
</div>
<p className="text-sm text-[#555]">{fecha}</p>
</div>
{/* Paciente */}
<p className="text-sm mb-4">
<span className="text-[#555]">Paciente: </span>
<span className="font-semibold">{paciente}</span>
</p>
{/* Cuerpo */}
<div className="flex gap-4 min-h-[220px]">
<p className="text-4xl font-bold text-[#1abc9c] leading-none select-none"></p>
<p className="text-sm whitespace-pre-wrap flex-1">{contenido}</p>
</div>
{/* Firma */}
<div className="mt-10 text-center">
<div className="inline-block border-t border-[#333] pt-1.5 px-8">
<p className="text-sm font-semibold">{doctor || 'Médico tratante'}</p>
<p className="text-xs text-[#555]">{clinic.name}</p>
</div>
</div>
</div>
</Modal>
);
};
export default RecetaPrint;

View File

@@ -0,0 +1,210 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Pencil, Trash2, Search, ScrollText } from 'lucide-react';
import {
Button,
Input,
TextArea,
Modal,
EmptyState,
Skeleton,
MobileCard,
toast,
} from './ui';
import { odooApi, type Receta } from '../services/odoo';
const RecetasPanel: FC = () => {
const [recetas, setRecetas] = useState<Receta[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Receta | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({ name: '', categoria: '', contenido: '' });
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getRecetas(search || undefined);
if (res.status === 'success') setRecetas(res.recetas);
} catch (err) {
toast.error('Error al cargar recetas');
console.error(err);
} finally {
setLoading(false);
}
}, [search]);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setForm({ name: '', categoria: '', contenido: '' });
setModalOpen(true);
};
const openEdit = (r: Receta) => {
setEditing(r);
setForm({ name: r.name, categoria: r.categoria, contenido: r.contenido });
setModalOpen(true);
};
const submit = async () => {
if (!form.name.trim() || !form.contenido.trim()) {
toast.error('Nombre y contenido son obligatorios');
return;
}
try {
setSubmitting(true);
if (editing) {
await odooApi.updateReceta(editing.id, form);
toast.success('Receta actualizada');
} else {
await odooApi.createReceta(form);
toast.success('Receta creada');
}
setModalOpen(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar receta');
console.error(err);
} finally {
setSubmitting(false);
}
};
const eliminar = async (r: Receta) => {
if (!window.confirm(`¿Eliminar la receta "${r.name}"?`)) return;
try {
await odooApi.deleteReceta(r.id);
toast.success('Receta eliminada');
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al eliminar receta');
console.error(err);
}
};
return (
<>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por nombre o categoría..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button onClick={openCreate} className="sm:ml-auto">
<Plus size={16} className="mr-2" />
Nueva receta
</Button>
</div>
{loading ? (
<Skeleton count={4} className="h-12 w-full" />
) : recetas.length === 0 ? (
<EmptyState
title="Sin recetas"
subtitle="Crea la primera plantilla de receta."
actionLabel="Nueva receta"
onAction={openCreate}
icon={<ScrollText size={28} />}
/>
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Categoría</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Contenido</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{recetas.map((r) => (
<tr key={r.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-medium text-theme-heading">{r.name}</td>
<td className="p-3 text-sm text-theme-muted">{r.categoria || '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
<span className="block max-w-md truncate">{r.contenido}</span>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)} title="Editar">
<Pencil size={16} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => eliminar(r)} title="Eliminar">
<Trash2 size={16} className="text-rose-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{recetas.map((r) => (
<MobileCard
key={r.id}
title={r.name}
subtitle={r.categoria || undefined}
rows={[{ label: 'Contenido', value: r.contenido.slice(0, 60) }]}
actions={
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(r)}><Pencil size={16} className="text-theme-muted" /></Button>
<Button variant="ghost" size="sm" onClick={() => eliminar(r)}><Trash2 size={16} className="text-rose-500" /></Button>
</div>
}
/>
))}
</div>
</>
)}
{/* Modal crear/editar receta */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar receta' : 'Nueva receta'}
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Nombre *"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="ej. Post-peeling, Antibiótico acné"
/>
<Input
label="Categoría"
value={form.categoria}
onChange={(e) => setForm({ ...form, categoria: e.target.value })}
placeholder="ej. Cuidados, Medicamento"
/>
<TextArea
label="Contenido * (medicamentos, dosis, indicaciones)"
value={form.contenido}
onChange={(e) => setForm({ ...form, contenido: e.target.value })}
/>
</div>
</Modal>
</>
);
};
export default RecetasPanel;

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)}

View File

@@ -0,0 +1,71 @@
import type { FC } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Palette, Check } from 'lucide-react';
import { useTheme, type Theme } from '../lib/theme';
const THEME_LABELS: Record<Theme, string> = {
skeen: 'SKEEN',
homenest: 'HomeNest',
clasico: 'Clásico',
};
interface ThemePickerProps {
triggerClassName?: string;
}
export const ThemePicker: FC<ThemePickerProps> = ({ triggerClassName }) => {
const { theme, setTheme } = useTheme();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
title="Cambiar tema"
className={
triggerClassName ||
'hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium transition border border-theme-border bg-theme-surface text-theme-heading hover:bg-theme-accent-bg'
}
>
<Palette size={14} />
{THEME_LABELS[theme]}
</button>
{open && (
<div className="absolute right-0 top-full mt-2 z-50 w-44 rounded-2xl border border-theme-border bg-theme-surface shadow-card p-1.5">
<p className="px-2.5 py-1.5 text-[10px] uppercase tracking-wider text-theme-muted font-medium">
Tema
</p>
{(Object.keys(THEME_LABELS) as Theme[]).map((t) => {
const selected = theme === t;
return (
<button
key={t}
type="button"
onClick={() => { setTheme(t); setOpen(false); }}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm transition-colors ${
selected ? 'bg-theme-accent-bg text-theme-heading font-medium' : 'text-theme-muted hover:bg-theme-bg hover:text-theme-heading'
}`}
>
{THEME_LABELS[t]}
{selected && <Check size={14} className="ml-auto" />}
</button>
);
})}
</div>
)}
</div>
);
};
export default ThemePicker;

View File

@@ -0,0 +1,375 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Pencil, KeyRound, ShieldCheck, Trash2, AlertTriangle } from 'lucide-react';
import {
Button,
Input,
Select,
Modal,
Badge,
EmptyState,
Skeleton,
MobileCard,
toast,
} from './ui';
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
import { ROLE_LABELS, useAuth } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items';
const roleOptions: { value: FrontendRole; label: string }[] = (
['admin', 'recepcion', 'medico', 'lectura'] as FrontendRole[]
).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
const roleBadge = (role: FrontendRole) => {
switch (role) {
case 'admin':
return 'danger';
case 'recepcion':
return 'info';
case 'medico':
return 'success';
default:
return 'default';
}
};
const UsuariosPanel: FC = () => {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<FrontendUser[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FrontendUser | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({ login: '', name: '', password: '', role: 'recepcion' as FrontendRole, active: true });
const [permUser, setPermUser] = useState<FrontendUser | null>(null);
const [permCustom, setPermCustom] = useState(false);
const [permSelected, setPermSelected] = useState<string[]>([]);
const [permSaving, setPermSaving] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.listFrontendUsers();
if (res.status === 'success') setUsers(res.users);
} catch (err) {
toast.error('Error al cargar usuarios');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setForm({ login: '', name: '', password: '', role: 'recepcion', active: true });
setModalOpen(true);
};
const openEdit = (u: FrontendUser) => {
setEditing(u);
setForm({ login: u.login, name: u.name, password: '', role: u.role, active: u.active !== false });
setModalOpen(true);
};
const submit = async () => {
if (!form.name.trim()) {
toast.error('El nombre es obligatorio');
return;
}
if (!editing && !form.login.trim()) {
toast.error('El usuario es obligatorio');
return;
}
if (!editing && form.password.length < 8) {
toast.error('La contraseña debe tener al menos 8 caracteres');
return;
}
if (editing && form.password && form.password.length < 8) {
toast.error('La nueva contraseña debe tener al menos 8 caracteres');
return;
}
try {
setSubmitting(true);
if (editing) {
await odooApi.updateFrontendUser(editing.id, {
name: form.name.trim(),
role: form.role,
active: form.active,
...(form.password ? { password: form.password } : {}),
});
toast.success('Usuario actualizado');
} else {
await odooApi.createFrontendUser({
login: form.login.trim(),
name: form.name.trim(),
password: form.password,
role: form.role,
});
toast.success('Usuario creado');
}
setModalOpen(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar usuario');
console.error(err);
} finally {
setSubmitting(false);
}
};
const toggleActive = async (u: FrontendUser) => {
try {
await odooApi.updateFrontendUser(u.id, { active: !(u.active !== false) });
await load();
} catch (err) {
toast.error('Error al cambiar estado');
console.error(err);
}
};
const eliminar = async (u: FrontendUser) => {
if (!window.confirm(`¿Eliminar al usuario "${u.login}"? Esta acción no se puede deshacer.`)) return;
try {
await odooApi.deleteFrontendUser(u.id);
toast.success('Usuario eliminado');
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al eliminar usuario');
console.error(err);
}
};
const openPermisos = (u: FrontendUser) => {
const custom = Array.isArray(u.allowed_menus) && u.allowed_menus.length > 0;
setPermUser(u);
setPermCustom(custom);
setPermSelected(custom && u.allowed_menus ? u.allowed_menus : MENU_ITEMS.map((m) => m.href));
};
const togglePerm = (href: string) => {
setPermSelected((prev) => (prev.includes(href) ? prev.filter((h) => h !== href) : [...prev, href]));
};
const savePermisos = async () => {
if (!permUser) return;
try {
setPermSaving(true);
await odooApi.updateFrontendUser(permUser.id, { allowed_menus: permCustom ? permSelected : [] });
toast.success('Permisos actualizados (se aplican en el próximo inicio de sesión del usuario)');
setPermUser(null);
await load();
} catch (err) {
toast.error('Error al guardar permisos');
console.error(err);
} finally {
setPermSaving(false);
}
};
const permWarning = permUser && currentUser && permUser.id === currentUser.id && permCustom && !permSelected.includes('/configuracion');
return (
<>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-theme-muted">Gestiona quién puede entrar al sistema, su rol y sus secciones visibles.</p>
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo usuario
</Button>
</div>
{loading ? (
<Skeleton count={4} className="h-12 w-full" />
) : users.length === 0 ? (
<EmptyState title="Sin usuarios" subtitle="Crea el primer usuario." actionLabel="Nuevo usuario" onAction={openCreate} />
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Usuario</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Rol</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Secciones</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Último acceso</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{users.map((u) => (
<tr key={u.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-mono text-theme-heading">{u.login}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{u.name}</td>
<td className="p-3"><Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge></td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
{u.allowed_menus && u.allowed_menus.length > 0
? `${u.allowed_menus.length} de ${MENU_ITEMS.length} secciones`
: 'Por rol'}
</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{u.last_login || '—'}</td>
<td className="p-3">
<Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openPermisos(u)} title="Permisos de menú">
<ShieldCheck size={16} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(u)} title="Editar">
<Pencil size={16} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)} title={u.active !== false ? 'Desactivar' : 'Activar'}>
<KeyRound size={16} className={u.active !== false ? 'text-amber-600' : 'text-theme-heading'} />
</Button>
<Button variant="ghost" size="sm" onClick={() => eliminar(u)} title="Eliminar">
<Trash2 size={16} className="text-rose-500" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{users.map((u) => (
<MobileCard
key={u.id}
title={u.name}
subtitle={u.login}
rows={[
{ label: 'Rol', value: <Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge> },
{ label: 'Estado', value: <Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge> },
{ label: 'Secciones', value: u.allowed_menus && u.allowed_menus.length > 0 ? `${u.allowed_menus.length} de ${MENU_ITEMS.length}` : 'Por rol' },
]}
actions={
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openPermisos(u)}><ShieldCheck size={16} className="text-theme-muted" /></Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={16} className="text-theme-muted" /></Button>
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)}><KeyRound size={16} className="text-amber-600" /></Button>
<Button variant="ghost" size="sm" onClick={() => eliminar(u)}><Trash2 size={16} className="text-rose-500" /></Button>
</div>
}
/>
))}
</div>
</>
)}
{/* Modal crear/editar usuario */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar usuario' : 'Nuevo usuario'}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Usuario (login)"
value={form.login}
onChange={(e) => setForm({ ...form, login: e.target.value })}
disabled={!!editing}
placeholder="ej. recepcion1"
/>
<Input
label="Nombre"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Nombre completo"
/>
<Select
label="Rol"
options={roleOptions}
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as FrontendRole })}
/>
<Input
label={editing ? 'Nueva contraseña (dejar vacío para no cambiar)' : 'Contraseña (mín. 8)'}
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
minLength={8}
/>
{editing && (
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={form.active}
onChange={(e) => setForm({ ...form, active: e.target.checked })}
className="rounded border-theme-border-strong"
/>
Usuario activo
</label>
)}
</div>
</Modal>
{/* Modal permisos de menú */}
<Modal
isOpen={!!permUser}
onClose={() => setPermUser(null)}
title={permUser ? `Permisos — ${permUser.name}` : 'Permisos'}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setPermUser(null)}>Cancelar</Button>
<Button onClick={savePermisos} loading={permSaving}>Guardar permisos</Button>
</>
}
>
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={!permCustom}
onChange={(e) => setPermCustom(!e.target.checked)}
className="rounded border-theme-border-strong"
/>
Usar permisos por rol (default)
</label>
{permCustom && (
<>
<p className="text-xs text-theme-muted">
{permSelected.length} de {MENU_ITEMS.length} secciones visibles
</p>
{permWarning && (
<div className="flex items-start gap-2 p-3 bg-theme-warning text-theme-warning-text rounded-xl text-xs">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<p>Estás por quitarte a ti mismo el acceso a Configuración. No podrás volver a abrir esta pantalla.</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[50vh] overflow-y-auto pr-1">
{MENU_ITEMS.map((item) => (
<label key={item.href} className="flex items-center gap-2 p-2.5 border border-theme-border rounded-lg cursor-pointer hover:bg-theme-bg text-sm text-theme-heading">
<input
type="checkbox"
checked={permSelected.includes(item.href)}
onChange={() => togglePerm(item.href)}
className="rounded border-theme-border-strong"
/>
{item.label}
</label>
))}
</div>
</>
)}
</div>
</Modal>
</>
);
};
export default UsuariosPanel;

View File

@@ -0,0 +1,178 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { CalendarX, ShoppingCart } from 'lucide-react';
import { Card, Badge, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type CaducidadRow, type SugerenciaRow } from '../../services/odoo';
import type { BadgeVariant } from '../ui/Badge';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
const caducidadBadge = (row: CaducidadRow): { variant: BadgeVariant; label: string } => {
if (row.vencido) return { variant: 'danger', label: `Vencido hace ${Math.abs(row.dias_restantes)} días` };
if (row.dias_restantes <= 30) return { variant: 'warning', label: `${row.dias_restantes} días` };
return { variant: 'info', label: `${row.dias_restantes} días` };
};
const AlertasPanel: FC = () => {
const [caducidades, setCaducidades] = useState<CaducidadRow[]>([]);
const [sugerencia, setSugerencia] = useState<SugerenciaRow[]>([]);
const [totalSugerencia, setTotalSugerencia] = useState({ items: 0, costo_total: 0 });
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getInventarioAlertas();
if (res.status === 'success') {
setCaducidades(res.caducidades);
setSugerencia(res.sugerencia_compra);
setTotalSugerencia(res.sugerencia_total);
}
} catch (err) {
toast.error('Error al cargar alertas');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `sugerencia-compra-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Sugerencia de compra',
title: 'Sugerencia de compra — inventario SKEEN',
subtitle: `${totalSugerencia.items} artículos bajo mínimo · costo estimado ${fmtMoney(totalSugerencia.costo_total)}`,
columns: [
{ header: 'Artículo', key: 'name' },
{ header: 'Categoría', key: 'category' },
{ header: 'Unidad', key: 'unit' },
{ header: 'Existencia', key: 'qty', format: 'number' },
{ header: 'Mínimo', key: 'qty_min', format: 'number' },
{ header: 'Óptimo', key: 'qty_optimal', format: 'number' },
{ header: 'Sugerido', key: 'sugerido', format: 'number' },
{ header: 'Costo estimado', key: 'costo_estimado', format: 'currency' },
],
rows: sugerencia.map((s) => ({ ...s })),
totals: { name: '', costo_estimado: totalSugerencia.costo_total },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-4">
{/* Caducidades */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
<CalendarX size={20} className="mr-2" /> Caducidades
</h3>
<p className="text-xs text-theme-muted mb-4">Artículos con fecha de caducidad registrada, ordenados por urgencia.</p>
{loading ? (
<Skeleton count={4} className="h-10 w-full" />
) : caducidades.length === 0 ? (
<EmptyState title="Sin caducidades" subtitle="Ningún artículo tiene fecha de caducidad registrada." icon={<CalendarX size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-left px-4 py-3 hidden sm:table-cell">Categoría</th>
<th className="text-right px-4 py-3">Existencia</th>
<th className="text-left px-4 py-3">Caduca</th>
<th className="text-left px-4 py-3">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{caducidades.map((c) => {
const b = caducidadBadge(c);
return (
<tr key={c.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{c.name}</td>
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{c.category || '-'}</td>
<td className="px-4 py-3 text-right text-theme-muted">{c.qty} {c.unit}</td>
<td className="px-4 py-3 text-theme-heading">{c.expiry_date}</td>
<td className="px-4 py-3"><Badge variant={b.variant}>{b.label}</Badge></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
{/* Sugerencia de compra */}
<Card>
<Card.Body>
<div className="flex items-start justify-between gap-3 mb-1">
<h3 className="font-heading text-xl text-theme-heading flex items-center">
<ShoppingCart size={20} className="mr-2" /> Sugerencia de compra
</h3>
<ExportButton onClick={exportar} loading={exporting} />
</div>
<p className="text-xs text-theme-muted mb-4">
Artículos bajo su stock mínimo (o sin existencia). Sugerido = óptimo existencia (o 2× mínimo).
</p>
{loading ? (
<Skeleton count={6} className="h-10 w-full" />
) : sugerencia.length === 0 ? (
<EmptyState title="Sin faltantes" subtitle="Todos los artículos están por encima de su mínimo." icon={<ShoppingCart size={28} />} />
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-right px-4 py-3">Existencia</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Mínimo</th>
<th className="text-right px-4 py-3 hidden md:table-cell">Óptimo</th>
<th className="text-right px-4 py-3">Sugerido</th>
<th className="text-right px-4 py-3">Costo est.</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{sugerencia.map((s) => (
<tr key={s.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{s.name}</td>
<td className="px-4 py-3 text-right text-theme-muted">{s.qty} {s.unit}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{s.qty_min || '-'}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{s.qty_optimal || '-'}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">+{s.sugerido} {s.unit}</td>
<td className="px-4 py-3 text-right text-theme-heading">{fmtMoney(s.costo_estimado)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end mt-3 pt-3 border-t border-theme-border">
<p className="text-sm text-theme-muted">
Total estimado: <span className="font-semibold text-theme-heading">{fmtMoney(totalSugerencia.costo_total)}</span>
{' '}({totalSugerencia.items} artículos)
</p>
</div>
</>
)}
</Card.Body>
</Card>
</div>
);
};
export default AlertasPanel;

View File

@@ -0,0 +1,255 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import {
Card,
Button,
Input,
Select,
Modal,
EmptyState,
Skeleton,
toast,
} from '../ui';
import { odooApi, type MovimientoRow, type InventoryItem } from '../../services/odoo';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
interface MovimientosPanelProps {
tipo: 'compra' | 'baja';
}
const MovimientosPanel: FC<MovimientosPanelProps> = ({ tipo }) => {
const esCompra = tipo === 'compra';
const [movimientos, setMovimientos] = useState<MovimientoRow[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [total, setTotal] = useState(0);
const [modalOpen, setModalOpen] = useState(false);
const [items, setItems] = useState<InventoryItem[]>([]);
const [itemSearch, setItemSearch] = useState('');
const [form, setForm] = useState({ item_id: '', qty: '1', cost: '', reference: '', notes: '' });
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const params: Record<string, string | number> = { type: tipo, page, page_size: 50 };
if (search) params.search = search;
const res = await odooApi.getMovimientos(params);
if (res.status === 'success') {
setMovimientos(res.movimientos);
setTotal(res.total ?? 0);
setTotalPages(res.total_pages ?? 0);
}
} catch (err) {
toast.error('Error al cargar movimientos');
console.error(err);
} finally {
setLoading(false);
}
}, [tipo, search, page]);
useEffect(() => { setPage(1); }, [search, tipo]);
useEffect(() => { load(); }, [load]);
// Catálogo de artículos para el select del modal
useEffect(() => {
if (!modalOpen) return;
odooApi.getInventarioItems(itemSearch || undefined)
.then((res) => { if (res.status === 'success') setItems(res.items); })
.catch(() => {});
}, [modalOpen, itemSearch]);
const openModal = () => {
setForm({ item_id: '', qty: '1', cost: '', reference: '', notes: '' });
setItemSearch('');
setModalOpen(true);
};
const guardar = async () => {
const qty = parseFloat(form.qty);
if (!form.item_id) {
toast.error('Selecciona un artículo');
return;
}
if (Number.isNaN(qty) || qty <= 0) {
toast.error('La cantidad debe ser mayor a 0');
return;
}
try {
setSaving(true);
const payload = {
item_id: parseInt(form.item_id, 10),
qty,
reference: form.reference,
notes: form.notes,
...(esCompra && form.cost !== '' ? { cost: parseFloat(form.cost) } : {}),
};
const res = esCompra ? await odooApi.createCompra(payload) : await odooApi.createBaja(payload);
if (res.status === 'success') {
toast.success(esCompra ? 'Compra registrada' : 'Baja registrada');
setModalOpen(false);
await load();
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al registrar el movimiento');
console.error(err);
} finally {
setSaving(false);
}
};
return (
<>
<Card className="mb-4">
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="relative flex-1 sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por artículo..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Button onClick={openModal} className="sm:ml-auto">
<Plus size={16} className="mr-2" />
{esCompra ? 'Registrar compra' : 'Registrar baja'}
</Button>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
{loading ? (
<Skeleton count={8} className="h-10 w-full" />
) : movimientos.length === 0 ? (
<EmptyState
title={esCompra ? 'Sin compras' : 'Sin bajas'}
subtitle={esCompra ? 'No hay compras registradas.' : 'No hay bajas/mermas registradas.'}
actionLabel={esCompra ? 'Registrar compra' : 'Registrar baja'}
onAction={openModal}
/>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Fecha</th>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-right px-4 py-3">Cantidad</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Antes</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Después</th>
<th className="text-left px-4 py-3 hidden md:table-cell">{esCompra ? 'Referencia' : 'Motivo / Referencia'}</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Notas</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{movimientos.map((m) => (
<tr key={m.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 text-theme-muted whitespace-nowrap">{m.date || '-'}</td>
<td className="px-4 py-3 font-medium text-theme-heading">{m.item}</td>
<td className={`px-4 py-3 text-right font-medium ${esCompra ? 'text-theme-heading' : 'text-rose-600'}`}>
{esCompra ? '+' : ''}{m.qty} {m.unit}
</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{m.before_qty}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{m.after_qty}</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{m.reference || '-'}</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">
<span className="block max-w-[200px] truncate">{m.notes || '-'}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between mt-4 pt-4 border-t border-theme-border">
<p className="text-sm text-theme-muted">{total} movimientos · página {page} de {totalPages || 1}</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Anterior</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>Siguiente</Button>
</div>
</div>
</>
)}
</Card.Body>
</Card>
{/* Modal registrar movimiento */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={esCompra ? 'Registrar compra' : 'Registrar baja / merma'}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={guardar} loading={saving}>{esCompra ? 'Registrar compra' : 'Registrar baja'}</Button>
</>
}
>
<div className="space-y-4">
<Input
placeholder="Buscar artículo..."
value={itemSearch}
onChange={(e) => setItemSearch(e.target.value)}
/>
<Select
label="Artículo *"
options={[
{ value: '', label: 'Selecciona artículo...' },
...items.map((i) => ({ value: String(i.id), label: `${i.name} (${i.qty} ${i.unit})` })),
]}
value={form.item_id}
onChange={(e) => setForm({ ...form, item_id: e.target.value })}
/>
<Input
label="Cantidad *"
type="number"
min="0.5"
step="0.5"
value={form.qty}
onChange={(e) => setForm({ ...form, qty: e.target.value })}
/>
{esCompra && (
<Input
label="Costo unitario (actualiza el costo del artículo)"
type="number"
min="0"
step="0.01"
value={form.cost}
onChange={(e) => setForm({ ...form, cost: e.target.value })}
placeholder="Opcional"
/>
)}
<Input
label={esCompra ? 'Referencia (proveedor / factura)' : 'Referencia'}
value={form.reference}
onChange={(e) => setForm({ ...form, reference: e.target.value })}
/>
<Input
label={esCompra ? 'Notas' : 'Motivo'}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
{esCompra && form.cost !== '' && (
<p className="text-xs text-theme-muted">
Costo total estimado: {fmtMoney((parseFloat(form.qty) || 0) * (parseFloat(form.cost) || 0))}
</p>
)}
</div>
</Modal>
</>
);
};
export default MovimientosPanel;

View File

@@ -0,0 +1,130 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { HandCoins } from 'lucide-react';
import { Card, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type AdeudoRow } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const AdeudosReport: FC = () => {
const [adeudos, setAdeudos] = useState<AdeudoRow[]>([]);
const [totalCartera, setTotalCartera] = useState(0);
const [totalPacientes, setTotalPacientes] = useState(0);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getAdeudos();
if (res.status === 'success') {
setAdeudos(res.adeudos);
setTotalCartera(res.total_cartera);
setTotalPacientes(res.total_pacientes);
}
} catch (err) {
toast.error('Error al cargar adeudos');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `adeudos-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Adeudos',
title: 'Adeudos de clientes',
subtitle: `${totalPacientes} pacientes con adeudo`,
columns: [
{ header: 'Paciente', key: 'name' },
{ header: 'Teléfono', key: 'phone' },
{ header: '# Ventas', key: 'num_ventas', format: 'number' },
{ header: 'Adeudo', key: 'total_adeudo', format: 'currency' },
{ header: 'Última venta', key: 'ultima_venta', format: 'date' },
],
rows: adeudos.map((a) => ({ ...a })),
totals: { name: '', total_adeudo: totalCartera, num_ventas: adeudos.reduce((a, x) => a + x.num_ventas, 0) },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-4 sm:space-y-6">
<div className="flex justify-end">
<ExportButton onClick={exportar} loading={exporting} />
</div>
<div className="grid grid-cols-2 gap-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Total cartera por cobrar</p>
<p className={`text-xl font-heading font-semibold ${totalCartera > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
{formatCurrency(totalCartera)}
</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Pacientes con adeudo</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{totalPacientes}</p>
</Card>
</div>
<Card>
<Card.Body>
{loading ? (
<Skeleton count={8} className="h-10 w-full" />
) : adeudos.length === 0 ? (
<EmptyState title="Sin adeudos" subtitle="Ningún paciente tiene saldo pendiente." icon={<HandCoins size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Paciente</th>
<th className="text-left px-4 py-3 hidden sm:table-cell">Teléfono</th>
<th className="text-right px-4 py-3"># Ventas</th>
<th className="text-right px-4 py-3">Adeudo</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Última venta</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{adeudos.map((a) => (
<tr key={a.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">
{a.phone ? (
<Link to={`/pacientes?q=${encodeURIComponent(a.phone)}`} className="hover:underline">
{a.name}
</Link>
) : (
a.name
)}
</td>
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{a.phone || '-'}</td>
<td className="px-4 py-3 text-right text-theme-muted">{a.num_ventas}</td>
<td className="px-4 py-3 text-right font-semibold text-rose-600">{formatCurrency(a.total_adeudo)}</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{a.ultima_venta || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
);
};
export default AdeudosReport;

View File

@@ -0,0 +1,136 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Stethoscope } from 'lucide-react';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type ComisionRow } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const ComisionesReport: FC = () => {
const [start, setStart] = useState(inicioDeMes());
const [end, setEnd] = useState(hoy());
const [rows, setRows] = useState<ComisionRow[]>([]);
const [totales, setTotales] = useState({ vendido: 0, comision: 0 });
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getComisiones(start, end);
if (res.status === 'success') {
setRows(res.comisiones);
setTotales({ vendido: res.total_vendido, comision: res.total_comision });
}
} catch (err) {
toast.error('Error al cargar comisiones');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `comisiones-${start}_${end}`,
sheetName: 'Comisiones',
title: 'Comisiones por médico',
subtitle: `Del ${start} al ${end}`,
columns: [
{ header: 'Médico', key: 'doctor' },
{ header: 'Puesto', key: 'job_title' },
{ header: 'Citas completadas', key: 'citas_done', format: 'number' },
{ header: 'Líneas', key: 'items', format: 'number' },
{ header: 'Ventas', key: 'sales', format: 'number' },
{ header: 'Total vendido', key: 'total_vendido', format: 'currency' },
{ header: '% Comisión', key: 'commission_pct', format: 'number' },
{ header: 'Comisión estimada', key: 'comision', format: 'currency' },
],
rows: rows.map((r) => ({ ...r })),
totals: { doctor: '', total_vendido: totales.vendido, comision: totales.comision },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-4">
<div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<ExportButton onClick={exportar} loading={exporting} />
</div>
<p className="text-sm text-theme-muted">
Vendido: <span className="font-semibold text-theme-heading">{formatCurrency(totales.vendido)}</span>
{' · '}Comisiones: <span className="font-semibold text-theme-heading">{formatCurrency(totales.comision)}</span>
</p>
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : rows.length === 0 ? (
<EmptyState
title="Sin comisiones"
subtitle="No hay artículos recetados por médicos en el periodo. Marca las líneas como “Artículo recetado” al crear la venta."
icon={<Stethoscope size={28} />}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Médico</th>
<th className="text-right px-4 py-3">Citas completadas</th>
<th className="text-right px-4 py-3">Líneas</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Ventas</th>
<th className="text-right px-4 py-3">Total vendido</th>
<th className="text-right px-4 py-3">% Comisión</th>
<th className="text-right px-4 py-3">Comisión estimada</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((r) => (
<tr key={r.doctor_id} className="hover:bg-theme-bg">
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{r.doctor}</p>
<p className="text-xs text-theme-muted">{r.job_title || '—'}</p>
</td>
<td className="px-4 py-3 text-right text-theme-muted">{r.citas_done}</td>
<td className="px-4 py-3 text-right text-theme-muted">{r.items}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{r.sales}</td>
<td className="px-4 py-3 text-right text-theme-heading">{formatCurrency(r.total_vendido)}</td>
<td className="px-4 py-3 text-right text-theme-muted">{r.commission_pct}%</td>
<td className="px-4 py-3 text-right font-semibold text-theme-heading">{formatCurrency(r.comision)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
);
};
export default ComisionesReport;

View File

@@ -0,0 +1,191 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { LayoutGrid } from 'lucide-react';
import { Card, Input, Badge, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type ConcentradoReport as ConcentradoData } from '../../services/odoo';
import type { BadgeVariant } from '../ui/Badge';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const citaStateLabels: Record<string, string> = {
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
};
const visitaStateLabels: Record<string, string> = {
en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada',
};
const metodoLabels: Record<string, string> = {
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
stripe: 'Stripe', mercadopago: 'MercadoPago',
};
const stateBadge = (state: string): BadgeVariant =>
state === 'done' || state === 'completada' ? 'success'
: state === 'cancelled' || state === 'no_show' || state === 'cancelada' ? 'danger'
: state === 'pending' || state === 'en_curso' ? 'warning' : 'info';
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const Kpi: FC<{ label: string; value: React.ReactNode; rose?: boolean }> = ({ label, value, rose }) => (
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">{label}</p>
<p className={`text-xl font-heading font-semibold ${rose ? 'text-rose-600' : 'text-theme-heading'}`}>{value}</p>
</Card>
);
const ConcentradoReport: FC = () => {
const [start, setStart] = useState(inicioDeMes());
const [end, setEnd] = useState(hoy());
const [data, setData] = useState<ConcentradoData | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getConcentrado(start, end);
if (res.status === 'success') {
const { citas, ventas, pagos, devoluciones, pacientes_nuevos, visitas, start: s, end: e } = res;
setData({ citas, ventas, pagos, devoluciones, pacientes_nuevos, visitas, start: s, end: e });
}
} catch (err) {
toast.error('Error al cargar concentrado');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
if (!data) return;
try {
setExporting(true);
const rows: Record<string, unknown>[] = [
{ seccion: 'Citas', concepto: 'Total', cantidad: data.citas.total, monto: null },
...Object.entries(data.citas.by_state).map(([k, v]) => ({ seccion: 'Citas', concepto: citaStateLabels[k] || k, cantidad: v, monto: null })),
{ seccion: 'Visitas', concepto: 'Total', cantidad: data.visitas.total, monto: null },
...Object.entries(data.visitas.by_state).map(([k, v]) => ({ seccion: 'Visitas', concepto: visitaStateLabels[k] || k, cantidad: v, monto: null })),
{ seccion: 'Ventas', concepto: 'Cantidad', cantidad: data.ventas.count, monto: null },
{ seccion: 'Ventas', concepto: 'Total vendido', cantidad: null, monto: data.ventas.total },
{ seccion: 'Ventas', concepto: 'Por cobrar', cantidad: null, monto: data.ventas.total_due },
{ seccion: 'Cobros', concepto: 'Total cobrado', cantidad: data.pagos.count, monto: data.pagos.total },
...Object.entries(data.pagos.by_method).map(([k, v]) => ({ seccion: 'Cobros', concepto: metodoLabels[k] || k, cantidad: null, monto: v })),
{ seccion: 'Devoluciones', concepto: 'Devuelto', cantidad: data.devoluciones.count, monto: data.devoluciones.total },
{ seccion: 'Pacientes', concepto: 'Nuevos en el periodo', cantidad: data.pacientes_nuevos, monto: null },
];
await exportToExcel({
filename: `concentrado-${start}_${end}`,
sheetName: 'Concentrado',
title: 'Concentrado del periodo',
subtitle: `Del ${start} al ${end}`,
columns: [
{ header: 'Sección', key: 'seccion' },
{ header: 'Concepto', key: 'concepto' },
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
{ header: 'Monto', key: 'monto', format: 'currency' },
],
rows,
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
</Card.Body>
</Card>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : data ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Kpi label="Citas" value={data.citas.total} />
<Kpi label="Visitas clínicas" value={data.visitas.total} />
<Kpi label="Pacientes nuevos" value={data.pacientes_nuevos} />
<Kpi label="Ventas" value={data.ventas.count} />
<Kpi label="Total vendido" value={formatCurrency(data.ventas.total)} />
<Kpi label="Cobrado" value={formatCurrency(data.pagos.total)} />
<Kpi label="Por cobrar" value={formatCurrency(data.ventas.total_due)} rose={data.ventas.total_due > 0} />
<Kpi label="Devoluciones" value={`${data.devoluciones.count} · ${formatCurrency(data.devoluciones.total)}`} rose={data.devoluciones.total > 0} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3 flex items-center">
<LayoutGrid size={18} className="mr-2" /> Citas por estado
</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(data.citas.by_state).map(([state, count]) => (
<Badge key={state} variant={stateBadge(state)}>
{citaStateLabels[state] || state}: {count}
</Badge>
))}
{data.citas.total === 0 && <p className="text-sm text-theme-muted">Sin citas.</p>}
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">Cobros por método</h3>
{Object.keys(data.pagos.by_method).length === 0 ? (
<p className="text-sm text-theme-muted">Sin cobros.</p>
) : (
<div className="space-y-2">
{Object.entries(data.pagos.by_method).map(([method, amount]) => (
<div key={method} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">{metodoLabels[method] || method}</span>
<span className="font-medium text-theme-heading">{formatCurrency(amount)}</span>
</div>
))}
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-lg text-theme-heading mb-3">Visitas por estado</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(data.visitas.by_state).map(([state, count]) => (
<Badge key={state} variant={stateBadge(state)}>
{visitaStateLabels[state] || state}: {count}
</Badge>
))}
{data.visitas.total === 0 && <p className="text-sm text-theme-muted">Sin visitas.</p>}
</div>
</Card.Body>
</Card>
</div>
</>
) : null}
</div>
);
};
export default ConcentradoReport;

View File

@@ -0,0 +1,154 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { Receipt, ExternalLink } from 'lucide-react';
import { Card, Button, Badge, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type CashClosing } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const PAGE = 20;
const CortesReport: FC = () => {
const [cortes, setCortes] = useState<CashClosing[]>([]);
const [loading, setLoading] = useState(true);
const [verMas, setVerMas] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getCashClosings();
if (res.status === 'success') setCortes(res.cash_closings);
} catch (err) {
toast.error('Error al cargar cortes de caja');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
const money = { format: 'currency' as const };
await exportToExcel({
filename: `cortes-de-caja-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Cortes de caja',
title: 'Cortes de caja',
subtitle: `${cortes.length} cortes`,
columns: [
{ header: 'Fecha', key: 'date', format: 'date' },
{ header: 'Usuario', key: 'user' },
{ header: 'Apertura', key: 'opening_cash', ...money },
{ header: 'Efectivo', key: 'total_cash', ...money },
{ header: 'Tarjeta', key: 'total_card', ...money },
{ header: 'Transferencia', key: 'total_transfer', ...money },
{ header: 'Otros', key: 'total_other', ...money },
{ header: 'Total ventas', key: 'total_sales', ...money },
{ header: 'Diferencia', key: 'difference', ...money },
{ header: 'Estado', key: 'estado' },
],
rows: cortes.map((c) => ({ ...c, estado: c.state === 'closed' ? 'Cerrado' : 'Abierto' })),
totals: {
date: '', user: '', opening_cash: cortes.reduce((a, c) => a + c.opening_cash, 0),
total_cash: cortes.reduce((a, c) => a + c.total_cash, 0),
total_card: cortes.reduce((a, c) => a + c.total_card, 0),
total_transfer: cortes.reduce((a, c) => a + c.total_transfer, 0),
total_other: cortes.reduce((a, c) => a + c.total_other, 0),
total_sales: cortes.reduce((a, c) => a + c.total_sales, 0),
},
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
const visibles = verMas ? cortes : cortes.slice(0, PAGE);
return (
<Card>
<Card.Body>
<div className="flex items-center justify-between mb-4">
<h3 className="font-heading text-xl text-theme-heading">Cortes de caja</h3>
<div className="flex items-center gap-2">
<ExportButton onClick={exportar} loading={exporting} />
<Link to="/cortes">
<Button variant="outline" size="sm">
<ExternalLink size={14} className="mr-1.5" />
Ir a Cortes
</Button>
</Link>
</div>
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : cortes.length === 0 ? (
<EmptyState title="Sin cortes" subtitle="Aún no hay cortes de caja registrados." icon={<Receipt size={28} />} />
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Fecha</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Usuario</th>
<th className="text-right px-4 py-3">Apertura</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Efectivo</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Tarjeta</th>
<th className="text-right px-4 py-3 hidden md:table-cell">Transfer.</th>
<th className="text-right px-4 py-3 hidden md:table-cell">Otros</th>
<th className="text-right px-4 py-3">Total ventas</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Diferencia</th>
<th className="text-left px-4 py-3">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{visibles.map((c) => (
<tr key={c.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 text-theme-heading whitespace-nowrap">{c.date}</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{c.user || '-'}</td>
<td className="px-4 py-3 text-right text-theme-muted">{formatCurrency(c.opening_cash)}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{formatCurrency(c.total_cash)}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{formatCurrency(c.total_card)}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{formatCurrency(c.total_transfer)}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{formatCurrency(c.total_other)}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(c.total_sales)}</td>
<td className={`px-4 py-3 text-right hidden sm:table-cell ${c.difference ? 'text-rose-600' : 'text-theme-muted'}`}>
{c.state === 'closed' ? formatCurrency(c.difference) : '-'}
</td>
<td className="px-4 py-3">
<Badge variant={c.state === 'closed' ? 'success' : 'warning'}>
{c.state === 'closed' ? 'Cerrado' : 'Abierto'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!verMas && cortes.length > PAGE && (
<div className="mt-4 text-center">
<Button variant="outline" size="sm" onClick={() => setVerMas(true)}>
Ver más ({cortes.length - PAGE} restantes)
</Button>
</div>
)}
</>
)}
</Card.Body>
</Card>
);
};
export default CortesReport;

View File

@@ -0,0 +1,287 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Calendar, ShoppingCart, CreditCard, Boxes, Stethoscope } from 'lucide-react';
import { Card, Input, Badge, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type DailyReport as DailyReportData, type DailyPorMedico } from '../../services/odoo';
import type { BadgeVariant } from '../ui/Badge';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const citaStateLabels: Record<string, string> = {
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
};
const citaStateBadge = (state: string): BadgeVariant => {
switch (state) {
case 'done': return 'success';
case 'cancelled': case 'no_show': return 'danger';
case 'pending': return 'warning';
default: return 'info';
}
};
const metodoLabels: Record<string, string> = {
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
stripe: 'Stripe', mercadopago: 'MercadoPago',
};
const moveTypeLabels: Record<string, string> = {
compra: 'Compra', venta: 'Venta', baja: 'Baja', ajuste: 'Ajuste',
};
const hoy = () => new Date().toISOString().split('T')[0];
const DailyReport: FC = () => {
const [fecha, setFecha] = useState(hoy());
const [report, setReport] = useState<DailyReportData | null>(null);
const [porMedico, setPorMedico] = useState<DailyPorMedico[] | null>(null);
const [verPorMedico, setVerPorMedico] = useState(false);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getDailyReport(fecha, verPorMedico ? 'medico' : undefined);
if (res.status === 'success') {
const { citas, ventas, pagos, inventario, date } = res;
setReport({ citas, ventas, pagos, inventario, date });
setPorMedico(res.por_medico || null);
}
} catch (err) {
toast.error('Error al cargar movimientos del día');
console.error(err);
} finally {
setLoading(false);
}
}, [fecha, verPorMedico]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
if (!report) return;
try {
setExporting(true);
const rows: Record<string, unknown>[] = [];
Object.entries(report.citas.by_state).forEach(([k, v]) =>
rows.push({ seccion: 'Citas', concepto: citaStateLabels[k] || k, cantidad: v, monto: null }));
rows.push({ seccion: 'Ventas', concepto: 'Cantidad', cantidad: report.ventas.count, monto: null });
rows.push({ seccion: 'Ventas', concepto: 'Total', cantidad: null, monto: report.ventas.total });
rows.push({ seccion: 'Ventas', concepto: 'Pagado', cantidad: null, monto: report.ventas.total_paid });
rows.push({ seccion: 'Ventas', concepto: 'Por cobrar', cantidad: null, monto: report.ventas.total_due });
Object.entries(report.pagos.by_method).forEach(([k, v]) =>
rows.push({ seccion: 'Cobros', concepto: metodoLabels[k] || k, cantidad: null, monto: v }));
Object.entries(report.inventario.by_type).forEach(([k, v]) =>
rows.push({ seccion: 'Inventario', concepto: moveTypeLabels[k] || k, cantidad: v, monto: null }));
if (porMedico) {
porMedico.forEach((r) =>
rows.push({ seccion: 'Por médico', concepto: r.medico, cantidad: r.citas, monto: r.ventas + r.cobros }));
}
await exportToExcel({
filename: `movimientos-diarios-${fecha}`,
sheetName: 'Movimientos diarios',
title: 'Movimientos diarios',
subtitle: `Fecha: ${fecha}${verPorMedico ? ' · desglose por médico' : ''}`,
columns: [
{ header: 'Sección', key: 'seccion' },
{ header: 'Concepto', key: 'concepto' },
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
{ header: 'Monto', key: 'monto', format: 'currency' },
],
rows,
totals: { seccion: '', concepto: 'Cobros del día', cantidad: report.pagos.count, monto: report.pagos.total },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex items-end gap-3">
<Input
label="Fecha"
type="date"
value={fecha}
onChange={(e) => setFecha(e.target.value)}
className="sm:max-w-[200px]"
/>
<button
type="button"
onClick={() => setVerPorMedico((v) => !v)}
className={`mb-1 px-3 py-2 text-sm font-medium rounded-full transition flex items-center gap-1.5 ${
verPorMedico ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
<Stethoscope size={14} />
Por médico
</button>
<div className="ml-auto mb-1">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
</Card.Body>
</Card>
{verPorMedico ? (
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Movimientos del día por médico</h3>
{loading ? (
<Skeleton count={5} className="h-12 w-full" />
) : !porMedico || porMedico.length === 0 ? (
<p className="text-sm text-theme-muted">Sin actividad por médico este día.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Médico</th>
<th className="text-right px-4 py-3">Citas</th>
<th className="text-left px-4 py-3">Estados</th>
<th className="text-right px-4 py-3">Ventas (recetado)</th>
<th className="text-right px-4 py-3">Cobros</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{porMedico.map((r) => (
<tr key={r.medico} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{r.medico}</td>
<td className="px-4 py-3 text-right text-theme-muted">{r.citas}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{Object.entries(r.by_state).map(([state, count]) => (
<Badge key={state} variant={citaStateBadge(state)}>
{citaStateLabels[state] || state}: {count}
</Badge>
))}
{r.citas === 0 && <span className="text-theme-muted">-</span>}
</div>
</td>
<td className="px-4 py-3 text-right text-theme-heading">{formatCurrency(r.ventas)}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(r.cobros)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
) : loading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-40 w-full" />)}
</div>
) : report ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* Citas del día */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
<Calendar size={20} className="mr-2" /> Citas del día
</h3>
<p className="text-sm text-theme-muted mb-4">Total: <span className="font-semibold text-theme-heading">{report.citas.total}</span></p>
{Object.keys(report.citas.by_state).length === 0 ? (
<p className="text-sm text-theme-muted">Sin citas este día.</p>
) : (
<div className="flex flex-wrap gap-2">
{Object.entries(report.citas.by_state).map(([state, count]) => (
<Badge key={state} variant={citaStateBadge(state)}>
{citaStateLabels[state] || state}: {count}
</Badge>
))}
</div>
)}
</Card.Body>
</Card>
{/* Ventas del día */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center">
<ShoppingCart size={20} className="mr-2" /> Ventas del día
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Cantidad</p>
<p className="text-lg font-semibold text-theme-heading">{report.ventas.count}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Total</p>
<p className="text-lg font-semibold text-theme-heading">{formatCurrency(report.ventas.total)}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Pagado</p>
<p className="text-lg font-semibold text-theme-heading">{formatCurrency(report.ventas.total_paid)}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">Por cobrar</p>
<p className={`text-lg font-semibold ${report.ventas.total_due > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
{formatCurrency(report.ventas.total_due)}
</p>
</div>
</div>
</Card.Body>
</Card>
{/* Cobros del día */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
<CreditCard size={20} className="mr-2" /> Cobros del día
</h3>
<p className="text-sm text-theme-muted mb-4">
{report.pagos.count} cobros · Total: <span className="font-semibold text-theme-heading">{formatCurrency(report.pagos.total)}</span>
</p>
{Object.keys(report.pagos.by_method).length === 0 ? (
<p className="text-sm text-theme-muted">Sin cobros este día.</p>
) : (
<div className="space-y-2">
{Object.entries(report.pagos.by_method).map(([method, amount]) => (
<div key={method} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">{metodoLabels[method] || method}</span>
<span className="font-medium text-theme-heading">{formatCurrency(amount)}</span>
</div>
))}
</div>
)}
</Card.Body>
</Card>
{/* Movimientos de inventario */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center">
<Boxes size={20} className="mr-2" /> Inventario del día
</h3>
<p className="text-sm text-theme-muted mb-4">Movimientos: <span className="font-semibold text-theme-heading">{report.inventario.total}</span></p>
{Object.keys(report.inventario.by_type).length === 0 ? (
<p className="text-sm text-theme-muted">Sin movimientos este día.</p>
) : (
<div className="flex flex-wrap gap-2">
{Object.entries(report.inventario.by_type).map(([type, count]) => (
<Badge key={type} variant="default">
{moveTypeLabels[type] || type}: {count}
</Badge>
))}
</div>
)}
</Card.Body>
</Card>
</div>
) : null}
</div>
);
};
export default DailyReport;

View File

@@ -0,0 +1,129 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { RotateCcw } from 'lucide-react';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type DevolucionRow } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const DevolucionesReport: FC = () => {
const [start, setStart] = useState(inicioDeMes());
const [end, setEnd] = useState(hoy());
const [devoluciones, setDevoluciones] = useState<DevolucionRow[]>([]);
const [totalDevuelto, setTotalDevuelto] = useState(0);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getDevoluciones(start, end);
if (res.status === 'success') {
setDevoluciones(res.devoluciones);
setTotalDevuelto(res.total_devuelto);
}
} catch (err) {
toast.error('Error al cargar devoluciones');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `devoluciones-${start}_${end}`,
sheetName: 'Devoluciones',
title: 'Devoluciones',
subtitle: `Del ${start} al ${end}`,
columns: [
{ header: 'Folio', key: 'folio' },
{ header: 'Paciente', key: 'paciente' },
{ header: 'Fecha venta', key: 'fecha', format: 'date' },
{ header: 'Total venta', key: 'total', format: 'currency' },
{ header: 'Devuelto', key: 'refund_amount', format: 'currency' },
{ header: 'Motivo', key: 'refund_reason' },
{ header: 'Fecha devolución', key: 'refunded_at' },
],
rows: devoluciones.map((d) => ({ ...d })),
totals: { folio: '', refund_amount: totalDevuelto, total: devoluciones.reduce((a, d) => a + d.total, 0) },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-4">
<div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<ExportButton onClick={exportar} loading={exporting} />
</div>
<p className="text-sm text-theme-muted">
Total devuelto: <span className={`font-semibold ${totalDevuelto > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>{formatCurrency(totalDevuelto)}</span>
</p>
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : devoluciones.length === 0 ? (
<EmptyState title="Sin devoluciones" subtitle="No hay ventas devueltas en el periodo." icon={<RotateCcw size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Folio</th>
<th className="text-left px-4 py-3">Paciente</th>
<th className="text-left px-4 py-3 hidden sm:table-cell">Fecha venta</th>
<th className="text-right px-4 py-3">Total venta</th>
<th className="text-right px-4 py-3">Devuelto</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Motivo</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Fecha devolución</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{devoluciones.map((d) => (
<tr key={d.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{d.folio}</td>
<td className="px-4 py-3 text-theme-heading">{d.paciente}</td>
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{d.fecha || '-'}</td>
<td className="px-4 py-3 text-right text-theme-muted">{formatCurrency(d.total)}</td>
<td className="px-4 py-3 text-right font-medium text-rose-600">{formatCurrency(d.refund_amount)}</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">
<span className="block max-w-[220px] truncate" title={d.refund_reason}>{d.refund_reason || '-'}</span>
</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{d.refunded_at || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
);
};
export default DevolucionesReport;

View File

@@ -0,0 +1,149 @@
import type { FC } from 'react';
import { useState } from 'react';
import { Download, Mail, Star, Tag } from 'lucide-react';
import { Card, Button, toast } from '../ui';
import { odooApi, type Patient } from '../../services/odoo';
import { exportToExcel } from '../../lib/exporter';
const PAGE_SIZE = 200;
const ExportarReport: FC = () => {
const [descargando, setDescargando] = useState<string | null>(null);
const fetchAllPatients = async (): Promise<Patient[]> => {
const all: Patient[] = [];
let page = 1;
let totalPages = 1;
do {
const res = await odooApi.getPatients({ page, page_size: PAGE_SIZE });
if (res.status !== 'success') break;
all.push(...res.patients);
totalPages = res.total_pages || 1;
page += 1;
} while (page <= totalPages);
return all;
};
const exportCorreos = async (soloVip: boolean) => {
const key = soloVip ? 'vip' : 'correos';
try {
setDescargando(key);
const pacientes = await fetchAllPatients();
const filtrados = pacientes
.filter((p) => p.email && (!soloVip || p.is_vip))
.sort((a, b) => a.name.localeCompare(b.name));
if (filtrados.length === 0) {
toast.error('No hay pacientes con email' + (soloVip ? ' VIP' : ''));
return;
}
await exportToExcel({
filename: `${soloVip ? 'correos-vip' : 'correos'}-skeen-${new Date().toISOString().split('T')[0]}`,
sheetName: soloVip ? 'Correos VIP' : 'Correos',
title: soloVip ? 'Correos de pacientes VIP' : 'Correos de pacientes',
subtitle: `${filtrados.length} correos`,
columns: [
{ header: 'Nombre', key: 'name' },
{ header: 'Email', key: 'email' },
{ header: 'Teléfono', key: 'phone' },
],
rows: filtrados.map((p) => ({ name: p.name, email: p.email, phone: p.phone })),
});
toast.success(`Excel descargado (${filtrados.length} correos)`);
} catch (err) {
toast.error('Error al exportar correos');
console.error(err);
} finally {
setDescargando(null);
}
};
const exportPrecios = async () => {
try {
setDescargando('precios');
const rows: Record<string, unknown>[] = [];
let page = 1;
let totalPages = 1;
do {
const res = await odooApi.getServices({ page, page_size: PAGE_SIZE });
if (res.status !== 'success') break;
res.services.forEach((s) => rows.push({
code: s.code, name: s.name, category: s.category, price: s.price,
package_price: s.package_price || 0, package_sessions: s.package_sessions || 0,
}));
totalPages = res.total_pages || 1;
page += 1;
} while (page <= totalPages);
await exportToExcel({
filename: `lista-precios-skeen-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Lista de precios',
title: 'Lista de precios de servicios',
subtitle: `${rows.length} servicios activos`,
columns: [
{ header: 'Código', key: 'code' },
{ header: 'Nombre', key: 'name' },
{ header: 'Categoría', key: 'category' },
{ header: 'Precio', key: 'price', format: 'currency' },
{ header: 'Precio paquete', key: 'package_price', format: 'currency' },
{ header: 'Sesiones paquete', key: 'package_sessions', format: 'number' },
],
rows,
});
toast.success(`Excel descargado (${rows.length} servicios)`);
} catch (err) {
toast.error('Error al exportar lista de precios');
console.error(err);
} finally {
setDescargando(null);
}
};
const cards = [
{
key: 'correos',
icon: Mail,
titulo: 'Exportar correos',
descripcion: 'Todos los pacientes con email registrado (nombre, email, teléfono).',
onClick: () => exportCorreos(false),
},
{
key: 'vip',
icon: Star,
titulo: 'Correos VIP',
descripcion: 'Solo pacientes marcados como VIP con email registrado.',
onClick: () => exportCorreos(true),
},
{
key: 'precios',
icon: Tag,
titulo: 'Lista de precios',
descripcion: 'Servicios activos con código, categoría, precio y precio de paquete.',
onClick: exportPrecios,
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
{cards.map(({ key, icon: Icon, titulo, descripcion, onClick }) => (
<Card key={key}>
<Card.Body>
<div className="flex flex-col items-start gap-3">
<div className="w-10 h-10 rounded-full bg-theme-accent-bg flex items-center justify-center text-theme-heading">
<Icon size={20} />
</div>
<div>
<h3 className="font-heading text-lg text-theme-heading">{titulo}</h3>
<p className="text-sm text-theme-muted mt-1">{descripcion}</p>
</div>
<Button onClick={onClick} loading={descargando === key} disabled={!!descargando}>
<Download size={16} className="mr-2" />
Descargar CSV
</Button>
</div>
</Card.Body>
</Card>
))}
</div>
);
};
export default ExportarReport;

View File

@@ -0,0 +1,162 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Clock } from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, Input, Badge, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type HoraAgendaRow } from '../../services/odoo';
import type { BadgeVariant } from '../ui/Badge';
const citaStateLabels: Record<string, string> = {
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
};
const citaStateBadge = (state: string): BadgeVariant => {
switch (state) {
case 'done': return 'success';
case 'cancelled': case 'no_show': return 'danger';
case 'pending': return 'warning';
default: return 'info';
}
};
const hoy = () => new Date().toISOString().split('T')[0];
const HorasAgendaReport: FC = () => {
const [start, setStart] = useState(hoy());
const [end, setEnd] = useState(hoy());
const [horas, setHoras] = useState<HoraAgendaRow[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getHorasAgenda(start, end);
if (res.status === 'success') setHoras(res.horas);
} catch (err) {
toast.error('Error al cargar horas de agenda');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
const rows: Record<string, unknown>[] = [];
horas.forEach((h) => {
Object.entries(h.by_state).forEach(([state, count]) =>
rows.push({ hora: h.hora, estado: citaStateLabels[state] || state, citas: count }));
});
await exportToExcel({
filename: `horas-agenda-${start}_${end}`,
sheetName: 'Horas agenda',
title: 'Horas agenda',
subtitle: `Del ${start} al ${end} · ${total} citas`,
columns: [
{ header: 'Hora', key: 'hora' },
{ header: 'Estado', key: 'estado' },
{ header: 'Citas', key: 'citas', format: 'number' },
],
rows,
totals: { hora: '', estado: 'Total citas', citas: total },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
const chartData = horas.map((h) => ({ name: h.hora, citas: h.total }));
const total = horas.reduce((acc, h) => acc + h.total, 0);
return (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<ExportButton onClick={exportar} loading={exporting} />
<p className="text-sm text-theme-muted sm:ml-auto">{total} citas en el periodo</p>
</div>
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Citas por hora del día</h3>
{loading ? (
<Skeleton className="h-[260px] w-full" />
) : total === 0 ? (
<EmptyState title="Sin citas" subtitle="No hay citas en el periodo." icon={<Clock size={28} />} />
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} allowDecimals={false} />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} />
<Bar dataKey="citas" fill="#57534e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</Card.Body>
</Card>
{!loading && total > 0 && (
<Card>
<Card.Body>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Hora</th>
<th className="text-right px-4 py-3">Citas</th>
<th className="text-left px-4 py-3">Estados</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{horas.filter((h) => h.total > 0).map((h) => (
<tr key={h.hora} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{h.hora}</td>
<td className="px-4 py-3 text-right text-theme-muted">{h.total}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{Object.entries(h.by_state).map(([state, count]) => (
<Badge key={state} variant={citaStateBadge(state)}>
{citaStateLabels[state] || state}: {count}
</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card.Body>
</Card>
)}
</div>
);
};
export default HorasAgendaReport;

View File

@@ -0,0 +1,209 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { BarChart3 } from 'lucide-react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const metodoLabels: Record<string, string> = {
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
stripe: 'Stripe', mercadopago: 'MercadoPago',
};
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
interface SalesData {
total_sales: number;
total_paid: number;
total_due: number;
count: number;
by_day: { date: string; total: number }[];
}
const IngresosReport: FC = () => {
const [start, setStart] = useState(inicioDeMes());
const [end, setEnd] = useState(hoy());
const [sales, setSales] = useState<SalesData | null>(null);
const [byMethod, setByMethod] = useState<Record<string, number>>({});
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const [salesRes, cashRes] = await Promise.all([
odooApi.getSalesReport(start, end),
odooApi.getCashReportRange(start, end),
]);
if (salesRes.status === 'success') {
setSales({
total_sales: salesRes.total_sales,
total_paid: salesRes.total_paid,
total_due: salesRes.total_due,
count: salesRes.count,
by_day: salesRes.by_day || [],
});
}
if (cashRes.status === 'success') setByMethod(cashRes.by_method);
} catch (err) {
toast.error('Error al cargar ingresos');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
if (!sales) return;
try {
setExporting(true);
const rows: Record<string, unknown>[] = sales.by_day.map((d) => ({
fecha: d.date, total: d.total, metodo: '',
}));
Object.entries(byMethod).forEach(([k, v]) =>
rows.push({ fecha: '', metodo: metodoLabels[k] || k, total: v }));
await exportToExcel({
filename: `ingresos-${start}_${end}`,
sheetName: 'Ingresos',
title: 'Ingresos',
subtitle: `Del ${start} al ${end} · primeras filas por día, luego por método de cobro`,
columns: [
{ header: 'Fecha', key: 'fecha', format: 'date' },
{ header: 'Método', key: 'metodo' },
{ header: 'Total', key: 'total', format: 'currency' },
],
rows,
totals: { fecha: '', metodo: 'Total vendido', total: sales.total_sales },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
const chartData = (sales?.by_day || []).map((d) => ({
name: new Date(`${d.date}T12:00:00`).toLocaleDateString('es-MX', { day: 'numeric', month: 'short' }),
total: d.total,
}));
const ticketPromedio = sales && sales.count > 0 ? sales.total_sales / sales.count : 0;
return (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[180px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[180px]" />
<div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
</Card.Body>
</Card>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : sales ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Total vendido</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(sales.total_sales)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Total cobrado</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(sales.total_paid)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Por cobrar</p>
<p className={`text-xl font-heading font-semibold ${sales.total_due > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
{formatCurrency(sales.total_due)}
</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Ticket promedio</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(ticketPromedio)}</p>
</Card>
</div>
) : null}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
<Card className="lg:col-span-2">
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Ingresos por día</h3>
{loading ? (
<Skeleton className="h-[280px] w-full" />
) : chartData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay ventas en el periodo." icon={<BarChart3 size={28} />} />
) : (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<defs>
<linearGradient id="ingresosGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#57534e" stopOpacity={0.25} />
<stop offset="100%" stopColor="#57534e" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} tickFormatter={(v: number) => formatCurrency(v)} />
<Tooltip
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }}
formatter={(v) => formatCurrency(Number(v))}
/>
<Area type="monotone" dataKey="total" stroke="#57534e" strokeWidth={2} fill="url(#ingresosGradient)" name="Ingresos" />
</AreaChart>
</ResponsiveContainer>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Cobros por método</h3>
{loading ? (
<Skeleton className="h-32 w-full" />
) : Object.keys(byMethod).length === 0 ? (
<p className="text-sm text-theme-muted">Sin cobros en el periodo.</p>
) : (
<div className="space-y-2">
{Object.entries(byMethod).map(([method, amount]) => (
<div key={method} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">{metodoLabels[method] || method}</span>
<span className="font-medium text-theme-heading">{formatCurrency(amount)}</span>
</div>
))}
</div>
)}
</Card.Body>
</Card>
</div>
</div>
);
};
export default IngresosReport;

View File

@@ -0,0 +1,187 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Boxes } from 'lucide-react';
import { Card, Badge, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type InventoryItem, type InventoryReportSummary } from '../../services/odoo';
import type { BadgeVariant } from '../ui/Badge';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const levelBadge = (level: string): BadgeVariant => {
switch (level) {
case 'out': return 'danger';
case 'critical': case 'low': return 'warning';
default: return 'success';
}
};
const levelLabels: Record<string, string> = {
out: 'Sin existencias', critical: 'Crítico', low: 'Bajo', optimal: 'Óptimo',
};
const FILTROS = [
{ key: 'todos', label: 'Todos' },
{ key: 'bajo', label: 'Bajo mínimo' },
{ key: 'out', label: 'Sin existencias' },
] as const;
const InventarioReport: FC = () => {
const [items, setItems] = useState<InventoryItem[]>([]);
const [summary, setSummary] = useState<InventoryReportSummary | null>(null);
const [loading, setLoading] = useState(true);
const [filtro, setFiltro] = useState<string>('todos');
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getInventoryReport();
if (res.status === 'success') {
setItems(res.items);
setSummary(res.summary);
}
} catch (err) {
toast.error('Error al cargar reporte de inventario');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const visibles = useMemo(() => {
if (filtro === 'out') return items.filter((i) => i.stock_level === 'out');
if (filtro === 'bajo') return items.filter((i) => i.stock_level === 'critical' || i.stock_level === 'low');
return items;
}, [items, filtro]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
const filtroLabel = FILTROS.find((f) => f.key === filtro)?.label || 'Todos';
await exportToExcel({
filename: `inventario-${filtro}-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Inventario',
title: 'Reporte de inventario',
subtitle: `Filtro: ${filtroLabel} · ${visibles.length} artículos · valor total ${formatCurrency(summary?.total_value ?? 0)}`,
columns: [
{ header: 'Artículo', key: 'name' },
{ header: 'Categoría', key: 'category' },
{ header: 'Unidad', key: 'unit' },
{ header: 'Existencia', key: 'qty', format: 'number' },
{ header: 'Mínimo', key: 'qty_min', format: 'number' },
{ header: 'Óptimo', key: 'qty_optimal', format: 'number' },
{ header: 'Costo', key: 'cost', format: 'currency' },
{ header: 'Valor', key: 'inventory_value', format: 'currency' },
{ header: 'Nivel', key: 'nivel' },
],
rows: visibles.map((i) => ({ ...i, nivel: levelLabels[i.stock_level] || i.stock_level })),
totals: { name: '', inventory_value: visibles.reduce((a, i) => a + i.inventory_value, 0) },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-4 sm:space-y-6">
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : summary ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Valor del inventario</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(summary.total_value)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Artículos</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{summary.count}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Sin existencias</p>
<p className={`text-xl font-heading font-semibold ${summary.out > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>{summary.out}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Bajo mínimo</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{summary.critical}</p>
</Card>
</div>
) : null}
<Card>
<Card.Body>
<div className="flex flex-wrap items-center gap-1.5 mb-4">
{FILTROS.map((f) => (
<button
key={f.key}
type="button"
onClick={() => setFiltro(f.key)}
className={`px-3 py-1.5 text-sm font-medium rounded-full transition ${
filtro === f.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{f.label}
</button>
))}
<div className="ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
{loading ? (
<Skeleton count={8} className="h-10 w-full" />
) : visibles.length === 0 ? (
<EmptyState title="Sin artículos" subtitle="No hay artículos con este filtro." icon={<Boxes size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Categoría</th>
<th className="text-left px-4 py-3 hidden sm:table-cell">Unidad</th>
<th className="text-right px-4 py-3">Existencia</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Mínimo</th>
<th className="text-right px-4 py-3 hidden md:table-cell">Óptimo</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Costo</th>
<th className="text-right px-4 py-3">Valor</th>
<th className="text-left px-4 py-3">Nivel</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{visibles.map((i) => (
<tr key={i.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{i.name}</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{i.category || '-'}</td>
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{i.unit}</td>
<td className="px-4 py-3 text-right text-theme-heading">{i.qty}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{i.qty_min || '-'}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden md:table-cell">{i.qty_optimal || '-'}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{formatCurrency(i.cost)}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(i.inventory_value)}</td>
<td className="px-4 py-3">
<Badge variant={levelBadge(i.stock_level)}>{levelLabels[i.stock_level] || i.stock_level}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
);
};
export default InventarioReport;

View File

@@ -0,0 +1,244 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Gauge, UserX, TrendingUp, UserPlus } from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type KpisReport as KpisData } from '../../services/odoo';
const pct = (v: number) => `${Math.round(v * 100)}%`;
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const Kpi: FC<{ label: string; value: React.ReactNode; icon: React.ReactNode; rose?: boolean }> = ({ label, value, icon, rose }) => (
<Card className="p-4 bg-theme-bg border-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-theme-muted">{icon}</span>
<p className="text-xs text-theme-muted">{label}</p>
</div>
<p className={`text-xl font-heading font-semibold ${rose ? 'text-rose-600' : 'text-theme-heading'}`}>{value}</p>
</Card>
);
const KpisReport: FC = () => {
const [start, setStart] = useState(inicioDeMes());
const [end, setEnd] = useState(hoy());
const [data, setData] = useState<KpisData | null>(null);
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getKpis(start, end);
if (res.status === 'success') {
const { no_show, ocupacion, leads, primera_vez, start: s, end: e } = res;
setData({ no_show, ocupacion, leads, primera_vez, start: s, end: e });
}
} catch (err) {
toast.error('Error al cargar KPIs');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const exportar = async () => {
if (!data) return;
try {
setExporting(true);
const rows: Record<string, unknown>[] = [];
data.no_show.por_medico.forEach((m) =>
rows.push({ seccion: 'No-show', medico: m.medico, citas: m.citas, casos: m.no_shows, horas: null, tasa: m.tasa }));
data.ocupacion.por_medico.forEach((o) =>
rows.push({ seccion: 'Ocupación', medico: o.medico, citas: null, casos: null, horas: o.horas_vendidas, tasa: o.ocupacion }));
rows.push({ seccion: 'Resumen', medico: 'No-show global', citas: data.no_show.total_citas, casos: data.no_show.total_no_shows, horas: null, tasa: data.no_show.tasa });
rows.push({ seccion: 'Resumen', medico: 'Conversión leads', citas: data.leads.total, casos: data.leads.ganados, horas: null, tasa: data.leads.tasa });
rows.push({ seccion: 'Resumen', medico: 'Primera vez', citas: data.primera_vez.primera + data.primera_vez.subsecuentes, casos: data.primera_vez.primera, horas: null, tasa: data.primera_vez.tasa });
await exportToExcel({
filename: `kpis-${start}_${end}`,
sheetName: 'KPIs',
title: 'KPIs clínicos SKEEN',
subtitle: `Del ${start} al ${end}`,
columns: [
{ header: 'Sección', key: 'seccion' },
{ header: 'Médico / Concepto', key: 'medico' },
{ header: 'Citas / Leads', key: 'citas', format: 'number' },
{ header: 'Casos', key: 'casos', format: 'number' },
{ header: 'Horas vendidas', key: 'horas', format: 'number' },
{ header: 'Tasa', key: 'tasa', format: 'percent' },
],
rows,
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
</Card.Body>
</Card>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : data ? (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Kpi label="Tasa no-show" value={pct(data.no_show.tasa)} icon={<UserX size={15} />} rose={data.no_show.tasa > 0.15} />
<Kpi label="Ocupación promedio" value={pct(data.ocupacion.promedio)} icon={<Gauge size={15} />} />
<Kpi label="Conversión de leads" value={pct(data.leads.tasa)} icon={<TrendingUp size={15} />} />
<Kpi label="Pacientes primera vez" value={pct(data.primera_vez.tasa)} icon={<UserPlus size={15} />} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* No-show por médico */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">No-show por médico</h3>
<p className="text-xs text-theme-muted mb-4">
{data.no_show.total_no_shows} no-shows de {data.no_show.total_citas} citas (canceladas no cuentan).
</p>
{data.no_show.por_medico.length === 0 ? (
<p className="text-sm text-theme-muted">Sin citas en el periodo.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Médico</th>
<th className="text-right px-4 py-3">Citas</th>
<th className="text-right px-4 py-3">No-shows</th>
<th className="text-right px-4 py-3">Tasa</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.no_show.por_medico.map((m) => (
<tr key={m.medico} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{m.medico}</td>
<td className="px-4 py-3 text-right text-theme-muted">{m.citas}</td>
<td className="px-4 py-3 text-right text-theme-muted">{m.no_shows}</td>
<td className={`px-4 py-3 text-right font-medium ${m.tasa > 0.15 ? 'text-rose-600' : 'text-theme-heading'}`}>
{pct(m.tasa)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
{/* Ocupación por médico */}
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">Ocupación por médico</h3>
<p className="text-xs text-theme-muted mb-4">
{data.ocupacion.dias_habiles} días hábiles × 8h (domingos no cuentan), menos bloqueos de agenda.
</p>
{data.ocupacion.por_medico.length === 0 ? (
<p className="text-sm text-theme-muted">Sin citas en el periodo.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Médico</th>
<th className="text-right px-4 py-3">Vendidas</th>
<th className="text-right px-4 py-3">Disponibles</th>
<th className="text-right px-4 py-3">Libres</th>
<th className="text-right px-4 py-3">Ocupación</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.ocupacion.por_medico.map((o) => (
<tr key={o.medico} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{o.medico}</td>
<td className="px-4 py-3 text-right text-theme-muted">{o.horas_vendidas}h</td>
<td className="px-4 py-3 text-right text-theme-muted">{o.horas_disponibles}h</td>
<td className="px-4 py-3 text-right text-theme-muted">{o.horas_libres}h</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{pct(o.ocupacion)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data.ocupacion.por_medico.length > 0 && (
<div className="mt-4">
<ResponsiveContainer width="100%" height={180}>
<BarChart data={data.ocupacion.por_medico.map((o) => ({ name: o.medico.split(' ')[0], ocupacion: Math.round(o.ocupacion * 100) }))} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 11 }} unit="%" />
<Tooltip contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }} formatter={(v) => `${v}%`} />
<Bar dataKey="ocupacion" fill="#57534e" radius={[4, 4, 0, 0]} name="Ocupación" />
</BarChart>
</ResponsiveContainer>
</div>
)}
</Card.Body>
</Card>
</div>
{/* Leads y primera vez */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted mb-1">Conversión de leads (deals creados en el periodo)</p>
<p className="text-sm text-theme-heading">
<span className="font-semibold">{data.leads.ganados}</span> ganados de{' '}
<span className="font-semibold">{data.leads.total}</span> leads ({pct(data.leads.tasa)})
{data.leads.dias_promedio_ganar !== null && (
<span className="text-theme-muted"> · prom. {data.leads.dias_promedio_ganar} días a ganar</span>
)}
</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted mb-1">Primera vez vs subsecuentes</p>
<p className="text-sm text-theme-heading">
<span className="font-semibold">{data.primera_vez.primera}</span> primera vez ·{' '}
<span className="font-semibold">{data.primera_vez.subsecuentes}</span> subsecuentes
</p>
</Card>
</div>
</>
) : (
<EmptyState title="Sin datos" subtitle="No se pudieron calcular los KPIs." />
)}
</div>
);
};
export default KpisReport;

View File

@@ -0,0 +1,118 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Users } from 'lucide-react';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type PagoClienteRow } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const PagosClientesReport: FC = () => {
const [start, setStart] = useState(inicioDeMes());
const [end, setEnd] = useState(hoy());
const [clientes, setClientes] = useState<PagoClienteRow[]>([]);
const [totalCobrado, setTotalCobrado] = useState(0);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getPagosClientes(start, end);
if (res.status === 'success') {
setClientes(res.clientes);
setTotalCobrado(res.total_cobrado);
}
} catch (err) {
toast.error('Error al cargar pagos por cliente');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `pagos-clientes-${start}_${end}`,
sheetName: 'Pagos por cliente',
title: 'Pagos · Clientes',
subtitle: `Del ${start} al ${end}`,
columns: [
{ header: 'Cliente', key: 'name' },
{ header: '# Pagos', key: 'num_pagos', format: 'number' },
{ header: 'Total cobrado', key: 'total_cobrado', format: 'currency' },
{ header: 'Último pago', key: 'ultimo_pago' },
],
rows: clientes.map((c) => ({ ...c })),
totals: { name: '', num_pagos: clientes.reduce((a, c) => a + c.num_pagos, 0), total_cobrado: totalCobrado },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-4">
<div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<ExportButton onClick={exportar} loading={exporting} />
</div>
<p className="text-sm text-theme-muted">
Total cobrado: <span className="font-semibold text-theme-heading">{formatCurrency(totalCobrado)}</span>
</p>
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : clientes.length === 0 ? (
<EmptyState title="Sin cobros" subtitle="No hay pagos registrados en el periodo." icon={<Users size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Cliente</th>
<th className="text-right px-4 py-3"># Pagos</th>
<th className="text-right px-4 py-3">Total cobrado</th>
<th className="text-left px-4 py-3 hidden sm:table-cell">Último pago</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{clientes.map((c) => (
<tr key={c.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{c.name}</td>
<td className="px-4 py-3 text-right text-theme-muted">{c.num_pagos}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(c.total_cobrado)}</td>
<td className="px-4 py-3 text-theme-muted hidden sm:table-cell">{c.ultimo_pago || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
);
};
export default PagosClientesReport;

View File

@@ -0,0 +1,169 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { CreditCard } from 'lucide-react';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type PagoServicioRow } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const metodoLabels: Record<string, string> = {
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
stripe: 'Stripe', mercadopago: 'MercadoPago',
};
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const PagosServiciosReport: FC = () => {
const [start, setStart] = useState(inicioDeMes());
const [end, setEnd] = useState(hoy());
const [servicios, setServicios] = useState<PagoServicioRow[]>([]);
const [byMethod, setByMethod] = useState<Record<string, number>>({});
const [totalCobrado, setTotalCobrado] = useState(0);
const [totalPagadoCompleto, setTotalPagadoCompleto] = useState(0);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getPagosServicios(start, end);
if (res.status === 'success') {
setServicios(res.servicios);
setByMethod(res.by_method);
setTotalCobrado(res.total_cobrado);
setTotalPagadoCompleto(res.total_pagado_completo);
}
} catch (err) {
toast.error('Error al cargar pagos por servicio');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
const rows: Record<string, unknown>[] = servicios.map((s) => ({
servicio: s.servicio, cantidad: s.cantidad, total: s.total, metodo: '', monto: null,
}));
Object.entries(byMethod).forEach(([k, v]) =>
rows.push({ servicio: '', cantidad: null, total: null, metodo: metodoLabels[k] || k, monto: v }));
await exportToExcel({
filename: `pagos-servicios-${start}_${end}`,
sheetName: 'Pagos por servicio',
title: 'Pagos · Servicios',
subtitle: `Del ${start} al ${end} · ventas liquidadas por servicio y cobros por método`,
columns: [
{ header: 'Servicio', key: 'servicio' },
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
{ header: 'Total', key: 'total', format: 'currency' },
{ header: 'Método', key: 'metodo' },
{ header: 'Monto', key: 'monto', format: 'currency' },
],
rows,
totals: { servicio: '', total: totalPagadoCompleto, monto: totalCobrado },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
</Card.Body>
</Card>
<div className="grid grid-cols-2 gap-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Total cobrado (pagos del periodo)</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(totalCobrado)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Ventas liquidadas en el periodo</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(totalPagadoCompleto)}</p>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
<Card className="lg:col-span-2">
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">Cobrado por servicio</h3>
<p className="text-xs text-theme-muted mb-4">Ventas totalmente pagadas del periodo, agrupadas por servicio.</p>
{loading ? (
<Skeleton count={6} className="h-10 w-full" />
) : servicios.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay ventas liquidadas en el periodo." icon={<CreditCard size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Servicio</th>
<th className="text-right px-4 py-3">Cantidad</th>
<th className="text-right px-4 py-3">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{servicios.map((s, i) => (
<tr key={i} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{s.servicio}</td>
<td className="px-4 py-3 text-right text-theme-muted">{s.cantidad}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(s.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Cobros por método</h3>
{loading ? (
<Skeleton className="h-32 w-full" />
) : Object.keys(byMethod).length === 0 ? (
<p className="text-sm text-theme-muted">Sin cobros en el periodo.</p>
) : (
<div className="space-y-2">
{Object.entries(byMethod).map(([method, amount]) => (
<div key={method} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">{metodoLabels[method] || method}</span>
<span className="font-medium text-theme-heading">{formatCurrency(amount)}</span>
</div>
))}
</div>
)}
</Card.Body>
</Card>
</div>
</div>
);
};
export default PagosServiciosReport;

View File

@@ -0,0 +1,106 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Package } from 'lucide-react';
import { Card, Badge, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type PaqueteRow } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const PaquetesReport: FC = () => {
const [paquetes, setPaquetes] = useState<PaqueteRow[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getPaquetes();
if (res.status === 'success') setPaquetes(res.paquetes);
} catch (err) {
toast.error('Error al cargar paquetes');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `paquetes-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Paquetes',
title: 'Paquetes de sesiones',
columns: [
{ header: 'Servicio', key: 'name' },
{ header: 'Sesiones', key: 'package_sessions', format: 'number' },
{ header: 'Precio paquete', key: 'package_price', format: 'currency' },
{ header: 'Precio sesión', key: 'price', format: 'currency' },
{ header: 'Citas agendadas', key: 'citas', format: 'number' },
{ header: 'Paquetes terminados', key: 'terminados', format: 'number' },
],
rows: paquetes.map((p) => ({ ...p })),
totals: { name: '', citas: paquetes.reduce((a, p) => a + p.citas, 0), terminados: paquetes.reduce((a, p) => a + p.terminados, 0) },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Card>
<Card.Body>
<div className="flex items-start justify-between mb-1">
<h3 className="font-heading text-xl text-theme-heading">Paquetes de sesiones</h3>
<ExportButton onClick={exportar} loading={exporting} />
</div>
<p className="text-xs text-theme-muted mb-4">Servicios vendidos como paquete: sesiones contratadas, citas agendadas y paquetes terminados.</p>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : paquetes.length === 0 ? (
<EmptyState title="Sin paquetes" subtitle="No hay servicios configurados como paquete." icon={<Package size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Servicio</th>
<th className="text-right px-4 py-3">Sesiones</th>
<th className="text-right px-4 py-3">Precio paquete</th>
<th className="text-right px-4 py-3 hidden sm:table-cell">Precio sesión</th>
<th className="text-right px-4 py-3">Citas agendadas</th>
<th className="text-right px-4 py-3">Paquetes terminados</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{paquetes.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{p.name}</td>
<td className="px-4 py-3 text-right text-theme-muted">{p.package_sessions}</td>
<td className="px-4 py-3 text-right text-theme-heading">{formatCurrency(p.package_price)}</td>
<td className="px-4 py-3 text-right text-theme-muted hidden sm:table-cell">{formatCurrency(p.price)}</td>
<td className="px-4 py-3 text-right text-theme-muted">{p.citas}</td>
<td className="px-4 py-3 text-right">
<Badge variant={p.terminados > 0 ? 'success' : 'default'}>{p.terminados}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
);
};
export default PaquetesReport;

View File

@@ -0,0 +1,104 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Share2 } from 'lucide-react';
import { Card, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type RecomendacionRow } from '../../services/odoo';
const RecomendacionesReport: FC = () => {
const [recomendaciones, setRecomendaciones] = useState<RecomendacionRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getRecomendaciones();
if (res.status === 'success') {
setRecomendaciones(res.recomendaciones);
setTotal(res.total);
}
} catch (err) {
toast.error('Error al cargar recomendaciones');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `recomendaciones-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Recomendaciones',
title: 'Pacientes por recomendación',
subtitle: `${total} pacientes recomendados`,
columns: [
{ header: '#', key: 'rank', format: 'number' },
{ header: 'Recomendador', key: 'recomendador' },
{ header: 'Pacientes', key: 'pacientes', format: 'number' },
],
rows: recomendaciones.map((r, i) => ({ ...r, rank: i + 1 })),
totals: { recomendador: '', pacientes: total },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Card>
<Card.Body>
<div className="flex items-center justify-between mb-4">
<h3 className="font-heading text-xl text-theme-heading">Pacientes por recomendación</h3>
<div className="flex items-center gap-3">
<p className="text-sm text-theme-muted">{total} pacientes recomendados</p>
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : recomendaciones.length === 0 ? (
<EmptyState
title="Sin recomendaciones"
subtitle="Ningún paciente tiene capturado el campo “Recomendado por”."
icon={<Share2 size={28} />}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">#</th>
<th className="text-left px-4 py-3">Recomendador</th>
<th className="text-right px-4 py-3">Pacientes</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{recomendaciones.map((r, i) => (
<tr key={r.recomendador} className="hover:bg-theme-bg">
<td className="px-4 py-3 text-theme-muted">{i + 1}</td>
<td className="px-4 py-3 font-medium text-theme-heading">{r.recomendador}</td>
<td className="px-4 py-3 text-right text-theme-muted">{r.pacientes}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
);
};
export default RecomendacionesReport;

View File

@@ -0,0 +1,113 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Trophy } from 'lucide-react';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type TopClienteRow } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const TopClientesReport: FC = () => {
const [start, setStart] = useState(inicioDeMes());
const [end, setEnd] = useState(hoy());
const [clientes, setClientes] = useState<TopClienteRow[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getTopClientes(start, end, 20);
if (res.status === 'success') setClientes(res.clientes);
} catch (err) {
toast.error('Error al cargar top clientes');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `top-clientes-${start}_${end}`,
sheetName: 'Top clientes',
title: 'Top clientes por compras',
subtitle: `Del ${start} al ${end}`,
columns: [
{ header: '#', key: 'rank', format: 'number' },
{ header: 'Cliente', key: 'name' },
{ header: '# Ventas', key: 'num_ventas', format: 'number' },
{ header: 'Total comprado', key: 'total', format: 'currency' },
{ header: 'Ticket promedio', key: 'ticket_promedio', format: 'currency' },
],
rows: clientes.map((c, i) => ({ ...c, rank: i + 1 })),
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3 mb-4 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : clientes.length === 0 ? (
<EmptyState title="Sin ventas" subtitle="No hay ventas en el periodo." icon={<Trophy size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">#</th>
<th className="text-left px-4 py-3">Cliente</th>
<th className="text-right px-4 py-3"># Ventas</th>
<th className="text-right px-4 py-3">Total comprado</th>
<th className="text-right px-4 py-3">Ticket promedio</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{clientes.map((c, i) => (
<tr key={c.id} className="hover:bg-theme-bg">
<td className="px-4 py-3 text-theme-muted">{i + 1}</td>
<td className="px-4 py-3 font-medium text-theme-heading">{c.name}</td>
<td className="px-4 py-3 text-right text-theme-muted">{c.num_ventas}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(c.total)}</td>
<td className="px-4 py-3 text-right text-theme-muted">{formatCurrency(c.ticket_promedio)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
);
};
export default TopClientesReport;

View File

@@ -0,0 +1,117 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { UserCheck } from 'lucide-react';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type VendedorRow } from '../../services/odoo';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const hoy = () => new Date().toISOString().split('T')[0];
const VendedoresReport: FC = () => {
const [start, setStart] = useState(inicioDeMes());
const [end, setEnd] = useState(hoy());
const [vendedores, setVendedores] = useState<VendedorRow[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getVendedores(start, end);
if (res.status === 'success') setVendedores(res.vendedores);
} catch (err) {
toast.error('Error al cargar vendedores');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
try {
setExporting(true);
await exportToExcel({
filename: `vendedores-${start}_${end}`,
sheetName: 'Vendedores',
title: 'Ventas por vendedor',
subtitle: `Del ${start} al ${end} · por usuario que registró la venta`,
columns: [
{ header: 'Usuario', key: 'usuario' },
{ header: '# Ventas', key: 'num_ventas', format: 'number' },
{ header: 'Total vendido', key: 'total', format: 'currency' },
{ header: 'Cobrado', key: 'cobrado', format: 'currency' },
],
rows: vendedores.map((v) => ({ ...v })),
totals: {
usuario: '',
num_ventas: vendedores.reduce((a, v) => a + v.num_ventas, 0),
total: vendedores.reduce((a, v) => a + v.total, 0),
cobrado: vendedores.reduce((a, v) => a + v.cobrado, 0),
},
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3 mb-4 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
<div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
<p className="text-xs text-theme-muted mb-4">Ventas agrupadas por el usuario que las registró en el sistema.</p>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : vendedores.length === 0 ? (
<EmptyState title="Sin ventas" subtitle="No hay ventas registradas en el periodo." icon={<UserCheck size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Usuario</th>
<th className="text-right px-4 py-3"># Ventas</th>
<th className="text-right px-4 py-3">Total vendido</th>
<th className="text-right px-4 py-3">Cobrado</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{vendedores.map((v, i) => (
<tr key={i} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{v.usuario}</td>
<td className="px-4 py-3 text-right text-theme-muted">{v.num_ventas}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">{formatCurrency(v.total)}</td>
<td className="px-4 py-3 text-right text-theme-muted">{formatCurrency(v.cobrado)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
);
};
export default VendedoresReport;

View File

@@ -0,0 +1,18 @@
import type { FC } from 'react';
import { Download } from 'lucide-react';
import { Button } from './Button';
interface ExportButtonProps {
onClick: () => void;
loading?: boolean;
}
/** Botón estándar de exportación de reportes (xlsx), arriba a la derecha */
export const ExportButton: FC<ExportButtonProps> = ({ onClick, loading }) => (
<Button variant="outline" size="sm" onClick={onClick} loading={loading}>
<Download size={14} className="mr-1.5" />
Exportar
</Button>
);
export default ExportButton;

View File

@@ -13,6 +13,10 @@ interface ModalProps {
footer?: ReactNode;
}
// Contador global de modales abiertos: el scroll del body solo se restaura
// cuando se cierra el ÚLTIMO modal (soporta modales anidados).
let openModals = 0;
const maxWidthClasses: Record<NonNullable<ModalProps['maxWidth']>, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
@@ -31,20 +35,23 @@ export const Modal: FC<ModalProps> = ({
footer,
}) => {
const contentRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!isOpen) return;
const originalOverflow = document.body.style.overflow;
openModals += 1;
document.body.style.overflow = 'hidden';
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape') onCloseRef.current();
};
document.addEventListener('keydown', handleKey);
return () => {
document.body.style.overflow = originalOverflow;
openModals = Math.max(0, openModals - 1);
if (openModals === 0) document.body.style.overflow = '';
document.removeEventListener('keydown', handleKey);
};
}, [isOpen, onClose]);
}, [isOpen]);
if (!isOpen) return null;

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@config "../tailwind.config.js";
@import "./styles/fonts.css";
/* =========================================================
@@ -96,6 +97,77 @@
--logo: '/skeen-brand/logos/Logo%20Completo%20Negro.png';
}
/* =========================================================
Tema Clásico (legacy AdminLTE 2.x)
========================================================= */
[data-theme="clasico"] {
/* Paleta */
--color-coral: #1abc9c;
--color-coral-hover: #16a085;
--color-mint: #2ecc71;
--color-lilac: #d9edf7;
--color-sky: #3498db;
--color-dark: #333333;
--color-charcoal: #444444;
--color-gray: #777777;
--color-light: #ecf0f5;
--color-white: #FFFFFF;
/* Semántico */
--text: var(--color-charcoal);
--text-muted: var(--color-gray);
--text-heading: var(--color-dark);
--text-inverse: var(--color-white);
--bg: var(--color-light);
--surface: var(--color-white);
--surface-elevated: var(--color-white);
--border: #d2d6de;
--border-strong: #dddddd;
--accent: var(--color-coral);
--accent-hover: var(--color-coral-hover);
--accent-bg: rgba(26, 188, 156, 0.12);
--success: #2ecc71;
--success-text: var(--color-white);
--warning: #f39c12;
--warning-text: var(--color-white);
--font-sans: 'Source Sans Pro', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-display: 'Source Sans Pro', Georgia, serif;
--font-heading: 'Source Sans Pro', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--sidebar-bg: #3E454C;
--sidebar-text: #dbdee1;
--sidebar-text-active: var(--color-white);
--sidebar-active-bg: #394046;
--sidebar-accent: #BED4AD;
--sidebar-group-bg: #353b41;
--sidebar-group-text: #6c7884;
--header-bg: #BED4AD;
--logo-block: #b1cb9c;
--logo: '/skeen-brand/logos/Logo%20Completo%20Blanco.png';
}
/* AdminLTE: esquinas casi cuadradas y sombras mínimas */
[data-theme="clasico"] .rounded-3xl,
[data-theme="clasico"] .rounded-2xl,
[data-theme="clasico"] .rounded-xl {
border-radius: 3px;
}
[data-theme="clasico"] .rounded-lg {
border-radius: 2px;
}
[data-theme="clasico"] .shadow-card {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
[data-theme="clasico"] .shadow-soft,
[data-theme="clasico"] .shadow-sm {
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
}
/* =========================================================
Base
========================================================= */
@@ -176,3 +248,23 @@ p {
::-webkit-scrollbar-thumb:hover {
opacity: 0.4;
}
/* Impresión del ticket POS y de la receta médica */
@media print {
body * {
visibility: hidden;
}
.print-ticket,
.print-ticket *,
.print-receta,
.print-receta * {
visibility: visible;
}
.print-ticket,
.print-receta {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
}

View File

@@ -17,17 +17,28 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/': ['admin', 'recepcion', 'medico', 'lectura'],
'/agenda': ['admin', 'recepcion', 'medico'],
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
'/pos': ['admin', 'recepcion'],
'/visitas': ['admin', 'recepcion', 'medico'],
'/consultas': ['admin', 'recepcion', 'medico'],
'/expedientes': ['admin', 'recepcion', 'medico'],
'/medicos': ['admin', 'recepcion'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura'],
'/productos': ['admin', 'recepcion', 'lectura'],
'/productos': ['admin', 'recepcion', 'lectura'], // deprecated: redirige a /inventario
'/ventas': ['admin', 'recepcion'],
'/pagos': ['admin', 'recepcion'],
'/monedero': ['admin', 'recepcion', 'lectura'],
'/inventario': ['admin', 'recepcion', 'lectura'],
'/cortes': ['admin', 'recepcion'],
'/reportes': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=comisiones': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=horas-agenda': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=paquetes': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura'],
'/configuracion': ['admin'],
'/usuarios': ['admin'],
'/cumpleanos': ['admin', 'recepcion'],
'/wacrm/messages': ['admin', 'recepcion'],
'/wacrm/leads': ['admin', 'recepcion'],
@@ -109,6 +120,10 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
const canSee = (href: string): boolean => {
if (!user) return false;
// Permisos personalizados: lista no vacía manda sobre los defaults por rol
if (user.allowed_menus && user.allowed_menus.length > 0) {
return user.allowed_menus.includes(href);
}
const allowed = MENU_ROLES[href];
if (!allowed) return true;
return allowed.includes(user.role);

View File

@@ -0,0 +1,148 @@
/**
* Exportación de reportes a Excel (.xlsx) con formato de marca SKEEN.
* Usa exceljs cargado on-demand (dynamic import) para no inflar el bundle inicial.
*/
export interface ExportColumn {
header: string;
key: string;
width?: number;
format?: 'currency' | 'number' | 'date' | 'text' | 'percent';
}
export interface ExportOptions {
filename: string;
sheetName: string;
title: string;
subtitle?: string;
columns: ExportColumn[];
rows: Record<string, unknown>[];
totals?: Record<string, unknown>;
}
const NUM_FORMATS: Record<string, string> = {
currency: '"$"#,##0.00',
number: '#,##0',
date: 'DD/MM/YYYY',
percent: '0%',
};
const toCellValue = (value: unknown, format?: string) => {
if (value === null || value === undefined || value === '') return '';
if (format === 'currency' || format === 'number') {
const n = typeof value === 'number' ? value : parseFloat(String(value));
return Number.isNaN(n) ? String(value) : n;
}
if (format === 'percent') {
const n = typeof value === 'number' ? value : parseFloat(String(value));
return Number.isNaN(n) ? String(value) : (n > 1 ? n / 100 : n);
}
if (format === 'date') {
const s = String(value);
const d = new Date(s.length === 10 ? `${s}T12:00:00` : s.replace(' ', 'T'));
return Number.isNaN(d.getTime()) ? s : d;
}
return String(value);
};
export const buildWorkbook = async (opts: ExportOptions) => {
const mod: unknown = await import('exceljs');
const ExcelJS = ((mod as { default?: unknown }).default ?? mod) as typeof import('exceljs');
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(opts.sheetName.slice(0, 31));
const lastCol = Math.max(opts.columns.length, 1);
const lastColLetter = ws.getColumn(lastCol).letter;
// Título (marca)
ws.mergeCells(`A1:${lastColLetter}1`);
const titleCell = ws.getCell('A1');
titleCell.value = opts.title;
titleCell.font = { bold: true, size: 14, color: { argb: 'FF1A1A1A' } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEDA588' } };
titleCell.alignment = { vertical: 'middle' };
ws.getRow(1).height = 24;
let rowIdx = 2;
if (opts.subtitle) {
ws.mergeCells(`A2:${lastColLetter}2`);
const sub = ws.getCell('A2');
sub.value = opts.subtitle;
sub.font = { size: 10, color: { argb: 'FF6B6B6B' } };
rowIdx = 3;
}
rowIdx += 1; // línea en blanco
// Encabezados
const headerRow = ws.getRow(rowIdx);
opts.columns.forEach((col, i) => {
const cell = headerRow.getCell(i + 1);
cell.value = col.header;
cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1A1A1A' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFD2D6DE' } } };
cell.alignment = { vertical: 'middle' };
});
headerRow.height = 18;
rowIdx += 1;
// Datos
for (const row of opts.rows) {
const r = ws.getRow(rowIdx);
opts.columns.forEach((col, i) => {
const cell = r.getCell(i + 1);
cell.value = toCellValue(row[col.key], col.format) as never;
if (col.format && NUM_FORMATS[col.format]) {
cell.numFmt = NUM_FORMATS[col.format];
}
});
rowIdx += 1;
}
// Totales
if (opts.totals) {
const r = ws.getRow(rowIdx);
opts.columns.forEach((col, i) => {
const cell = r.getCell(i + 1);
const v = opts.totals![col.key];
cell.value = (i === 0 && (v === undefined || v === '')) ? 'Totales' : toCellValue(v, col.format) as never;
cell.font = { bold: true };
cell.border = { top: { style: 'thin', color: { argb: 'FF1A1A1A' } } };
if (col.format && NUM_FORMATS[col.format]) {
cell.numFmt = NUM_FORMATS[col.format];
}
});
}
// Anchos: explícito o estimado por contenido (tope 50)
opts.columns.forEach((col, i) => {
if (col.width) {
ws.getColumn(i + 1).width = col.width;
return;
}
let max = col.header.length;
for (const row of opts.rows.slice(0, 500)) {
const v = row[col.key];
if (v !== null && v !== undefined) max = Math.max(max, String(v).length);
}
if (opts.totals && opts.totals[col.key] !== undefined) {
max = Math.max(max, String(opts.totals[col.key]).length);
}
ws.getColumn(i + 1).width = Math.min(max + 3, 50);
});
return wb;
};
export const exportToExcel = async (opts: ExportOptions): Promise<void> => {
const wb = await buildWorkbook(opts);
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = opts.filename.endsWith('.xlsx') ? opts.filename : `${opts.filename}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

View File

@@ -0,0 +1,29 @@
// Lista canónica de secciones del sistema (para permisos de menú por usuario)
export const MENU_ITEMS: { label: string; href: string }[] = [
{ label: 'Dashboard', href: '/' },
{ label: 'Agenda', href: '/agenda' },
{ label: 'Pacientes', href: '/pacientes' },
{ label: 'Visitas', href: '/visitas' },
{ label: 'Últimas Visitas', href: '/expedientes' },
{ label: 'Médicos', href: '/medicos' },
{ label: 'Servicios', href: '/servicios' },
{ label: 'Punto de Venta', href: '/pos' },
{ label: 'Ventas', href: '/ventas' },
{ label: 'Pagos', href: '/pagos' },
{ label: 'Monedero', href: '/monedero' },
{ label: 'Inventario', href: '/inventario' },
{ label: 'Cortes de Caja', href: '/cortes' },
{ label: 'Reportes', href: '/reportes' },
{ label: 'Comisiones', href: '/reportes?tab=comisiones' },
{ label: 'Adeudos', href: '/reportes?tab=adeudos' },
{ label: 'Horas agenda', href: '/reportes?tab=horas-agenda' },
{ label: 'Paquetes', href: '/reportes?tab=paquetes' },
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
{ label: 'Exportar', href: '/reportes?tab=exportar' },
{ label: 'Cumpleañeros', href: '/cumpleanos' },
{ label: 'Configuración', href: '/configuracion' },
{ label: 'Mensajes WACRM', href: '/wacrm/messages' },
{ label: 'Leads WACRM', href: '/wacrm/leads' },
];

View File

@@ -0,0 +1,143 @@
import type { FC, ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useAuth } from './auth';
const LINKS_PREFIX = 'skeen-quicklinks:';
const MODE_PREFIX = 'skeen-quicklinks-mode:';
// Accesos iniciales sugeridos la primera vez (el usuario puede quitarlos)
const DEFAULT_LINKS = ['/agenda', '/pacientes', '/ventas'];
export type QuickLinksMode = 'menu' | 'right' | 'bottom';
export const QUICK_LINKS_MODE_LABELS: Record<QuickLinksMode, string> = {
menu: 'En el menú lateral',
right: 'Barra de iconos a la derecha',
bottom: 'Barra de iconos abajo',
};
interface QuickLinksValue {
links: string[];
mode: QuickLinksMode;
editing: boolean;
setEditing: (v: boolean) => void;
setMode: (m: QuickLinksMode) => void;
toggle: (href: string) => void;
remove: (href: string) => void;
move: (href: string, dir: -1 | 1) => void;
isPinned: (href: string) => boolean;
}
const QuickLinksContext = createContext<QuickLinksValue | null>(null);
const readJson = <T,>(key: string, fallback: T): T => {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
};
const writeJson = (key: string, value: unknown) => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// localStorage lleno o no disponible: se mantiene solo en memoria
}
};
const readLinks = (key: string): string[] => {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
if (raw === null) {
writeJson(key, DEFAULT_LINKS);
return DEFAULT_LINKS;
}
const parsed = readJson<unknown>(key, []);
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
};
const readMode = (key: string): QuickLinksMode => {
const m = readJson<string>(key, 'menu');
return m === 'right' || m === 'bottom' || m === 'menu' ? m : 'menu';
};
/**
* Accesos rápidos personalizables, persistidos en localStorage por usuario.
* Compartido por SkeenLayout, HomeNestLayout y QuickLinksDock.
*/
export const QuickLinksProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth();
const linksKey = `${LINKS_PREFIX}${user?.id ?? 'anon'}`;
const modeKey = `${MODE_PREFIX}${user?.id ?? 'anon'}`;
const [links, setLinks] = useState<string[]>(() => readLinks(linksKey));
const [mode, setModeState] = useState<QuickLinksMode>(() => readMode(modeKey));
const [editing, setEditing] = useState(false);
useEffect(() => {
setLinks(readLinks(linksKey));
setModeState(readMode(modeKey));
setEditing(false);
}, [linksKey, modeKey]);
const persistLinks = useCallback(
(updater: (prev: string[]) => string[]) => {
setLinks((prev) => {
const next = updater(prev);
writeJson(linksKey, next);
return next;
});
},
[linksKey]
);
const setMode = useCallback(
(m: QuickLinksMode) => {
setModeState(m);
writeJson(modeKey, m);
},
[modeKey]
);
const toggle = useCallback(
(href: string) =>
persistLinks((prev) => (prev.includes(href) ? prev.filter((l) => l !== href) : [...prev, href])),
[persistLinks]
);
const remove = useCallback(
(href: string) => persistLinks((prev) => prev.filter((l) => l !== href)),
[persistLinks]
);
const move = useCallback(
(href: string, dir: -1 | 1) =>
persistLinks((prev) => {
const i = prev.indexOf(href);
const j = i + dir;
if (i < 0 || j < 0 || j >= prev.length) return prev;
const next = [...prev];
[next[i], next[j]] = [next[j], next[i]];
return next;
}),
[persistLinks]
);
const isPinned = useCallback((href: string) => links.includes(href), [links]);
return (
<QuickLinksContext.Provider
value={{ links, mode, editing, setEditing, setMode, toggle, remove, move, isPinned }}
>
{children}
</QuickLinksContext.Provider>
);
};
export function useQuickLinks(): QuickLinksValue {
const ctx = useContext(QuickLinksContext);
if (!ctx) throw new Error('useQuickLinks debe usarse dentro de QuickLinksProvider');
return ctx;
}

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
type Theme = 'skeen' | 'homenest';
export type Theme = 'skeen' | 'homenest' | 'clasico';
interface ThemeContextValue {
theme: Theme;
@@ -11,11 +11,12 @@ interface ThemeContextValue {
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
const STORAGE_KEY = 'skeen-theme';
const THEME_ORDER: Theme[] = ['skeen', 'homenest', 'clasico'];
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'skeen';
const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
if (stored === 'skeen' || stored === 'homenest') return stored;
if (stored === 'skeen' || stored === 'homenest' || stored === 'clasico') return stored;
return 'skeen';
}
@@ -30,7 +31,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
};
const toggleTheme = () => setTheme(theme === 'skeen' ? 'homenest' : 'skeen');
const toggleTheme = () => {
const idx = THEME_ORDER.indexOf(theme);
setTheme(THEME_ORDER[(idx + 1) % THEME_ORDER.length]);
};
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, Plus, PackageCheck, Sparkles, RotateCcw } from 'lucide-react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, Edit2, Ban, Trash2 } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
@@ -17,7 +17,8 @@ import {
toast,
badgeForAppointmentState,
} from '../components/ui';
import { odooApi, type Appointment, type Patient, type Service, type Doctor } from '../services/odoo';
import { useSearchParams } from 'react-router-dom';
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo';
const stateOptions = [
{ value: '', label: 'Todos los estados' },
@@ -28,6 +29,7 @@ const stateOptions = [
];
const Agenda: FC = () => {
const [searchParams] = useSearchParams();
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -36,12 +38,24 @@ const Agenda: FC = () => {
const [submitting, setSubmitting] = useState(false);
const [updating, setUpdating] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [date, setDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
const [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState('');
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores');
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow'>('');
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
const [savingEdit, setSavingEdit] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
const [slotsLoading, setSlotsLoading] = useState(false);
const [bloqueos, setBloqueos] = useState<Bloqueo[]>([]);
const [bloqueosOpen, setBloqueosOpen] = useState(false);
const [bloqueoForm, setBloqueoForm] = useState({ doctor_id: '', date: '', all_day: false, time_from: '13:00', time_to: '14:00', motivo: '' });
const [savingBloqueo, setSavingBloqueo] = useState(false);
const [dragApt, setDragApt] = useState<Appointment | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [form, setForm] = useState({
patient_id: '',
@@ -73,13 +87,212 @@ const Agenda: FC = () => {
return true;
});
// ---- Filtros rápidos ----
const timeToFloat = (t: string): number | null => {
const m = /^(\d{1,2}):(\d{2})/.exec((t || '').trim());
return m ? parseInt(m[1], 10) + parseInt(m[2], 10) / 60 : null;
};
const nowDate = new Date();
const nowFloat = nowDate.getHours() + nowDate.getMinutes() / 60;
const todayStr = `${nowDate.getFullYear()}-${String(nowDate.getMonth() + 1).padStart(2, '0')}-${String(nowDate.getDate()).padStart(2, '0')}`;
// Candidata a check-in: confirmada, de hoy, ya empezó y aún no termina
const isCheckinDue = (a: Appointment): boolean => {
const start = timeToFloat(a.time);
if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false;
const end = start + (a.duration ?? 30) / 60;
return start <= nowFloat && nowFloat < end;
};
// Candidata a no-show: confirmada, de hoy, ya debió terminar
const isNoShowDue = (a: Appointment): boolean => {
const start = timeToFloat(a.time);
if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false;
return start + (a.duration ?? 30) / 60 <= nowFloat;
};
const visibleAppointments = filteredAppointments.filter((apt) => {
if (quickFilter === 'primera' && !apt.is_first_visit) return false;
if (quickFilter === 'checkin' && !isCheckinDue(apt)) return false;
if (quickFilter === 'noshow' && !isNoShowDue(apt)) return false;
return true;
});
const countPrimera = filteredAppointments.filter((a) => a.is_first_visit).length;
const countCheckin = filteredAppointments.filter(isCheckinDue).length;
const countNoShow = filteredAppointments.filter(isNoShowDue).length;
// ---- Vista por médico (grid doctores x horas) ----
const GRID_BASE_START = 9; // horario clínica: 9:00
const GRID_BASE_END = 18; // horario clínica: 18:00
const aptHour = (t: string): number | null => {
const m = /^(\d{1,2}):?(\d{2})?/.exec((t || '').trim());
if (!m) return null;
const h = parseInt(m[1], 10);
return Number.isNaN(h) ? null : h;
};
// Las canceladas no ocupan horario: el hueco queda libre
const gridAppointments = visibleAppointments.filter((a) => a.state !== 'cancelled');
const bookedHours = gridAppointments
.map((a) => aptHour(a.time))
.filter((h): h is number => h !== null);
const gridStart = bookedHours.length ? Math.min(GRID_BASE_START, ...bookedHours) : GRID_BASE_START;
const gridEnd = bookedHours.length ? Math.max(GRID_BASE_END, ...bookedHours) : GRID_BASE_END;
const gridHours = Array.from({ length: gridEnd - gridStart + 1 }, (_, i) => gridStart + i);
const gridDoctors = doctorFilter
? doctors.filter((d) => String(d.id) === doctorFilter)
: doctors;
const hasUnassigned = !doctorFilter && gridAppointments.some((a) => !a.doctor_id);
const gridColumns: { key: string; label: string; subtitle: string; doctorId: number | null }[] = [
...gridDoctors.map((d) => ({ key: `d-${d.id}`, label: d.name, subtitle: d.job_title || '', doctorId: d.id })),
...(hasUnassigned ? [{ key: 'none', label: 'Sin asignar', subtitle: '', doctorId: null }] : []),
];
const gridCellAppointments = (doctorId: number | null, hour: number) =>
gridAppointments.filter((a) =>
(doctorId ? a.doctor_id === doctorId : !a.doctor_id) && aptHour(a.time) === hour);
const gridDoctorCount = (doctorId: number | null) =>
gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length;
// Bloqueo que cubre una celda (médico × hora)
const bloqueoEnCelda = (doctorId: number | null, hour: number) =>
doctorId
? bloqueos.find((b) => b.doctor_id === doctorId && (b.all_day || (hour >= b.time_from && hour < b.time_to)))
: undefined;
// Drag & drop para mover citas (solo pendientes/confirmadas)
const DRAGGABLE_STATES = ['pending', 'confirmed'];
const handleDrop = async (doctorId: number | null, hour: number) => {
if (!dragApt) return;
const apt = dragApt;
setDragApt(null);
setDropTarget(null);
if ((apt.doctor_id ?? null) === doctorId && aptHour(apt.time) === hour) return;
const nombreCol = doctorId ? (doctors.find((d) => d.id === doctorId)?.name || '') : 'Sin asignar';
const horaStr = `${String(hour).padStart(2, '0')}:00`;
if (!window.confirm(`¿Mover la cita de ${apt.patient} a ${nombreCol} ${horaStr}?`)) return;
try {
await odooApi.updateAppointment(apt.id, {
doctor_id: doctorId ?? null,
time: hour as unknown as string,
});
toast.success('Cita movida');
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo mover la cita');
console.error(err);
}
};
const chipDragProps = (apt: Appointment) =>
DRAGGABLE_STATES.includes(apt.state)
? {
draggable: true,
onDragStart: (e: React.DragEvent) => { e.stopPropagation(); setDragApt(apt); },
onDragEnd: () => { setDragApt(null); setDropTarget(null); },
}
: {};
const openBloqueos = () => {
setBloqueoForm({ doctor_id: doctorFilter, date, all_day: false, time_from: '13:00', time_to: '14:00', motivo: '' });
setBloqueosOpen(true);
};
const saveBloqueo = async () => {
if (!bloqueoForm.doctor_id || !bloqueoForm.date) {
toast.error('Médico y fecha son obligatorios');
return;
}
try {
setSavingBloqueo(true);
await odooApi.createBloqueo({
doctor_id: parseInt(bloqueoForm.doctor_id, 10),
date: bloqueoForm.date,
all_day: bloqueoForm.all_day,
time_from: parseTimeToFloat(bloqueoForm.time_from) ?? 9,
time_to: parseTimeToFloat(bloqueoForm.time_to) ?? 10,
motivo: bloqueoForm.motivo,
});
toast.success('Bloqueo creado');
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al crear bloqueo');
console.error(err);
} finally {
setSavingBloqueo(false);
}
};
const removeBloqueo = async (id: number) => {
try {
await odooApi.deleteBloqueo(id);
toast.success('Bloqueo eliminado');
await load();
} catch (err) {
toast.error('Error al eliminar bloqueo');
console.error(err);
}
};
const horaFloatStr = (h: number) => {
const hh = Math.floor(h);
const mm = Math.round((h - hh) * 60);
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
};
const initials = (name: string) =>
name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase();
// Colores por estado siguiendo la paleta de la marca (coral/mint/sky/lilac)
const gridStateClass: Record<string, string> = {
pending: 'border-brand-coral bg-theme-accent-bg text-theme-heading',
confirmed: 'border-emerald-700 bg-brand-mint text-theme-heading',
arrived: 'border-sky-700 bg-brand-sky text-theme-heading',
in_progress: 'border-violet-600 bg-brand-lilac text-theme-heading',
done: 'border-theme-border-strong bg-theme-bg text-theme-muted',
no_show: 'border-rose-400 bg-rose-50 text-rose-700',
};
const gridStateLegend: { state: string; label: string; dot: string }[] = [
{ state: 'pending', label: 'Pendiente', dot: 'bg-brand-coral' },
{ state: 'confirmed', label: 'Confirmada', dot: 'bg-brand-mint border border-emerald-700' },
{ state: 'arrived', label: 'Llegó', dot: 'bg-brand-sky border border-sky-700' },
{ state: 'in_progress', label: 'En curso', dot: 'bg-brand-lilac border border-violet-600' },
{ state: 'done', label: 'Completada', dot: 'bg-theme-border-strong' },
];
const firstName = (full: string) => (full || '').trim().split(' ')[0] || '-';
const gridLegend = (
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
{gridStateLegend.map((item) => (
<span key={item.state} className="inline-flex items-center gap-1.5 text-xs text-theme-muted">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${item.dot}`} />
{item.label}
</span>
))}
<span className="text-xs text-theme-muted">
· Las citas canceladas no bloquean horario: su hueco aparece como libre.
</span>
</div>
);
const load = useCallback(async () => {
try {
setLoading(true);
setError(null);
const params: Record<string, string> = date ? { date } : {};
const res = await odooApi.getAppointments(params);
if (res.status === 'success') setAppointments(res.appointments);
if (res.status === 'success') {
setAppointments(res.appointments);
setBloqueos(res.bloqueos || []);
}
} catch (err) {
setError('Error al cargar citas');
toast.error('Error al cargar citas');
@@ -147,6 +360,7 @@ const Agenda: FC = () => {
setUpdating(id);
await odooApi.updateAppointmentStatus(id, action);
toast.success('Estado actualizado');
setDetailApt(null);
await load();
} catch (err) {
toast.error('Error al actualizar estado');
@@ -156,6 +370,47 @@ const Agenda: FC = () => {
}
};
const openAptDetail = (apt: Appointment) => {
setDetailApt(apt);
setEditMode(false);
setEditForm({
date: apt.date,
time: apt.time,
doctor_id: apt.doctor_id ? String(apt.doctor_id) : '',
notes: apt.notes || '',
});
};
const saveAptEdit = async () => {
if (!detailApt) return;
if (!editForm.date || !editForm.time) {
toast.error('Fecha y hora son obligatorias');
return;
}
try {
setSavingEdit(true);
await odooApi.updateAppointment(detailApt.id, {
date: editForm.date,
time: parseTimeToFloat(editForm.time) as unknown as string,
doctor_id: editForm.doctor_id ? parseInt(editForm.doctor_id, 10) : undefined,
notes: editForm.notes,
});
toast.success('Reserva actualizada');
setDetailApt(null);
await load();
} catch (err) {
toast.error('Error al actualizar la reserva');
console.error(err);
} finally {
setSavingEdit(false);
}
};
const aptStateLabels: Record<string, string> = {
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
};
const handlePackageFinished = async (id: number, finished: boolean) => {
try {
setPkgUpdating(id);
@@ -341,6 +596,17 @@ const Agenda: FC = () => {
<CheckCheck size={16} className="text-blue-600" />
</Button>
)}
{(apt.state === 'confirmed' || apt.state === 'arrived') && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'no_show')}
disabled={updating === apt.id}
title="No se presentó (no show)"
>
<UserX size={16} className="text-rose-500" />
</Button>
)}
{apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
apt.package_finished ? (
<Button
@@ -381,6 +647,10 @@ const Agenda: FC = () => {
return (
<Layout title="Agenda" subtitle="Gestión de citas">
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
<Button variant="outline" onClick={openBloqueos}>
<Ban size={16} className="mr-2" />
Bloqueos
</Button>
<Button variant="outline" onClick={() => setExpressOpen(true)}>
<Sparkles size={16} className="mr-2" />
Valoración express
@@ -415,13 +685,287 @@ const Agenda: FC = () => {
onChange={(e) => setDoctorFilter(e.target.value)}
className="sm:max-w-[220px]"
/>
<div className="flex gap-1 sm:ml-auto">
<Button
variant={viewMode === 'lista' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('lista')}
title="Vista de lista"
>
<List size={16} className="mr-1" />
Lista
</Button>
<Button
variant={viewMode === 'doctores' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('doctores')}
title="Médicos en columnas, horas en filas"
>
<LayoutGrid size={16} className="mr-1" />
Por médico
</Button>
<Button
variant={viewMode === 'horas' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('horas')}
title="Médicos en filas, horas en columnas"
>
<Rows3 size={16} className="mr-1" />
Por hora
</Button>
</div>
</div>
{/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{viewMode !== 'lista' && (
<button
type="button"
onClick={() => setQuickFilter(quickFilter === 'libres' ? '' : 'libres')}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
quickFilter === 'libres'
? 'bg-brand-mint text-theme-heading border-emerald-700'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
Espacios libres
</button>
)}
{([
{ key: 'primera', label: 'Primera vez', count: countPrimera },
{ key: 'checkin', label: 'Check-in', count: countCheckin },
{ key: 'noshow', label: 'No show', count: countNoShow },
] as const).map((chip) => (
<button
key={chip.key}
type="button"
onClick={() => setQuickFilter(quickFilter === chip.key ? '' : chip.key)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
quickFilter === chip.key
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{chip.label}
<span className={`ml-1.5 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] ${
quickFilter === chip.key ? 'bg-theme-surface/30 text-theme-inverse' : 'bg-theme-surface text-theme-heading border border-theme-border'
}`}>
{chip.count}
</span>
</button>
))}
{quickFilter && (
<button
type="button"
onClick={() => setQuickFilter('')}
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
>
Quitar filtro
</button>
)}
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : filteredAppointments.length === 0 ? (
) : viewMode === 'doctores' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista por médico."
/>
) : (
<div>
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 z-10 w-20 bg-theme-surface p-3 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Hora
</th>
{gridColumns.map((col) => (
<th key={col.key} className="min-w-[180px] p-3 border-b border-l border-theme-border">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(col.label)}
</span>
<div className="min-w-0 text-left">
<p className="truncate text-sm font-semibold text-theme-heading normal-case">
{col.label}
</p>
<p className="truncate text-[11px] font-normal text-theme-muted normal-case">
{col.subtitle ? `${col.subtitle} · ` : ''}
{gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'}
</p>
</div>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{gridHours.map((hour) => (
<tr key={hour}>
<td className="sticky left-0 z-10 bg-theme-surface p-3 text-xs font-semibold text-theme-muted align-top whitespace-nowrap border-b border-theme-border">
{`${String(hour).padStart(2, '0')}:00`}
</td>
{gridColumns.map((col) => {
const cell = gridCellAppointments(col.doctorId, hour);
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
const cellKey = `${col.key}-${hour}`;
return (
<td
key={col.key}
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
className={`p-1.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
>
{bloqueo ? (
<div
className="rounded-xl border border-theme-border py-3.5 text-center text-[11px] font-medium text-theme-muted"
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
>
<Ban size={12} className="inline mr-1" />
Bloqueado{bloqueo.motivo ? `${bloqueo.motivo}` : ''}
</div>
) : cell.length === 0 ? (
<div className={`rounded-xl border py-3.5 text-center text-[11px] font-medium transition-colors ${
quickFilter === 'libres'
? 'bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
: 'border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
}`}>
Libre
</div>
) : (
cell.map((apt) => (
<div
key={apt.id}
onClick={() => openAptDetail(apt)}
{...chipDragProps(apt)}
className={`mb-1.5 rounded-xl border-l-4 px-2.5 py-1.5 text-xs shadow-card transition-shadow hover:shadow-soft ${
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
>
<div className="font-semibold truncate">
{apt.time} · {apt.patient}
</div>
<div className="truncate opacity-75">{apt.service}</div>
</div>
))
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{gridLegend}
</div>
)
) : viewMode === 'horas' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista por hora."
/>
) : (
<div>
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 z-10 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Médico
</th>
{gridHours.map((hour) => (
<th key={hour} className="min-w-[96px] p-2.5 text-center text-xs font-medium text-theme-muted border-b border-l border-theme-border">
{`${String(hour).padStart(2, '0')}:00`}
</th>
))}
</tr>
</thead>
<tbody>
{gridColumns.map((col) => (
<tr key={col.key}>
<td className="sticky left-0 z-10 bg-theme-surface p-2.5 border-b border-theme-border">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-[10px] font-bold text-theme-heading">
{initials(col.label)}
</span>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-theme-heading">
{col.label}
</p>
<p className="text-[10px] text-theme-muted">
{gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'}
</p>
</div>
</div>
</td>
{gridHours.map((hour) => {
const cell = gridCellAppointments(col.doctorId, hour);
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
const cellKey = `${col.key}-${hour}`;
return (
<td
key={hour}
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
>
{bloqueo ? (
<div
className="rounded-lg border border-theme-border py-2 text-center text-[10px] font-medium text-theme-muted"
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
title={`Bloqueado — ${bloqueo.motivo || 'sin motivo'}`}
>
<Ban size={11} className="inline" />
</div>
) : cell.length === 0 ? (
<div
className={`rounded-lg border py-2 transition-colors ${
quickFilter === 'libres'
? 'bg-brand-mint border-emerald-700'
: 'border-dashed border-theme-border hover:bg-theme-accent-bg'
}`}
title="Libre"
/>
) : (
cell.map((apt) => (
<div
key={apt.id}
onClick={() => openAptDetail(apt)}
{...chipDragProps(apt)}
className={`mb-1 rounded-lg border-l-2 px-1.5 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
>
<div className="font-semibold truncate">
{apt.time} · {firstName(apt.patient)}
</div>
</div>
))
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{gridLegend}
</div>
)
) : visibleAppointments.length === 0 ? (
<EmptyState
title="Sin citas"
subtitle="No hay citas para los filtros seleccionados."
@@ -435,14 +979,18 @@ const Agenda: FC = () => {
<table className="w-full">
<TableHeader />
<tbody className="divide-y">
{filteredAppointments.map((apt) => (
{visibleAppointments.map((apt) => (
<tr key={apt.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-heading">
<Clock size={14} className="inline mr-1 text-theme-muted" />
{apt.time}
</td>
<td className="p-3 text-sm font-medium text-theme-heading">{apt.patient}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.service}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">
<button type="button" onClick={() => openAptDetail(apt)} className="hover:underline hover:text-theme-heading text-left">
{apt.service}
</button>
</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
<td className="p-3">
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
@@ -459,7 +1007,7 @@ const Agenda: FC = () => {
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{filteredAppointments.map((apt) => (
{visibleAppointments.map((apt) => (
<MobileCard
key={apt.id}
title={apt.patient}
@@ -479,6 +1027,98 @@ const Agenda: FC = () => {
</Card.Body>
</Card>
{/* Modal de bloqueos de agenda */}
<Modal
isOpen={bloqueosOpen}
onClose={() => setBloqueosOpen(false)}
title={`Bloqueos de agenda — ${date}`}
maxWidth="lg"
footer={<Button variant="outline" onClick={() => setBloqueosOpen(false)}>Cerrar</Button>}
>
<div className="space-y-4">
{/* Alta rápida */}
<div className="p-3 bg-theme-bg rounded-xl space-y-3">
<p className="text-xs font-medium text-theme-muted uppercase">Nuevo bloqueo</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Select
label="Médico *"
options={[{ value: '', label: 'Seleccionar médico...' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={bloqueoForm.doctor_id}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, doctor_id: e.target.value })}
/>
<Input
label="Fecha *"
type="date"
value={bloqueoForm.date}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, date: e.target.value })}
/>
<Input
label="Motivo"
placeholder="Comida, Vacaciones, Junta..."
value={bloqueoForm.motivo}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, motivo: e.target.value })}
/>
<label className="flex items-center gap-2 self-end pb-2 cursor-pointer">
<input
type="checkbox"
checked={bloqueoForm.all_day}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, all_day: e.target.checked })}
className="w-4 h-4 rounded border-theme-border-strong"
/>
<span className="text-sm text-theme-heading">Todo el día</span>
</label>
{!bloqueoForm.all_day && (
<>
<Input
label="Desde"
type="time"
value={bloqueoForm.time_from}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, time_from: e.target.value })}
/>
<Input
label="Hasta"
type="time"
value={bloqueoForm.time_to}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, time_to: e.target.value })}
/>
</>
)}
</div>
<div className="flex justify-end">
<Button size="sm" onClick={saveBloqueo} loading={savingBloqueo}>
<Plus size={14} className="mr-1" />
Crear bloqueo
</Button>
</div>
</div>
{/* Lista del día */}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Bloqueos de este día</p>
{bloqueos.length === 0 ? (
<p className="text-sm text-theme-muted py-4 text-center">No hay bloqueos en la fecha seleccionada.</p>
) : (
<ul className="space-y-1.5 max-h-56 overflow-y-auto">
{bloqueos.map((b) => (
<li key={b.id} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">
<span className="font-medium">{b.doctor}</span>
<span className="text-theme-muted">
{' · '}{b.all_day ? 'Todo el día' : `${horaFloatStr(b.time_from)}${horaFloatStr(b.time_to)}`}
{b.motivo ? ` · ${b.motivo}` : ''}
</span>
</span>
<Button variant="ghost" size="sm" onClick={() => removeBloqueo(b.id)} title="Eliminar">
<Trash2 size={14} className="text-rose-500" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</Modal>
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
@@ -635,6 +1275,124 @@ const Agenda: FC = () => {
)}
</div>
</Modal>
{/* Modal detalle de cita: confirmar / no show / editar reserva */}
<Modal
isOpen={!!detailApt}
onClose={() => setDetailApt(null)}
title={editMode ? 'Editar reserva' : 'Detalle de la cita'}
maxWidth="md"
footer={
editMode ? (
<>
<Button variant="outline" onClick={() => setEditMode(false)}>Volver</Button>
<Button onClick={saveAptEdit} loading={savingEdit}>Guardar cambios</Button>
</>
) : undefined
}
>
{detailApt && !editMode && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForAppointmentState(detailApt.state)}>
{aptStateLabels[detailApt.state] || detailApt.state}
</Badge>
{detailApt.is_first_visit && (
<Badge variant="primary">Primera vez</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Paciente</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.patient}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Servicio</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.service}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Fecha y hora</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.date} · {detailApt.time}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Médico</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.doctor || 'Sin asignar'}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Duración</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.duration ? `${detailApt.duration} min` : '-'}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Precio</p>
<p className="text-sm font-medium text-theme-heading">${detailApt.price}</p>
</div>
</div>
{detailApt.notes && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Notas</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{detailApt.notes}</p>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
{detailApt.state === 'pending' && (
<Button size="sm" onClick={() => handleStatus(detailApt.id, 'confirm')} disabled={updating === detailApt.id}>
<CheckCircle size={16} className="mr-1.5" /> Confirmar
</Button>
)}
{detailApt.state === 'confirmed' && (
<Button size="sm" onClick={() => handleStatus(detailApt.id, 'arrive')} disabled={updating === detailApt.id}>
<UserCheck size={16} className="mr-1.5" /> Llegó
</Button>
)}
{(detailApt.state === 'confirmed' || detailApt.state === 'arrived') && (
<Button size="sm" variant="danger" onClick={() => handleStatus(detailApt.id, 'no_show')} disabled={updating === detailApt.id}>
<UserX size={16} className="mr-1.5" /> No se presentó
</Button>
)}
{detailApt.state !== 'done' && detailApt.state !== 'cancelled' && detailApt.state !== 'no_show' && (
<Button size="sm" variant="outline" onClick={() => handleStatus(detailApt.id, 'done')} disabled={updating === detailApt.id}>
<CheckCheck size={16} className="mr-1.5" /> Completar
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
<Edit2 size={16} className="mr-1.5" /> Editar reserva
</Button>
</div>
</div>
)}
{detailApt && editMode && (
<div className="space-y-4">
<p className="text-sm text-theme-muted">
{detailApt.patient} · {detailApt.service}
</p>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={editForm.date}
onChange={(e) => setEditForm({ ...editForm, date: e.target.value })}
/>
<Input
label="Hora *"
type="time"
value={editForm.time}
onChange={(e) => setEditForm({ ...editForm, time: e.target.value })}
/>
</div>
<Select
label="Médico"
options={[{ value: '', label: 'Sin cambios' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={editForm.doctor_id}
onChange={(e) => setEditForm({ ...editForm, doctor_id: e.target.value })}
/>
<TextArea
label="Notas"
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
/>
</div>
)}
</Modal>
</Layout>
);
};

View File

@@ -1,10 +1,26 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Phone, MapPin, Mail, Globe, Save, Building2, ArrowRightLeft } from 'lucide-react';
import Layout from '../components/Layout';
import UsuariosPanel from '../components/UsuariosPanel';
import RecetasPanel from '../components/RecetasPanel';
import CatalogoPanel from '../components/CatalogoPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'recetas', label: 'Recetas' },
{ key: 'catalogos', label: 'Catálogos' },
] as const;
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
{ key: 'procedimientos', label: 'Procedimientos' },
] as const;
interface ClinicSettings {
name: string;
phone: string;
@@ -24,6 +40,13 @@ const defaultSettings: ClinicSettings = {
const STORAGE_KEY = 'skeen_clinic_settings';
const Configuracion: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'clinica';
const setTab = (t: string) => setSearchParams({ tab: t });
const [subcatalogo, setSubcatalogo] = useState<string>(() => {
const sub = searchParams.get('sub');
return sub === 'procedimientos' ? 'procedimientos' : 'diagnosticos';
});
const [settings, setSettings] = useState<ClinicSettings>(defaultSettings);
const [loading, setLoading] = useState(true);
@@ -89,6 +112,55 @@ const Configuracion: FC = () => {
<Layout title="Configuración" subtitle="Ajustes de la clínica">
<PageHeader title="Configuración" subtitle="Información y ajustes de la clínica" />
{/* Tabs de secciones */}
<div className="flex flex-wrap gap-1.5 mb-6">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'usuarios' ? (
<Card>
<Card.Body>
<UsuariosPanel />
</Card.Body>
</Card>
) : tab === 'recetas' ? (
<Card>
<Card.Body>
<RecetasPanel />
</Card.Body>
</Card>
) : tab === 'catalogos' ? (
<Card>
<Card.Body>
<div className="flex flex-wrap gap-1.5 mb-4">
{SUBCATALOGOS.map((s) => (
<button
key={s.key}
type="button"
onClick={() => setSubcatalogo(s.key)}
className={`px-3 py-1.5 text-sm font-medium rounded-full transition ${
subcatalogo === s.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{s.label}
</button>
))}
</div>
<CatalogoPanel kind={subcatalogo as 'diagnosticos' | 'procedimientos'} titulo={subcatalogo === 'diagnosticos' ? 'Diagnósticos' : 'Procedimientos'} />
</Card.Body>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card>
<Card.Body>
@@ -234,6 +306,7 @@ const Configuracion: FC = () => {
</Card.Body>
</Card>
</div>
)}
</Layout>
);
};

View File

@@ -0,0 +1,395 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Search, ClipboardPlus } from 'lucide-react';
import Layout from '../components/Layout';
import { RecetaSection, RecetaPrint } from '../components/RecetaPrint';
import {
Card,
Button,
Input,
Select,
TextArea,
Modal,
Badge,
EmptyState,
PageHeader,
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Visita, type Doctor, type Patient, type Diagnostico } from '../services/odoo';
const hoy = () => new Date().toISOString().split('T')[0];
const Consultas: FC = () => {
const [consultas, setConsultas] = useState<Visita[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [diagnosticos, setDiagnosticos] = useState<Diagnostico[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [fecha, setFecha] = useState(hoy());
const [doctorFiltro, setDoctorFiltro] = useState('');
const [busqueda, setBusqueda] = useState('');
// Modal nueva consulta
const [modalNueva, setModalNueva] = useState(false);
const [pacienteQuery, setPacienteQuery] = useState('');
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
const [pacienteSel, setPacienteSel] = useState<Patient | null>(null);
const [formNueva, setFormNueva] = useState({
doctor_id: '',
motivo: '',
diag_sel: '',
diag_libre: '',
tratamiento: '',
notas: '',
});
// Modal edición
const [selected, setSelected] = useState<Visita | null>(null);
const [formEdit, setFormEdit] = useState({ motivo: '', diagnostico: '', tratamiento: '', notas: '', receta_text: '', doctor_id: '' });
const [printOpen, setPrintOpen] = useState(false);
const doctorOptions = [
{ value: '', label: 'Sin asignar' },
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
];
const load = useCallback(async () => {
try {
setLoading(true);
const params: Record<string, string | number> = { origen: 'consulta', page_size: 100 };
params.date = fecha || 'todas';
if (doctorFiltro) params.doctor_id = doctorFiltro;
if (busqueda) params.search = busqueda;
const res = await odooApi.getVisitas(params);
if (res.status === 'success') setConsultas(res.visitas);
} catch (err) {
toast.error('Error al cargar consultas');
console.error(err);
} finally {
setLoading(false);
}
}, [fecha, doctorFiltro, busqueda]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
odooApi.getDoctors()
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
.catch(() => {});
odooApi.getDiagnosticos()
.then((res) => { if (res.status === 'success') setDiagnosticos(res.items); })
.catch(() => {});
}, []);
// Búsqueda server-side de paciente para el modal
useEffect(() => {
if (!modalNueva || pacienteSel) return;
if (pacienteQuery.trim().length < 2) {
setPacienteResults([]);
return;
}
const t = setTimeout(() => {
odooApi.getPatients({ search: pacienteQuery.trim(), page_size: 8 })
.then((res) => { if (res.status === 'success') setPacienteResults(res.patients); })
.catch(() => {});
}, 300);
return () => clearTimeout(t);
}, [pacienteQuery, modalNueva, pacienteSel]);
const openNueva = () => {
setPacienteSel(null);
setPacienteQuery('');
setPacienteResults([]);
setFormNueva({ doctor_id: '', motivo: '', diag_sel: '', diag_libre: '', tratamiento: '', notas: '' });
setModalNueva(true);
};
const guardarNueva = async () => {
if (!pacienteSel) {
toast.error('Selecciona un paciente');
return;
}
const diagnostico = formNueva.diag_sel
? (diagnosticos.find((d) => String(d.id) === formNueva.diag_sel)?.name || '')
: formNueva.diag_libre.trim();
try {
setSubmitting(true);
const res = await odooApi.createConsulta({
partner_id: pacienteSel.id,
doctor_id: formNueva.doctor_id ? parseInt(formNueva.doctor_id, 10) : null,
motivo: formNueva.motivo,
diagnostico,
tratamiento: formNueva.tratamiento,
notas: formNueva.notas,
});
if (res.status === 'success') {
toast.success('Consulta registrada');
setModalNueva(false);
await load();
}
} catch (err) {
toast.error('Error al registrar la consulta');
console.error(err);
} finally {
setSubmitting(false);
}
};
const openEdit = (v: Visita) => {
setSelected(v);
setFormEdit({
motivo: v.motivo || '',
diagnostico: v.diagnostico || '',
tratamiento: v.tratamiento || '',
notas: v.notas || '',
receta_text: v.receta_text || '',
doctor_id: v.doctor_id ? String(v.doctor_id) : '',
});
};
const guardarEdicion = async () => {
if (!selected) return;
try {
setSubmitting(true);
const res = await odooApi.updateVisita(selected.id, {
motivo: formEdit.motivo,
diagnostico: formEdit.diagnostico,
tratamiento: formEdit.tratamiento,
notas: formEdit.notas,
receta_text: formEdit.receta_text,
doctor_id: formEdit.doctor_id ? parseInt(formEdit.doctor_id, 10) : null,
});
if (res.status === 'success') {
toast.success('Consulta actualizada');
setSelected(null);
await load();
}
} catch (err) {
toast.error('Error al actualizar la consulta');
console.error(err);
} finally {
setSubmitting(false);
}
};
return (
<Layout title="Consultas Médicas" subtitle="Registro directo de consultas">
<PageHeader title="Consultas Médicas" subtitle="Consultas registradas sin cita previa">
<Button onClick={openNueva}>
<Plus size={16} className="mr-2" />
Nueva consulta
</Button>
</PageHeader>
<Card>
<Card.Body>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4 sm:mb-6">
<div className="flex items-end gap-2">
<div className="flex-1">
<Input label="Fecha" type="date" value={fecha === 'todas' ? '' : fecha} onChange={(e) => setFecha(e.target.value || 'todas')} />
</div>
<Button
variant={fecha === 'todas' ? 'primary' : 'outline'}
size="sm"
onClick={() => setFecha(fecha === 'todas' ? hoy() : 'todas')}
className="mb-1"
>
Todas
</Button>
</div>
<Select
label="Médico"
options={[{ value: '', label: 'Todos' }, ...doctorOptions.slice(1)]}
value={doctorFiltro}
onChange={(e) => setDoctorFiltro(e.target.value)}
/>
<Input label="Paciente" placeholder="Buscar por nombre..." value={busqueda} onChange={(e) => setBusqueda(e.target.value)} />
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : consultas.length === 0 ? (
<EmptyState
title="Sin consultas"
subtitle="No hay consultas registradas con los filtros seleccionados."
actionLabel="Nueva consulta"
onAction={openNueva}
icon={<ClipboardPlus size={28} />}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Fecha</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Motivo</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Diagnóstico</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Médico</th>
</tr>
</thead>
<tbody className="divide-y">
{consultas.map((c) => (
<tr key={c.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => openEdit(c)}>
<td className="p-3 text-sm text-theme-muted whitespace-nowrap">{c.date_start || '-'}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{c.patient}</td>
<td className="p-3 text-sm text-theme-muted">{c.motivo || '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">
<span className="block max-w-[220px] truncate">{c.diagnostico || '-'}</span>
</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{c.doctor || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
{/* Modal nueva consulta */}
<Modal
isOpen={modalNueva}
onClose={() => setModalNueva(false)}
title="Nueva consulta"
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={() => setModalNueva(false)}>Cancelar</Button>
<Button onClick={guardarNueva} loading={submitting} disabled={!pacienteSel}>Guardar consulta</Button>
</>
}
>
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
{/* Paciente */}
<div>
<p className="text-sm font-medium text-theme-heading mb-1.5">Paciente *</p>
{pacienteSel ? (
<div className="flex items-center justify-between p-3 bg-theme-bg rounded-xl">
<span className="text-sm font-medium text-theme-heading">{pacienteSel.name} <span className="text-theme-muted">· {pacienteSel.phone}</span></span>
<Button variant="ghost" size="sm" onClick={() => { setPacienteSel(null); setPacienteQuery(''); }}>
Cambiar
</Button>
</div>
) : (
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por nombre o teléfono..."
value={pacienteQuery}
onChange={(e) => setPacienteQuery(e.target.value)}
className="pl-10"
/>
{pacienteResults.length > 0 && (
<div className="absolute z-20 top-full left-0 right-0 mt-1 rounded-xl border border-theme-border bg-theme-surface shadow-card max-h-56 overflow-y-auto">
{pacienteResults.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { setPacienteSel(p); setPacienteResults([]); }}
className="w-full text-left px-3 py-2.5 text-sm hover:bg-theme-bg transition"
>
<span className="font-medium text-theme-heading">{p.name}</span>
<span className="text-theme-muted"> · {p.phone}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<Select
label="Médico / Atendió"
options={doctorOptions}
value={formNueva.doctor_id}
onChange={(e) => setFormNueva({ ...formNueva, doctor_id: e.target.value })}
/>
<Input
label="Motivo de consulta"
value={formNueva.motivo}
onChange={(e) => setFormNueva({ ...formNueva, motivo: e.target.value })}
/>
<Select
label="Diagnóstico (catálogo)"
options={[
{ value: '', label: 'Texto libre / sin diagnóstico' },
...diagnosticos.map((d) => ({ value: String(d.id), label: d.name })),
]}
value={formNueva.diag_sel}
onChange={(e) => setFormNueva({ ...formNueva, diag_sel: e.target.value })}
/>
{!formNueva.diag_sel && (
<TextArea
label="Diagnóstico (texto libre)"
value={formNueva.diag_libre}
onChange={(e) => setFormNueva({ ...formNueva, diag_libre: e.target.value })}
/>
)}
<TextArea
label="Tratamiento"
value={formNueva.tratamiento}
onChange={(e) => setFormNueva({ ...formNueva, tratamiento: e.target.value })}
/>
<TextArea
label="Observaciones"
value={formNueva.notas}
onChange={(e) => setFormNueva({ ...formNueva, notas: e.target.value })}
/>
</div>
</Modal>
{/* Modal edición */}
<Modal
isOpen={!!selected}
onClose={() => setSelected(null)}
title={selected ? `Consulta — ${selected.patient}` : 'Consulta'}
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>Cerrar</Button>
<Button onClick={guardarEdicion} loading={submitting}>Guardar</Button>
</>
}
>
{selected && (
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="success">Completada</Badge>
<span className="text-sm text-theme-muted">{selected.date_start || '-'}</span>
</div>
<Select
label="Médico / Atendió"
options={doctorOptions}
value={formEdit.doctor_id}
onChange={(e) => setFormEdit({ ...formEdit, doctor_id: e.target.value })}
/>
<Input label="Motivo" value={formEdit.motivo} onChange={(e) => setFormEdit({ ...formEdit, motivo: e.target.value })} />
<TextArea label="Diagnóstico" value={formEdit.diagnostico} onChange={(e) => setFormEdit({ ...formEdit, diagnostico: e.target.value })} />
<TextArea label="Tratamiento" value={formEdit.tratamiento} onChange={(e) => setFormEdit({ ...formEdit, tratamiento: e.target.value })} />
<TextArea label="Observaciones" value={formEdit.notas} onChange={(e) => setFormEdit({ ...formEdit, notas: e.target.value })} />
<RecetaSection
value={formEdit.receta_text}
onChange={(v) => setFormEdit({ ...formEdit, receta_text: v })}
onPrint={() => setPrintOpen(true)}
/>
</div>
)}
</Modal>
{/* Vista de impresión de la receta */}
{selected && (
<RecetaPrint
isOpen={printOpen}
onClose={() => setPrintOpen(false)}
paciente={selected.patient}
doctor={selected.doctor}
contenido={formEdit.receta_text}
/>
)}
</Layout>
);
};
export default Consultas;

View File

@@ -5,7 +5,7 @@ import Layout from '../components/Layout';
import { Card, PageHeader, Button, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { Birthday } from '../services/odoo';
import { downloadCsv } from '../lib/utils';
import { exportToExcel } from '../lib/exporter';
type Period = 'today' | 'week' | 'month';
@@ -50,18 +50,35 @@ const Cumpleanos: FC = () => {
};
}, [period]);
const exportBirthdays = () => {
const rows: (string | number | boolean | null | undefined)[][] = [
['Nombre', 'Teléfono', 'Fecha nacimiento', 'Cumple el', 'Cumple años', 'Última visita', 'Total gastado', 'Adeudo', 'VIP'],
];
items.forEach((b) =>
rows.push([
b.name, b.phone, b.birth_date, b.occurs_on, b.turning_age, b.last_visit,
b.total_spent, b.amount_due, b.is_vip ? 'Sí' : 'No',
])
);
downloadCsv(`cumpleaneros-skeen-${period}-${new Date().toISOString().split('T')[0]}.csv`, rows);
toast.success(`CSV descargado (${items.length} cumpleañeros)`);
const exportBirthdays = async () => {
try {
await exportToExcel({
filename: `cumpleaneros-skeen-${period}-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Cumpleañeros',
title: 'Cumpleañeros SKEEN',
subtitle: `Periodo: ${period} · ${items.length} pacientes`,
columns: [
{ header: 'Nombre', key: 'name' },
{ header: 'Teléfono', key: 'phone' },
{ header: 'Fecha nacimiento', key: 'birth_date', format: 'date' },
{ header: 'Cumple el', key: 'occurs_on', format: 'date' },
{ header: 'Cumple años', key: 'turning_age', format: 'number' },
{ header: 'Última visita', key: 'last_visit', format: 'date' },
{ header: 'Total gastado', key: 'total_spent', format: 'currency' },
{ header: 'Adeudo', key: 'amount_due', format: 'currency' },
{ header: 'VIP', key: 'vip' },
],
rows: items.map((b) => ({
name: b.name, phone: b.phone, birth_date: b.birth_date, occurs_on: b.occurs_on,
turning_age: b.turning_age, last_visit: b.last_visit, total_spent: b.total_spent,
amount_due: b.amount_due, vip: b.is_vip ? 'Sí' : 'No',
})),
});
toast.success(`Excel descargado (${items.length} cumpleañeros)`);
} catch (err) {
toast.error('Error al exportar');
console.error(err);
}
};
return (

View File

@@ -1,5 +1,6 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Calendar,
Users,
@@ -40,6 +41,7 @@ import {
} from '../components/ui';
import { odooApi, type DashboardStats, type Appointment, type Service, type ChartPoint } from '../services/odoo';
import { useTheme } from '../lib/theme';
import { useAuth } from '../lib/auth';
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
@@ -76,6 +78,8 @@ const StatCard: FC<StatCardProps> = ({ title, value, icon, trend, loading }) =>
const Dashboard: FC = () => {
const { theme } = useTheme();
const { user } = useAuth();
const navigate = useNavigate();
const isHomeNest = theme === 'homenest';
const [stats, setStats] = useState<DashboardStats | null>(null);
const [chartData, setChartData] = useState<ChartPoint[]>([]);
@@ -256,10 +260,10 @@ const Dashboard: FC = () => {
</p>
</div>
<div className="flex flex-wrap gap-3 shrink-0">
<Button variant="primary" size="md">
<Button variant="primary" size="md" onClick={() => navigate('/agenda')}>
<Calendar size={16} className="mr-2" /> Nueva cita
</Button>
<Button variant="outline" size="md">
<Button variant="outline" size="md" onClick={() => navigate('/reportes')}>
<TrendingUp size={16} className="mr-2" /> Ver reportes
</Button>
</div>
@@ -277,10 +281,10 @@ const Dashboard: FC = () => {
) : (
<>
<PageHeader title="Dashboard" subtitle="Resumen general de la clínica">
<Button variant="outline" size="sm">
<Sparkles size={16} className="mr-2" />
Bienvenido
</Button>
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium bg-theme-accent-bg text-theme-heading">
<Sparkles size={16} />
Bienvenido{user?.name ? `, ${user.name.split(' ')[0]}` : ''}
</span>
</PageHeader>
<div className="bg-gradient-to-r from-[#EDA588] to-[#C7BCDD] rounded-2xl p-6 sm:p-8 text-white mb-6 sm:mb-8 shadow-sm">
@@ -292,7 +296,7 @@ const Dashboard: FC = () => {
</p>
</div>
<div className="shrink-0">
<Button variant="secondary" size="sm" className="bg-theme-surface/20 text-white border-white/30 hover:bg-theme-surface/30">
<Button variant="secondary" size="sm" className="bg-theme-surface/20 text-white border-white/30 hover:bg-theme-surface/30" onClick={() => navigate('/reportes')}>
<TrendingUp size={16} className="mr-2" />
Ver reportes
</Button>

View File

@@ -1,11 +1,21 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Boxes, Plus, AlertTriangle, PackageCheck, ArrowDownToLine, ArrowUpFromLine, History } from 'lucide-react';
import Layout from '../components/Layout';
import MovimientosPanel from '../components/inventario/MovimientosPanel';
import AlertasPanel from '../components/inventario/AlertasPanel';
import { Card, Button, Input, Select, Modal, PageHeader, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary } from '../services/odoo';
const TABS = [
{ key: 'items', label: 'Items' },
{ key: 'compras', label: 'Compras' },
{ key: 'bajas', label: 'Bajas' },
{ key: 'alertas', label: 'Alertas' },
] as const;
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
@@ -50,6 +60,9 @@ const emptyItem: Partial<InventoryItem> = {
};
const Inventario: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'items';
const setTab = (t: string) => setSearchParams({ tab: t });
const [items, setItems] = useState<InventoryItem[]>([]);
const [summary, setSummary] = useState<InventorySummary>({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 });
const [loading, setLoading] = useState(true);
@@ -71,6 +84,7 @@ const Inventario: FC = () => {
const [moves, setMoves] = useState<InventoryMove[]>([]);
const load = useCallback(async () => {
if (tab !== 'items') return;
setLoading(true);
try {
const res = await odooApi.getInventory({
@@ -85,7 +99,7 @@ const Inventario: FC = () => {
} finally {
setLoading(false);
}
}, [kind, level, search]);
}, [kind, level, search, tab]);
useEffect(() => { load(); }, [load]);
@@ -147,11 +161,37 @@ const Inventario: FC = () => {
title="Inventario y consumibles"
subtitle="Existencias, niveles y movimientos (compras, ventas, bajas, ajustes)"
>
<Button onClick={() => setNewOpen(true)}>
<Plus size={16} className="mr-2" /> Nuevo item
</Button>
{tab === 'items' && (
<Button onClick={() => setNewOpen(true)}>
<Plus size={16} className="mr-2" /> Nuevo item
</Button>
)}
</PageHeader>
{/* Tabs */}
<div className="flex flex-wrap gap-1.5 mb-4">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'compras' ? (
<MovimientosPanel tipo="compra" />
) : tab === 'bajas' ? (
<MovimientosPanel tipo="baja" />
) : tab === 'alertas' ? (
<AlertasPanel />
) : (
<>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-4">
<Card><Card.Body>
@@ -308,6 +348,8 @@ const Inventario: FC = () => {
</div>
)}
</Modal>
</>
)}
</Layout>
);
};

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Search, Plus, Minus, Wallet } from 'lucide-react';
import Layout from '../components/Layout';
import {
@@ -14,11 +15,32 @@ import {
MobileCard,
toast,
} from '../components/ui';
import { odooApi, type Wallet as WalletType } from '../services/odoo';
import { odooApi, type Wallet as WalletType, type MonederoPuntosReport } from '../services/odoo';
type TxType = 'accrual' | 'redemption';
const TABS = [
{ key: 'cuentas', label: 'Cuentas' },
{ key: 'reporte', label: 'Reporte de puntos' },
] as const;
const txTypeLabels: Record<string, string> = {
accrual: 'Acumulación', redemption: 'Redención', adjustment: 'Ajuste', expiration: 'Vencimiento',
};
const inicioDeMes = () => {
const d = new Date();
d.setDate(1);
return d.toISOString().split('T')[0];
};
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
const Monedero: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'cuentas';
const setTab = (t: string) => setSearchParams({ tab: t });
const [wallets, setWallets] = useState<WalletType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -33,8 +55,13 @@ const Monedero: FC = () => {
const [points, setPoints] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [reporte, setReporte] = useState<MonederoPuntosReport | null>(null);
const [reporteLoading, setReporteLoading] = useState(false);
const [repStart, setRepStart] = useState(inicioDeMes());
const [repEnd, setRepEnd] = useState(() => new Date().toISOString().split('T')[0]);
const load = useCallback(async () => {
if (tab !== 'cuentas') return;
try {
setLoading(true);
setError(null);
@@ -51,7 +78,24 @@ const Monedero: FC = () => {
} finally {
setLoading(false);
}
}, [search, page, pageSize]);
}, [search, page, pageSize, tab]);
const loadReporte = useCallback(async () => {
if (tab !== 'reporte') return;
try {
setReporteLoading(true);
const res = await odooApi.getMonederoPuntos(repStart || undefined, repEnd || undefined);
if (res.status === 'success') {
const { by_type, puntos_activos, cuentas, equivalente_mxn } = res;
setReporte({ by_type, puntos_activos, cuentas, equivalente_mxn });
}
} catch (err) {
toast.error('Error al cargar reporte de puntos');
console.error(err);
} finally {
setReporteLoading(false);
}
}, [tab, repStart, repEnd]);
useEffect(() => {
setPage(1);
@@ -61,6 +105,10 @@ const Monedero: FC = () => {
load();
}, [load]);
useEffect(() => {
loadReporte();
}, [loadReporte]);
const openModal = (w: WalletType, type: TxType) => {
setSelected(w);
setTxType(type);
@@ -103,6 +151,78 @@ const Monedero: FC = () => {
<Layout title="Monedero" subtitle="Puntos y recompensas">
<PageHeader title="Monedero" subtitle="Busca por teléfono y gestiona puntos" />
{/* Tabs */}
<div className="flex flex-wrap gap-1.5 mb-4">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'reporte' ? (
<>
<Card className="mb-4">
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3">
<Input label="Inicio" type="date" value={repStart} onChange={(e) => setRepStart(e.target.value)} className="sm:max-w-[160px]" />
<Input label="Fin" type="date" value={repEnd} onChange={(e) => setRepEnd(e.target.value)} className="sm:max-w-[160px]" />
</div>
</Card.Body>
</Card>
{reporteLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : reporte ? (
<>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Puntos activos</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{reporte.puntos_activos} pts</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Equivalente</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{fmtMoney(reporte.equivalente_mxn)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Cuentas con monedero</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{reporte.cuentas}</p>
</Card>
</div>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-1">Movimientos del periodo</h3>
<p className="text-xs text-theme-muted mb-4">1 punto = $1 MXN (conversión fija del sistema).</p>
{Object.keys(reporte.by_type).length === 0 ? (
<p className="text-sm text-theme-muted">Sin movimientos en el periodo.</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{Object.entries(reporte.by_type).map(([type, points]) => (
<div key={type} className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted">{txTypeLabels[type] || type}</p>
<p className={`text-lg font-semibold ${type === 'redemption' || type === 'expiration' ? 'text-rose-600' : 'text-theme-heading'}`}>
{points > 0 && type !== 'redemption' && type !== 'expiration' ? '+' : ''}{points} pts
</p>
</div>
))}
</div>
)}
</Card.Body>
</Card>
</>
) : null}
</>
) : (
<>
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
@@ -258,6 +378,8 @@ const Monedero: FC = () => {
</div>
)}
</Modal>
</>
)}
</Layout>
);
};

View File

@@ -1,11 +1,12 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import {
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
Download,
Download, ChevronRight, AlertTriangle, ClipboardList, Camera,
} from 'lucide-react';
import Layout from '../components/Layout';
import PatientAdjuntos from '../components/PatientAdjuntos';
import {
Card,
Button,
@@ -20,9 +21,12 @@ import {
toast,
TextArea,
badgeForAppointmentState,
badgeForSaleState,
} from '../components/ui';
import { odooApi, type Patient, type Appointment, type Sale } from '../services/odoo';
import { downloadCsv } from '../lib/utils';
import type { BadgeVariant } from '../components/ui/Badge';
import { useSearchParams } from 'react-router-dom';
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta } from '../services/odoo';
import { exportToExcel } from '../lib/exporter';
const genderOptions = [
{ value: '', label: 'No especificado' },
@@ -75,22 +79,66 @@ const formatBlood = (bt?: string | false) => {
return opt?.label || bt.toUpperCase();
};
const appointmentStateLabels: Record<string, string> = {
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
};
const saleStateLabels: Record<string, string> = {
draft: 'Borrador', confirmed: 'Confirmada', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada',
};
const visitaStateLabels: Record<string, string> = {
en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada',
};
const paymentStateLabels: Record<string, string> = {
not_paid: 'No pagado', partial: 'Pago parcial', paid: 'Pagado',
};
const branchLabels: Record<string, string> = { rosarito: 'Rosarito', tijuana: 'Tijuana' };
const mediumLabels: Record<string, string> = { onsite: 'Presencial', videocall: 'Videollamada' };
const completionBadgeClasses = (pct?: number) => {
const v = pct ?? 0;
if (v >= 100) return 'bg-brand-mint text-theme-heading';
if (v < 40) return 'bg-rose-100 text-rose-700';
return 'bg-theme-warning text-theme-warning-text';
};
const badgeForVisitaState = (state: string): BadgeVariant =>
state === 'en_curso' ? 'info' : state === 'completada' ? 'success' : state === 'cancelada' ? 'danger' : 'default';
const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [
{ key: 'is_pregnant', label: 'Embarazo' },
{ key: 'heart_disease', label: 'Enfermedad cardíaca' },
{ key: 'diabetes', label: 'Diabetes' },
{ key: 'faints_with_needles', label: 'Se desmaya con agujas' },
];
const Pacientes: FC = () => {
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [searchParams] = useSearchParams();
const [search, setSearch] = useState(() => searchParams.get('q') || '');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [selected, setSelected] = useState<Patient | null>(null);
const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[] } | null>(null);
const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta?: EstadoCuenta } | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [tab, setTab] = useState<'info' | 'clinical' | 'appointments' | 'sales'>('info');
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info');
const [entryDetail, setEntryDetail] = useState<
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null
>(null);
const [onlyIncomplete, setOnlyIncomplete] = useState(false);
const [onlyVip, setOnlyVip] = useState(false);
const [onlyRecent, setOnlyRecent] = useState(false);
const [doctorFilter, setDoctorFilter] = useState('');
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Patient | null>(null);
const [photo, setPhoto] = useState<string | null>(null);
const photoRef = useRef<HTMLInputElement>(null);
const emptyForm: PatientForm = {
name: '',
@@ -106,6 +154,8 @@ const Pacientes: FC = () => {
emergency_phone: '',
home_phone: '',
mobile: '',
whatsapp: '',
primary_doctor_id: '',
address_notes: '',
referred_by: '',
patient_comments: '',
@@ -140,7 +190,15 @@ const Pacientes: FC = () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getPatients({ search, page, page_size: pageSize });
const res = await odooApi.getPatients({
search,
page,
page_size: pageSize,
incomplete: onlyIncomplete ? 1 : undefined,
vip: onlyVip ? 1 : undefined,
recent: onlyRecent ? 1 : undefined,
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
});
if (res.status === 'success') {
setPatients(res.patients);
setTotal(res.total ?? 0);
@@ -153,11 +211,17 @@ const Pacientes: FC = () => {
} finally {
setLoading(false);
}
}, [search, page, pageSize]);
}, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
useEffect(() => {
setPage(1);
}, [search]);
}, [search, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
useEffect(() => {
odooApi.getDoctors()
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
.catch(() => {});
}, []);
useEffect(() => {
load();
@@ -169,7 +233,7 @@ const Pacientes: FC = () => {
setHistoryLoading(true);
try {
const res = await odooApi.getPatientHistory(patient.id);
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales });
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales, visitas: res.visitas || [], estado_cuenta: res.estado_cuenta });
} catch (err) {
toast.error('Error al cargar historial');
console.error(err);
@@ -181,11 +245,13 @@ const Pacientes: FC = () => {
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setPhoto(null);
setModalOpen(true);
};
const openEdit = (patient: Patient) => {
setEditing(patient);
setPhoto(null);
setForm({
...emptyForm,
...patient,
@@ -193,6 +259,8 @@ const Pacientes: FC = () => {
gender: patient.gender || '',
blood_type: patient.blood_type || '',
marital_status: patient.marital_status || '',
whatsapp: patient.whatsapp || '',
primary_doctor_id: patient.primary_doctor_id ? String(patient.primary_doctor_id) : '',
children_count: patient.children_count ?? 0,
is_pregnant: patient.is_pregnant ?? false,
is_breastfeeding: patient.is_breastfeeding ?? false,
@@ -219,6 +287,19 @@ const Pacientes: FC = () => {
setForm((prev) => ({ ...prev, ...patch }));
};
const onPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
toast.error('La foto excede 5 MB');
return;
}
const reader = new FileReader();
reader.onload = () => setPhoto(String(reader.result || ''));
reader.readAsDataURL(file);
};
const save = async () => {
if (!form.name || !form.phone) {
toast.error('Nombre y teléfono son obligatorios');
@@ -232,10 +313,13 @@ const Pacientes: FC = () => {
gender: form.gender || undefined,
blood_type: form.blood_type || undefined,
marital_status: form.marital_status || undefined,
primary_doctor_id: form.primary_doctor_id ? parseInt(form.primary_doctor_id, 10) : null,
};
if (photo) payload.photo = photo.split(',')[1] || '';
if (editing) {
await odooApi.updatePatient(editing.id, payload);
const res = await odooApi.updatePatient(editing.id, payload);
toast.success('Paciente actualizado');
if (selected?.id === editing.id) setSelected(res.patient);
} else {
await odooApi.createPatient(payload);
toast.success('Paciente creado');
@@ -254,6 +338,7 @@ const Pacientes: FC = () => {
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Expediente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Teléfono</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Edad</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Última visita</th>
@@ -285,18 +370,47 @@ const Pacientes: FC = () => {
</div>
);
const exportPatients = () => {
const rows: (string | number | boolean | null | undefined)[][] = [
['Expediente', 'Nombre', 'Teléfono', 'Email', 'Fecha nacimiento', 'Género', 'Última visita', 'Visitas', 'Puntos monedero', 'Total gastado', 'Fuente', 'VIP'],
];
patients.forEach((p) =>
rows.push([
p.patient_id, p.name, p.phone, p.email, p.birth_date, p.gender || '', p.last_visit,
p.total_visits, p.wallet_points, p.total_spent, p.source, p.is_vip ? 'Sí' : 'No',
])
);
downloadCsv(`pacientes-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
toast.success(`CSV descargado (${patients.length} pacientes de la vista actual)`);
const timelineItems = history
? [
...history.appointments.map((a) => ({ kind: 'cita' as const, date: a.date || '', item: a })),
...history.sales.map((s) => ({ kind: 'venta' as const, date: (s.date || '').slice(0, 10), item: s })),
...history.visitas.map((v) => ({ kind: 'visita' as const, date: (v.date_start || '').slice(0, 10), item: v })),
].sort((a, b) => b.date.localeCompare(a.date))
: [];
const exportPatients = async () => {
try {
await exportToExcel({
filename: `pacientes-skeen-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Pacientes',
title: 'Pacientes SKEEN',
subtitle: `${patients.length} pacientes de la vista actual`,
columns: [
{ header: 'Expediente', key: 'patient_id' },
{ header: 'Nombre', key: 'name' },
{ header: 'Teléfono', key: 'phone' },
{ header: 'Email', key: 'email' },
{ header: 'Fecha nacimiento', key: 'birth_date', format: 'date' },
{ header: 'Género', key: 'gender' },
{ header: 'Última visita', key: 'last_visit', format: 'date' },
{ header: 'Visitas', key: 'total_visits', format: 'number' },
{ header: 'Puntos monedero', key: 'wallet_points', format: 'number' },
{ header: 'Total gastado', key: 'total_spent', format: 'currency' },
{ header: 'Fuente', key: 'source' },
{ header: 'VIP', key: 'vip' },
],
rows: patients.map((p) => ({
patient_id: p.patient_id, name: p.name, phone: p.phone, email: p.email,
birth_date: p.birth_date, gender: p.gender || '', last_visit: p.last_visit,
total_visits: p.total_visits, wallet_points: p.wallet_points,
total_spent: p.total_spent, source: p.source, vip: p.is_vip ? 'Sí' : 'No',
})),
});
toast.success(`Excel descargado (${patients.length} pacientes de la vista actual)`);
} catch (err) {
toast.error('Error al exportar');
console.error(err);
}
};
return (
@@ -324,6 +438,49 @@ const Pacientes: FC = () => {
className="pl-10"
/>
</div>
<Select
options={[
{ value: '', label: 'Todos los médicos' },
...doctors.map((d) => ({
value: String(d.id),
label: d.patient_count !== undefined ? `${d.name} (${d.patient_count})` : d.name,
})),
]}
value={doctorFilter}
onChange={(e) => setDoctorFilter(e.target.value)}
className="sm:max-w-[220px]"
/>
</div>
{/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{([
{ key: 'vip', label: 'VIP', active: onlyVip, set: setOnlyVip },
{ key: 'recent', label: 'Visitas últimos 6 meses', active: onlyRecent, set: setOnlyRecent },
{ key: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
] as const).map((chip) => (
<button
key={chip.key}
type="button"
onClick={() => chip.set(!chip.active)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
chip.active
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{chip.label}
</button>
))}
{(onlyVip || onlyRecent || onlyIncomplete || doctorFilter) && (
<button
type="button"
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
>
Quitar filtros
</button>
)}
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
@@ -350,6 +507,14 @@ const Pacientes: FC = () => {
{p.name}
</button>
</td>
<td className="p-3 text-sm">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${completionBadgeClasses(p.expediente_completion)}`}
title={p.expediente_missing && p.expediente_missing.length > 0 ? `Falta: ${p.expediente_missing.join(', ')}` : 'Expediente completo'}
>
{p.expediente_completion ?? 0}%
</span>
</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{p.phone}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{p.age ?? '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{p.last_visit || '-'}</td>
@@ -378,6 +543,7 @@ const Pacientes: FC = () => {
title={p.name}
subtitle={p.phone}
rows={[
{ label: 'Expediente', value: `${p.expediente_completion ?? 0}%` },
{ label: 'Edad', value: p.age ?? '-' },
{ label: 'Última visita', value: p.last_visit || '-' },
{ label: 'Visitas', value: p.total_visits },
@@ -441,6 +607,26 @@ const Pacientes: FC = () => {
}
>
<div className="space-y-6 max-h-[70vh] overflow-y-auto pr-1">
{/* Foto del paciente */}
<div className="flex items-center gap-4">
{photo ? (
<img src={photo} alt="Foto" className="w-16 h-16 rounded-full object-cover border border-theme-border shrink-0" />
) : editing?.has_photo && editing.photo_url ? (
<img src={odooApi.patientPhotoUrl(editing.photo_url)} alt={editing.name} className="w-16 h-16 rounded-full object-cover border border-theme-border shrink-0" />
) : (
<div className="w-16 h-16 rounded-full bg-theme-accent-bg flex items-center justify-center text-theme-muted shrink-0">
<Camera size={22} />
</div>
)}
<div>
<input ref={photoRef} type="file" accept="image/*" className="hidden" onChange={onPhotoChange} />
<Button variant="outline" size="sm" onClick={() => photoRef.current?.click()}>
<Camera size={14} className="mr-1.5" /> Subir foto
</Button>
<p className="text-xs text-theme-muted mt-1">JPG o PNG, máx 5 MB.</p>
</div>
</div>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<User size={16} className="mr-2 text-theme-muted" /> Datos generales
@@ -451,9 +637,16 @@ const Pacientes: FC = () => {
<Input label="Email" type="email" value={form.email} onChange={(e) => updateForm({ email: e.target.value })} />
<Input label="Celular" value={form.mobile} onChange={(e) => updateForm({ mobile: e.target.value })} />
<Input label="Teléfono casa" value={form.home_phone} onChange={(e) => updateForm({ home_phone: e.target.value })} />
<Input label="WhatsApp" value={form.whatsapp} onChange={(e) => updateForm({ whatsapp: e.target.value })} />
<Input label="Fecha de nacimiento" type="date" value={form.birth_date} onChange={(e) => updateForm({ birth_date: e.target.value })} />
<Select label="Género" options={genderOptions} value={form.gender} onChange={(e) => updateForm({ gender: e.target.value })} />
<Select label="Tipo sanguíneo" options={bloodOptions} value={form.blood_type} onChange={(e) => updateForm({ blood_type: e.target.value })} />
<Select
label="Médico asignado"
options={[{ value: '', label: 'Sin asignar' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={form.primary_doctor_id}
onChange={(e) => updateForm({ primary_doctor_id: e.target.value })}
/>
</div>
</section>
@@ -570,14 +763,85 @@ const Pacientes: FC = () => {
onClose={() => setSelected(null)}
title={selected?.name || 'Detalle del paciente'}
maxWidth="2xl"
footer={
<Button variant="outline" onClick={() => selected && openEdit(selected)}>
<Edit2 size={16} className="mr-2" />
Editar paciente
</Button>
}
>
<div className="space-y-4">
{/* Encabezado con foto */}
{selected && (
<div className="flex items-center gap-3">
{selected.has_photo && selected.photo_url ? (
<img
src={odooApi.patientPhotoUrl(selected.photo_url)}
alt={selected.name}
className="w-14 h-14 rounded-full object-cover border border-theme-border shrink-0"
/>
) : (
<div className="w-14 h-14 rounded-full bg-theme-accent-bg flex items-center justify-center text-lg font-bold text-theme-heading shrink-0">
{selected.name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase()).join('')}
</div>
)}
<div className="min-w-0">
<p className="font-semibold text-theme-heading truncate">{selected.name}</p>
<p className="text-xs text-theme-muted truncate">
{selected.patient_id}{selected.primary_doctor ? ` · Médico: ${selected.primary_doctor}` : ''}
</p>
</div>
</div>
)}
{/* Alertas clínicas */}
{selected?.allergies && (
<div className="flex items-start gap-2 p-3 bg-rose-100 text-rose-700 rounded-xl text-sm">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<p><span className="font-semibold">Alergias:</span> {selected.allergies}</p>
</div>
)}
{selected && CRITICAL_ALERTS.some(({ key }) => selected[key]) && (
<div className="flex flex-wrap gap-2">
{CRITICAL_ALERTS.filter(({ key }) => selected[key]).map(({ key, label }) => (
<span key={key} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-theme-warning text-theme-warning-text">
<AlertTriangle size={12} />
{label}
</span>
))}
</div>
)}
{/* Completitud del expediente */}
{selected && (
<div className="p-3 bg-theme-bg rounded-xl space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-theme-muted">Completitud del expediente</p>
<p className="text-sm font-semibold text-theme-heading">{selected.expediente_completion ?? 0}%</p>
</div>
<div className="h-2 bg-theme-surface border border-theme-border rounded-full overflow-hidden">
<div
className={`h-full ${(selected.expediente_completion ?? 0) >= 100 ? 'bg-brand-mint' : 'bg-brand-coral'}`}
style={{ width: `${selected.expediente_completion ?? 0}%` }}
/>
</div>
{selected.expediente_missing && selected.expediente_missing.length > 0 && (
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-xs text-theme-muted">Falta: {selected.expediente_missing.join(', ')}</p>
<Button variant="outline" size="sm" onClick={() => openEdit(selected)}>
Completar expediente
</Button>
</div>
)}
</div>
)}
<div className="flex gap-2 border-b border-theme-border pb-2 overflow-x-auto">
{([
{ key: 'info', label: 'Información general' },
{ key: 'clinical', label: 'Historia clínica' },
{ key: 'appointments', label: 'Citas' },
{ key: 'sales', label: 'Ventas' },
{ key: 'timeline', label: 'Historial' },
{ key: 'docs', label: 'Documentos' },
] as const).map(({ key, label }) => (
<button
key={key}
@@ -595,10 +859,37 @@ const Pacientes: FC = () => {
<Skeleton count={4} className="h-16 w-full" />
) : tab === 'info' ? (
<div className="space-y-4">
{/* Estado de cuenta */}
{history?.estado_cuenta && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Estado de cuenta</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<p className="text-xs text-theme-muted">Puntos monedero</p>
<p className="text-sm font-semibold text-theme-heading">{history.estado_cuenta.puntos_monedero} pts</p>
</div>
<div>
<p className="text-xs text-theme-muted">Total gastado</p>
<p className="text-sm font-semibold text-theme-heading">${history.estado_cuenta.total_gastado}</p>
</div>
<div>
<p className="text-xs text-theme-muted">Adeudo pendiente</p>
<p className={`text-sm font-semibold ${history.estado_cuenta.adeudo > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
${history.estado_cuenta.adeudo}
</p>
</div>
<div>
<p className="text-xs text-theme-muted">Total visitas</p>
<p className="text-sm font-semibold text-theme-heading">{history.estado_cuenta.visitas_total}</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<InfoRow label="Folio" value={selected?.patient_id} icon={FileText} />
<InfoRow label="Teléfono" value={selected?.phone} icon={Phone} />
<InfoRow label="Celular" value={selected?.mobile} icon={Phone} />
<InfoRow label="WhatsApp" value={selected?.whatsapp} icon={Phone} />
<InfoRow label="Email" value={selected?.email} icon={Mail} />
<InfoRow label="Teléfono casa" value={selected?.home_phone} icon={Phone} />
<InfoRow label="Fecha de nacimiento" value={selected?.birth_date} icon={Calendar} />
@@ -611,6 +902,7 @@ const Pacientes: FC = () => {
<InfoRow label="Contacto de emergencia" value={selected?.emergency_contact} icon={AlertCircle} />
<InfoRow label="Tel. emergencia" value={selected?.emergency_phone} icon={Phone} />
<InfoRow label="Recomendado por" value={selected?.referred_by} icon={Users} />
<InfoRow label="Médico asignado" value={selected?.primary_doctor || 'Sin asignar'} icon={Stethoscope} />
<InfoRow label="Puntos" value={`${selected?.wallet_points ?? 0} pts`} icon={Heart} />
</div>
{selected?.address_notes && (
@@ -634,27 +926,39 @@ const Pacientes: FC = () => {
</div>
) : tab === 'clinical' ? (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<ClinicalItem label="Embarazo" value={selected?.is_pregnant} />
<ClinicalItem label="Lactancia" value={selected?.is_breastfeeding} />
<ClinicalItem label="Anticonceptivos" value={selected?.uses_contraceptives} />
<ClinicalItem label="Problemas de riñón" value={selected?.kidney_problems} />
<ClinicalItem label="Dolor de espalda" value={selected?.back_pain} />
<ClinicalItem label="Enfermedades cardíacas" value={selected?.heart_disease} />
<ClinicalItem label="Problemas respiratorios" value={selected?.respiratory_problems} />
<ClinicalItem label="Presión arterial" value={selected?.blood_pressure} />
<ClinicalItem label="Diabetes" value={selected?.diabetes} />
<ClinicalItem label="Problemas de tiroides" value={selected?.thyroid} />
<ClinicalItem label="Colitis" value={selected?.colitis} />
<ClinicalItem label="Estreñimiento" value={selected?.constipation} />
<ClinicalItem label="Problemas de hígado" value={selected?.liver_problems} />
<ClinicalItem label="Cirugías" value={selected?.surgeries} />
<ClinicalItem label="Varices" value={selected?.varicose_veins} />
<ClinicalItem label="Migraña" value={selected?.migraine} />
<ClinicalItem label="Se desmaya con agujas" value={selected?.faints_with_needles} />
<div>
<h4 className="text-sm font-semibold text-theme-heading mb-2 flex items-center">
<Baby size={16} className="mr-2 text-theme-muted" /> Gineco-obstétricos
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<ClinicalItem label="Embarazo" value={selected?.is_pregnant} />
<ClinicalItem label="Lactancia" value={selected?.is_breastfeeding} />
<ClinicalItem label="Anticonceptivos" value={selected?.uses_contraceptives} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<InfoRow label="Número de hijos" value={selected?.children_count} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoRow label="Número de hijos" value={selected?.children_count} />
<div>
<h4 className="text-sm font-semibold text-theme-heading mb-2 flex items-center">
<Stethoscope size={16} className="mr-2 text-theme-muted" /> Antecedentes
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<ClinicalItem label="Problemas de riñón" value={selected?.kidney_problems} />
<ClinicalItem label="Dolor de espalda" value={selected?.back_pain} />
<ClinicalItem label="Enfermedades cardíacas" value={selected?.heart_disease} />
<ClinicalItem label="Problemas respiratorios" value={selected?.respiratory_problems} />
<ClinicalItem label="Presión arterial" value={selected?.blood_pressure} />
<ClinicalItem label="Diabetes" value={selected?.diabetes} />
<ClinicalItem label="Problemas de tiroides" value={selected?.thyroid} />
<ClinicalItem label="Colitis" value={selected?.colitis} />
<ClinicalItem label="Estreñimiento" value={selected?.constipation} />
<ClinicalItem label="Problemas de hígado" value={selected?.liver_problems} />
<ClinicalItem label="Cirugías" value={selected?.surgeries} />
<ClinicalItem label="Varices" value={selected?.varicose_veins} />
<ClinicalItem label="Migraña" value={selected?.migraine} />
<ClinicalItem label="Se desmaya con agujas" value={selected?.faints_with_needles} />
</div>
</div>
{[
{ label: 'Alergias', value: selected?.allergies },
@@ -671,43 +975,208 @@ const Pacientes: FC = () => {
) : null
)}
</div>
) : tab === 'appointments' ? (
history && history.appointments.length > 0 ? (
<div className="space-y-2">
{history.appointments.map((a) => (
<div key={a.id} className="p-3 border border-theme-border rounded-xl text-sm">
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">
{a.date} {a.time}
</p>
<Badge variant={badgeForAppointmentState(a.state)}>{a.state}</Badge>
</div>
<p className="text-theme-muted mt-1">{a.service}</p>
</div>
))}
</div>
) : (
<EmptyState title="Sin citas" subtitle="Este paciente no tiene citas registradas." icon={<Activity size={28} />} />
)
) : tab === 'docs' ? (
<div className="space-y-6">
{selected && (
<>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<FileText size={16} className="mr-2 text-theme-muted" /> Expediente escaneado
</h4>
<PatientAdjuntos patientId={selected.id} kind="expediente" />
</section>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<ClipboardList size={16} className="mr-2 text-theme-muted" /> Galería de imágenes
</h4>
<PatientAdjuntos patientId={selected.id} kind="imagen" />
</section>
</>
)}
</div>
) : (
history && history.sales.length > 0 ? (
timelineItems.length > 0 ? (
<div className="space-y-2">
{history.sales.map((s) => (
<div key={s.id} className="p-3 border border-theme-border rounded-xl text-sm">
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">{s.name}</p>
<p className="font-semibold text-theme-heading">${s.total}</p>
{timelineItems.map((entry) => (
<button
key={`${entry.kind}-${entry.item.id}`}
type="button"
onClick={() => setEntryDetail(entry)}
className="w-full text-left p-3 border border-theme-border rounded-xl text-sm transition-all cursor-pointer hover:shadow-card hover:border-theme-border-strong"
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-theme-heading flex items-center">
{entry.kind === 'cita' ? (
<Calendar size={14} className="mr-1.5 text-theme-muted shrink-0" />
) : entry.kind === 'visita' ? (
<ClipboardList size={14} className="mr-1.5 text-theme-muted shrink-0" />
) : (
<ShoppingBag size={14} className="mr-1.5 text-theme-muted shrink-0" />
)}
{entry.kind === 'cita' ? `${entry.item.date} ${entry.item.time}` : entry.date}
</p>
<span className="flex items-center gap-1.5">
<Badge variant={
entry.kind === 'cita'
? badgeForAppointmentState(entry.item.state)
: entry.kind === 'visita'
? badgeForVisitaState(entry.item.state)
: badgeForSaleState(entry.item.state)
}>
{entry.kind === 'cita'
? (appointmentStateLabels[entry.item.state] || entry.item.state)
: entry.kind === 'visita'
? (visitaStateLabels[entry.item.state] || entry.item.state)
: (saleStateLabels[entry.item.state] || entry.item.state)}
</Badge>
<ChevronRight size={14} className="text-theme-muted" />
</span>
</div>
<p className="text-theme-muted mt-1">{s.date} {s.state}</p>
</div>
<p className="text-theme-muted mt-1">
{entry.kind === 'cita'
? entry.item.service
: entry.kind === 'visita'
? (entry.item.motivo || entry.item.servicio || 'Visita clínica')
: `${entry.item.name}$${entry.item.total}`}
</p>
</button>
))}
</div>
) : (
<EmptyState title="Sin ventas" subtitle="Este paciente no tiene ventas registradas." icon={<ShoppingBag size={28} />} />
<EmptyState title="Sin historial" subtitle="Este paciente no tiene citas, ventas ni visitas registradas." icon={<Activity size={28} />} />
)
)}
</div>
</Modal>
{/* Modal detalle de cita/venta/visita del historial */}
<Modal
isOpen={!!entryDetail}
onClose={() => setEntryDetail(null)}
title={entryDetail?.kind === 'cita' ? 'Detalle de la cita' : entryDetail?.kind === 'visita' ? 'Detalle de la visita' : 'Detalle de la venta'}
maxWidth="lg"
>
{entryDetail?.kind === 'cita' && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForAppointmentState(entryDetail.item.state)}>
{appointmentStateLabels[entryDetail.item.state] || entryDetail.item.state}
</Badge>
<Badge variant={entryDetail.item.payment_state === 'paid' ? 'paid' : entryDetail.item.payment_state === 'partial' ? 'warning' : 'unpaid'}>
{paymentStateLabels[entryDetail.item.payment_state] || entryDetail.item.payment_state}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoRow label="Referencia" value={entryDetail.item.reference} icon={FileText} />
<InfoRow label="Fecha y hora" value={`${entryDetail.item.date} · ${entryDetail.item.time}`} icon={Calendar} />
<InfoRow label="Servicio" value={entryDetail.item.service} icon={Stethoscope} />
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
<InfoRow label="Duración" value={entryDetail.item.duration ? `${entryDetail.item.duration} min` : '-'} />
<InfoRow label="Precio" value={`$${entryDetail.item.price}`} />
<InfoRow label="Monto pagado" value={`$${entryDetail.item.amount_paid ?? 0}`} />
<InfoRow label="Sucursal" value={branchLabels[entryDetail.item.branch || ''] || entryDetail.item.branch || '-'} icon={MapPin} />
<InfoRow label="Medio" value={mediumLabels[entryDetail.item.medium || ''] || entryDetail.item.medium || '-'} />
{entryDetail.item.package_finished && (
<InfoRow label="Paquete terminado" value={entryDetail.item.package_finished_date || 'Sí'} />
)}
</div>
{entryDetail.item.notes && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Notas</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{entryDetail.item.notes}</p>
</div>
)}
</div>
)}
{entryDetail?.kind === 'venta' && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForSaleState(entryDetail.item.state)}>
{saleStateLabels[entryDetail.item.state] || entryDetail.item.state}
</Badge>
{entryDetail.item.refunded && (
<Badge variant="danger">Reembolsada ${entryDetail.item.refund_amount}</Badge>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<InfoRow label="Folio" value={entryDetail.item.name} icon={FileText} />
<InfoRow label="Fecha" value={entryDetail.item.date} icon={Calendar} />
<InfoRow label="Subtotal" value={`$${entryDetail.item.subtotal}`} />
<InfoRow label="Descuento" value={`$${entryDetail.item.discount}`} />
<InfoRow label="Impuesto" value={`$${entryDetail.item.tax}`} />
<InfoRow label="Total" value={`$${entryDetail.item.total}`} />
<InfoRow label="Pagado" value={`$${entryDetail.item.amount_paid}`} />
<InfoRow label="Por cobrar" value={`$${entryDetail.item.amount_due}`} />
</div>
{entryDetail.item.refunded && entryDetail.item.refund_reason && (
<div className="p-3 bg-rose-100 rounded-xl">
<p className="text-xs text-rose-700 mb-1">Motivo de reembolso{entryDetail.item.refunded_at ? ` · ${entryDetail.item.refunded_at}` : ''}</p>
<p className="text-sm text-rose-700">{entryDetail.item.refund_reason}</p>
</div>
)}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Conceptos</p>
<div className="border border-theme-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2.5 text-xs font-medium text-theme-muted">Servicio</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Cant.</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Precio</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Subtotal</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{entryDetail.item.lines.map((line) => (
<tr key={line.id}>
<td className="p-2.5 text-theme-heading">
{line.service || line.description}
{line.prescribed_by && (
<span className="block text-xs text-theme-muted">Recetó: {line.prescribed_by}</span>
)}
</td>
<td className="p-2.5 text-right text-theme-muted">{line.quantity}</td>
<td className="p-2.5 text-right text-theme-muted">${line.price_unit}</td>
<td className="p-2.5 text-right font-medium text-theme-heading">${line.subtotal}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{entryDetail?.kind === 'visita' && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForVisitaState(entryDetail.item.state)}>
{visitaStateLabels[entryDetail.item.state] || entryDetail.item.state}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoRow label="Folio" value={entryDetail.item.name} icon={FileText} />
<InfoRow label="Inicio" value={entryDetail.item.date_start || '-'} icon={Calendar} />
<InfoRow label="Fin" value={entryDetail.item.date_end || '-'} icon={Calendar} />
<InfoRow label="Servicio" value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
<InfoRow label="Cosmetóloga" value={entryDetail.item.cosmetologa || 'Sin asignar'} icon={User} />
</div>
{[
{ label: 'Motivo de consulta', value: entryDetail.item.motivo },
{ label: 'Diagnóstico', value: entryDetail.item.diagnostico },
{ label: 'Tratamiento', value: entryDetail.item.tratamiento },
{ label: 'Notas', value: entryDetail.item.notas },
].map(({ label, value }) =>
value ? (
<div key={label} className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">{label}</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{value}</p>
</div>
) : null
)}
</div>
)}
</Modal>
</Layout>
);
};
@@ -726,6 +1195,8 @@ interface PatientForm {
emergency_phone: string;
home_phone: string;
mobile: string;
whatsapp: string;
primary_doctor_id: string;
address_notes: string;
referred_by: string;
patient_comments: string;

View File

@@ -22,6 +22,17 @@ const methods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago', 'Stripe'
const methodOptions = methods.map((m) => ({ value: m, label: m }));
const hoy = () => new Date().toISOString().split('T')[0];
const metodoLabels: Record<string, string> = {
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
stripe: 'Stripe', mercadopago: 'MercadoPago',
};
const estadoLabels: Record<string, string> = {
pending: 'Pendiente', processing: 'Procesando', completed: 'Completado',
failed: 'Fallido', refunded: 'Reembolsado',
};
const Pagos: FC = () => {
const [payments, setPayments] = useState<Payment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
@@ -32,6 +43,8 @@ const Pagos: FC = () => {
const [patient, setPatient] = useState('');
const [amount, setAmount] = useState('');
const [method, setMethod] = useState('Efectivo');
const [fecha, setFecha] = useState(hoy());
const [detalle, setDetalle] = useState<Payment | null>(null);
const load = async () => {
try {
@@ -95,6 +108,11 @@ const Pagos: FC = () => {
...patients.map((p) => ({ value: p.name, label: `${p.name} (${p.phone})` })),
];
// Filtro por día (create_date "YYYY-MM-DD HH:MM"); vacío = todos
const visibles = fecha
? payments.filter((p) => (p.create_date || '').startsWith(fecha))
: payments;
return (
<Layout title="Pagos" subtitle="Transacciones">
<PageHeader title="Pagos" subtitle="Crea y confirma pagos">
@@ -106,14 +124,33 @@ const Pagos: FC = () => {
<Card>
<Card.Body>
<div className="flex items-end gap-3 mb-4">
<Input
label="Fecha"
type="date"
value={fecha}
onChange={(e) => setFecha(e.target.value)}
className="sm:max-w-[180px]"
/>
<Button
variant={fecha === '' ? 'primary' : 'outline'}
size="sm"
onClick={() => setFecha(fecha === '' ? hoy() : '')}
className="mb-1"
>
Todos
</Button>
<p className="text-sm text-theme-muted mb-1 sm:ml-auto">{visibles.length} cobros</p>
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : payments.length === 0 ? (
) : visibles.length === 0 ? (
<EmptyState
title="Sin pagos"
subtitle="No hay pagos registrados."
subtitle={fecha ? 'No hay cobros registrados en esta fecha.' : 'No hay pagos registrados.'}
actionLabel="Nuevo pago"
onAction={() => setModalOpen(true)}
icon={<CreditCard size={28} />}
@@ -133,17 +170,17 @@ const Pagos: FC = () => {
</tr>
</thead>
<tbody className="divide-y">
{payments.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
{visibles.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => setDetalle(p)}>
<td className="p-3 text-sm text-theme-muted">{p.name}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{p.patient}</td>
<td className="p-3 text-sm font-semibold text-theme-heading">${p.amount}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{p.payment_method}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{metodoLabels[p.payment_method] || p.payment_method}</td>
<td className="p-3">
<Badge variant={badgeForPaymentState(p.state)}>{p.state}</Badge>
<Badge variant={badgeForPaymentState(p.state)}>{estadoLabels[p.state] || p.state}</Badge>
</td>
<td className="p-3">
{p.state !== 'confirmed' && p.state !== 'posted' && (
<td className="p-3" onClick={(e) => e.stopPropagation()}>
{p.state !== 'confirmed' && p.state !== 'posted' && p.state !== 'completed' && (
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)} title="Confirmar">
<CheckCircle size={16} className="text-theme-heading" />
</Button>
@@ -156,18 +193,19 @@ const Pagos: FC = () => {
</div>
<div className="sm:hidden space-y-3">
{payments.map((p) => (
{visibles.map((p) => (
<MobileCard
key={p.id}
title={p.patient}
subtitle={p.name}
rows={[
{ label: 'Monto', value: `$${p.amount}` },
{ label: 'Método', value: p.payment_method },
{ label: 'Estado', value: <Badge variant={badgeForPaymentState(p.state)}>{p.state}</Badge> },
{ label: 'Método', value: metodoLabels[p.payment_method] || p.payment_method },
{ label: 'Estado', value: <Badge variant={badgeForPaymentState(p.state)}>{estadoLabels[p.state] || p.state}</Badge> },
]}
onClick={() => setDetalle(p)}
actions={
p.state !== 'confirmed' && p.state !== 'posted' && (
p.state !== 'confirmed' && p.state !== 'posted' && p.state !== 'completed' && (
<Button variant="ghost" size="sm" onClick={() => confirm(p.id)}>
<CheckCircle size={16} className="text-theme-heading" />
</Button>
@@ -181,6 +219,54 @@ const Pagos: FC = () => {
</Card.Body>
</Card>
{/* Modal detalle del cobro */}
<Modal
isOpen={!!detalle}
onClose={() => setDetalle(null)}
title={detalle ? `Cobro ${detalle.name}` : 'Detalle del cobro'}
maxWidth="md"
>
{detalle && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Badge variant={badgeForPaymentState(detalle.state)}>{estadoLabels[detalle.state] || detalle.state}</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Paciente</p>
<p className="text-sm font-medium text-theme-heading">{detalle.patient}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Monto</p>
<p className="text-sm font-semibold text-theme-heading">${detalle.amount}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Método</p>
<p className="text-sm font-medium text-theme-heading">{metodoLabels[detalle.payment_method] || detalle.payment_method}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Fecha de registro</p>
<p className="text-sm font-medium text-theme-heading">{detalle.create_date || '-'}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Referencia proveedor</p>
<p className="text-sm font-medium text-theme-heading break-words">{detalle.provider_reference || '-'}</p>
</div>
</div>
{detalle.payment_url && (
<a
href={detalle.payment_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-sm text-theme-heading underline"
>
Abrir link de pago
</a>
)}
</div>
)}
</Modal>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}

601
frontend/src/pages/Pos.tsx Normal file
View File

@@ -0,0 +1,601 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2,
} from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
Select,
Modal,
EmptyState,
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Service, type Patient, type PosCheckoutResult } from '../services/odoo';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
const METODO_OPTIONS = [
{ value: 'cash', label: 'Efectivo' },
{ value: 'card', label: 'Tarjeta' },
{ value: 'transfer', label: 'Transferencia' },
{ value: 'stripe', label: 'Stripe' },
{ value: 'mercadopago', label: 'MercadoPago' },
];
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
interface TicketLine {
service: Service;
qty: number;
price: number;
}
const Pos: FC = () => {
// Catálogo
const [services, setServices] = useState<Service[]>([]);
const [catalogLoading, setCatalogLoading] = useState(true);
const [search, setSearch] = useState('');
const [categoria, setCategoria] = useState('');
const [soloFavoritos, setSoloFavoritos] = useState(false);
// Ticket
const [lines, setLines] = useState<TicketLine[]>([]);
const [discount, setDiscount] = useState('');
const [paciente, setPaciente] = useState<Patient | null>(null);
const [pacienteQuery, setPacienteQuery] = useState('');
const [pacienteResults, setPacienteResults] = useState<Patient[]>([]);
const [altaRapida, setAltaRapida] = useState(false);
const [altaForm, setAltaForm] = useState({ name: '', phone: '' });
// Cobro
const [cobroOpen, setCobroOpen] = useState(false);
const [metodo, setMetodo] = useState('cash');
const [recibido, setRecibido] = useState('');
const [conPuntos, setConPuntos] = useState(false);
const [cobrando, setCobrando] = useState(false);
// Resultado
const [resultado, setResultado] = useState<PosCheckoutResult | null>(null);
// Corte de caja
const [cajaAbierta, setCajaAbierta] = useState(true);
// Mobile
const [ticketMovil, setTicketMovil] = useState(false);
// ---- Catálogo ----
const loadServices = useCallback(async () => {
try {
setCatalogLoading(true);
const res = await odooApi.getServices({ search: search || undefined, category: categoria || undefined, page_size: 200 });
if (res.status === 'success') setServices(res.services);
} catch (err) {
toast.error('Error al cargar servicios');
console.error(err);
} finally {
setCatalogLoading(false);
}
}, [search, categoria]);
useEffect(() => {
const t = setTimeout(loadServices, 300);
return () => clearTimeout(t);
}, [loadServices]);
useEffect(() => {
odooApi.getCashClosings(new Date().toISOString().split('T')[0])
.then((res) => {
if (res.status === 'success') setCajaAbierta(res.cash_closings.some((c) => c.state === 'open'));
})
.catch(() => {});
}, []);
// Búsqueda server-side de paciente
useEffect(() => {
if (paciente || altaRapida) return;
if (pacienteQuery.trim().length < 2) {
setPacienteResults([]);
return;
}
const t = setTimeout(() => {
odooApi.getPatients({ search: pacienteQuery.trim(), page_size: 8 })
.then((res) => { if (res.status === 'success') setPacienteResults(res.patients); })
.catch(() => {});
}, 300);
return () => clearTimeout(t);
}, [pacienteQuery, paciente, altaRapida]);
const categorias = useMemo(() => {
const set = new Map<string, string>();
services.forEach((s) => { if (s.category) set.set(s.category, s.category); });
return [...set.keys()];
}, [services]);
const visibles = useMemo(
() => (soloFavoritos ? services.filter((s) => s.is_favorite) : services),
[services, soloFavoritos]
);
// ---- Ticket ----
const addLine = (service: Service) => {
setLines((prev) => {
const found = prev.find((l) => l.service.id === service.id);
if (found) return prev.map((l) => (l.service.id === service.id ? { ...l, qty: l.qty + 1 } : l));
return [...prev, { service, qty: 1, price: service.price }];
});
};
const updateLine = (id: number, patch: Partial<TicketLine>) => {
setLines((prev) => prev.map((l) => (l.service.id === id ? { ...l, ...patch } : l)));
};
const removeLine = (id: number) => setLines((prev) => prev.filter((l) => l.service.id !== id));
const limpiarTicket = () => {
if (lines.length === 0) return;
if (window.confirm('¿Vaciar el ticket?')) setLines([]);
};
const subtotal = lines.reduce((a, l) => a + l.qty * l.price, 0);
const discountNum = Math.min(parseFloat(discount) || 0, subtotal);
const total = Math.max(0, subtotal - discountNum);
const numItems = lines.reduce((a, l) => a + l.qty, 0);
const puntosPaciente = paciente?.wallet_points ?? 0;
const puedePuntos = puntosPaciente >= total && total > 0;
const cambio = metodo === 'cash' && recibido ? Math.max(0, (parseFloat(recibido) || 0) - total) : 0;
const crearPacienteRapido = async () => {
if (!altaForm.name.trim() || !altaForm.phone.trim()) {
toast.error('Nombre y teléfono son obligatorios');
return;
}
try {
const res = await odooApi.createPatient({ name: altaForm.name.trim(), phone: altaForm.phone.trim() });
if (res.status === 'success') {
setPaciente(res.patient);
setAltaRapida(false);
setAltaForm({ name: '', phone: '' });
toast.success('Paciente creado');
}
} catch (err) {
toast.error('Error al crear paciente');
console.error(err);
}
};
const abrirCobro = () => {
if (!paciente) {
toast.error('Selecciona un paciente');
return;
}
if (lines.length === 0) {
toast.error('El ticket está vacío');
return;
}
setMetodo('cash');
setRecibido('');
setConPuntos(false);
setCobroOpen(true);
};
const cobrar = async () => {
if (!paciente) return;
try {
setCobrando(true);
const res = await odooApi.posCheckout({
partner_id: paciente.id,
lines: lines.map((l) => ({ service_id: l.service.id, quantity: l.qty, price_unit: l.price })),
discount: discountNum,
payment_method: metodo,
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
pay_with_points: conPuntos,
});
if (res.status === 'success') {
setResultado(res);
setCobroOpen(false);
toast.success(`Venta ${res.sale.name} cobrada`);
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cobrar');
console.error(err);
} finally {
setCobrando(false);
}
};
const nuevaVenta = () => {
setResultado(null);
setLines([]);
setDiscount('');
setPaciente(null);
setPacienteQuery('');
};
// ---- Bloques del ticket (desktop sticky y modal móvil) ----
const pacienteBlock = paciente ? (
<div className="flex items-center justify-between p-2.5 bg-theme-bg rounded-xl">
<div className="min-w-0">
<p className="text-sm font-medium text-theme-heading truncate">{paciente.name}</p>
<p className="text-xs text-theme-muted flex items-center gap-1.5">
{paciente.phone}
{puntosPaciente > 0 && (
<span className="inline-flex items-center gap-0.5"><Wallet size={11} /> {puntosPaciente} pts</span>
)}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => { setPaciente(null); setPacienteQuery(''); }}>Cambiar</Button>
</div>
) : altaRapida ? (
<div className="p-2.5 bg-theme-bg rounded-xl space-y-2">
<Input placeholder="Nombre *" value={altaForm.name} onChange={(e) => setAltaForm({ ...altaForm, name: e.target.value })} />
<Input placeholder="Teléfono *" value={altaForm.phone} onChange={(e) => setAltaForm({ ...altaForm, phone: e.target.value })} />
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setAltaRapida(false)}>Cancelar</Button>
<Button size="sm" onClick={crearPacienteRapido}>Crear paciente</Button>
</div>
</div>
) : (
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar paciente por nombre o teléfono..."
value={pacienteQuery}
onChange={(e) => setPacienteQuery(e.target.value)}
className="pl-10"
/>
{pacienteResults.length > 0 && (
<div className="absolute z-20 top-full left-0 right-0 mt-1 rounded-xl border border-theme-border bg-theme-surface shadow-card max-h-56 overflow-y-auto">
{pacienteResults.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { setPaciente(p); setPacienteResults([]); }}
className="w-full text-left px-3 py-2 text-sm hover:bg-theme-bg transition"
>
<span className="font-medium text-theme-heading">{p.name}</span>
<span className="text-theme-muted"> · {p.phone}</span>
</button>
))}
<button
type="button"
onClick={() => { setAltaRapida(true); setPacienteResults([]); }}
className="w-full text-left px-3 py-2 text-sm text-theme-heading hover:bg-theme-bg border-t border-theme-border flex items-center gap-1.5"
>
<Plus size={14} /> Alta rápida de paciente
</button>
</div>
)}
{pacienteQuery.trim().length >= 2 && pacienteResults.length === 0 && (
<button
type="button"
onClick={() => setAltaRapida(true)}
className="mt-1.5 text-xs text-theme-heading underline flex items-center gap-1"
>
<Plus size={12} /> Alta rápida de paciente
</button>
)}
</div>
);
const linesBlock = lines.length === 0 ? (
<p className="text-sm text-theme-muted text-center py-8">Toca un servicio para agregarlo al ticket.</p>
) : (
<div className="divide-y divide-theme-border">
{lines.map((l) => (
<div key={l.service.id} className="flex items-center gap-1.5 py-2">
<button
type="button"
onClick={() => removeLine(l.service.id)}
className="text-theme-muted hover:text-rose-600 shrink-0"
title="Quitar"
>
<X size={14} />
</button>
<p className="flex-1 min-w-0 text-sm font-medium text-theme-heading truncate" title={l.service.name}>
{l.service.name}
</p>
<div className="flex items-center gap-0.5 shrink-0">
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
<span className="w-6 text-center text-sm font-medium text-theme-heading">{l.qty}</span>
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: l.qty + 1 })}><Plus size={12} /></Button>
</div>
<input
type="number"
min={0}
step={50}
value={l.price}
onChange={(e) => updateLine(l.service.id, { price: parseFloat(e.target.value) || 0 })}
className="w-[70px] shrink-0 border border-theme-border-strong rounded-lg px-1.5 py-1 text-xs text-right"
title="Precio unitario"
/>
<span className="w-[72px] shrink-0 text-sm font-semibold text-theme-heading text-right">{fmtMoney(l.qty * l.price)}</span>
</div>
))}
</div>
);
const totalsBlock = (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">Subtotal</span>
<span className="text-theme-heading">{fmtMoney(subtotal)}</span>
</div>
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-theme-muted">Descuento</span>
<input
type="number"
min={0}
step={50}
value={discount}
onChange={(e) => setDiscount(e.target.value)}
placeholder="0"
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-theme-heading">Total</span>
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
</div>
<Button
className="w-full mt-1 !py-3 text-base"
onClick={abrirCobro}
disabled={lines.length === 0 || !paciente}
>
<ShoppingBag size={18} className="mr-2" />
Cobrar {fmtMoney(total)}
</Button>
</>
);
return (
<Layout title="Punto de Venta" subtitle="Arma el ticket y cobra en un solo paso">
{!cajaAbierta && (
<div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-theme-warning text-theme-warning-text text-xs">
<AlertTriangle size={13} className="shrink-0" />
Sin corte de caja abierto hoy
<Link to="/cortes" className="font-semibold underline">Abrir caja</Link>
</span>
</div>
)}
<div className="flex flex-col lg:flex-row gap-4 items-start">
{/* Catálogo */}
<Card className="flex-1 min-w-0 w-full">
<Card.Body>
<div className="relative mb-3">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar servicio..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
<button
type="button"
onClick={() => setSoloFavoritos((v) => !v)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
soloFavoritos ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
<Star size={12} fill={soloFavoritos ? 'currentColor' : 'none'} /> Favoritos
</button>
<button
type="button"
onClick={() => { setCategoria(''); setSoloFavoritos(false); }}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
categoria === '' && !soloFavoritos ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
Todos
</button>
{categorias.map((c) => (
<button
key={c}
type="button"
onClick={() => { setCategoria(c); setSoloFavoritos(false); }}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition capitalize ${
categoria === c ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{c}
</button>
))}
</div>
{catalogLoading ? (
<Skeleton count={8} className="h-16 w-full" />
) : visibles.length === 0 ? (
<EmptyState title="Sin servicios" subtitle="No hay servicios con estos filtros." icon={<Store size={28} />} />
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
{visibles.map((s) => (
<button
key={s.id}
type="button"
onClick={() => addLine(s)}
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
>
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]" title={s.name}>
{s.name}
</p>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-theme-muted">{s.duration_min} min</span>
<span className="text-sm font-bold text-theme-heading">{fmtMoney(s.price)}</span>
</div>
</button>
))}
</div>
)}
</Card.Body>
</Card>
{/* Ticket desktop: panel sticky full-height */}
<Card className="hidden lg:flex lg:flex-col w-[390px] shrink-0 sticky top-6 max-h-[calc(100vh-7rem)]">
<div className="p-4 pb-3 border-b border-theme-border shrink-0">
<div className="flex items-center justify-between mb-2.5">
<h3 className="font-heading text-lg text-theme-heading">Ticket</h3>
{lines.length > 0 && (
<Button variant="ghost" size="sm" onClick={limpiarTicket} title="Vaciar ticket">
<Trash2 size={15} className="text-theme-muted" />
</Button>
)}
</div>
{pacienteBlock}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-1">
{linesBlock}
</div>
<div className="p-4 pt-3 border-t border-theme-border shrink-0 space-y-2">
{totalsBlock}
</div>
</Card>
</div>
{/* Botón flotante móvil */}
{lines.length > 0 && (
<button
type="button"
onClick={() => setTicketMovil(true)}
className="lg:hidden fixed bottom-4 right-4 z-30 px-5 py-3 rounded-full bg-theme-accent text-theme-inverse shadow-card font-semibold text-sm"
>
Ver ticket ({numItems}) {fmtMoney(total)}
</button>
)}
{/* Ticket móvil */}
<Modal isOpen={ticketMovil} onClose={() => setTicketMovil(false)} title="Ticket" maxWidth="md">
<div className="space-y-3">
{pacienteBlock}
<div className="max-h-[38vh] overflow-y-auto">{linesBlock}</div>
<div className="border-t border-theme-border pt-3 space-y-2">{totalsBlock}</div>
</div>
</Modal>
{/* Modal de cobro */}
<Modal
isOpen={cobroOpen}
onClose={() => setCobroOpen(false)}
title={`Cobrar ${fmtMoney(total)}`}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setCobroOpen(false)}>Cancelar</Button>
<Button onClick={cobrar} loading={cobrando}>Confirmar cobro</Button>
</>
}
>
<div className="space-y-4">
<Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
{metodo === 'cash' && !conPuntos && (
<>
<Input
label="Recibido"
type="number"
min={0}
step={10}
value={recibido}
onChange={(e) => setRecibido(e.target.value)}
placeholder={String(total)}
/>
{recibido && (
<p className="text-sm">
<span className="text-theme-muted">Cambio: </span>
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>{fmtMoney(cambio)}</span>
</p>
)}
</>
)}
{puedePuntos && (
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={conPuntos}
onChange={(e) => setConPuntos(e.target.checked)}
className="w-4 h-4 rounded border-theme-border-strong"
/>
<span className="text-sm text-theme-heading flex items-center gap-1.5">
<Wallet size={15} className="text-theme-muted" />
Pagar con puntos ({puntosPaciente} pts disponibles)
</span>
</label>
)}
{conPuntos && (
<p className="text-xs text-theme-muted">
Se canjearán {Math.ceil(total)} pts (1 pt = $1). El paciente acumula puntos solo por lo pagado en dinero.
</p>
)}
</div>
</Modal>
{/* Ticket de éxito */}
<Modal
isOpen={!!resultado}
onClose={nuevaVenta}
title="Venta cobrada"
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => window.print()}>
<Printer size={16} className="mr-2" /> Imprimir
</Button>
<Button onClick={nuevaVenta}>Nueva venta</Button>
</>
}
>
{resultado && (
<div className="print-ticket text-sm">
<div className="text-center border-b border-dashed border-theme-border pb-3 mb-3">
<p className="font-heading font-bold text-theme-heading">SKEEN Derma Experts</p>
<p className="text-xs text-theme-muted">Playas de Rosarito, B.C.</p>
</div>
<div className="flex justify-between text-xs text-theme-muted mb-2">
<span>{resultado.sale.name}</span>
<span>{resultado.sale.date}</span>
</div>
<p className="text-sm mb-3"><span className="text-theme-muted">Paciente: </span><span className="font-medium text-theme-heading">{resultado.sale.patient}</span></p>
<table className="w-full text-sm mb-3">
<tbody>
{resultado.sale.lines.map((l) => (
<tr key={l.id}>
<td className="py-1 text-theme-heading">{l.quantity} × {l.service || l.description}</td>
<td className="py-1 text-right text-theme-heading">{fmtMoney(l.subtotal)}</td>
</tr>
))}
</tbody>
</table>
<div className="border-t border-dashed border-theme-border pt-2 space-y-1 text-sm">
<div className="flex justify-between"><span className="text-theme-muted">Subtotal</span><span>{fmtMoney(resultado.sale.subtotal)}</span></div>
{resultado.sale.discount > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Descuento</span><span>-{fmtMoney(resultado.sale.discount)}</span></div>
)}
{resultado.puntos_usados > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Puntos canjeados</span><span>-{resultado.puntos_usados} pts</span></div>
)}
<div className="flex justify-between font-bold text-theme-heading"><span>Total</span><span>{fmtMoney(resultado.sale.total)}</span></div>
<div className="flex justify-between"><span className="text-theme-muted">Método</span><span>{resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos'}</span></div>
{resultado.cambio > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Cambio</span><span>{fmtMoney(resultado.cambio)}</span></div>
)}
{resultado.puntos_ganados > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Puntos ganados</span><span>+{resultado.puntos_ganados} pts</span></div>
)}
<div className="flex justify-between"><span className="text-theme-muted">Saldo monedero</span><span>{resultado.wallet_points} pts</span></div>
</div>
<p className="text-center text-xs text-theme-muted mt-4">¡Gracias por su visita!</p>
</div>
)}
</Modal>
</Layout>
);
};
export default Pos;

View File

@@ -1,129 +0,0 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Package, Search } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Input, Badge, EmptyState, PageHeader, Skeleton, MobileCard, toast } from '../components/ui';
import { odooApi, type Product } from '../services/odoo';
const translateType = (type: string) => {
const map: Record<string, string> = {
product: 'Producto almacenable',
service: 'Servicio',
consu: 'Consumible',
};
return map[type] || type;
};
const Productos: FC = () => {
const [products, setProducts] = useState<Product[]>([]);
const [filtered, setFiltered] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
useEffect(() => {
const load = async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getProducts();
if (res.status === 'success') {
setProducts(res.products);
setFiltered(res.products);
}
} catch (err) {
setError('Error al cargar productos');
toast.error('Error al cargar productos');
console.error(err);
} finally {
setLoading(false);
}
};
load();
}, []);
useEffect(() => {
const term = search.toLowerCase();
setFiltered(
products.filter(
(p) =>
p.name.toLowerCase().includes(term) ||
(p.default_code && p.default_code.toLowerCase().includes(term)) ||
p.type.toLowerCase().includes(term)
)
);
}, [search, products]);
return (
<Layout title="Productos" subtitle="Inventario">
<PageHeader title="Productos" subtitle="Catálogo de productos" />
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por nombre, código o tipo..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : filtered.length === 0 ? (
<EmptyState title="Sin productos" subtitle="No se encontraron productos." icon={<Package size={28} />} />
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Referencia</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Tipo</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Precio</th>
</tr>
</thead>
<tbody className="divide-y">
{filtered.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-muted">{p.default_code || '-'}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{p.name}</td>
<td className="p-3">
<Badge variant="default">{translateType(p.type)}</Badge>
</td>
<td className="p-3 text-sm font-semibold text-theme-heading">${p.list_price}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{filtered.map((p) => (
<MobileCard
key={p.id}
title={p.name}
subtitle={<Badge variant="default">{translateType(p.type)}</Badge>}
rows={[
{ label: 'Referencia', value: p.default_code || '-' },
{ label: 'Precio', value: `$${p.list_price}` },
]}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
</Layout>
);
};
export default Productos;

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Download, BarChart3 } from 'lucide-react';
import {
BarChart,
@@ -14,13 +15,51 @@ import {
Cell,
} from 'recharts';
import Layout from '../components/Layout';
import DailyReport from '../components/reportes/DailyReport';
import CortesReport from '../components/reportes/CortesReport';
import IngresosReport from '../components/reportes/IngresosReport';
import InventarioReport from '../components/reportes/InventarioReport';
import AdeudosReport from '../components/reportes/AdeudosReport';
import ComisionesReport from '../components/reportes/ComisionesReport';
import PagosServiciosReport from '../components/reportes/PagosServiciosReport';
import PagosClientesReport from '../components/reportes/PagosClientesReport';
import DevolucionesReport from '../components/reportes/DevolucionesReport';
import TopClientesReport from '../components/reportes/TopClientesReport';
import ExportarReport from '../components/reportes/ExportarReport';
import HorasAgendaReport from '../components/reportes/HorasAgendaReport';
import PaquetesReport from '../components/reportes/PaquetesReport';
import VendedoresReport from '../components/reportes/VendedoresReport';
import ConcentradoReport from '../components/reportes/ConcentradoReport';
import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
import KpisReport from '../components/reportes/KpisReport';
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo';
import { downloadCsv } from '../lib/utils';
import { exportToExcel } from '../lib/exporter';
const COLORS = ['#57534e', '#a8a29e', '#d6d3d1', '#78716c', '#f59e0b', '#10b981'];
const TABS = [
{ key: 'general', label: 'General' },
{ key: 'diario', label: 'Movimientos diarios' },
{ key: 'cortes', label: 'Cortes de caja' },
{ key: 'ingresos', label: 'Ingresos' },
{ key: 'inventario', label: 'Inventario' },
{ key: 'adeudos', label: 'Adeudos' },
{ key: 'comisiones', label: 'Comisiones' },
{ key: 'pagos-servicios', label: 'Pagos · Servicios' },
{ key: 'pagos-clientes', label: 'Pagos · Clientes' },
{ key: 'devoluciones', label: 'Devoluciones' },
{ key: 'top-clientes', label: 'Top clientes' },
{ key: 'exportar', label: 'Exportar' },
{ key: 'horas-agenda', label: 'Horas agenda' },
{ key: 'paquetes', label: 'Paquetes' },
{ key: 'vendedores', label: 'Vendedores' },
{ key: 'concentrado', label: 'Concentrado' },
{ key: 'recomendaciones', label: 'Recomendaciones' },
{ key: 'kpis', label: 'KPIs' },
] as const;
interface SalesReport {
total_sales: number;
total_paid: number;
@@ -39,6 +78,9 @@ interface CashReport {
}
const Reportes: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'general';
const setTab = (t: string) => setSearchParams({ tab: t });
const [start, setStart] = useState(() => {
const d = new Date();
d.setDate(1);
@@ -55,6 +97,7 @@ const Reportes: FC = () => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (tab !== 'general') return;
const load = async () => {
try {
setLoading(true);
@@ -89,7 +132,7 @@ const Reportes: FC = () => {
}
};
load();
}, [start, end, today]);
}, [start, end, today, tab]);
const aptData = appointmentsReport
? Object.entries(appointmentsReport.by_state).map(([name, value]) => ({ name, value }))
@@ -98,54 +141,107 @@ const Reportes: FC = () => {
? Object.entries(cashReport.by_method).map(([name, value]) => ({ name, value }))
: [];
const exportCSV = () => {
const rows: (string | number)[][] = [];
rows.push(['Reporte SKEEN']);
rows.push(['Rango', `${start} a ${end}`]);
rows.push([]);
rows.push(['Ventas']);
rows.push(['Total', 'Pagado', 'Por cobrar', 'Cantidad']);
if (salesReport) {
rows.push([
salesReport.total_sales,
salesReport.total_paid,
salesReport.total_due,
salesReport.count,
]);
const [exporting, setExporting] = useState(false);
const exportExcel = async () => {
try {
setExporting(true);
const rows: Record<string, unknown>[] = [];
if (salesReport) {
rows.push({ seccion: 'Ventas', concepto: 'Total', cantidad: null, monto: salesReport.total_sales });
rows.push({ seccion: 'Ventas', concepto: 'Pagado', cantidad: null, monto: salesReport.total_paid });
rows.push({ seccion: 'Ventas', concepto: 'Por cobrar', cantidad: null, monto: salesReport.total_due });
rows.push({ seccion: 'Ventas', concepto: 'Cantidad', cantidad: salesReport.count, monto: null });
}
aptData.forEach((row) => rows.push({ seccion: 'Citas por estado', concepto: row.name, cantidad: row.value, monto: null }));
cashData.forEach((row) => rows.push({ seccion: 'Efectivo por método', concepto: row.name, cantidad: null, monto: row.value }));
commissions.forEach((c) =>
rows.push({ seccion: 'Comisiones', concepto: c.doctor, cantidad: c.items, monto: c.commission }));
await exportToExcel({
filename: `reporte-skeen-${start}_${end}`,
sheetName: 'General',
title: 'Reporte general SKEEN',
subtitle: `Rango ${start} a ${end} · efectivo del ${today}`,
columns: [
{ header: 'Sección', key: 'seccion' },
{ header: 'Concepto', key: 'concepto' },
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
{ header: 'Monto', key: 'monto', format: 'currency' },
],
rows,
totals: { seccion: '', concepto: 'Comisión total', monto: commTotals.commission },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
rows.push([]);
rows.push(['Citas por estado']);
rows.push(['Estado', 'Cantidad']);
aptData.forEach((row) => rows.push([row.name, row.value]));
rows.push([]);
rows.push(['Efectivo por método']);
rows.push(['Método', 'Monto']);
cashData.forEach((row) => rows.push([row.name, row.value]));
rows.push([]);
rows.push(['Comisiones por médico']);
rows.push(['Médico', 'Puesto', '% Comisión', 'Artículos', 'Ventas', 'Base recetada', 'Comisión']);
commissions.forEach((c) =>
rows.push([c.doctor, c.job_title || '', c.commission_pct, c.items, c.sales, c.base, c.commission])
);
rows.push(['', '', '', '', 'Totales', commTotals.base, commTotals.commission]);
downloadCsv(`reporte-skeen-${start}_${end}.csv`, rows);
toast.success('CSV descargado');
};
return (
<Layout title="Reportes" subtitle="Análisis del mes y día">
<PageHeader title="Reportes" subtitle="Análisis del mes y día">
<Button variant="outline" onClick={exportCSV} disabled={loading || !salesReport}>
<Download size={16} className="mr-2" />
Exportar CSV
</Button>
{tab === 'general' && (
<Button variant="outline" onClick={exportExcel} disabled={loading || !salesReport} loading={exporting}>
<Download size={16} className="mr-2" />
Exportar
</Button>
)}
</PageHeader>
{/* Tabs de reportes */}
<div className="flex flex-wrap gap-1.5 mb-6">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'diario' ? (
<DailyReport />
) : tab === 'cortes' ? (
<CortesReport />
) : tab === 'ingresos' ? (
<IngresosReport />
) : tab === 'inventario' ? (
<InventarioReport />
) : tab === 'adeudos' ? (
<AdeudosReport />
) : tab === 'comisiones' ? (
<ComisionesReport />
) : tab === 'pagos-servicios' ? (
<PagosServiciosReport />
) : tab === 'pagos-clientes' ? (
<PagosClientesReport />
) : tab === 'devoluciones' ? (
<DevolucionesReport />
) : tab === 'top-clientes' ? (
<TopClientesReport />
) : tab === 'exportar' ? (
<ExportarReport />
) : tab === 'horas-agenda' ? (
<HorasAgendaReport />
) : tab === 'paquetes' ? (
<PaquetesReport />
) : tab === 'vendedores' ? (
<VendedoresReport />
) : tab === 'concentrado' ? (
<ConcentradoReport />
) : tab === 'recomendaciones' ? (
<RecomendacionesReport />
) : tab === 'kpis' ? (
<KpisReport />
) : (
<>
<Card className="mb-6">
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4">
@@ -295,6 +391,8 @@ const Reportes: FC = () => {
)}
</Card.Body>
</Card>
</>
)}
</Layout>
);
};

View File

@@ -1,11 +1,12 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Star, Edit2, Check, X, Plus, Clock, Tag, Package } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { Star, Edit2, Plus, Clock, Tag, Package, Search } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
Select,
TextArea,
Modal,
EmptyState,
@@ -13,40 +14,86 @@ import {
Skeleton,
toast,
} from '../components/ui';
import { useSearchParams } from 'react-router-dom';
import { odooApi, type Service } from '../services/odoo';
const categoryOptions = [
{ value: '', label: 'Todas las categorías' },
{ value: 'consulta', label: 'Consulta' },
{ value: 'tratamiento', label: 'Tratamiento' },
{ value: 'procedimiento', label: 'Procedimiento' },
{ value: 'paquete', label: 'Paquete' },
];
const categoryFormOptions = categoryOptions.filter((o) => o.value !== '');
const categoryLabels: Record<string, string> = {
consulta: 'Consulta',
tratamiento: 'Tratamiento',
procedimiento: 'Procedimiento',
paquete: 'Paquete',
};
interface ServiceForm {
name: string;
code: string;
category: string;
price: string;
package_price: string;
package_sessions: string;
package_notes: string;
duration_min: string;
description: string;
color: string;
service_group: string;
}
const emptyForm: ServiceForm = {
name: '',
code: '',
category: 'tratamiento',
price: '',
package_price: '',
package_sessions: '',
package_notes: '',
duration_min: '30',
description: '',
color: '#EDA588',
service_group: '',
};
const Servicios: FC = () => {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchParams] = useSearchParams();
const [search, setSearch] = useState(() => searchParams.get('q') || '');
const [categoryFilter, setCategoryFilter] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const pageSize = 60;
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Service | null>(null);
const [editPrice, setEditPrice] = useState('');
const [editPackagePrice, setEditPackagePrice] = useState('');
const [editPackageSessions, setEditPackageSessions] = useState('');
const [editPackageNotes, setEditPackageNotes] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<ServiceForm>(emptyForm);
const [form, setForm] = useState({
name: '',
code: '',
category: '',
price: '',
package_price: '',
package_sessions: '',
package_notes: '',
duration_min: '',
description: '',
color: '#78716c',
service_group: '',
});
const load = async () => {
const load = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getServices();
if (res.status === 'success') setServices(res.services);
const res = await odooApi.getServices({
search: search || undefined,
category: categoryFilter || undefined,
page,
page_size: pageSize,
});
if (res.status === 'success') {
setServices(res.services);
setTotal(res.total ?? 0);
setTotalPages(res.total_pages ?? 0);
}
} catch (err) {
setError('Error al cargar servicios');
toast.error('Error al cargar servicios');
@@ -54,11 +101,16 @@ const Servicios: FC = () => {
} finally {
setLoading(false);
}
};
}, [search, categoryFilter, page]);
useEffect(() => {
load();
}, []);
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
useEffect(() => {
setPage(1);
}, [search, categoryFilter]);
const toggleFavorite = async (s: Service) => {
try {
@@ -71,81 +123,93 @@ const Servicios: FC = () => {
}
};
const startEdit = (s: Service) => {
setEditing(s);
setEditPrice(String(s.price));
setEditPackagePrice(String(s.package_price ?? ''));
setEditPackageSessions(String(s.package_sessions ?? ''));
setEditPackageNotes(s.package_notes || '');
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setModalOpen(true);
};
const savePrice = async () => {
const openEdit = (s: Service) => {
setEditing(s);
setForm({
name: s.name,
code: s.code || '',
category: s.category || 'tratamiento',
price: String(s.price),
package_price: s.package_price ? String(s.package_price) : '',
package_sessions: s.package_sessions ? String(s.package_sessions) : '',
package_notes: s.package_notes || '',
duration_min: String(s.duration_min),
description: s.description || '',
color: s.color || '#EDA588',
service_group: s.service_group || '',
});
setModalOpen(true);
};
const deactivate = async () => {
if (!editing) return;
const price = parseFloat(editPrice);
const packagePrice = editPackagePrice === '' ? 0 : parseFloat(editPackagePrice);
const packageSessions = editPackageSessions === '' ? 0 : parseInt(editPackageSessions, 10);
if (Number.isNaN(price) || price < 0) {
toast.error('Precio inválido');
return;
}
if (!window.confirm(`¿Desactivar "${editing.name}"? Dejará de aparecer en el catálogo y en los selectores de citas/ventas.`)) return;
try {
await odooApi.updateService(editing.id, {
price,
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
package_notes: editPackageNotes,
});
toast.success('Servicio actualizado');
setSubmitting(true);
await odooApi.updateService(editing.id, { active: false } as Partial<Service>);
toast.success('Servicio desactivado');
setModalOpen(false);
setEditing(null);
await load();
} catch (err) {
toast.error('Error al actualizar servicio');
toast.error('Error al desactivar servicio');
console.error(err);
} finally {
setSubmitting(false);
}
};
const create = async () => {
const save = async () => {
const price = parseFloat(form.price);
const duration = parseInt(form.duration_min, 10);
const packagePrice = form.package_price === '' ? 0 : parseFloat(form.package_price);
const packageSessions = form.package_sessions === '' ? 0 : parseInt(form.package_sessions, 10);
if (!form.name || Number.isNaN(price) || price < 0) {
toast.error('Nombre y precio válidos son obligatorios');
if (!form.name.trim()) {
toast.error('El nombre es obligatorio');
return;
}
if (Number.isNaN(price) || price < 0) {
toast.error('Precio inválido');
return;
}
if (form.category === 'paquete' && (Number.isNaN(packageSessions) || packageSessions <= 0)) {
toast.error('Un paquete debe tener al menos 1 sesión');
return;
}
const payload: Partial<Service> = {
name: form.name.trim(),
code: form.code.trim(),
category: form.category,
price,
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
package_notes: form.package_notes,
duration_min: Number.isNaN(duration) ? 30 : duration,
description: form.description,
color: form.color,
service_group: form.service_group,
};
try {
setSubmitting(true);
await odooApi.createService({
name: form.name,
code: form.code,
category: form.category,
price,
package_price: Number.isNaN(packagePrice) ? 0 : packagePrice,
package_sessions: Number.isNaN(packageSessions) ? 0 : packageSessions,
package_notes: form.package_notes,
duration_min: Number.isNaN(duration) ? 0 : duration,
description: form.description,
color: form.color,
service_group: form.service_group,
});
toast.success('Servicio creado');
setCreateOpen(false);
setForm({
name: '',
code: '',
category: '',
price: '',
package_price: '',
package_sessions: '',
package_notes: '',
duration_min: '',
description: '',
color: '#78716c',
service_group: '',
});
if (editing) {
await odooApi.updateService(editing.id, payload);
toast.success('Servicio actualizado');
} else {
await odooApi.createService(payload);
toast.success('Servicio creado');
}
setModalOpen(false);
setEditing(null);
await load();
} catch (err) {
toast.error('Error al crear servicio');
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || (editing ? 'Error al actualizar servicio' : 'Error al crear servicio'));
console.error(err);
} finally {
setSubmitting(false);
@@ -158,12 +222,30 @@ const Servicios: FC = () => {
return (
<Layout title="Servicios" subtitle="Catálogo de tratamientos">
<PageHeader title="Servicios" subtitle="Gestiona precios, paquetes y favoritos">
<Button onClick={() => setCreateOpen(true)}>
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo servicio
</Button>
</PageHeader>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por nombre o código..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Select
options={categoryOptions}
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="sm:max-w-[200px]"
/>
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
@@ -173,93 +255,60 @@ const Servicios: FC = () => {
) : services.length === 0 ? (
<EmptyState
title="Sin servicios"
subtitle="No hay servicios registrados."
subtitle={search || categoryFilter ? 'No hay servicios que coincidan con los filtros.' : 'No hay servicios registrados.'}
actionLabel="Nuevo servicio"
onAction={() => setCreateOpen(true)}
onAction={openCreate}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((s) => (
<Card key={s.id} className="hover:shadow-md transition">
<Card.Body>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 min-w-0">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: s.color || '#78716c' }}
/>
<h4 className="font-medium text-theme-heading pr-2 truncate">{s.name}</h4>
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((s) => (
<Card key={s.id} className="hover:shadow-md transition">
<Card.Body>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 min-w-0">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: s.color || '#78716c' }}
/>
<h4 className="font-medium text-theme-heading pr-2 truncate">{s.name}</h4>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleFavorite(s)}
title={s.is_favorite ? 'Quitar favorito' : 'Marcar favorito'}
>
<Star
size={18}
className={s.is_favorite ? 'text-amber-500' : 'text-theme-muted'}
fill={s.is_favorite ? 'currentColor' : 'none'}
/>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
<Tag size={12} className="mr-1" />
{categoryLabels[s.category] || s.category || 'Sin categoría'}
</span>
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
<Clock size={12} className="mr-1" />
{s.duration_min} min
</span>
{s.code && (
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
{s.code}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleFavorite(s)}
title={s.is_favorite ? 'Quitar favorito' : 'Marcar favorito'}
>
<Star
size={18}
className={s.is_favorite ? 'text-amber-500' : 'text-theme-muted'}
fill={s.is_favorite ? 'currentColor' : 'none'}
/>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-theme-muted mb-4">
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
<Tag size={12} className="mr-1" />
{s.category || 'Sin categoría'}
</span>
<span className="inline-flex items-center bg-theme-bg rounded-md px-2 py-0.5">
<Clock size={12} className="mr-1" />
{s.duration_min} min
</span>
</div>
{editing?.id === s.id ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Input
label="Precio"
type="number"
value={editPrice}
onChange={(e) => setEditPrice(e.target.value)}
/>
<Input
label="Precio paquete"
type="number"
value={editPackagePrice}
onChange={(e) => setEditPackagePrice(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
label="Sesiones"
type="number"
value={editPackageSessions}
onChange={(e) => setEditPackageSessions(e.target.value)}
/>
<Input
label="Notas paquete"
value={editPackageNotes}
onChange={(e) => setEditPackageNotes(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={savePrice} title="Guardar">
<Check size={16} className="text-theme-heading" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(null)} title="Cancelar">
<X size={16} className="text-theme-muted" />
</Button>
</div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-theme-muted">Precio sesión</p>
<p className="text-lg font-heading font-semibold text-theme-heading">{formatCurrency(s.price)}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => startEdit(s)} title="Editar">
<Button variant="ghost" size="sm" onClick={() => openEdit(s)} title="Editar">
<Edit2 size={16} className="text-theme-muted" />
</Button>
</div>
@@ -275,22 +324,43 @@ const Servicios: FC = () => {
</div>
)}
</div>
)}
</Card.Body>
</Card>
))}
</div>
</Card.Body>
</Card>
))}
</div>
{/* Paginación */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-theme-border">
<p className="text-sm text-theme-muted">
{total} servicios · página {page} de {totalPages || 1}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Anterior
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Siguiente
</Button>
</div>
</div>
</>
)}
{/* Modal crear/editar */}
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
title="Nuevo servicio"
maxWidth="md"
isOpen={modalOpen}
onClose={() => { setModalOpen(false); setEditing(null); }}
title={editing ? `Editar: ${editing.name}` : 'Nuevo servicio'}
maxWidth="lg"
footer={
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
<Button onClick={create} loading={submitting}>Crear servicio</Button>
{editing && (
<Button variant="danger" onClick={deactivate} loading={submitting} className="sm:mr-auto">
Desactivar
</Button>
)}
<Button variant="outline" onClick={() => { setModalOpen(false); setEditing(null); }}>Cancelar</Button>
<Button onClick={save} loading={submitting}>{editing ? 'Guardar cambios' : 'Crear servicio'}</Button>
</>
}
>
@@ -305,9 +375,11 @@ const Servicios: FC = () => {
label="Código"
value={form.code}
onChange={(e) => setForm({ ...form, code: e.target.value })}
placeholder="Ej. DEP-AXI"
/>
<Input
label="Categoría"
<Select
label="Categoría *"
options={categoryFormOptions}
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
/>
@@ -345,17 +417,28 @@ const Servicios: FC = () => {
value={form.package_notes}
onChange={(e) => setForm({ ...form, package_notes: e.target.value })}
/>
<Input
label="Color"
type="color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Grupo"
value={form.service_group}
onChange={(e) => setForm({ ...form, service_group: e.target.value })}
placeholder="Ej. Faciales, Corporales"
/>
<Input
label="Color"
type="color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
</div>
<TextArea
label="Descripción"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
<p className="text-xs text-theme-muted">
Si el nombre o código ya existen en el catálogo, el sistema lo rechazará para evitar duplicados.
</p>
</div>
</Modal>
</Layout>

View File

@@ -0,0 +1,324 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Search, FolderClock, FileText, Images, StickyNote } from 'lucide-react';
import Layout from '../components/Layout';
import PatientAdjuntos from '../components/PatientAdjuntos';
import {
Card,
Button,
Input,
Select,
TextArea,
Modal,
EmptyState,
MobileCard,
PageHeader,
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type ExpedienteReciente, type Doctor } from '../services/odoo';
const UltimasVisitas: FC = () => {
const [expedientes, setExpedientes] = useState<ExpedienteReciente[]>([]);
const [loading, setLoading] = useState(true);
const [recencia, setRecencia] = useState<'' | '6m' | '6m_plus'>('');
const [onlyVip, setOnlyVip] = useState(false);
const [doctorFilter, setDoctorFilter] = useState('');
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [modalAdjuntos, setModalAdjuntos] = useState<{ patient: ExpedienteReciente; kind: 'expediente' | 'imagen' } | null>(null);
const [modalNotas, setModalNotas] = useState<ExpedienteReciente | null>(null);
const [notas, setNotas] = useState('');
const [savingNotas, setSavingNotas] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const params: Record<string, string | number> = { page, page_size: 50 };
if (recencia) {
params.recencia = recencia;
} else {
// Sin chip de recencia: todos los pacientes con visita, más reciente primero
params.date_from = 'todas';
}
if (onlyVip) params.vip = '1';
if (doctorFilter) params.doctor_id = doctorFilter;
if (search) params.search = search;
const res = await odooApi.getExpedientesRecientes(params);
if (res.status === 'success') {
setExpedientes(res.expedientes);
setTotal(res.total ?? 0);
setTotalPages(res.total_pages ?? 0);
}
} catch (err) {
toast.error('Error al cargar expedientes');
console.error(err);
} finally {
setLoading(false);
}
}, [recencia, onlyVip, doctorFilter, search, page]);
useEffect(() => {
setPage(1);
}, [recencia, onlyVip, doctorFilter, search]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
odooApi.getDoctors()
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
.catch(() => {});
}, []);
const openNotas = (p: ExpedienteReciente) => {
setModalNotas(p);
setNotas(p.internal_notes || '');
};
const saveNotas = async () => {
if (!modalNotas) return;
try {
setSavingNotas(true);
await odooApi.updatePatient(modalNotas.id, { internal_notes: notas });
toast.success('Notas actualizadas');
setModalNotas(null);
await load();
} catch (err) {
toast.error('Error al guardar notas');
console.error(err);
} finally {
setSavingNotas(false);
}
};
return (
<Layout title="Expedientes" subtitle="Expedientes y actividad de pacientes">
<PageHeader title="Expedientes" subtitle="Expedientes, galería y notas de los pacientes" />
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div className="relative w-full sm:max-w-sm">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Buscar por nombre o teléfono..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{([
{ key: '6m', label: 'Visita últimos 6 meses' },
{ key: '6m_plus', label: '+6 meses sin visita' },
] as const).map((chip) => (
<button
key={chip.key}
type="button"
onClick={() => setRecencia(recencia === chip.key ? '' : chip.key)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
recencia === chip.key
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{chip.label}
</button>
))}
<button
type="button"
onClick={() => setOnlyVip(!onlyVip)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
onlyVip
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
VIP
</button>
<Select
options={[
{ value: '', label: 'Todos los médicos' },
...doctors.map((d) => ({
value: String(d.id),
label: d.patient_count !== undefined ? `${d.name} (${d.patient_count})` : d.name,
})),
]}
value={doctorFilter}
onChange={(e) => setDoctorFilter(e.target.value)}
className="sm:max-w-[220px]"
/>
{(recencia || onlyVip || doctorFilter) && (
<button
type="button"
onClick={() => { setRecencia(''); setOnlyVip(false); setDoctorFilter(''); }}
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
>
Quitar filtros
</button>
)}
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : expedientes.length === 0 ? (
<EmptyState
title="Sin visitas recientes"
subtitle="No hay expedientes que coincidan con los filtros seleccionados."
icon={<FolderClock size={28} />}
/>
) : (
<>
<div className="hidden lg:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Fecha de visita</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Cliente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Edad</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Teléfono</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Médico asignado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Exp. escaneado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Galería</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Notas</th>
</tr>
</thead>
<tbody className="divide-y">
{expedientes.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-muted whitespace-nowrap">{p.last_visit || '-'}</td>
<td className="p-3 text-sm">
<p className="font-medium text-theme-heading">{p.name}</p>
<p className="text-xs text-theme-muted">{p.patient_id}</p>
</td>
<td className="p-3 text-sm text-theme-muted">{p.age ?? '-'}</td>
<td className="p-3 text-sm text-theme-muted">{p.phone || '-'}</td>
<td className="p-3 text-sm text-theme-muted">{p.primary_doctor || '-'}</td>
<td className="p-3 text-sm">
<Button variant="outline" size="sm" onClick={() => setModalAdjuntos({ patient: p, kind: 'expediente' })}>
<FileText size={14} className="mr-1.5" />
{p.expediente_count > 0 ? `Ver (${p.expediente_count})` : 'Subir'}
</Button>
</td>
<td className="p-3 text-sm">
<Button variant="outline" size="sm" onClick={() => setModalAdjuntos({ patient: p, kind: 'imagen' })}>
<Images size={14} className="mr-1.5" />
{p.imagenes_count} fotos
</Button>
</td>
<td className="p-3 text-sm">
<button
type="button"
onClick={() => openNotas(p)}
className="text-left text-theme-muted hover:text-theme-heading max-w-[180px] truncate block"
title={p.internal_notes || 'Agregar notas'}
>
{p.internal_notes ? p.internal_notes : <StickyNote size={14} />}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="lg:hidden space-y-3">
{expedientes.map((p) => (
<MobileCard
key={p.id}
title={p.name}
subtitle={`${p.last_visit || '-'} · ${p.patient_id}`}
rows={[
{ label: 'Edad', value: p.age ?? '-' },
{ label: 'Teléfono', value: p.phone || '-' },
{ label: 'Médico', value: p.primary_doctor || '-' },
{ label: 'Notas', value: p.internal_notes ? p.internal_notes.slice(0, 40) : '-' },
]}
actions={
<>
<Button variant="ghost" size="sm" title="Expediente escaneado" onClick={() => setModalAdjuntos({ patient: p, kind: 'expediente' })}>
<FileText size={16} className="text-theme-muted" />
{p.expediente_count > 0 && <span className="text-xs text-theme-muted ml-0.5">{p.expediente_count}</span>}
</Button>
<Button variant="ghost" size="sm" title="Galería" onClick={() => setModalAdjuntos({ patient: p, kind: 'imagen' })}>
<Images size={16} className="text-theme-muted" />
{p.imagenes_count > 0 && <span className="text-xs text-theme-muted ml-0.5">{p.imagenes_count}</span>}
</Button>
<Button variant="ghost" size="sm" title="Notas" onClick={() => openNotas(p)}>
<StickyNote size={16} className="text-theme-muted" />
</Button>
</>
}
/>
))}
</div>
{/* Paginación */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-theme-border">
<p className="text-sm text-theme-muted">
{total} pacientes · página {page} de {totalPages || 1}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Anterior
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Siguiente
</Button>
</div>
</div>
</>
)}
</Card.Body>
</Card>
{/* Modal de adjuntos (expediente escaneado o galería) */}
<Modal
isOpen={!!modalAdjuntos}
onClose={() => setModalAdjuntos(null)}
title={modalAdjuntos
? `${modalAdjuntos.kind === 'expediente' ? 'Expediente escaneado' : 'Galería'}${modalAdjuntos.patient.name}`
: 'Archivos'}
maxWidth="2xl"
>
{modalAdjuntos && (
<PatientAdjuntos
patientId={modalAdjuntos.patient.id}
kind={modalAdjuntos.kind}
onChange={load}
/>
)}
</Modal>
{/* Modal de notas */}
<Modal
isOpen={!!modalNotas}
onClose={() => setModalNotas(null)}
title={modalNotas ? `Notas — ${modalNotas.name}` : 'Notas'}
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setModalNotas(null)}>Cancelar</Button>
<Button onClick={saveNotas} loading={savingNotas}>Guardar</Button>
</>
}
>
<TextArea
label="Notas internas (solo equipo)"
value={notas}
onChange={(e) => setNotas(e.target.value)}
/>
</Modal>
</Layout>
);
};
export default UltimasVisitas;

View File

@@ -1,265 +0,0 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Pencil, KeyRound } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
Button,
Input,
Select,
Modal,
Badge,
EmptyState,
PageHeader,
Skeleton,
MobileCard,
toast,
} from '../components/ui';
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
import { ROLE_LABELS } from '../lib/auth';
const roleOptions: { value: FrontendRole; label: string }[] = (
['admin', 'recepcion', 'medico', 'lectura'] as FrontendRole[]
).map((r) => ({ value: r, label: ROLE_LABELS[r] }));
const roleBadge = (role: FrontendRole) => {
switch (role) {
case 'admin':
return 'danger';
case 'recepcion':
return 'info';
case 'medico':
return 'success';
default:
return 'default';
}
};
const Usuarios: FC = () => {
const [users, setUsers] = useState<FrontendUser[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FrontendUser | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({ login: '', name: '', password: '', role: 'recepcion' as FrontendRole, active: true });
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.listFrontendUsers();
if (res.status === 'success') setUsers(res.users);
} catch (err) {
toast.error('Error al cargar usuarios');
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditing(null);
setForm({ login: '', name: '', password: '', role: 'recepcion', active: true });
setModalOpen(true);
};
const openEdit = (u: FrontendUser) => {
setEditing(u);
setForm({ login: u.login, name: u.name, password: '', role: u.role, active: u.active !== false });
setModalOpen(true);
};
const submit = async () => {
if (!form.name.trim()) {
toast.error('El nombre es obligatorio');
return;
}
if (!editing && !form.login.trim()) {
toast.error('El usuario es obligatorio');
return;
}
if (!editing && form.password.length < 8) {
toast.error('La contraseña debe tener al menos 8 caracteres');
return;
}
if (editing && form.password && form.password.length < 8) {
toast.error('La nueva contraseña debe tener al menos 8 caracteres');
return;
}
try {
setSubmitting(true);
if (editing) {
await odooApi.updateFrontendUser(editing.id, {
name: form.name.trim(),
role: form.role,
active: form.active,
...(form.password ? { password: form.password } : {}),
});
toast.success('Usuario actualizado');
} else {
await odooApi.createFrontendUser({
login: form.login.trim(),
name: form.name.trim(),
password: form.password,
role: form.role,
});
toast.success('Usuario creado');
}
setModalOpen(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar usuario');
console.error(err);
} finally {
setSubmitting(false);
}
};
const toggleActive = async (u: FrontendUser) => {
try {
await odooApi.updateFrontendUser(u.id, { active: !(u.active !== false) });
await load();
} catch (err) {
toast.error('Error al cambiar estado');
console.error(err);
}
};
return (
<Layout title="Usuarios" subtitle="Cuentas y roles del frontend">
<PageHeader title="Usuarios" subtitle="Gestiona quién puede entrar al sistema y su rol">
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo usuario
</Button>
</PageHeader>
<Card>
<Card.Body>
{loading ? (
<Skeleton count={4} className="h-12 w-full" />
) : users.length === 0 ? (
<EmptyState title="Sin usuarios" subtitle="Crea el primer usuario." actionLabel="Nuevo usuario" onAction={openCreate} />
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Usuario</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Rol</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Último acceso</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{users.map((u) => (
<tr key={u.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-mono text-theme-heading">{u.login}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{u.name}</td>
<td className="p-3"><Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge></td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{u.last_login || '—'}</td>
<td className="p-3">
<Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge>
</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(u)} title="Editar">
<Pencil size={16} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)} title={u.active !== false ? 'Desactivar' : 'Activar'}>
<KeyRound size={16} className={u.active !== false ? 'text-amber-600' : 'text-theme-heading'} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{users.map((u) => (
<MobileCard
key={u.id}
title={u.name}
subtitle={u.login}
rows={[
{ label: 'Rol', value: <Badge variant={roleBadge(u.role)}>{ROLE_LABELS[u.role]}</Badge> },
{ label: 'Estado', value: <Badge variant={u.active !== false ? 'success' : 'default'}>{u.active !== false ? 'Activo' : 'Inactivo'}</Badge> },
]}
actions={
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(u)}><Pencil size={16} className="text-theme-muted" /></Button>
<Button variant="ghost" size="sm" onClick={() => toggleActive(u)}><KeyRound size={16} className="text-amber-600" /></Button>
</div>
}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar usuario' : 'Nuevo usuario'}
maxWidth="md"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={submit} loading={submitting}>{editing ? 'Guardar' : 'Crear'}</Button>
</>
}
>
<div className="space-y-4">
<Input
label="Usuario (login)"
value={form.login}
onChange={(e) => setForm({ ...form, login: e.target.value })}
disabled={!!editing}
placeholder="ej. recepcion1"
/>
<Input
label="Nombre"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Nombre completo"
/>
<Select
label="Rol"
options={roleOptions}
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as FrontendRole })}
/>
<Input
label={editing ? 'Nueva contraseña (dejar vacío para no cambiar)' : 'Contraseña (mín. 8)'}
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
minLength={8}
/>
{editing && (
<label className="inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={form.active}
onChange={(e) => setForm({ ...form, active: e.target.checked })}
className="rounded border-theme-border-strong"
/>
Usuario activo
</label>
)}
</div>
</Modal>
</Layout>
);
};
export default Usuarios;

View File

@@ -18,7 +18,7 @@ import {
badgeForSaleState,
} from '../components/ui';
import { odooApi, type Sale, type SaleLine, type Service, type Patient, type Doctor } from '../services/odoo';
import { downloadCsv } from '../lib/utils';
import { exportToExcel } from '../lib/exporter';
interface LineForm {
service_id: string;
@@ -246,19 +246,40 @@ const Ventas: FC = () => {
}
};
const exportSales = () => {
const rows: (string | number)[][] = [
['Referencia', 'Paciente', 'Fecha', 'Subtotal', 'Descuento', 'Impuesto', 'Total', 'Pagado', 'Por cobrar', 'Estado', 'Devuelta', 'Monto devuelto'],
];
sales.forEach((s) =>
rows.push([
s.name, s.patient, s.date, s.subtotal, s.discount, s.tax, s.total,
s.amount_paid, s.amount_due, s.state, s.refunded ? 'Sí' : 'No', s.refund_amount || 0,
])
);
rows.push(['', '', 'Totales', '', '', '', sales.reduce((a, s) => a + s.total, 0), sales.reduce((a, s) => a + s.amount_paid, 0), sales.reduce((a, s) => a + s.amount_due, 0), '', '', sales.reduce((a, s) => a + (s.refund_amount || 0), 0)]);
downloadCsv(`ventas-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
toast.success(`CSV descargado (${sales.length} ventas de la vista actual)`);
const exportSales = async () => {
try {
await exportToExcel({
filename: `ventas-skeen-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Ventas',
title: 'Ventas SKEEN',
subtitle: `${sales.length} ventas de la vista actual`,
columns: [
{ header: 'Referencia', key: 'name' },
{ header: 'Paciente', key: 'patient' },
{ header: 'Fecha', key: 'date', format: 'date' },
{ header: 'Subtotal', key: 'subtotal', format: 'currency' },
{ header: 'Descuento', key: 'discount', format: 'currency' },
{ header: 'Impuesto', key: 'tax', format: 'currency' },
{ header: 'Total', key: 'total', format: 'currency' },
{ header: 'Pagado', key: 'amount_paid', format: 'currency' },
{ header: 'Por cobrar', key: 'amount_due', format: 'currency' },
{ header: 'Estado', key: 'state' },
{ header: 'Devuelta', key: 'devuelta' },
{ header: 'Monto devuelto', key: 'refund_amount', format: 'currency' },
],
rows: sales.map((s) => ({ ...s, devuelta: s.refunded ? 'Sí' : 'No', refund_amount: s.refund_amount || 0 })),
totals: {
name: '', total: sales.reduce((a, s) => a + s.total, 0),
amount_paid: sales.reduce((a, s) => a + s.amount_paid, 0),
amount_due: sales.reduce((a, s) => a + s.amount_due, 0),
refund_amount: sales.reduce((a, s) => a + (s.refund_amount || 0), 0),
},
});
toast.success(`Excel descargado (${sales.length} ventas de la vista actual)`);
} catch (err) {
toast.error('Error al exportar');
console.error(err);
}
};
const patientOptions = [

View File

@@ -0,0 +1,614 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { ClipboardList, CheckCircle2, XCircle, X, Plus, Camera, FileText } from 'lucide-react';
import Layout from '../components/Layout';
import { RecetaSection, RecetaPrint } from '../components/RecetaPrint';
import {
Card,
Button,
Input,
Select,
TextArea,
Modal,
Badge,
EmptyState,
MobileCard,
PageHeader,
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
import type { BadgeVariant } from '../components/ui/Badge';
const visitaStateLabels: Record<string, string> = {
en_curso: 'En curso',
completada: 'Completada',
cancelada: 'Cancelada',
};
const badgeForVisitaState = (state: string): BadgeVariant =>
state === 'en_curso' ? 'info' : state === 'completada' ? 'success' : state === 'cancelada' ? 'danger' : 'default';
const estadoOptions = [
{ value: '', label: 'Todos' },
{ value: 'en_curso', label: 'En curso' },
{ value: 'completada', label: 'Completada' },
{ value: 'cancelada', label: 'Cancelada' },
];
const adjuntoKindLabels: Record<string, string> = {
antes: 'Antes',
despues: 'Después',
documento: 'Documento',
};
const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
const hoy = () => new Date().toISOString().split('T')[0];
const Visitas: FC = () => {
const [visitas, setVisitas] = useState<Visita[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [fecha, setFecha] = useState(hoy());
const [estado, setEstado] = useState('');
const [doctorFiltro, setDoctorFiltro] = useState('');
const [selected, setSelected] = useState<Visita | null>(null);
const [form, setForm] = useState({
motivo: '',
diagnostico: '',
tratamiento: '',
notas: '',
receta_text: '',
doctor_id: '',
cosmetologa_id: '',
});
const [printOpen, setPrintOpen] = useState(false);
const [items, setItems] = useState<InventoryItem[]>([]);
const [itemSearch, setItemSearch] = useState('');
const [nuevoItem, setNuevoItem] = useState('');
const [nuevaQty, setNuevaQty] = useState('1');
const [visor, setVisor] = useState<VisitaAdjunto | null>(null);
const fileAntesRef = useRef<HTMLInputElement>(null);
const fileDespuesRef = useRef<HTMLInputElement>(null);
const fileDocRef = useRef<HTMLInputElement>(null);
const doctorOptions = [
{ value: '', label: 'Sin asignar' },
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
];
const load = useCallback(async () => {
try {
setLoading(true);
const params: Record<string, string | number> = {};
if (fecha) params.date = fecha;
if (estado) params.state = estado;
if (doctorFiltro) params.doctor_id = doctorFiltro;
const res = await odooApi.getVisitas(params);
if (res.status === 'success') setVisitas(res.visitas);
} catch (err) {
toast.error('Error al cargar visitas');
console.error(err);
} finally {
setLoading(false);
}
}, [fecha, estado, doctorFiltro]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
odooApi.getDoctors()
.then((res) => {
if (res.status === 'success') setDoctors(res.doctors);
})
.catch(() => {});
}, []);
// Catálogo de artículos del inventario para el picker de insumos
useEffect(() => {
if (!selected || selected.state !== 'en_curso') return;
odooApi.getInventarioItems(itemSearch || undefined)
.then((res) => {
if (res.status === 'success') setItems(res.items);
})
.catch(() => {});
}, [selected, itemSearch]);
const itemOptions = [
{ value: '', label: 'Selecciona artículo...' },
...items.map((it) => ({ value: String(it.id), label: `${it.name} (${it.qty} ${it.unit})` })),
];
const agregarInsumo = async () => {
if (!selected || !nuevoItem) return;
const qty = parseFloat(nuevaQty);
if (Number.isNaN(qty) || qty <= 0) {
toast.error('La cantidad debe ser mayor a 0');
return;
}
try {
setSubmitting(true);
const res = await odooApi.addVisitaInsumo(selected.id, { item_id: parseInt(nuevoItem, 10), qty });
if (res.status === 'success') {
applyUpdate(res.visita);
setNuevoItem('');
setNuevaQty('1');
toast.success('Insumo agregado');
}
} catch (err) {
toast.error('Error al agregar insumo');
console.error(err);
} finally {
setSubmitting(false);
}
};
const quitarInsumo = async (lineId: number) => {
if (!selected) return;
try {
setSubmitting(true);
const res = await odooApi.deleteVisitaInsumo(selected.id, lineId);
if (res.status === 'success') applyUpdate(res.visita);
} catch (err) {
toast.error('Error al quitar insumo');
console.error(err);
} finally {
setSubmitting(false);
}
};
const subirAdjunto = (kind: 'antes' | 'despues' | 'documento') => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !selected) return;
if (file.size > MAX_ADJUNTO_BYTES) {
toast.error('El archivo excede 10 MB');
return;
}
const visitaId = selected.id;
const reader = new FileReader();
reader.onload = async () => {
const file_b64 = String(reader.result || '').split(',')[1] || '';
try {
setSubmitting(true);
const res = await odooApi.uploadVisitaAdjunto(visitaId, {
kind,
name: file.name,
file_b64,
mimetype: file.type || 'application/octet-stream',
});
if (res.status === 'success') {
applyUpdate(res.visita);
toast.success('Archivo subido');
}
} catch (err) {
toast.error('Error al subir el archivo');
console.error(err);
} finally {
setSubmitting(false);
}
};
reader.readAsDataURL(file);
};
const borrarAdjunto = async (adjId: number) => {
if (!window.confirm('¿Eliminar este archivo?')) return;
try {
setSubmitting(true);
const res = await odooApi.deleteVisitaAdjunto(adjId);
if (res.status === 'success') applyUpdate(res.visita);
} catch (err) {
toast.error('Error al eliminar el archivo');
console.error(err);
} finally {
setSubmitting(false);
}
};
const openDetail = (v: Visita) => {
setSelected(v);
setForm({
motivo: v.motivo || '',
diagnostico: v.diagnostico || '',
tratamiento: v.tratamiento || '',
notas: v.notas || '',
receta_text: v.receta_text || '',
doctor_id: v.doctor_id ? String(v.doctor_id) : '',
cosmetologa_id: v.cosmetologa_id ? String(v.cosmetologa_id) : '',
});
};
const updateForm = (patch: Partial<typeof form>) => {
setForm((prev) => ({ ...prev, ...patch }));
};
const applyUpdate = (v: Visita) => {
setSelected(v);
setVisitas((prev) => prev.map((it) => (it.id === v.id ? v : it)));
};
const save = async () => {
if (!selected) return;
try {
setSubmitting(true);
const res = await odooApi.updateVisita(selected.id, {
motivo: form.motivo,
diagnostico: form.diagnostico,
tratamiento: form.tratamiento,
notas: form.notas,
receta_text: form.receta_text,
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : null,
cosmetologa_id: form.cosmetologa_id ? parseInt(form.cosmetologa_id, 10) : null,
});
if (res.status === 'success') {
applyUpdate(res.visita);
toast.success('Visita actualizada');
}
} catch (err) {
toast.error('Error al guardar la visita');
console.error(err);
} finally {
setSubmitting(false);
}
};
const completar = async () => {
if (!selected) return;
try {
setSubmitting(true);
const res = await odooApi.completeVisita(selected.id);
if (res.status === 'success') {
applyUpdate(res.visita);
toast.success('Visita completada');
}
} catch (err) {
toast.error('Error al completar la visita');
console.error(err);
} finally {
setSubmitting(false);
}
};
const cancelar = async () => {
if (!selected) return;
try {
setSubmitting(true);
const res = await odooApi.cancelVisita(selected.id);
if (res.status === 'success') {
applyUpdate(res.visita);
toast.success('Visita cancelada');
}
} catch (err) {
toast.error('Error al cancelar la visita');
console.error(err);
} finally {
setSubmitting(false);
}
};
const hora = (v: Visita) => (v.date_start ? v.date_start.split(' ')[1] || '' : '-');
return (
<Layout title="Visitas" subtitle="Registro de visitas clínicas">
<PageHeader title="Visitas" subtitle="Consulta y completa las visitas clínicas del día" />
<Card>
<Card.Body>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4 sm:mb-6">
<Input label="Fecha" type="date" value={fecha} onChange={(e) => setFecha(e.target.value)} />
<Select label="Estado" options={estadoOptions} value={estado} onChange={(e) => setEstado(e.target.value)} />
<Select
label="Médico"
options={[{ value: '', label: 'Todos' }, ...doctorOptions.slice(1)]}
value={doctorFiltro}
onChange={(e) => setDoctorFiltro(e.target.value)}
/>
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : visitas.length === 0 ? (
<EmptyState
title={fecha === hoy() ? 'Sin visitas hoy' : 'Sin visitas'}
subtitle="No se encontraron visitas con los filtros seleccionados."
icon={<ClipboardList size={28} />}
/>
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Hora</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Servicio</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Médico</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Motivo</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
</tr>
</thead>
<tbody className="divide-y">
{visitas.map((v) => (
<tr key={v.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => openDetail(v)}>
<td className="p-3 text-sm text-theme-muted">{hora(v)}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{v.patient}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{v.servicio || '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{v.doctor || '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden lg:table-cell truncate max-w-[200px]">{v.motivo || '-'}</td>
<td className="p-3 text-sm">
<Badge variant={badgeForVisitaState(v.state)}>
{visitaStateLabels[v.state] || v.state}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{visitas.map((v) => (
<MobileCard
key={v.id}
title={v.patient}
subtitle={hora(v)}
rows={[
{ label: 'Servicio', value: v.servicio || '-' },
{ label: 'Médico', value: v.doctor || '-' },
{
label: 'Estado',
value: (
<Badge variant={badgeForVisitaState(v.state)}>
{visitaStateLabels[v.state] || v.state}
</Badge>
),
},
]}
onClick={() => openDetail(v)}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
{/* Modal detalle/edición */}
<Modal
isOpen={!!selected}
onClose={() => setSelected(null)}
title={selected ? `Visita ${selected.name}${selected.patient}` : 'Visita'}
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>Cerrar</Button>
{selected?.state === 'en_curso' && (
<Button variant="outline" onClick={cancelar} loading={submitting}>
<XCircle size={16} className="mr-2" />
Cancelar visita
</Button>
)}
<Button onClick={save} loading={submitting}>Guardar</Button>
{selected?.state === 'en_curso' && (
<Button onClick={completar} loading={submitting}>
<CheckCircle2 size={16} className="mr-2" />
Completar visita
</Button>
)}
</>
}
>
{selected && (
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForVisitaState(selected.state)}>
{visitaStateLabels[selected.state] || selected.state}
</Badge>
<span className="text-sm text-theme-muted">
{selected.date_start || '-'}{selected.date_end ? `${selected.date_end}` : ''}
</span>
{selected.servicio && (
<span className="text-sm text-theme-muted">· {selected.servicio}</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Select
label="Médico"
options={doctorOptions}
value={form.doctor_id}
onChange={(e) => updateForm({ doctor_id: e.target.value })}
/>
<Select
label="Cosmetóloga"
options={doctorOptions}
value={form.cosmetologa_id}
onChange={(e) => updateForm({ cosmetologa_id: e.target.value })}
/>
</div>
<Input label="Motivo de consulta" value={form.motivo} onChange={(e) => updateForm({ motivo: e.target.value })} />
<TextArea label="Diagnóstico" value={form.diagnostico} onChange={(e) => updateForm({ diagnostico: e.target.value })} />
<TextArea label="Tratamiento" value={form.tratamiento} onChange={(e) => updateForm({ tratamiento: e.target.value })} />
<TextArea label="Notas" value={form.notas} onChange={(e) => updateForm({ notas: e.target.value })} />
<RecetaSection
value={form.receta_text}
onChange={(v) => updateForm({ receta_text: v })}
onPrint={() => setPrintOpen(true)}
/>
{/* Insumos usados */}
<section className="border-t border-theme-border pt-4">
<h4 className="text-sm font-semibold text-theme-heading mb-3">Insumos usados</h4>
{selected.insumos_descargados && (
<p className="text-xs text-theme-muted mb-2">Insumos descargados del inventario.</p>
)}
{selected.insumos.length === 0 ? (
<p className="text-sm text-theme-muted mb-3">Sin insumos registrados.</p>
) : (
<ul className="space-y-1 mb-3">
{selected.insumos.map((l) => (
<li key={l.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">
{l.item}
{l.notes && <span className="block text-xs text-theme-muted">{l.notes}</span>}
</span>
<span className="flex items-center gap-2 shrink-0">
<span className="text-theme-muted">{l.qty} {l.unit}</span>
{selected.state === 'en_curso' && (
<Button variant="ghost" size="sm" onClick={() => quitarInsumo(l.id)} title="Quitar" disabled={submitting}>
<X size={14} className="text-theme-muted" />
</Button>
)}
</span>
</li>
))}
</ul>
)}
{selected.state === 'en_curso' && (
<div className="space-y-2">
<Input
placeholder="Buscar artículo..."
value={itemSearch}
onChange={(e) => setItemSearch(e.target.value)}
/>
<div className="flex items-end gap-2">
<div className="flex-1">
<Select
options={itemOptions}
value={nuevoItem}
onChange={(e) => setNuevoItem(e.target.value)}
/>
</div>
<div className="w-24">
<Input
type="number"
min="0.5"
step="0.5"
value={nuevaQty}
onChange={(e) => setNuevaQty(e.target.value)}
/>
</div>
<Button onClick={agregarInsumo} disabled={!nuevoItem} loading={submitting}>
<Plus size={16} className="mr-1" />
Agregar
</Button>
</div>
</div>
)}
</section>
{/* Fotos y documentos */}
<section className="border-t border-theme-border pt-4">
<h4 className="text-sm font-semibold text-theme-heading mb-3">Fotos y documentos</h4>
{selected.state === 'en_curso' && (
<div className="flex flex-wrap gap-2 mb-3">
<input ref={fileAntesRef} type="file" accept="image/*" className="hidden" onChange={subirAdjunto('antes')} />
<input ref={fileDespuesRef} type="file" accept="image/*" className="hidden" onChange={subirAdjunto('despues')} />
<input ref={fileDocRef} type="file" className="hidden" onChange={subirAdjunto('documento')} />
<Button variant="outline" size="sm" onClick={() => fileAntesRef.current?.click()} disabled={submitting}>
<Camera size={14} className="mr-1.5" /> Foto antes
</Button>
<Button variant="outline" size="sm" onClick={() => fileDespuesRef.current?.click()} disabled={submitting}>
<Camera size={14} className="mr-1.5" /> Foto después
</Button>
<Button variant="outline" size="sm" onClick={() => fileDocRef.current?.click()} disabled={submitting}>
<FileText size={14} className="mr-1.5" /> Documento
</Button>
</div>
)}
{selected.adjuntos.length === 0 ? (
<p className="text-sm text-theme-muted">Sin archivos adjuntos.</p>
) : (
<>
{selected.adjuntos.filter((a) => a.kind !== 'documento').length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
{selected.adjuntos.filter((a) => a.kind !== 'documento').map((a) => (
<div key={a.id} className="relative">
<button type="button" onClick={() => setVisor(a)} className="w-full">
<img
src={odooApi.visitaAdjuntoUrl(a.url)}
alt={a.name}
className="w-full h-24 object-cover rounded-xl border border-theme-border"
/>
</button>
<span className="absolute bottom-1 left-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-theme-surface/90 text-theme-heading">
{adjuntoKindLabels[a.kind] || a.kind}
</span>
{selected.state === 'en_curso' && (
<button
type="button"
onClick={() => borrarAdjunto(a.id)}
title="Eliminar"
className="absolute top-1 right-1 p-1 rounded-full bg-theme-surface/90 text-theme-muted hover:text-rose-600"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
)}
{selected.adjuntos.filter((a) => a.kind === 'documento').length > 0 && (
<ul className="space-y-1">
{selected.adjuntos.filter((a) => a.kind === 'documento').map((a) => (
<li key={a.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
<a
href={odooApi.visitaAdjuntoUrl(a.url)}
target="_blank"
rel="noreferrer"
className="flex items-center text-theme-heading hover:underline min-w-0"
>
<FileText size={14} className="mr-1.5 text-theme-muted shrink-0" />
<span className="truncate">{a.name}</span>
</a>
{selected.state === 'en_curso' && (
<Button variant="ghost" size="sm" onClick={() => borrarAdjunto(a.id)} title="Eliminar" disabled={submitting}>
<X size={14} className="text-theme-muted" />
</Button>
)}
</li>
))}
</ul>
)}
</>
)}
</section>
</div>
)}
</Modal>
{/* Visor de foto */}
<Modal
isOpen={!!visor}
onClose={() => setVisor(null)}
title={visor ? `${adjuntoKindLabels[visor.kind] || visor.kind}${visor.name}` : 'Foto'}
maxWidth="2xl"
>
{visor && (
<img
src={odooApi.visitaAdjuntoUrl(visor.url)}
alt={visor.name}
className="w-full rounded-xl"
/>
)}
</Modal>
{/* Vista de impresión de la receta */}
{selected && (
<RecetaPrint
isOpen={printOpen}
onClose={() => setPrintOpen(false)}
paciente={selected.patient}
doctor={selected.doctor}
contenido={form.receta_text}
/>
)}
</Layout>
);
};
export default Visitas;

View File

@@ -1,5 +1,6 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
RefreshCw,
Target,
@@ -13,11 +14,19 @@ import {
MoreHorizontal,
User,
ArrowRightCircle,
UserPlus,
MessageSquare,
} from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, Select, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
import { odooApi, type WacrmLead, type WacrmPipeline } from '../services/odoo';
interface Member {
id: string;
name: string;
role: string;
}
const statusOptions = [
{ value: '', label: 'Todos los estados' },
{ value: 'open', label: 'Abierto' },
@@ -66,6 +75,12 @@ const WacrmLeads: FC = () => {
const [selectedLead, setSelectedLead] = useState<WacrmLead | null>(null);
const [stageModalOpen, setStageModalOpen] = useState(false);
const [movingStage, setMovingStage] = useState<string | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [filtroAsign, setFiltroAsign] = useState<'todos' | 'sin_asignar' | 'mios'>('todos');
const [convirtiendo, setConvirtiendo] = useState(false);
const [currentAgentId] = useState(() =>
typeof window !== 'undefined' ? localStorage.getItem('skeen_current_agent_id') || '' : ''
);
const load = useCallback(async () => {
try {
@@ -136,6 +151,46 @@ const WacrmLeads: FC = () => {
load();
}, [load]);
useEffect(() => {
odooApi.getWacrmMembers()
.then((res) => { if (res.status === 'success') setMembers(res.members); })
.catch(() => {});
}, []);
const assignLead = async (lead: WacrmLead, memberId: string | null) => {
try {
const res = await odooApi.updateWacrmLeadStatus(lead.id, { assigned_to: memberId });
if (res.status === 'success') {
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
setSelectedLead(res.lead);
toast.success(memberId ? 'Lead asignado' : 'Lead desasignado');
}
} catch (err) {
toast.error('Error al asignar lead');
console.error(err);
}
};
const convertirLead = async (lead: WacrmLead) => {
try {
setConvirtiendo(true);
const res = await odooApi.convertWacrmLead(lead.id);
if (res.status === 'success') {
setLeads((prev) => prev.map((l) => (l.id === lead.id ? res.lead : l)));
setSelectedLead(res.lead);
toast.success(res.created ? 'Paciente creado y ligado' : 'Lead ligado a paciente existente');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al convertir lead');
console.error(err);
} finally {
setConvirtiendo(false);
}
};
const memberName = (id?: string) => (id ? members.find((m) => m.id === id)?.name : undefined);
// Auto-sync cada 15 segundos
useEffect(() => {
const interval = setInterval(() => {
@@ -155,9 +210,12 @@ const WacrmLeads: FC = () => {
);
const filteredLeads = useMemo(() => {
if (!activePipeline) return leads;
return leads.filter((l) => l.pipeline_id === activePipeline.id);
}, [leads, activePipeline]);
let out = leads;
if (activePipeline) out = out.filter((l) => l.pipeline_id === activePipeline.id);
if (filtroAsign === 'sin_asignar') out = out.filter((l) => !l.assigned_to);
if (filtroAsign === 'mios') out = out.filter((l) => !!currentAgentId && l.assigned_to === currentAgentId);
return out;
}, [leads, activePipeline, filtroAsign, currentAgentId]);
const stats = useMemo(() => {
const open = leads.filter((l) => l.status === 'open');
@@ -291,6 +349,20 @@ const WacrmLeads: FC = () => {
<div className="flex items-center gap-1.5 text-xs text-theme-muted mb-2">
<User size={12} />
<span className="truncate">{l.contact_name || l.contact_phone || '—'}</span>
{l.assigned_to && (
<span className="px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full truncate max-w-[100px]">
{memberName(l.assigned_to) || 'Asignado'}
</span>
)}
{l.partner_id && (
<Link
to={`/pacientes?q=${encodeURIComponent(l.contact_phone || '')}`}
className="px-1.5 py-0.5 bg-theme-success text-theme-heading rounded-full shrink-0"
onClick={(e) => e.stopPropagation()}
>
Paciente
</Link>
)}
</div>
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-theme-heading">
@@ -422,6 +494,24 @@ const WacrmLeads: FC = () => {
options={pipelineOptions}
className="w-full lg:w-52"
/>
<div className="flex items-center gap-1.5">
{([
{ key: 'todos', label: 'Todos' },
{ key: 'sin_asignar', label: 'Sin asignar' },
{ key: 'mios', label: 'Míos' },
] as const).map((f) => (
<button
key={f.key}
type="button"
onClick={() => setFiltroAsign(f.key)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
filtroAsign === f.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{f.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<div className="inline-flex rounded-lg border border-theme-border-strong overflow-hidden">
<button
@@ -468,31 +558,69 @@ const WacrmLeads: FC = () => {
</Card.Body>
</Card>
{/* Modal cambiar etapa */}
<Modal isOpen={stageModalOpen} onClose={() => setStageModalOpen(false)} title="Mover a etapa">
<div className="space-y-2 max-h-96 overflow-y-auto">
{activePipeline?.stages.map((stage) => {
const isCurrent = selectedLead?.stage_id === stage.id;
return (
<button
key={stage.external_id}
disabled={isCurrent || !!movingStage}
onClick={() => selectedLead && changeStage(selectedLead, stage.external_id)}
className={`w-full flex items-center justify-between p-3 rounded-lg border text-left transition ${
isCurrent
? 'bg-theme-bg border-theme-border-strong text-theme-muted cursor-not-allowed'
: 'bg-theme-surface border-theme-border-strong hover:border-[#D6D3D1] hover:bg-theme-bg'
}`}
>
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: stage.color }} />
<span className="text-sm font-medium text-theme-heading">{stage.name}</span>
</div>
{isCurrent && <span className="text-xs text-theme-muted">Actual</span>}
</button>
);
})}
</div>
{/* Modal detalle del lead */}
<Modal isOpen={stageModalOpen} onClose={() => setStageModalOpen(false)} title={selectedLead ? `Lead — ${selectedLead.title}` : 'Lead'}>
{selectedLead && (
<div className="space-y-4">
{/* Asignación y acciones */}
<div className="space-y-3">
<Select
label="Asignado a"
options={[{ value: '', label: 'Sin asignar' }, ...members.map((m) => ({ value: m.id, label: m.name }))]}
value={selectedLead.assigned_to || ''}
onChange={(e) => assignLead(selectedLead, e.target.value || null)}
/>
<div className="flex flex-wrap gap-2">
{selectedLead.partner_id ? (
<Link to={`/pacientes?q=${encodeURIComponent(selectedLead.contact_phone || '')}`}>
<Badge variant="success">Paciente ver expediente</Badge>
</Link>
) : (
<Button variant="outline" size="sm" onClick={() => convertirLead(selectedLead)} loading={convirtiendo}>
<UserPlus size={14} className="mr-1.5" />
Convertir en paciente
</Button>
)}
{selectedLead.conversation_id && (
<Link to={`/wacrm/messages?conv=${selectedLead.conversation_id}`}>
<Button variant="outline" size="sm">
<MessageSquare size={14} className="mr-1.5" />
Ver conversación
</Button>
</Link>
)}
</div>
</div>
{/* Mover a etapa */}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Mover a etapa</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{activePipeline?.stages.map((stage) => {
const isCurrent = selectedLead?.stage_id === stage.id;
return (
<button
key={stage.external_id}
disabled={isCurrent || !!movingStage}
onClick={() => selectedLead && changeStage(selectedLead, stage.external_id)}
className={`w-full flex items-center justify-between p-3 rounded-lg border text-left transition ${
isCurrent
? 'bg-theme-bg border-theme-border-strong text-theme-muted cursor-not-allowed'
: 'bg-theme-surface border-theme-border-strong hover:border-[#D6D3D1] hover:bg-theme-bg'
}`}
>
<div className="flex items-center gap-3">
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: stage.color }} />
<span className="text-sm font-medium text-theme-heading">{stage.name}</span>
</div>
{isCurrent && <span className="text-xs text-theme-muted">Actual</span>}
</button>
);
})}
</div>
</div>
</div>
)}
</Modal>
</Layout>
);

View File

@@ -1,9 +1,10 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown } from 'lucide-react';
import { Link, useSearchParams } from 'react-router-dom';
import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown, Heart, Calendar } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
import { odooApi, type WacrmConversation, type WacrmMessage } from '../services/odoo';
import { odooApi, type WacrmConversation, type WacrmMessage, type Patient } from '../services/odoo';
interface Member {
id: string;
@@ -32,6 +33,16 @@ const WacrmMessages: FC = () => {
}
return '';
});
const [filtro, setFiltro] = useState<'todas' | 'mias' | 'sin_asignar' | 'no_leidas'>('todas');
const [searchParams] = useSearchParams();
const convParam = searchParams.get('conv');
// Ficha del paciente ligado al teléfono de la conversación
const [patientInfo, setPatientInfo] = useState<Patient | null>(null);
const [patientSearched, setPatientSearched] = useState(false);
const [crearPacienteOpen, setCrearPacienteOpen] = useState(false);
const [nuevoNombre, setNuevoNombre] = useState('');
const [creandoPaciente, setCreandoPaciente] = useState(false);
// Refs para evitar loops y dependency churn
const selectedRef = useRef<WacrmConversation | null>(null);
@@ -128,6 +139,61 @@ const WacrmMessages: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Preselección por ?conv=<external_id> (deep-link desde Leads)
useEffect(() => {
if (!convParam || conversations.length === 0) return;
const found = conversations.find((c) => c.external_id === convParam);
if (found) {
userSelectedRef.current = true;
setSelected(found);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [convParam, conversations]);
// Buscar paciente por teléfono al cambiar de conversación
useEffect(() => {
setPatientInfo(null);
setPatientSearched(false);
setCrearPacienteOpen(false);
if (!selected?.contact_phone) return;
const phone = selected.contact_phone;
const digits = phone.replace(/\D/g, '');
const query = digits.length > 10 ? digits.slice(-10) : digits;
odooApi.getPatients({ search: query, page_size: 5 })
.then((res) => {
if (res.status === 'success') {
const match = res.patients.find((p) => (p.phone || '').replace(/\D/g, '').endsWith(digits.slice(-10)))
|| res.patients[0];
setPatientInfo(match || null);
}
setPatientSearched(true);
})
.catch(() => setPatientSearched(true));
}, [selected]);
const crearPaciente = async () => {
if (!selected) return;
const digits = (selected.contact_phone || '').replace(/\D/g, '');
const phone = digits.length === 10 ? `52${digits}` : digits;
try {
setCreandoPaciente(true);
const res = await odooApi.createPatient({
name: nuevoNombre.trim() || selected.contact_name || phone,
phone,
});
if (res.status === 'success') {
setPatientInfo(res.patient);
setCrearPacienteOpen(false);
toast.success('Paciente creado');
}
} catch (err) {
toast.error('Error al crear paciente');
console.error(err);
} finally {
setCreandoPaciente(false);
}
};
// Cuando cambia la búsqueda, recargar lista pero no forzar selección
useEffect(() => {
loadConversations();
@@ -212,6 +278,21 @@ const WacrmMessages: FC = () => {
};
const assignedMember = members.find((m) => m.id === selected?.assigned_agent_id);
const agentName = (id?: string) => (id ? members.find((m) => m.id === id)?.name : undefined);
const conversationsFiltradas = conversations.filter((c) => {
if (filtro === 'mias') return !!currentAgentId && c.assigned_agent_id === currentAgentId;
if (filtro === 'sin_asignar') return !c.assigned_agent_id;
if (filtro === 'no_leidas') return c.unread_count > 0;
return true;
});
const FILTROS = [
{ key: 'todas', label: 'Todas' },
{ key: 'mias', label: 'Mías' },
{ key: 'sin_asignar', label: 'Sin asignar' },
{ key: 'no_leidas', label: 'No leídas' },
] as const;
return (
<Layout title="WACRM — Mensajes" subtitle="Conversaciones de WhatsApp sincronizadas">
@@ -260,9 +341,26 @@ const WacrmMessages: FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6 h-[calc(100vh-220px)] min-h-[400px]">
{/* Lista de conversaciones */}
<Card className="lg:col-span-1 overflow-hidden flex flex-col">
<div className="flex flex-wrap gap-1.5 p-2.5 border-b border-theme-border shrink-0">
{FILTROS.map((f) => (
<button
key={f.key}
type="button"
onClick={() => setFiltro(f.key)}
className={`px-2.5 py-1 text-xs font-medium rounded-full transition ${
filtro === f.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{f.label}
</button>
))}
</div>
<Card.Body className="p-0 overflow-y-auto flex-1">
<div className="divide-y">
{conversations.map((c) => (
{conversationsFiltradas.length === 0 && (
<p className="text-sm text-theme-muted text-center py-8">Sin conversaciones con este filtro.</p>
)}
{conversationsFiltradas.map((c) => (
<button
key={c.external_id}
onClick={() => handleSelect(c)}
@@ -286,8 +384,8 @@ const WacrmMessages: FC = () => {
<div className="flex items-center justify-between mt-1">
<p className="text-[10px] text-theme-muted">{c.last_message_at || c.created_at || ''}</p>
{c.assigned_agent_id && (
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full truncate max-w-[120px]">
Asignado
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded-full truncate max-w-[140px]">
{agentName(c.assigned_agent_id) || 'Asignado'}
</span>
)}
</div>
@@ -322,6 +420,47 @@ const WacrmMessages: FC = () => {
{selected.assigned_agent_id ? 'Reasignar' : 'Asignar'}
</Button>
</div>
{/* Ficha del paciente (match por teléfono) */}
{patientSearched && patientInfo && (
<div className="px-4 py-2.5 border-b border-theme-border bg-theme-surface flex items-center gap-3 flex-wrap">
<Link
to={`/pacientes?q=${encodeURIComponent(patientInfo.phone)}`}
className="text-sm font-medium text-theme-heading hover:underline"
>
{patientInfo.name}
</Link>
<span className="text-xs text-theme-muted inline-flex items-center gap-1">
<Calendar size={11} /> Última visita: {patientInfo.last_visit || '—'}
</span>
<span className="text-xs text-theme-muted inline-flex items-center gap-1">
<Heart size={11} /> {patientInfo.wallet_points} pts
</span>
</div>
)}
{patientSearched && !patientInfo && (
<div className="px-4 py-2.5 border-b border-theme-border bg-theme-surface">
{!crearPacienteOpen ? (
<button
type="button"
onClick={() => { setNuevoNombre(selected.contact_name || ''); setCrearPacienteOpen(true); }}
className="text-xs text-theme-heading underline inline-flex items-center gap-1"
>
<UserPlus size={12} /> No es paciente Crear paciente
</button>
) : (
<div className="flex items-center gap-2">
<Input
placeholder="Nombre del paciente"
value={nuevoNombre}
onChange={(e) => setNuevoNombre(e.target.value)}
className="flex-1"
/>
<Button size="sm" onClick={crearPaciente} loading={creandoPaciente}>Crear</Button>
<Button variant="ghost" size="sm" onClick={() => setCrearPacienteOpen(false)}>Cancelar</Button>
</div>
)}
</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 ? (
<p className="text-center text-sm text-theme-muted py-8">No hay mensajes en esta conversación</p>

View File

@@ -58,7 +58,10 @@ export interface Appointment {
state: string;
payment_state: string;
price: number;
doctor_id?: number;
amount_paid?: number;
duration?: number;
is_first_visit?: boolean;
doctor_id?: number | null;
doctor?: string;
branch?: string;
medium?: string;
@@ -67,6 +70,64 @@ export interface Appointment {
package_finished_date?: string | null;
}
export interface Bloqueo {
id: number;
doctor_id: number;
doctor: string;
date: string;
all_day: boolean;
time_from: number;
time_to: number;
motivo: string;
}
export type VisitaState = 'en_curso' | 'completada' | 'cancelada';
export interface VisitaInsumo {
id: number;
item_id: number;
item: string;
unit: string;
qty: number;
notes: string;
}
export interface VisitaAdjunto {
id: number;
kind: 'antes' | 'despues' | 'documento';
name: string;
mimetype: string;
notes: string;
create_date: string | null;
url: string;
}
export interface Visita {
id: number;
name: string;
cita_id: number | null;
partner_id: number;
patient: string;
phone: string;
doctor_id: number | null;
doctor: string | null;
cosmetologa_id: number | null;
cosmetologa: string | null;
servicio_id: number | null;
servicio: string | null;
motivo: string;
diagnostico: string;
tratamiento: string;
notas: string;
receta_text?: string;
state: VisitaState;
date_start: string | null;
date_end: string | null;
insumos: VisitaInsumo[];
insumos_descargados: boolean;
adjuntos: VisitaAdjunto[];
}
export interface Patient {
id: number;
patient_id: string;
@@ -83,8 +144,12 @@ export interface Patient {
total_spent: number;
source: string;
is_vip: boolean;
primary_doctor_id?: number;
primary_doctor_id?: number | null;
primary_doctor?: string;
whatsapp?: string;
has_photo?: boolean;
photo_url?: string;
photo?: string;
// Datos personales extendidos
birthplace?: string;
occupation?: string;
@@ -122,6 +187,42 @@ export interface Patient {
allergies?: string;
medical_history?: string;
current_medication?: string;
// Completitud de expediente
expediente_completion?: number;
expediente_missing?: string[];
}
export interface EstadoCuenta {
puntos_monedero: number;
total_gastado: number;
adeudo: number;
visitas_total: number;
}
export interface PatientAdjunto {
id: number;
kind: 'expediente' | 'imagen';
name: string;
mimetype: string;
notes: string;
create_date: string | null;
url: string;
}
export interface ExpedienteReciente {
id: number;
patient_id: string;
name: string;
phone: string;
age: number;
birth_date: string | null;
primary_doctor: string | null;
last_visit: string | null;
internal_notes: string;
expediente_count: number;
imagenes_count: number;
has_photo: boolean;
photo_url: string;
}
export interface Service {
@@ -147,6 +248,7 @@ export interface Doctor {
work_phone: string;
work_email: string;
commission_pct: number;
patient_count?: number;
}
export interface Product {
@@ -364,6 +466,8 @@ export interface WacrmLead {
expected_close_date: string | null;
notes: string;
assigned_to: string;
partner_id?: number | null;
conversation_id?: string;
created_at: string | null;
}
@@ -377,6 +481,237 @@ export interface FrontendUser {
must_change_password?: boolean;
last_login?: string | null;
active?: boolean;
allowed_menus?: string[] | null;
}
export interface Receta {
id: number;
name: string;
categoria: string;
contenido: string;
}
export interface Diagnostico {
id: number;
name: string;
categoria: string;
descripcion: string;
}
export interface Procedimiento {
id: number;
name: string;
categoria: string;
descripcion: string;
}
export interface AdeudoRow {
id: number;
name: string;
phone: string;
num_ventas: number;
total_adeudo: number;
ultima_venta: string | null;
}
export interface ComisionRow {
doctor_id: number;
doctor: string;
job_title: string;
items: number;
sales: number;
citas_done: number;
total_vendido: number;
commission_pct: number;
comision: number;
}
export interface MovimientoRow extends InventoryMove {
item: string;
unit: string;
}
export interface CaducidadRow {
id: number;
name: string;
category: string;
qty: number;
unit: string;
expiry_date: string;
dias_restantes: number;
vencido: boolean;
}
export interface SugerenciaRow {
id: number;
name: string;
category: string;
unit: string;
qty: number;
qty_min: number;
qty_optimal: number;
sugerido: number;
costo_estimado: number;
}
export interface PagoServicioRow {
servicio: string;
cantidad: number;
total: number;
}
export interface PagoClienteRow {
id: number;
name: string;
num_pagos: number;
total_cobrado: number;
ultimo_pago: string | null;
}
export interface DevolucionRow {
id: number;
folio: string;
paciente: string;
fecha: string | null;
total: number;
refund_amount: number;
refund_reason: string;
refunded_at: string | null;
}
export interface TopClienteRow {
id: number;
name: string;
num_ventas: number;
total: number;
ticket_promedio: number;
}
export interface DailyPorMedico {
medico: string;
citas: number;
by_state: Record<string, number>;
ventas: number;
cobros: number;
}
export interface DailyPorRecepcion {
usuario: string;
ventas_count: number;
ventas_total: number;
cobros_total: number;
}
export interface HoraAgendaRow {
hora: string;
total: number;
by_state: Record<string, number>;
}
export interface PaqueteRow {
id: number;
name: string;
package_sessions: number;
package_price: number;
price: number;
citas: number;
terminados: number;
}
export interface VendedorRow {
usuario: string;
num_ventas: number;
total: number;
cobrado: number;
}
export interface ConcentradoReport {
start: string;
end: string;
citas: { total: number; by_state: Record<string, number> };
ventas: { count: number; total: number; total_paid: number; total_due: number };
pagos: { total: number; count: number; by_method: Record<string, number> };
devoluciones: { count: number; total: number };
pacientes_nuevos: number;
visitas: { total: number; by_state: Record<string, number> };
}
export interface RecomendacionRow {
recomendador: string;
pacientes: number;
}
export interface MonederoPuntosReport {
by_type: Record<string, number>;
puntos_activos: number;
cuentas: number;
equivalente_mxn: number;
}
export interface KpisNoShowMedico {
medico: string;
citas: number;
no_shows: number;
tasa: number;
}
export interface KpisOcupacionMedico {
medico: string;
horas_vendidas: number;
horas_disponibles: number;
horas_libres: number;
ocupacion: number;
}
export interface KpisReport {
start: string;
end: string;
no_show: { tasa: number; total_citas: number; total_no_shows: number; por_medico: KpisNoShowMedico[] };
ocupacion: { promedio: number; dias_habiles: number; por_medico: KpisOcupacionMedico[] };
leads: { total: number; ganados: number; tasa: number; dias_promedio_ganar: number | null };
primera_vez: { primera: number; subsecuentes: number; tasa: number };
}
export interface AppNotification {
tipo: string;
titulo: string;
detalle: string;
link: string;
severity: 'info' | 'warning' | 'danger';
}
export interface PosCheckoutPayload {
partner_id: number;
lines: { service_id: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[];
discount?: number;
payment_method?: string;
amount_received?: number;
pay_with_points?: boolean;
}
export interface PosCheckoutResult {
status: string;
sale: Sale;
payment: Payment | null;
cambio: number;
puntos_usados: number;
puntos_ganados: number;
wallet_points: number;
}
export interface DailyReport {
date: string;
citas: { total: number; by_state: Record<string, number> };
ventas: { count: number; total: number; total_paid: number; total_due: number };
pagos: { total: number; count: number; by_method: Record<string, number> };
inventario: { total: number; by_type: Record<string, number> };
}
export interface InventoryReportSummary {
total_value: number;
count: number;
out: number;
critical: number;
}
export interface ApiResponse<T> {
@@ -428,11 +763,16 @@ export const odooApi = {
return data;
},
async updateFrontendUser(id: number, payload: Partial<{ name: string; role: FrontendRole; active: boolean; password: string; must_change_password: boolean }>): Promise<{ status: string; user: FrontendUser; message?: string }> {
async updateFrontendUser(id: number, payload: Partial<{ name: string; role: FrontendRole; active: boolean; password: string; must_change_password: boolean; allowed_menus: string[] | null }>): Promise<{ status: string; user: FrontendUser; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`, payload);
return data;
},
async deleteFrontendUser(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`);
return data;
},
// Health
async healthCheck(): Promise<unknown> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/health`);
@@ -451,7 +791,7 @@ export const odooApi = {
},
// Citas
async getAppointments(params?: Record<string, string | number>): Promise<{ status: string; appointments: Appointment[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
async getAppointments(params?: Record<string, string | number>): Promise<{ status: string; appointments: Appointment[]; bloqueos?: Bloqueo[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments`, { params });
return data;
},
@@ -486,8 +826,81 @@ export const odooApi = {
return data;
},
// Bloqueos de agenda
async getBloqueos(params?: Record<string, string | number>): Promise<{ status: string; bloqueos: Bloqueo[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/bloqueos`, { params });
return data;
},
async createBloqueo(payload: { doctor_id: number; date: string; all_day?: boolean; time_from?: number; time_to?: number; motivo?: string }): Promise<{ status: string; bloqueo: Bloqueo }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/bloqueos`, payload);
return data;
},
async deleteBloqueo(id: number): Promise<{ status: string; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/bloqueos/${id}`);
return data;
},
// Visitas clínicas
async getVisitas(params?: Record<string, string | number>): Promise<{ status: string; visitas: Visita[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/visitas`, { params });
return data;
},
async getVisita(id: number): Promise<{ status: string; visita: Visita }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/visitas/${id}`);
return data;
},
async updateVisita(id: number, visita: Partial<Visita>): Promise<{ status: string; visita: Visita }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/visitas/${id}`, visita);
return data;
},
async completeVisita(id: number): Promise<{ status: string; visita: Visita }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/visitas/${id}/complete`);
return data;
},
async cancelVisita(id: number): Promise<{ status: string; visita: Visita }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/visitas/${id}/cancel`);
return data;
},
async addVisitaInsumo(id: number, payload: { item_id: number; qty: number; notes?: string }): Promise<{ status: string; visita: Visita }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/visitas/${id}/insumos`, payload);
return data;
},
async deleteVisitaInsumo(id: number, lineId: number): Promise<{ status: string; visita: Visita }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/visitas/${id}/insumos/${lineId}`);
return data;
},
async getInventarioItems(search?: string): Promise<{ status: string; items: InventoryItem[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventory/items`, { params: search ? { search } : {} });
return data;
},
async uploadVisitaAdjunto(id: number, payload: { kind: string; name: string; file_b64: string; mimetype: string; notes?: string }): Promise<{ status: string; visita: Visita }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/visitas/${id}/adjuntos`, payload);
return data;
},
async deleteVisitaAdjunto(adjId: number): Promise<{ status: string; visita: Visita }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/visitas/adjuntos/${adjId}`);
return data;
},
// URL del binario de un adjunto con el token en query (para <img src>)
visitaAdjuntoUrl(url: string): string {
const token = localStorage.getItem('skeen_token') || '';
return `${ODOO_BASE}${url}?token=${encodeURIComponent(token)}`;
},
// Pacientes
async getPatients(params?: string | { search?: string; page?: number; page_size?: number }): Promise<{ status: string; patients: Patient[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
async getPatients(params?: string | { search?: string; page?: number; page_size?: number; incomplete?: number; vip?: number; recent?: number; doctor_id?: number }): Promise<{ status: string; patients: Patient[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const p = typeof params === 'string' ? { search: params } : params || {};
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients`, { params: p });
return data;
@@ -503,14 +916,131 @@ export const odooApi = {
return data;
},
async getPatientHistory(id: number): Promise<{ status: string; appointments: Appointment[]; sales: Sale[] }> {
async getPatientHistory(id: number): Promise<{ status: string; appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta: EstadoCuenta }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}/history`);
return data;
},
// URL de la foto del paciente con el token en query (para <img src>)
patientPhotoUrl(url: string): string {
const token = localStorage.getItem('skeen_token') || '';
return `${ODOO_BASE}${url}?token=${encodeURIComponent(token)}`;
},
// Expedientes recientes y adjuntos de paciente
async getExpedientesRecientes(params?: Record<string, string | number>): Promise<{ status: string; expedientes: ExpedienteReciente[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/expedientes-recientes`, { params });
return data;
},
async getPatientAdjuntos(id: number, kind?: string): Promise<{ status: string; adjuntos: PatientAdjunto[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}/adjuntos`, { params: kind ? { kind } : {} });
return data;
},
async uploadPatientAdjunto(id: number, payload: { kind: string; name: string; file_b64: string; mimetype: string; notes?: string }): Promise<{ status: string; adjunto: PatientAdjunto }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}/adjuntos`, payload);
return data;
},
async deletePatientAdjunto(adjId: number): Promise<{ status: string; message: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/patients/adjuntos/${adjId}`);
return data;
},
// URL del binario de un adjunto de paciente con el token en query (para <img src>)
patientAdjuntoUrl(url: string): string {
const token = localStorage.getItem('skeen_token') || '';
return `${ODOO_BASE}${url}?token=${encodeURIComponent(token)}`;
},
// Recetas (plantillas)
async getRecetas(search?: string): Promise<{ status: string; recetas: Receta[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/recetas`, { params: search ? { search } : {} });
return data;
},
async createReceta(receta: Partial<Receta>): Promise<{ status: string; receta: Receta }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/recetas`, receta);
return data;
},
async updateReceta(id: number, receta: Partial<Receta>): Promise<{ status: string; receta: Receta }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/recetas/${id}`, receta);
return data;
},
async deleteReceta(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/recetas/${id}`);
return data;
},
// Catálogos: diagnósticos y procedimientos
async getDiagnosticos(search?: string): Promise<{ status: string; items: Diagnostico[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/diagnosticos`, { params: search ? { search } : {} });
return data;
},
async createDiagnostico(payload: Partial<Diagnostico>): Promise<{ status: string; item: Diagnostico }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/diagnosticos`, payload);
return data;
},
async updateDiagnostico(id: number, payload: Partial<Diagnostico>): Promise<{ status: string; item: Diagnostico }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/diagnosticos/${id}`, payload);
return data;
},
async deleteDiagnostico(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/diagnosticos/${id}`);
return data;
},
async getProcedimientos(search?: string): Promise<{ status: string; items: Procedimiento[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/procedimientos`, { params: search ? { search } : {} });
return data;
},
async createProcedimiento(payload: Partial<Procedimiento>): Promise<{ status: string; item: Procedimiento }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/procedimientos`, payload);
return data;
},
async updateProcedimiento(id: number, payload: Partial<Procedimiento>): Promise<{ status: string; item: Procedimiento }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/procedimientos/${id}`, payload);
return data;
},
async deleteProcedimiento(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/procedimientos/${id}`);
return data;
},
// Consultas médicas directas
async createConsulta(payload: { partner_id: number; doctor_id?: number | null; motivo?: string; diagnostico?: string; tratamiento?: string; notas?: string }): Promise<{ status: string; visita: Visita }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/consultas`, payload);
return data;
},
// Reporte de adeudos (cartera por cobrar)
async getAdeudos(): Promise<{ status: string; adeudos: AdeudoRow[]; total_cartera: number; total_pacientes: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/adeudos`);
return data;
},
// Servicios
async getServices(): Promise<{ status: string; services: Service[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/services`);
async getServices(params?: { search?: string; category?: string; page?: number; page_size?: number }): Promise<{ status: string; services: Service[]; total?: number; page?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/services`, { params });
return data;
},
async globalSearch(q: string): Promise<{
status: string;
patients: { id: number; name: string; phone: string }[];
appointments: { id: number; patient: string; service: string; date: string; time: string }[];
services: { id: number; name: string; price: number }[];
}> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/search`, { params: { q } });
return data;
},
@@ -618,11 +1148,119 @@ export const odooApi = {
},
// Reportes
async getSalesReport(start?: string, end?: string): Promise<{ status: string; total_sales: number; total_paid: number; total_due: number; count: number }> {
async getSalesReport(start?: string, end?: string): Promise<{ status: string; total_sales: number; total_paid: number; total_due: number; count: number; by_day?: { date: string; total: number }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/sales`, { params: { start, end } });
return data;
},
async getDailyReport(date?: string, by?: 'medico' | 'recepcion'): Promise<{ status: string } & DailyReport & { por_medico?: DailyPorMedico[]; por_recepcion?: DailyPorRecepcion[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/daily`, {
params: { ...(date ? { date } : {}), ...(by ? { by } : {}) },
});
return data;
},
async getComisiones(start?: string, end?: string): Promise<{ status: string; start: string; end: string; comisiones: ComisionRow[]; total_vendido: number; total_comision: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/comisiones`, { params: { start, end } });
return data;
},
async getPagosServicios(start?: string, end?: string): Promise<{ status: string; total_cobrado: number; total_pagos: number; by_method: Record<string, number>; servicios: PagoServicioRow[]; total_pagado_completo: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/pagos-servicios`, { params: { start, end } });
return data;
},
async getPagosClientes(start?: string, end?: string): Promise<{ status: string; clientes: PagoClienteRow[]; total_cobrado: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/pagos-clientes`, { params: { start, end } });
return data;
},
async getDevoluciones(start?: string, end?: string): Promise<{ status: string; devoluciones: DevolucionRow[]; total_devuelto: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/devoluciones`, { params: { start, end } });
return data;
},
async getTopClientes(start?: string, end?: string, limit = 20): Promise<{ status: string; clientes: TopClienteRow[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/top-clientes`, { params: { start, end, limit } });
return data;
},
async getMovimientos(params?: Record<string, string | number>): Promise<{ status: string; movimientos: MovimientoRow[]; total?: number; page?: number; page_size?: number; total_pages?: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventario/movimientos`, { params });
return data;
},
async createCompra(payload: { item_id: number; qty: number; cost?: number; reference?: string; notes?: string }): Promise<{ status: string; move: InventoryMove; item: InventoryItem }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/inventario/compras`, payload);
return data;
},
async createBaja(payload: { item_id: number; qty: number; reference?: string; notes?: string }): Promise<{ status: string; move: InventoryMove; item: InventoryItem }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/inventario/bajas`, payload);
return data;
},
async getInventarioAlertas(): Promise<{ status: string; caducidades: CaducidadRow[]; sugerencia_compra: SugerenciaRow[]; sugerencia_total: { items: number; costo_total: number } }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/inventario/alertas`);
return data;
},
async getHorasAgenda(start?: string, end?: string): Promise<{ status: string; horas: HoraAgendaRow[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/horas-agenda`, { params: { start, end } });
return data;
},
async getPaquetes(): Promise<{ status: string; paquetes: PaqueteRow[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/paquetes`);
return data;
},
async getVendedores(start?: string, end?: string): Promise<{ status: string; vendedores: VendedorRow[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/vendedores`, { params: { start, end } });
return data;
},
async getConcentrado(start?: string, end?: string): Promise<{ status: string } & ConcentradoReport> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/concentrado`, { params: { start, end } });
return data;
},
async getRecomendaciones(): Promise<{ status: string; recomendaciones: RecomendacionRow[]; total: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/recomendaciones`);
return data;
},
async getMonederoPuntos(start?: string, end?: string): Promise<{ status: string } & MonederoPuntosReport> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/monedero-puntos`, { params: { start, end } });
return data;
},
async getKpis(start?: string, end?: string): Promise<{ status: string } & KpisReport> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/kpis`, { params: { start, end } });
return data;
},
async getNotifications(): Promise<{ status: string; notifications: AppNotification[]; total: number }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notifications`);
return data;
},
// POS: venta + pago en una sola operación
async posCheckout(payload: PosCheckoutPayload): Promise<PosCheckoutResult> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/pos/checkout`, payload);
return data;
},
async getCashReportRange(start: string, end: string): Promise<{ status: string; total: number; by_method: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/cash`, { params: { start, end } });
return data;
},
async getInventoryReport(): Promise<{ status: string; summary: InventoryReportSummary; items: InventoryItem[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/inventory`);
return data;
},
async getAppointmentsReport(start?: string, end?: string): Promise<{ status: string; total: number; by_state: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/appointments`, { params: { start, end } });
return data;
@@ -700,7 +1338,7 @@ export const odooApi = {
return data;
},
async getWacrmMembers(): Promise<{ status: string; members: { id: string; name: string; email?: string; avatar_url?: string; role: string }[] }> {
async getWacrmMembers(): Promise<{ status: string; members: { id: string; profile_id?: string; name: string; email?: string; avatar_url?: string; role: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/members`);
return data;
},
@@ -741,8 +1379,13 @@ export const odooApi = {
return data;
},
async updateWacrmLeadStatus(id: number, payload: { status?: 'open' | 'won' | 'lost'; stage_id?: string }): Promise<{ status: string; lead: WacrmLead }> {
async updateWacrmLeadStatus(id: number, payload: { status?: 'open' | 'won' | 'lost'; stage_id?: string; assigned_to?: string | null }): Promise<{ status: string; lead: WacrmLead }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/wacrm/leads/${id}/status`, payload);
return data;
},
async convertWacrmLead(id: number): Promise<{ status: string; lead: WacrmLead; patient_id: number; created: boolean; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/leads/${id}/convert`);
return data;
},
};

View File

@@ -75,27 +75,5 @@
font-display: swap;
}
/* Google Fonts para HomeNest (cargadas también en index.html) */
@font-face {
font-family: 'Alike';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('https://fonts.gstatic.com/s/alike/v20/HIqhi7Y-DAyCmA5nqQ0ubw.woff2') format('woff2');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 100 1000;
font-display: swap;
src: url('https://fonts.gstatic.com/s/dmsans/v15/rP2Yp2ywxg089UriI5-g4vlHRe3PeY0OevCw-Ob1.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url('https://fonts.gstatic.com/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjPVmUsaaDhw.woff2') format('woff2');
}
/* Google Fonts para HomeNest: se cargan desde index.html (css2 con display=swap).
No duplicar @font-face aquí: las URLs firmadas de gstatic caducan y dan 404. */

View File

@@ -22,6 +22,6 @@ export default defineConfig({
},
build: {
outDir: 'dist',
sourcemap: true,
sourcemap: false,
},
})

357
migracion/delta_import.py Normal file
View File

@@ -0,0 +1,357 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Delta import SKEEN: trae citas y pacientes nuevos del legacy desde la última
migración hasta hoy (+60 días de futuras), SIN duplicar.
- Legacy: SOLO LECTURA (reusa login/fetch de extraer_skeen.py).
- Citas: dedup por terna (partner_id, date, time). Existentes: actualiza state
solo con información que el legacy sí expone (ver STATUS_MAP). El endpoint de
agenda NO expone pagos, así que payment_state/amount_paid no se tocan.
- Pacientes: crea solo legacy_id nuevo Y teléfono nuevo. Sin detalle clínico
nuevo; usa caché detalle_pacientes.json para email/fecha_nac/sexo si existe.
Uso: /root/odoo-venv/bin/python /root/migracion/delta_import.py
"""
import csv
import html
import json
import re
import sys
import time
import hashlib
from datetime import datetime, timedelta
from pathlib import Path
sys.path.insert(0, '/root/migracion')
sys.path.insert(0, '/root/skeen-odoo')
import extraer_skeen as ex # login(), fetch_json(), paginate(), dt_params(), normalize_phone()
import odoo
from odoo import api, SUPERUSER_ID
START = '2026-06-01'
HOY = datetime.now().date()
END = HOY + timedelta(days=60)
STAMP = datetime.now().strftime('%Y%m%d_%H%M%S')
OUTDIR = Path('/root/migracion/extraccion')
# Leyenda de status del legacy (radios name="status" en /agenda):
# 0 Normal, 1 No vino, 2 Canceló/Reagendó, 3 Canceló, 4/5 Lista de espera,
# 6 No citar, 7 Vacaciones, 8 Horario de comida
EXCLUDE_STATUS = {6, 7, 8}
def map_state(status, confirmada, tiene_visita):
"""Estado Odoo según status legacy. None = no importar (bloques de agenda)."""
if status in EXCLUDE_STATUS:
return None
if status == 1:
return 'no_show'
if status in (2, 3):
return 'cancelled'
if tiene_visita:
return 'done'
return 'confirmed' if confirmada else 'pending'
# Solo avanzar estados: nunca regresar done/arrived/etc. a confirmed/pending
RANK = {'pending': 0, 'confirmed': 1, 'arrived': 2, 'in_progress': 3,
'done': 4, 'cancelled': 5, 'no_show': 5}
def should_update(actual, nuevo):
if nuevo == actual:
return False
if nuevo in ('cancelled', 'no_show'):
return actual not in ('cancelled', 'no_show')
return RANK.get(nuevo, 0) > RANK.get(actual, 0)
def normalize_name(name):
return ' '.join(str(name or '').split()).lower()
def parse_time_hhmm(inicio):
inicio = (inicio or '').strip()
if len(inicio) == 4:
return float(inicio[:2]) + float(inicio[2:]) / 60.0
if len(inicio) == 3:
return float(inicio[0]) + float(inicio[1:]) / 60.0
return 9.0
def extract_service(text, patient_name):
text = html.unescape(text or '')
if patient_name:
text = re.sub(r'^' + re.escape(patient_name) + r'\s*[-–—]\s*', '', text, flags=re.IGNORECASE)
text = re.sub(r'\s*\(\d+\s*(sesión|sesiones|unidad|unidades)\s*\)\s*$', '', text, flags=re.IGNORECASE)
return ' '.join(text.split()).strip()
def log(msg):
print(f'[{datetime.now().strftime("%H:%M:%S")}] {msg}', flush=True)
def main():
# ---------- Conexión Odoo ----------
odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf'])
db = odoo.sql_db.db_connect('skeen_odoo')
cr = db.cursor()
env = api.Environment(cr, SUPERUSER_ID, {})
Partner = env['res.partner'].sudo()
Servicio = env['skeen.servicio'].sudo()
CitaModel = env.registry['skeen.cita']
Cita = env['skeen.cita'].sudo()
# Desactivar validación de solapamiento durante la importación
original_check = CitaModel._check_availability
CitaModel._check_availability = lambda self: None
# ---------- Cachés Odoo ----------
log('Cargando cachés de Odoo...')
partner_by_legacy = {}
partner_by_phone = {}
cr.execute("SELECT id, phone, legacy_id FROM res_partner WHERE is_patient = true AND active = true")
for pid, phone, legacy_id in cr.fetchall():
if legacy_id:
partner_by_legacy[str(legacy_id)] = pid
if phone:
partner_by_phone.setdefault(phone, pid)
service_by_name = {}
cr.execute("SELECT id, name FROM skeen_servicio")
for sid, name in cr.fetchall():
if name:
service_by_name.setdefault(normalize_name(name), sid)
doctor_by_name = {}
cr.execute("SELECT id, name FROM hr_employee")
for eid, name in cr.fetchall():
if name:
doctor_by_name.setdefault(normalize_name(name), eid)
# Citas existentes en el rango (dedup + posibles actualizaciones)
existing_citas = {}
cr.execute(
"SELECT id, partner_id, date, time, state FROM skeen_cita WHERE date >= %s AND date <= %s",
(START, END.strftime('%Y-%m-%d')))
for cid, pid, d, t, state in cr.fetchall():
existing_citas[(pid, d.strftime('%Y-%m-%d'), round(float(t), 2))] = (cid, state)
log(f' Pacientes: {len(partner_by_legacy)} legacy / {len(partner_by_phone)} teléfonos | '
f'Servicios: {len(service_by_name)} | Citas en rango: {len(existing_citas)}')
medicos_map = {
'32': 'Dra. Alejandra Ramos', '33': 'Dra. Lidia Martinez',
'46': 'Dra. Fernanda Cerecer', '49': 'RODRIGUEZ FRIDA',
'54': 'Dr. Benjamín Adrián', '79': 'XIMENA', '80': 'ELY',
}
def get_or_create_service(name):
name = name.strip() or 'Servicio genérico'
key = normalize_name(name)
if key in service_by_name:
return service_by_name[key]
code = 'HIST_' + hashlib.md5(key.encode('utf-8')).hexdigest()[:12]
new_s = Servicio.create({'name': name, 'code': code, 'category': 'tratamiento',
'price': 0, 'duration_min': 30})
service_by_name[key] = new_s.id
return new_s.id
# ---------- Login legacy ----------
ex.login()
# ---------- 1. Pacientes nuevos ----------
log('Descargando expedientes del legacy...')
exp_rows = ex.paginate('/expedientes/json', lambda s, l: ex.dt_params(s, l), 'expedientes')
detail_cache = ex.load_detail_cache()
pacientes_nuevos = 0
omitidos_legacy = 0
omitidos_phone = 0
omitidos_invalid = 0
delta_pacientes = []
for r in exp_rows:
legacy_id = str(r.get('id') or '').strip()
nombre = (r.get('nombre') or '').strip()
if not legacy_id or not nombre or nombre.upper().startswith('. . NO CITAR'):
omitidos_invalid += 1
continue
if legacy_id in partner_by_legacy:
omitidos_legacy += 1
continue
phone = ex.normalize_phone((r.get('telefono') or '').strip())
if phone and phone in partner_by_phone:
omitidos_phone += 1
continue
if not phone:
omitidos_invalid += 1
continue
detail = detail_cache.get(legacy_id, {})
email = (detail.get('email') or '').strip()
if email.lower() in ('no@no.com', 'no@no'):
email = ''
medico = (r.get('medico_principal') or '').strip()
doctor_id = doctor_by_name.get(normalize_name(medico)) if medico else False
vals = {
'name': nombre,
'phone': phone,
'legacy_id': legacy_id,
'is_patient': True,
'source': 'other', # el Selection no admite 'legacy'
'is_vip': bool(r.get('vip')),
}
if email:
vals['email'] = email
if detail.get('fecha_nacimiento'):
vals['birth_date'] = detail['fecha_nacimiento']
if detail.get('sexo'):
vals['gender'] = detail['sexo']
if doctor_id:
vals['primary_doctor_id'] = doctor_id
if (r.get('notas') or '').strip():
vals['patient_comments'] = r['notas'].strip()
Partner.create(vals)
pacientes_nuevos += 1
partner_by_legacy[legacy_id] = True # placeholder, id real no necesario abajo
if phone:
partner_by_phone[phone] = True
delta_pacientes.append({'legacy_id': legacy_id, 'nombre': nombre, 'telefono': phone,
'folio': r.get('folio', ''), 'vip': '1' if r.get('vip') else '0'})
if pacientes_nuevos % 50 == 0:
cr.commit()
log(f' {pacientes_nuevos} pacientes nuevos...')
cr.commit()
log(f'Pacientes nuevos creados: {pacientes_nuevos} '
f'(omitidos: {omitidos_legacy} por legacy_id, {omitidos_phone} por teléfono, {omitidos_invalid} inválidos)')
# Recargar mapa legacy_id → id real (los nuevos ya están en BD)
cr.execute("SELECT id, legacy_id FROM res_partner WHERE legacy_id IS NOT NULL AND legacy_id != ''")
partner_by_legacy = {str(lid): pid for pid, lid in cr.fetchall()}
# ---------- 2. Citas del delta ----------
log(f'Descargando citas legacy {START}{END}...')
delta_rows = []
current = datetime.strptime(START, '%Y-%m-%d').date()
while current <= END:
ds = current.strftime('%Y-%m-%d')
try:
citas = ex.fetch_json(f'/agenda/citas/{ds}').get('citas', [])
except Exception as e:
log(f' ERROR {ds}: {e}')
citas = []
for c in citas:
delta_rows.append(c)
current += timedelta(days=1)
time.sleep(0.05)
log(f'Citas legacy descargadas (crudo): {len(delta_rows)}')
nuevas = 0
actualizadas = 0
omitidas_sin_paciente = 0
omitidas_estado = 0
ya_existian = 0
batch = []
csv_rows = []
for c in delta_rows:
nombre = (c.get('nombre') or '').strip()
if not nombre or nombre.upper().startswith('. . NO CITAR'):
continue
status = c.get('status') or 0
tiene_visita = bool(c.get('visitas_id'))
state = map_state(status, c.get('confirmada'), tiene_visita)
if state is None:
omitidas_estado += 1
continue
expediente_id = str(c.get('expedientes_id') or '').strip()
partner_id = partner_by_legacy.get(expediente_id) if expediente_id else None
if not partner_id:
phone = ex.normalize_phone(c.get('telefono') or '') or ex.normalize_phone(c.get('whatsapp') or '')
partner_id = partner_by_phone.get(phone) if phone else None
if not partner_id:
omitidas_sin_paciente += 1
continue
fecha = (c.get('fecha') or '').strip()
if not fecha:
continue
hora = round(parse_time_hhmm(c.get('inicio')), 2)
key = (partner_id, fecha, hora)
servicio_nombre = extract_service(c.get('titulo', ''), nombre) or 'Servicio genérico'
medico_id = str(c.get('medicos_id') or '')
doctor_nombre = medicos_map.get(medico_id, '')
doctor_id = doctor_by_name.get(normalize_name(doctor_nombre)) if doctor_nombre else False
notas = html.unescape(c.get('observaciones') or '')
csv_rows.append({'legacy_id': c.get('id', ''), 'expedientes_id': expediente_id,
'paciente': nombre, 'servicio': servicio_nombre, 'fecha': fecha,
'hora': c.get('inicio', ''), 'estado': state})
if key in existing_citas:
ya_existian += 1
cid, estado_actual = existing_citas[key]
if should_update(estado_actual, state):
Cita.browse(cid).write({'state': state})
actualizadas += 1
continue
batch.append({
'partner_id': partner_id,
'servicio_id': get_or_create_service(servicio_nombre),
'date': fecha,
'time': hora,
'state': state,
'payment_state': 'not_paid',
'amount_paid': 0,
'doctor_id': doctor_id,
'branch': 'rosarito',
'medium': 'onsite',
'notes': notas,
})
existing_citas[key] = (None, state) # dedup dentro del mismo delta
if len(batch) >= 200:
Cita.create(batch)
nuevas += len(batch)
batch = []
cr.commit()
log(f' {nuevas} citas nuevas...')
if batch:
Cita.create(batch)
nuevas += len(batch)
cr.commit()
# ---------- Guardar CSVs del delta (archivos nuevos, no toca originales) ----------
if csv_rows:
with open(OUTDIR / f'delta_citas_{STAMP}.csv', 'w', newline='', encoding='utf-8') as f:
w = csv.DictWriter(f, fieldnames=['legacy_id', 'expedientes_id', 'paciente', 'servicio',
'fecha', 'hora', 'estado'], extrasaction='ignore')
w.writeheader()
w.writerows(csv_rows)
if delta_pacientes:
with open(OUTDIR / f'delta_pacientes_{STAMP}.csv', 'w', newline='', encoding='utf-8') as f:
w = csv.DictWriter(f, fieldnames=['legacy_id', 'nombre', 'telefono', 'folio', 'vip'])
w.writeheader()
w.writerows(delta_pacientes)
# Restaurar validación y cerrar
CitaModel._check_availability = original_check
cr.commit()
cr.close()
log('========== RESUMEN DELTA ==========')
log(f'Pacientes nuevos: {pacientes_nuevos}')
log(f'Citas nuevas: {nuevas}')
log(f'Citas actualizadas: {actualizadas}')
log(f'Citas ya existentes: {ya_existian}')
log(f'Citas omitidas (sin paciente): {omitidas_sin_paciente}')
log(f'Citas omitidas (bloques agenda): {omitidas_estado}')
log(f'CSVs: extraccion/delta_citas_{STAMP}.csv / delta_pacientes_{STAMP}.csv')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Detecta duplicados de pacientes por telefono + similitud de nombre.
Solo lee; genera /root/migracion/reporte_duplicados.csv para revision."""
import csv
import re
import unicodedata
import difflib
import xmlrpc.client
c = xmlrpc.client.ServerProxy('http://localhost:8069/xmlrpc/2/common')
uid = c.authenticate('skeen_odoo', 'admin', 'skeen_admin_2026', {})
m = xmlrpc.client.ServerProxy('http://localhost:8069/xmlrpc/2/object')
def kw(model, method, args, kwar=None):
return m.execute_kw('skeen_odoo', uid, 'skeen_admin_2026', model, method, args, kwar or {})
def norm_name(s):
s = unicodedata.normalize('NFKD', s or '')
s = ''.join(ch for ch in s if not unicodedata.combining(ch))
s = re.sub(r'[^a-z0-9 ]', ' ', s.lower())
return ' '.join(sorted(s.split())) # orden independiente
def norm_phone(p):
return re.sub(r'\D', '', p or '')
FIELDS = ['id', 'name', 'phone', 'email', 'birth_date', 'legacy_id',
'total_visits', 'last_visit', 'create_date', 'active']
patients = []
offset = 0
while True:
batch = kw('res.partner', 'search_read',
[[['is_patient', '=', True]]],
{'fields': FIELDS, 'limit': 1000, 'offset': offset, 'order': 'id'})
if not batch:
break
patients.extend(batch)
offset += len(batch)
print(f'Pacientes leidos: {len(patients)}')
# Agrupar por telefono
by_phone = {}
for p in patients:
ph = norm_phone(p['phone'])
if ph:
by_phone.setdefault(ph, []).append(p)
reporte = []
grupos_dup = 0
grupos_familia = 0
for phone, group in sorted(by_phone.items()):
if len(group) < 2:
continue
names = [norm_name(p['name']) for p in group]
# similitud maxima dentro del grupo
max_ratio = 0.0
for i in range(len(names)):
for j in range(i + 1, len(names)):
max_ratio = max(max_ratio, difflib.SequenceMatcher(None, names[i], names[j]).ratio())
tipo = 'duplicado_probable' if max_ratio >= 0.80 else 'telefono_compartido'
if tipo == 'duplicado_probable':
grupos_dup += 1
else:
grupos_familia += 1
# candidato a conservar: mas visitas, luego mas antiguo
canon = sorted(group, key=lambda p: (-(p['total_visits'] or 0), p['create_date']))[0]
for p in group:
reporte.append({
'telefono': phone,
'tipo': tipo,
'similitud_nombre': f'{max_ratio:.2f}',
'conservar': 'SI' if p['id'] == canon['id'] else '',
'id': p['id'],
'nombre': p['name'],
'email': p['email'] or '',
'fecha_nac': p['birth_date'] or '',
'visitas': p['total_visits'] or 0,
'ultima_visita': p['last_visit'] or '',
'legacy_id': p['legacy_id'] or '',
'creado': p['create_date'],
})
# Nombres identicos con telefonos distintos (posible duplicado extra)
by_name = {}
for p in patients:
n = norm_name(p['name'])
if len(n) >= 8:
by_name.setdefault(n, []).append(p)
extra = 0
for name, group in by_name.items():
phones = {norm_phone(p['phone']) for p in group}
if len(group) > 1 and len(phones) > 1:
extra += 1
canon = sorted(group, key=lambda p: (-(p['total_visits'] or 0), p['create_date']))[0]
for p in group:
reporte.append({
'telefono': norm_phone(p['phone']),
'tipo': 'mismo_nombre_distinto_telefono',
'similitud_nombre': '1.00',
'conservar': 'SI' if p['id'] == canon['id'] else '',
'id': p['id'],
'nombre': p['name'],
'email': p['email'] or '',
'fecha_nac': p['birth_date'] or '',
'visitas': p['total_visits'] or 0,
'ultima_visita': p['last_visit'] or '',
'legacy_id': p['legacy_id'] or '',
'creado': p['create_date'],
})
out = '/root/migracion/reporte_duplicados.csv'
with open(out, 'w', encoding='utf-8', newline='') as f:
w = csv.DictWriter(f, fieldnames=['telefono', 'tipo', 'similitud_nombre', 'conservar',
'id', 'nombre', 'email', 'fecha_nac', 'visitas',
'ultima_visita', 'legacy_id', 'creado'])
w.writeheader()
w.writerows(reporte)
dup_rows = [r for r in reporte if r['tipo'] == 'duplicado_probable']
print(f'\nGrupos por telefono con duplicado probable: {grupos_dup} ({len(dup_rows)} registros)')
print(f'Grupos telefono compartido (familia): {grupos_familia}')
print(f'Grupos mismo nombre, distinto telefono: {extra}')
print(f'\nReporte: {out} ({len(reporte)} filas)')
print('\nMuestra de duplicados probables:')
shown = set()
for r in dup_rows[:20]:
key = r['telefono']
if key not in shown:
print(f" tel {key}:")
shown.add(key)
print(f" [{'CONSERVAR' if r['conservar'] else 'fusionar '}] id={r['id']} {r['nombre']} (visitas={r['visitas']})")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Importa las VISITAS del sistema legacy SKEEN a skeen.visita (idempotente, dedup por legacy_id).
- Legacy SOLO LECTURA. Crudo guardado en extraccion/visitas_legacy_YYYYMMDD_HHMMSS.json.
- date_start: created_at interpretado en America/Tijuana (convertido a UTC para que
los filtros del frontend por fecha caigan en el día correcto).
- doctor_id: matching por tokens (sin acentos) de medico_principal contra hr.employee.
- cosmetologa_id: mapa fijo de ids legacy conocidos ({79: XIMENA, 80: ELY}); otros → False
(el legacy no expone endpoint de catálogo — /medicos/json no existe).
- state = 'completada' para todas (los status legacy son del flujo viejo y no mapean limpio).
Uso: /root/odoo-venv/bin/python /root/migracion/importar_visitas.py
"""
import html
import json
import sys
import time
import unicodedata
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
sys.path.insert(0, '/root/migracion')
sys.path.insert(0, '/root/skeen-odoo')
import extraer_skeen as ex # login(), fetch_json()
import odoo
from odoo import api, SUPERUSER_ID
TZ_LOCAL = ZoneInfo('America/Tijuana')
STAMP = datetime.now().strftime('%Y%m%d_%H%M%S')
RAW_PATH = Path(f'/root/migracion/extraccion/visitas_legacy_{STAMP}.json')
# Cosmetólogas conocidas del legacy (id → nombre en hr.employee)
COSMETOLOGAS_LEGACY = {79: 'XIMENA', 80: 'ELY'}
def log(msg):
print(f'[{datetime.now().strftime("%H:%M:%S")}] {msg}', flush=True)
def strip_accents(s):
return ''.join(c for c in unicodedata.normalize('NFD', s or '') if unicodedata.category(c) != 'Mn')
def norm(s):
return ' '.join(strip_accents(str(s or '')).lower().split())
def visitas_params(start, length):
"""Params DataTables completos (el endpoint da 500 si faltan)"""
p = {
'draw': 1, 'start': start, 'length': length,
'search[value]': '', 'search[regex]': 'false',
'fecha_registro': '', 'medicos_id': '', 'cosmetologos_id': '',
'order[0][column]': '0', 'order[0][dir]': 'desc',
'_': int(time.time() * 1000),
}
for i in range(10):
p[f'columns[{i}][data]'] = str(i)
p[f'columns[{i}][searchable]'] = 'true'
p[f'columns[{i}][orderable]'] = 'true'
p[f'columns[{i}][search][value]'] = ''
p[f'columns[{i}][search][regex]'] = 'false'
return p
def main():
# ---------- Odoo ----------
odoo.tools.config.parse_config(['-c', '/root/skeen-odoo/odoo.conf'])
db = odoo.sql_db.db_connect('skeen_odoo')
cr = db.cursor()
env = api.Environment(cr, SUPERUSER_ID, {})
Visita = env['skeen.visita'].sudo()
log('Cargando cachés de Odoo...')
partner_by_legacy = {}
cr.execute("SELECT id, legacy_id FROM res_partner WHERE legacy_id IS NOT NULL AND legacy_id != ''")
for pid, lid in cr.fetchall():
partner_by_legacy[str(lid)] = pid
existing_visitas = set()
cr.execute("SELECT legacy_id FROM skeen_visita WHERE legacy_id IS NOT NULL AND legacy_id != ''")
existing_visitas = {r[0] for r in cr.fetchall()}
service_by_name = {}
cr.execute("SELECT id, name FROM skeen_servicio")
for sid, name in cr.fetchall():
if name:
service_by_name.setdefault(norm(name), sid)
employees = []
cr.execute("SELECT id, name FROM hr_employee")
for eid, name in cr.fetchall():
employees.append((eid, name or ''))
# tokens significativos por empleado (sin dr/dra ni tokens cortos)
emp_tokens = []
for eid, name in employees:
toks = [t for t in norm(name).split() if len(t) > 3 and t not in ('dra.', 'dr.')]
if toks:
emp_tokens.append((eid, name, set(toks)))
log(f' Partners con legacy_id: {len(partner_by_legacy)} | Visitas ya importadas: {len(existing_visitas)} | Empleados: {len(employees)}')
def match_doctor(texto):
t = norm(texto)
if not t:
return False
best = False
for eid, name, toks in emp_tokens:
if toks and all(tok in t for tok in toks):
# preferir el match con más tokens (más específico)
if not best or len(toks) > best[1]:
best = (eid, len(toks))
return best[0] if best else False
employee_by_name = {norm(name): eid for eid, name in employees}
cosmetologa_map = {}
for cid, nombre in COSMETOLOGAS_LEGACY.items():
eid = employee_by_name.get(norm(nombre))
if eid:
cosmetologa_map[cid] = eid
log(f' Cosmetólogas mapeadas: {cosmetologa_map}')
# ---------- Legacy ----------
ex.login()
log('Descargando visitas del legacy...')
rows = []
start = 0
page_size = 500
while True:
data = ex.fetch_json('/visitas/json', visitas_params(start, page_size))
chunk = data.get('data', [])
if not chunk:
break
rows.extend(chunk)
total = data.get('recordsTotal') or 0
log(f' página start={start}: {len(chunk)} (acumulado {len(rows)}/{total})')
if len(chunk) < page_size:
break
start += page_size
time.sleep(0.3)
log(f'Total visitas legacy: {len(rows)}')
with open(RAW_PATH, 'w', encoding='utf-8') as f:
json.dump(rows, f, ensure_ascii=False)
log(f'Crudo guardado en {RAW_PATH}')
# ---------- Importación ----------
importadas = 0
omitidas_sin_paciente = 0
omitidas_existentes = 0
errores = 0
batch = []
for r in rows:
legacy_id = str(r.get('id') or '').strip()
if not legacy_id:
continue
if legacy_id in existing_visitas:
omitidas_existentes += 1
continue
exp_id = str(r.get('expedientes_id') or '').strip()
partner_id = partner_by_legacy.get(exp_id)
if not partner_id:
omitidas_sin_paciente += 1
continue
# created_at legacy es hora local Tijuana → UTC
created = (r.get('created_at') or '').strip()
try:
dt_local = datetime.strptime(created, '%Y-%m-%d %H:%M:%S').replace(tzinfo=TZ_LOCAL)
date_start = dt_local.astimezone(ZoneInfo('UTC')).strftime('%Y-%m-%d %H:%M:%S')
except Exception:
date_start = created or False
motivo = html.unescape(r.get('otro_motivo') or '').strip()
conceptos = r.get('conceptos') or []
partes = []
for c in conceptos:
titulo = html.unescape(c.get('titulo') or '').strip()
subtotal = c.get('subtotal') or ''
partes.append(f'{titulo} — ${subtotal}' if subtotal else titulo)
notas = f"Conceptos: {'; '.join(partes)}" if partes else ''
cosmetologa_id = cosmetologa_map.get(r.get('cosmetologos_id') or 0, False)
# Servicio: match exacto normalizado del primer concepto (no crea servicios)
servicio_id = False
if conceptos:
servicio_id = service_by_name.get(norm(html.unescape(conceptos[0].get('titulo') or '')), False)
batch.append({
'legacy_id': legacy_id,
'partner_id': partner_id,
'doctor_id': match_doctor(r.get('medico_principal')),
'cosmetologa_id': cosmetologa_id,
'servicio_id': servicio_id,
'date_start': date_start,
'motivo': motivo,
'notas': notas,
'state': 'completada',
})
existing_visitas.add(legacy_id) # dedup dentro del mismo lote
if len(batch) >= 500:
try:
Visita.create(batch)
importadas += len(batch)
batch = []
cr.commit()
log(f' {importadas} visitas importadas...')
except Exception as e:
cr.rollback()
log(f' ERROR en lote: {e}')
errores += len(batch)
batch = []
if batch:
try:
Visita.create(batch)
importadas += len(batch)
cr.commit()
except Exception as e:
cr.rollback()
log(f' ERROR en lote final: {e}')
errores += len(batch)
cr.close()
log('========== RESUMEN VISITAS ==========')
log(f'Importadas: {importadas}')
log(f'Ya existían (legacy_id): {omitidas_existentes}')
log(f'Omitidas sin paciente: {omitidas_sin_paciente}')
log(f'Errores: {errores}')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,171 @@
name,kind,sku,category,unit,qty,qty_optimal,qty_min,cost,expiry_date,notes
EPIOLOGY CREMA 28g,producto,799439039070,EPIOLOGY,pieza,0.0,30.0,15.0,476.0,,
EPIOLOGY CLEANSER 110 ml,producto,799439039063,EPIOLOGY,pieza,50.0,30.0,15.0,359.0,,
EPIOLOGY SPOT 10 g,producto,797776088935,EPIOLOGY,pieza,180.0,0.0,5.0,476.0,,
PASE MEDICO,producto,PASE,GENERAL,pieza,440.0,0.0,0.0,420.0,,
KEROSEB EMULSION,producto,8424561008374,HD,pieza,70.0,6.0,3.0,469.68,,
KEROSEB SHAMPOO,producto,8424561008329,HD,pieza,-30.0,15.0,5.0,398.18,,
BLUMOIST,producto,8424561008152,HD,pieza,-10.0,25.0,20.0,930.0,,
NOX 3C SERUM,producto,8424561008046,HD,pieza,130.0,7.0,3.0,917.0,,
IVERMECTINA 1%,producto,7502247140318,DERMICO,pieza,40.0,10.0,5.0,281.0,,
INTENSIVE HYALURONIC MASQUE,producto,3461020014038,ESTHEDERM,pieza,40.0,0.0,0.0,1301.0,,
INTENSIVE AHA PEEL SERUM CONCENTRE,producto,3461020014137,ESTHEDERM,pieza,20.0,2.0,1.0,1194.828,,
OSMOCLEAN DESINCRUSTANTE,producto,3461020013550,ESTHEDERM,pieza,50.0,0.0,2.0,579.0,,
INTENSIVE PROPOLIS + ZINC,producto,3461023492185,ESTHEDERM,pieza,50.0,1.0,1.0,1050.0,,
INTENSIVE AHA PEEL GENTLE SERUM,producto,3461020014144,ESTHEDERM,pieza,90.0,4.0,2.0,1259.0,,
INTENSIVE HYALURONIC SERUM,producto,3461020014014,ESTHEDERM,pieza,150.0,4.0,1.0,995.32,,
INTENSIVE RETINOL CREME,producto,3461020003438,ESTHEDERM,pieza,100.0,7.0,3.0,1846.0,,
INTENSIVE HYALURONIC EYE SERUM,producto,3461020003025,ESTHEDERM,pieza,120.0,0.0,3.0,1267.0,,
BRUME 200 ML,producto,3461022003054,ESTHEDERM,pieza,50.0,0.0,5.0,504.78,,
INTENSIVE PROPOLIS+SALICYLIC ACID,producto,3461023492161,ESTHEDERM,pieza,60.0,0.0,0.0,1376.0,,
ANTHELIOS UVMUNE 50+ FLUID INVISIBLE ULTRA LONG 50ML,producto,3337875797597,LA ROCHE POSAY,pieza,90.0,5.0,3.0,410.56,,
ANTHELIOS UVMUNE 50+COLOR FLUID 50ml,producto,3337875797641,LA ROCHE POSAY,pieza,70.0,3.0,1.0,410.56,,
CICAPLAST BAUME B5,producto,3337875816809,LA ROCHE POSAY,pieza,190.0,0.0,3.0,218.71,,
GENTLE CLEANSER CREAM,producto,3606000463981,SKIN CEUTICALS,pieza,60.0,0.0,3.0,538.45,,
EXOMEGA CONTROL CREMA,producto,3282770073577,A-DERMA,pieza,50.0,15.0,5.0,454.57,,
PIGMENTBIO H2O,producto,3701129800102,BIODERMA,pieza,30.0,0.0,3.0,300.68,,
DERMALIVE,producto,7501258212687,GENERAL,pieza,0.0,10.0,5.0,342.54,,
AMINOTER 30 CAPSULAS,producto,7508006182667,GENERAL,pieza,50.0,5.0,2.0,732.15,,
VASTIONIN,producto,7502002461153,GENERAL,pieza,1320.0,0.0,10.0,415.0,,
KENALOG 40,producto,370121104926,GENERAL,pieza,60.0,0.0,3.0,550.0,,
ZELOGLIN CREMA,producto,7503019846032,GENERAL,pieza,60.0,10.0,5.0,599.0,,
CICAPLAST LABIOS,producto,30106659,LA ROCHE POSAY,pieza,20.0,0.0,0.0,202.27,,
NIOGERMOX,producto,8429420050921,ISDIN,pieza,50.0,5.0,1.0,468.0,,
CLEANANCE COMEDOMED,producto,3282770202854,AVENE,pieza,50.0,5.0,3.0,563.81,,
UREADIN ULTRA 40,producto,8470001532411,ISDIN,pieza,90.0,5.0,3.0,284.89,,
NOURKRIN WOMAN,producto,7506334400088,NOURKRIN,pieza,40.0,7.0,3.0,1076.0,,
NOURKRIN MAN,producto,7506334400071,NOURKRIN,pieza,30.0,7.0,3.0,1076.0,,
NOURKRIN RADIANCE,producto,5707725100156,NOURKRIN,pieza,20.0,5.0,2.0,958.19,,
AOX EYE GEL,producto,635494348200,SKIN CEUTICALS,pieza,0.0,5.0,2.0,1279.7,,
PHYSICAL EYE UV DEFENSE,producto,3606000400504,SKIN CEUTICALS,pieza,0.0,0.0,2.0,576.95,,
PHYTO CORRECTIVE,producto,635494314205,SKIN CEUTICALS,pieza,40.0,7.0,5.0,1066.34,,
HYDRATING B5,producto,635494317206,SKIN CEUTICALS,pieza,20.0,0.0,1.0,1401.95,,
RETEXTURING ACTIVATOR,producto,635494327205,SKIN CEUTICALS,pieza,60.0,0.0,1.0,1374.45,,
REPLENISHING CLEANSER CREAM,producto,3606000464186,SKIN CEUTICALS,pieza,130.0,0.0,1.0,631.95,,
BLEMISH+AGE CLEANSER,producto,3606000471467,SKIN CEUTICALS,pieza,10.0,7.0,5.0,639.61,,
PHYSICAL FUSION UV DEFENSE,producto,3606000495432,SKIN CEUTICALS,pieza,120.0,5.0,3.0,648.45,,
BLEMISH AND AGE TONER,producto,3606000471382,SKIN CEUTICALS,pieza,40.0,0.0,0.0,604.45,,
PHYSICAL MATTE UV DEFENSE,producto,3606000419292,SKIN CEUTICALS,pieza,0.0,5.0,2.0,615.91,,
RETINOL 1.0,producto,3606000511040,SKIN CEUTICALS,pieza,80.0,0.0,1.0,1649.45,,
METACELL RENEWAL B3,producto,3606000495470,SKIN CEUTICALS,pieza,50.0,0.0,1.0,1814.45,,
SOOTHING CLEANSER FOAM,producto,3606000463622,SKIN CEUTICALS,pieza,40.0,0.0,2.0,686.95,,
PHLORETIN CF,producto,635494328202,SKIN CEUTICALS,pieza,30.0,8.0,5.0,2228.0,,
SERUM 10,producto,635494310207,SIN DEFINIR,pieza,40.0,0.0,2.0,1566.95,,
DISCOLORATION DEFENSE,producto,3606000481244,SKIN CEUTICALS,pieza,20.0,8.0,3.0,1704.45,,
PHLORETIN CF GEL,producto,635494347203,SKIN CEUTICALS,pieza,40.0,0.0,0.0,2584.45,,
A.G.E ADVANCED EYE,producto,3606000604643,SKIN CEUTICALS,pieza,0.0,1.0,1.0,1417.2,,
ADVANCED SCAR CONTROL,producto,3606000557079,SKIN CEUTICALS,pieza,30.0,0.0,5.0,1759.45,,
BLEMISH + AGE DEFENSE,producto,635494391206,SKIN CEUTICALS,pieza,120.0,0.0,0.0,1649.45,,
ADVANCED BRIGHTENING UV DEFENSE,producto,3337875702478,SKIN CEUTICALS,pieza,30.0,0.0,5.0,714.45,,
RESVERATROL B E,producto,3606000475380,SKIN CEUTICALS,pieza,30.0,0.0,3.0,2584.45,,
SILYMARIN CF,producto,3337875746267,SKIN CEUTICALS,pieza,30.0,2.0,1.0,2228.0,,
PHYTO CORRECTIVE MASQUE,producto,3606000436725,SKIN CEUTICALS,pieza,40.0,0.0,3.0,1429.45,,
REDNESS NEUTRALIZER,producto,3606000495395,SKIN CEUTICALS,pieza,10.0,0.0,3.0,1335.95,,
GLYCOLIC RENEWAL CLEANSER GEL,producto,3606000481121,SKIN CEUTICALS,pieza,50.0,0.0,5.0,851.95,,
CLARIFYING CLAY MASQUE,producto,635494330205,SKIN CEUTICALS,pieza,30.0,1.0,1.0,867.2,,
C E FERULIC,producto,635494363210,SKIN CEUTICALS,pieza,60.0,0.0,3.0,2228.0,,
DAILY MOISTURE,producto,3606000482111,SKIN CEUTICALS,pieza,40.0,0.0,3.0,1115.95,,
CLEAR SHAMPOO,producto,3372290121121,LAZARTIGUE,pieza,10.0,0.0,0.0,518.0,,
CLEAR ANTI DANDRUFF,producto,3372290140320,LAZARTIGUE,pieza,30.0,0.0,0.0,557.0,,
PURIFY EXTRA SHAMPOO,producto,3372290124122,LAZARTIGUE,pieza,10.0,0.0,0.0,517.0,,
PURIFY SHAMPOO,producto,3372290124528,LAZARTIGUE,pieza,30.0,0.0,0.0,518.0,,
EXTRA GENTLE SHAMPOO,producto,3372290125020,LAZARTIGUE,pieza,0.0,0.0,0.0,447.0,,
COLOUR PROTECT MASK,producto,3372290111528,LAZARTIGUE,pieza,0.0,0.0,0.0,880.0,,
STRONGER HAIR SERUM,producto,3372290143024,LAZARTIGUE,pieza,10.0,0.0,0.0,978.0,,
EVEN UP,producto,813419020008,COLORSCIENCE,pieza,10.0,0.0,2.0,0.0,,
LERA-CO,producto,7508006184500,GENERAL,pieza,20.0,7.0,3.0,897.06,,
ANTHELIOS Toque seco con color FPS50+ para piel grasa 50 ml,producto,3337875545891,LA ROCHE POSAY,pieza,30.0,0.0,5.0,383.15,,
MESOPROTECH,producto,MESO,GENERAL,pieza,10.0,0.0,0.0,975.0,,
MINOXIDIL 1MG 30 CAPS DERMICO,producto,7506606800059,GENERAL,pieza,10.0,0.0,5.0,280.0,,
MINOXIDIL 2.5MG 30 CAPS DERMICO,producto,750660680042,GENERAL,pieza,90.0,0.0,5.0,370.0,,
PHOTODERM COVER TOUCH CLARO,producto,3701129803424,BIODERMA,pieza,10.0,0.0,5.0,409.0,,
PURESKIN,producto,8424561009678,GENERAL,pieza,130.0,0.0,5.0,353.19,,
D-CORRECTIVE,producto,8424561009647,GENERAL,pieza,80.0,10.0,5.0,688.5,,
MELAN TXA NIGHT GEL CREAM,producto,8424561008961,HD,pieza,150.0,5.0,2.0,763.27,,
COSMELAN 2 FACIAL CREAM,producto,cosmelanfacial,GENERAL,pieza,20.0,5.0,2.0,4080.0,,
MELAN RECOVERY,producto,MELAN,GENERAL,pieza,20.0,0.0,2.0,0.0,,
NOX CREMA DIA,producto,8424561008053,HD,pieza,0.0,0.0,3.0,1359.12,,
CUTERAL,producto,7508006182490,GENERAL,pieza,40.0,10.0,5.0,345.65,,
HYDRA SHAVE,producto,8424561009609,GENERAL,pieza,40.0,5.0,2.0,396.83,,
TRANEX PLUS serum,producto,TP,SKEEN,pieza,130.0,20.0,8.0,360.0,,
DSPOT H,producto,dh,SKEEN,pieza,30.0,10.0,5.0,203.0,,
DSPOT-3,producto,dsc,SKEEN,pieza,70.0,5.0,2.0,181.034,,
SENSIBIO H2O 250 ml,producto,3401575390447,BIODERMA,pieza,50.0,7.0,3.0,315.0,,
HAIR SKEEN SOLUCION,producto,HS,SKEEN,pieza,70.0,10.0,5.0,269.0,,
ACNEE PEEL SPRAY,producto,AP,SIN DEFINIR,pieza,30.0,10.0,5.0,185.0,,
HAIR SKEEN FORTE,producto,HS FORTE,SKEEN,pieza,50.0,10.0,5.0,350.0,,
GLICOSKEEN,producto,GLK01,SIN DEFINIR,pieza,50.0,10.0,5.0,275.0,,
DUO CLEAN SCRUB,producto,SCR01,SIN DEFINIR,pieza,120.0,13.0,7.0,220.0,,
AOX CLEANSER,producto,AOX01,SIN DEFINIR,pieza,80.0,10.0,5.0,172.41,,
ALASTIN inhance post-injection serum,producto,851144006270,GENERAL,pieza,120.0,3.0,1.0,569.16,,
ANTHELIOS GEL CREMA ANTI BRILLO SIN COLOR,producto,3337875546409,LA ROCHE POSAY,pieza,70.0,5.0,2.0,359.04,,
RETICLIN GEL,producto,RETI,SKEEN,pieza,50.0,10.0,5.0,240.0,,
ADARRETIN P GEL,producto,ADP01,SKEEN,pieza,40.0,15.0,7.0,350.0,,
TACROSKEEN,producto,TACRO,GENERAL,pieza,90.0,20.0,8.0,440.0,,
SMOOTH OUT POWER,producto,pbs,GENERAL,pieza,10.0,0.0,2.0,0.0,,
LIFTING POWER,producto,PBSS,GENERAL,pieza,10.0,0.0,3.0,0.0,,
transformational INFUSION,producto,8436533670090,ICON,pieza,10.0,0.0,1.0,461.0,,
MESH,producto,8436533670144,ICON,pieza,10.0,0.0,0.0,350.0,,
BIOLOGY AC HYDRA,producto,3282770388855,GENERAL,pieza,80.0,0.0,5.0,399.94,,
ANTHELIOS UV MUNE OIL CONTROL 50+sin color 50ml,producto,3337875847292,LA ROCHE POSAY,pieza,70.0,0.0,5.0,410.56,,
ANTHELIOS UV MUNE 400 50+ OIL CONTROL CON COLOR 50ml,producto,3337875847087,LA ROCHE POSAY,pieza,100.0,0.0,5.0,410.56,,
FREE ACONDICIONADOR HIDRA ICON,producto,8436533670076,SIN DEFINIR,pieza,20.0,0.0,2.0,329.0,,
Alastin restorative neck complex,producto,851144006966,SIN DEFINIR,pieza,60.0,4.0,2.0,1230.0,,
SHIFT TREATMENT,producto,8436533670106,ICON,pieza,10.0,0.0,0.0,284.0,,
MINOXIDIL 5 MG Y DUTASTERIDA 0.5 MG,producto,7502247195226,SIN DEFINIR,pieza,150.0,0.0,0.0,720.0,,
RETINIC SOFT GEL,producto,RET,SIN DEFINIR,pieza,30.0,15.0,5.0,180.0,,
MINOXIDIL DERMICO 5 MG,producto,7502247153790,SIN DEFINIR,pieza,130.0,0.0,0.0,450.0,,
MINOXIDIL 2.5 MG + DUTASTERIDA 0.5 MG,producto,7502247195219,SIN DEFINIR,pieza,60.0,0.0,0.0,670.0,,
TERBICREAM,producto,TERBI,SIN DEFINIR,pieza,70.0,15.0,8.0,150.0,,
ROLLON DUCRAY HIDROSIS CONTROL,producto,ROLLON DUCRAY,SIN DEFINIR,pieza,110.0,5.0,1.0,300.41,,
FUCIDIN UNGUENTO,producto,5702191029291,SIN DEFINIR,pieza,70.0,20.0,10.0,285.02,,
anthelios leche,producto,anthelios leche hidratante,LA ROCHE POSAY,pieza,20.0,5.0,3.0,507.04,,
dermatolol tx,producto,dermatolol tx,SIN DEFINIR,pieza,20.0,10.0,5.0,400.0,,
DSPOT SH,producto,DSPOT SH,SIN DEFINIR,pieza,50.0,10.0,5.0,203.0,,
EXOMEGA ACEITE DE BAÑO 500 ML,producto,3282770393859,A-DERMA,pieza,20.0,15.0,5.0,387.81,,
DERCOS SHAMPOO,producto,3337875574358,SIN DEFINIR,pieza,10.0,0.0,0.0,400.58,,
DERMALIBOUR,producto,3282771057392,A-DERMA,pieza,20.0,10.0,5.0,218.18,,
METROGEL,producto,3499320001168,SIN DEFINIR,pieza,20.0,0.0,0.0,610.0,,
MELAB3 SERUM,producto,3337875890021,LA ROCHE POSAY,pieza,60.0,7.0,3.0,766.86,,
ROSAE PROTECTIVE EMULSION,producto,8424561008473,SIN DEFINIR,pieza,110.0,10.0,5.0,420.0,,
GLUTAMEX SUN STICK,producto,8809875902622,SIN DEFINIR,pieza,10.0,0.0,0.0,660.0,,
ANTHELIOS FPS50+ UV MUN 400 CREMA HIDRATANTE 50 ML.,producto,3337875797719,LA ROCHE POSAY,pieza,10.0,0.0,0.0,348.32,,
PROTECTIVE LIP FIG,producto,856901008627,SIN DEFINIR,pieza,70.0,0.0,0.0,430.0,,
BOB KIDS,producto,856901008764,SIN DEFINIR,pieza,50.0,2.0,1.0,377.99,,
PADS,producto,PADS,SIN DEFINIR,pieza,170.0,0.0,0.0,70.0,,
TEEN DERM ALPHA PURE,producto,3760269771314,SIN DEFINIR,pieza,70.0,0.0,0.0,432.64,,
LACTOKEY,producto,53131613,SIN DEFINIR,pieza,50.0,0.0,0.0,813.38,,
SENSILIS 10D RECOVERY KIT,producto,8428749927907,DERMOFARM,pieza,10.0,5.0,2.0,824.0,,
ACIDO TRANEXAMICO 250MG 60 CAPSULAS,producto,7506606800318,GENERAL,pieza,30.0,20.0,10.0,570.0,,
CALMING CREAM,producto,51241200,SKEEN,pieza,-70.0,0.0,0.0,200.0,,
CETAPHIL OPTIMAL HYDRATION,producto,3499320011655,SIN DEFINIR,pieza,10.0,0.0,0.0,308.16,,
MINERAL 89 CREMA BOOST,producto,3337875831888,SIN DEFINIR,pieza,70.0,0.0,0.0,412.67,,
SERUM ISOBIOTIC,producto,3770009858083,MEDICEUTICS,pieza,90.0,5.0,2.0,1358.0,,
ANTHELIOS UVAIR,producto,3337875917810,LA ROCHE POSAY,pieza,30.0,0.0,0.0,300.94,,
P-TIOX,producto,3337875898485,SKIN CEUTICALS,pieza,30.0,0.0,0.0,2227.97,,
GLISODIN,producto,3760084965011,ISOCELL,pieza,70.0,0.0,0.0,1374.0,,
INTENSIVE HYALURONIC CREME,producto,3461020003001,ESTHEDERM,pieza,20.0,0.0,0.0,1846.0,,
NEOSKIN STIAE,producto,8437026968137,EPIOLOGY,pieza,110.0,0.0,0.0,1698.4,,
TRIPEPTIDE T NECK REÁIR,producto,3606000555075,SKINCEUTICALS,pieza,10.0,0.0,0.0,1842.0,,
HIDROSIS CONTROL ROLL ON,producto,3282770108453,DUCRAY,pieza,100.0,0.0,0.0,315.0,,
YOUTH CREAM,producto,YOUTH CREAM,SKEEN,pieza,70.0,0.0,0.0,300.0,,
CALMING TONER,producto,CALMING TONER,SKEEN,pieza,80.0,0.0,0.0,170.0,,
HYDROCLEAR CREAM,producto,HYDROCLEAR CREAM,SKEEN,pieza,50.0,0.0,0.0,190.0,,
DERLUMA-G,producto,7501258215909,ADVAITA,pieza,30.0,0.0,0.0,0.0,,
DERLUMA UNGUENTO,producto,7501258215893,ADVAITA,pieza,0.0,0.0,0.0,0.0,,
ANTI-AGING LIP CARE,producto,3770009858076,MEDICEUTICS,pieza,10.0,0.0,0.0,431.9,,
HELIOCARE 360,producto,HELIOCARE 360,SIN DEFINIR,pieza,0.0,0.0,0.0,703.0,,
ULTRA FLUID OIL CONTROL INVISIBLE,producto,3282770400410,AVENE,pieza,100.0,0.0,0.0,425.0,,
EXOMEGA CONTROL DERMOLIMPIADOR EN ACEITE EMOLIENTE,producto,3282770393842,AVENE,pieza,0.0,0.0,0.0,252.0,,
EUCERIN ANTI PIGMENT DUAL SERUM,producto,7319470066786,EUCERIN,pieza,130.0,0.0,0.0,824.0,,
ULTRA FLUID OIL CONTROL TONO MEDIO,producto,3282770400441,AVENE,pieza,120.0,0.0,0.0,424.22,,
INTENSIVE PRO-COLLAGEN,producto,3461020003490,ESTHEDERM,pieza,10.0,0.0,0.0,1846.0,,
IRON PLUS,producto,IRON,NUTRIADN,pieza,50.0,0.0,0.0,594.82,,
STRESS SOPPORT,producto,STRESS SUPPORT,NUTRIADN,pieza,50.0,0.0,0.0,594.82,,
MAGNESIUM SUPPORT,producto,MAGNESIUM SUPPORT,NUTRIADN,pieza,50.0,0.0,0.0,500.0,,
DIGESTIVE ENZYMES,producto,DIGESTIVE ENZYMES,NUTRIADN,pieza,50.0,0.0,0.0,637.93,,
METABOLIC SUPPORT,producto,METABOLIC SUPPORT,NUTRIADN,pieza,50.0,0.0,0.0,594.82,,
INOSITOL,producto,INOSITOL,NUTRIADN,pieza,20.0,0.0,0.0,853.44,,
AKKERMANSIA,producto,AKKERMANSIA,NUTRIADN,pieza,50.0,0.0,0.0,724.13,,
OMEGA 3,producto,OMEGA 3,NUTRIADN,pieza,80.0,0.0,0.0,594.82,,
EFFACLAR H ISO BIOME,producto,3337875777797,LA ROCHE POSAY,pieza,120.0,0.0,0.0,410.56,,
1 name kind sku category unit qty qty_optimal qty_min cost expiry_date notes
2 EPIOLOGY CREMA 28g producto 799439039070 EPIOLOGY pieza 0.0 30.0 15.0 476.0
3 EPIOLOGY CLEANSER 110 ml producto 799439039063 EPIOLOGY pieza 50.0 30.0 15.0 359.0
4 EPIOLOGY SPOT 10 g producto 797776088935 EPIOLOGY pieza 180.0 0.0 5.0 476.0
5 PASE MEDICO producto PASE GENERAL pieza 440.0 0.0 0.0 420.0
6 KEROSEB EMULSION producto 8424561008374 HD pieza 70.0 6.0 3.0 469.68
7 KEROSEB SHAMPOO producto 8424561008329 HD pieza -30.0 15.0 5.0 398.18
8 BLUMOIST producto 8424561008152 HD pieza -10.0 25.0 20.0 930.0
9 NOX 3C SERUM producto 8424561008046 HD pieza 130.0 7.0 3.0 917.0
10 IVERMECTINA 1% producto 7502247140318 DERMICO pieza 40.0 10.0 5.0 281.0
11 INTENSIVE HYALURONIC MASQUE producto 3461020014038 ESTHEDERM pieza 40.0 0.0 0.0 1301.0
12 INTENSIVE AHA PEEL SERUM CONCENTRE producto 3461020014137 ESTHEDERM pieza 20.0 2.0 1.0 1194.828
13 OSMOCLEAN DESINCRUSTANTE producto 3461020013550 ESTHEDERM pieza 50.0 0.0 2.0 579.0
14 INTENSIVE PROPOLIS + ZINC producto 3461023492185 ESTHEDERM pieza 50.0 1.0 1.0 1050.0
15 INTENSIVE AHA PEEL GENTLE SERUM producto 3461020014144 ESTHEDERM pieza 90.0 4.0 2.0 1259.0
16 INTENSIVE HYALURONIC SERUM producto 3461020014014 ESTHEDERM pieza 150.0 4.0 1.0 995.32
17 INTENSIVE RETINOL CREME producto 3461020003438 ESTHEDERM pieza 100.0 7.0 3.0 1846.0
18 INTENSIVE HYALURONIC EYE SERUM producto 3461020003025 ESTHEDERM pieza 120.0 0.0 3.0 1267.0
19 BRUME 200 ML producto 3461022003054 ESTHEDERM pieza 50.0 0.0 5.0 504.78
20 INTENSIVE PROPOLIS+SALICYLIC ACID producto 3461023492161 ESTHEDERM pieza 60.0 0.0 0.0 1376.0
21 ANTHELIOS UVMUNE 50+ FLUID INVISIBLE ULTRA LONG 50ML producto 3337875797597 LA ROCHE POSAY pieza 90.0 5.0 3.0 410.56
22 ANTHELIOS UVMUNE 50+COLOR FLUID 50ml producto 3337875797641 LA ROCHE POSAY pieza 70.0 3.0 1.0 410.56
23 CICAPLAST BAUME B5 producto 3337875816809 LA ROCHE POSAY pieza 190.0 0.0 3.0 218.71
24 GENTLE CLEANSER CREAM producto 3606000463981 SKIN CEUTICALS pieza 60.0 0.0 3.0 538.45
25 EXOMEGA CONTROL CREMA producto 3282770073577 A-DERMA pieza 50.0 15.0 5.0 454.57
26 PIGMENTBIO H2O producto 3701129800102 BIODERMA pieza 30.0 0.0 3.0 300.68
27 DERMALIVE producto 7501258212687 GENERAL pieza 0.0 10.0 5.0 342.54
28 AMINOTER 30 CAPSULAS producto 7508006182667 GENERAL pieza 50.0 5.0 2.0 732.15
29 VASTIONIN producto 7502002461153 GENERAL pieza 1320.0 0.0 10.0 415.0
30 KENALOG 40 producto 370121104926 GENERAL pieza 60.0 0.0 3.0 550.0
31 ZELOGLIN CREMA producto 7503019846032 GENERAL pieza 60.0 10.0 5.0 599.0
32 CICAPLAST LABIOS producto 30106659 LA ROCHE POSAY pieza 20.0 0.0 0.0 202.27
33 NIOGERMOX producto 8429420050921 ISDIN pieza 50.0 5.0 1.0 468.0
34 CLEANANCE COMEDOMED producto 3282770202854 AVENE pieza 50.0 5.0 3.0 563.81
35 UREADIN ULTRA 40 producto 8470001532411 ISDIN pieza 90.0 5.0 3.0 284.89
36 NOURKRIN WOMAN producto 7506334400088 NOURKRIN pieza 40.0 7.0 3.0 1076.0
37 NOURKRIN MAN producto 7506334400071 NOURKRIN pieza 30.0 7.0 3.0 1076.0
38 NOURKRIN RADIANCE producto 5707725100156 NOURKRIN pieza 20.0 5.0 2.0 958.19
39 AOX EYE GEL producto 635494348200 SKIN CEUTICALS pieza 0.0 5.0 2.0 1279.7
40 PHYSICAL EYE UV DEFENSE producto 3606000400504 SKIN CEUTICALS pieza 0.0 0.0 2.0 576.95
41 PHYTO CORRECTIVE producto 635494314205 SKIN CEUTICALS pieza 40.0 7.0 5.0 1066.34
42 HYDRATING B5 producto 635494317206 SKIN CEUTICALS pieza 20.0 0.0 1.0 1401.95
43 RETEXTURING ACTIVATOR producto 635494327205 SKIN CEUTICALS pieza 60.0 0.0 1.0 1374.45
44 REPLENISHING CLEANSER CREAM producto 3606000464186 SKIN CEUTICALS pieza 130.0 0.0 1.0 631.95
45 BLEMISH+AGE CLEANSER producto 3606000471467 SKIN CEUTICALS pieza 10.0 7.0 5.0 639.61
46 PHYSICAL FUSION UV DEFENSE producto 3606000495432 SKIN CEUTICALS pieza 120.0 5.0 3.0 648.45
47 BLEMISH AND AGE TONER producto 3606000471382 SKIN CEUTICALS pieza 40.0 0.0 0.0 604.45
48 PHYSICAL MATTE UV DEFENSE producto 3606000419292 SKIN CEUTICALS pieza 0.0 5.0 2.0 615.91
49 RETINOL 1.0 producto 3606000511040 SKIN CEUTICALS pieza 80.0 0.0 1.0 1649.45
50 METACELL RENEWAL B3 producto 3606000495470 SKIN CEUTICALS pieza 50.0 0.0 1.0 1814.45
51 SOOTHING CLEANSER FOAM producto 3606000463622 SKIN CEUTICALS pieza 40.0 0.0 2.0 686.95
52 PHLORETIN CF producto 635494328202 SKIN CEUTICALS pieza 30.0 8.0 5.0 2228.0
53 SERUM 10 producto 635494310207 SIN DEFINIR pieza 40.0 0.0 2.0 1566.95
54 DISCOLORATION DEFENSE producto 3606000481244 SKIN CEUTICALS pieza 20.0 8.0 3.0 1704.45
55 PHLORETIN CF GEL producto 635494347203 SKIN CEUTICALS pieza 40.0 0.0 0.0 2584.45
56 A.G.E ADVANCED EYE producto 3606000604643 SKIN CEUTICALS pieza 0.0 1.0 1.0 1417.2
57 ADVANCED SCAR CONTROL producto 3606000557079 SKIN CEUTICALS pieza 30.0 0.0 5.0 1759.45
58 BLEMISH + AGE DEFENSE producto 635494391206 SKIN CEUTICALS pieza 120.0 0.0 0.0 1649.45
59 ADVANCED BRIGHTENING UV DEFENSE producto 3337875702478 SKIN CEUTICALS pieza 30.0 0.0 5.0 714.45
60 RESVERATROL B E producto 3606000475380 SKIN CEUTICALS pieza 30.0 0.0 3.0 2584.45
61 SILYMARIN CF producto 3337875746267 SKIN CEUTICALS pieza 30.0 2.0 1.0 2228.0
62 PHYTO CORRECTIVE MASQUE producto 3606000436725 SKIN CEUTICALS pieza 40.0 0.0 3.0 1429.45
63 REDNESS NEUTRALIZER producto 3606000495395 SKIN CEUTICALS pieza 10.0 0.0 3.0 1335.95
64 GLYCOLIC RENEWAL CLEANSER GEL producto 3606000481121 SKIN CEUTICALS pieza 50.0 0.0 5.0 851.95
65 CLARIFYING CLAY MASQUE producto 635494330205 SKIN CEUTICALS pieza 30.0 1.0 1.0 867.2
66 C E FERULIC producto 635494363210 SKIN CEUTICALS pieza 60.0 0.0 3.0 2228.0
67 DAILY MOISTURE producto 3606000482111 SKIN CEUTICALS pieza 40.0 0.0 3.0 1115.95
68 CLEAR SHAMPOO producto 3372290121121 LAZARTIGUE pieza 10.0 0.0 0.0 518.0
69 CLEAR ANTI DANDRUFF producto 3372290140320 LAZARTIGUE pieza 30.0 0.0 0.0 557.0
70 PURIFY EXTRA SHAMPOO producto 3372290124122 LAZARTIGUE pieza 10.0 0.0 0.0 517.0
71 PURIFY SHAMPOO producto 3372290124528 LAZARTIGUE pieza 30.0 0.0 0.0 518.0
72 EXTRA GENTLE SHAMPOO producto 3372290125020 LAZARTIGUE pieza 0.0 0.0 0.0 447.0
73 COLOUR PROTECT MASK producto 3372290111528 LAZARTIGUE pieza 0.0 0.0 0.0 880.0
74 STRONGER HAIR SERUM producto 3372290143024 LAZARTIGUE pieza 10.0 0.0 0.0 978.0
75 EVEN UP producto 813419020008 COLORSCIENCE pieza 10.0 0.0 2.0 0.0
76 LERA-CO producto 7508006184500 GENERAL pieza 20.0 7.0 3.0 897.06
77 ANTHELIOS Toque seco con color FPS50+ para piel grasa 50 ml producto 3337875545891 LA ROCHE POSAY pieza 30.0 0.0 5.0 383.15
78 MESOPROTECH producto MESO GENERAL pieza 10.0 0.0 0.0 975.0
79 MINOXIDIL 1MG 30 CAPS DERMICO producto 7506606800059 GENERAL pieza 10.0 0.0 5.0 280.0
80 MINOXIDIL 2.5MG 30 CAPS DERMICO producto 750660680042 GENERAL pieza 90.0 0.0 5.0 370.0
81 PHOTODERM COVER TOUCH CLARO producto 3701129803424 BIODERMA pieza 10.0 0.0 5.0 409.0
82 PURESKIN producto 8424561009678 GENERAL pieza 130.0 0.0 5.0 353.19
83 D-CORRECTIVE producto 8424561009647 GENERAL pieza 80.0 10.0 5.0 688.5
84 MELAN TXA NIGHT GEL CREAM producto 8424561008961 HD pieza 150.0 5.0 2.0 763.27
85 COSMELAN 2 FACIAL CREAM producto cosmelanfacial GENERAL pieza 20.0 5.0 2.0 4080.0
86 MELAN RECOVERY producto MELAN GENERAL pieza 20.0 0.0 2.0 0.0
87 NOX CREMA DIA producto 8424561008053 HD pieza 0.0 0.0 3.0 1359.12
88 CUTERAL producto 7508006182490 GENERAL pieza 40.0 10.0 5.0 345.65
89 HYDRA SHAVE producto 8424561009609 GENERAL pieza 40.0 5.0 2.0 396.83
90 TRANEX PLUS serum producto TP SKEEN pieza 130.0 20.0 8.0 360.0
91 DSPOT H producto dh SKEEN pieza 30.0 10.0 5.0 203.0
92 DSPOT-3 producto dsc SKEEN pieza 70.0 5.0 2.0 181.034
93 SENSIBIO H2O 250 ml producto 3401575390447 BIODERMA pieza 50.0 7.0 3.0 315.0
94 HAIR SKEEN SOLUCION producto HS SKEEN pieza 70.0 10.0 5.0 269.0
95 ACNEE PEEL SPRAY producto AP SIN DEFINIR pieza 30.0 10.0 5.0 185.0
96 HAIR SKEEN FORTE producto HS FORTE SKEEN pieza 50.0 10.0 5.0 350.0
97 GLICOSKEEN producto GLK01 SIN DEFINIR pieza 50.0 10.0 5.0 275.0
98 DUO CLEAN SCRUB producto SCR01 SIN DEFINIR pieza 120.0 13.0 7.0 220.0
99 AOX CLEANSER producto AOX01 SIN DEFINIR pieza 80.0 10.0 5.0 172.41
100 ALASTIN inhance post-injection serum producto 851144006270 GENERAL pieza 120.0 3.0 1.0 569.16
101 ANTHELIOS GEL CREMA ANTI BRILLO SIN COLOR producto 3337875546409 LA ROCHE POSAY pieza 70.0 5.0 2.0 359.04
102 RETICLIN GEL producto RETI SKEEN pieza 50.0 10.0 5.0 240.0
103 ADARRETIN P GEL producto ADP01 SKEEN pieza 40.0 15.0 7.0 350.0
104 TACROSKEEN producto TACRO GENERAL pieza 90.0 20.0 8.0 440.0
105 SMOOTH OUT POWER producto pbs GENERAL pieza 10.0 0.0 2.0 0.0
106 LIFTING POWER producto PBSS GENERAL pieza 10.0 0.0 3.0 0.0
107 transformational INFUSION producto 8436533670090 ICON pieza 10.0 0.0 1.0 461.0
108 MESH producto 8436533670144 ICON pieza 10.0 0.0 0.0 350.0
109 BIOLOGY AC HYDRA producto 3282770388855 GENERAL pieza 80.0 0.0 5.0 399.94
110 ANTHELIOS UV MUNE OIL CONTROL 50+sin color 50ml producto 3337875847292 LA ROCHE POSAY pieza 70.0 0.0 5.0 410.56
111 ANTHELIOS UV MUNE 400 50+ OIL CONTROL CON COLOR 50ml producto 3337875847087 LA ROCHE POSAY pieza 100.0 0.0 5.0 410.56
112 FREE ACONDICIONADOR HIDRA ICON producto 8436533670076 SIN DEFINIR pieza 20.0 0.0 2.0 329.0
113 Alastin restorative neck complex producto 851144006966 SIN DEFINIR pieza 60.0 4.0 2.0 1230.0
114 SHIFT TREATMENT producto 8436533670106 ICON pieza 10.0 0.0 0.0 284.0
115 MINOXIDIL 5 MG Y DUTASTERIDA 0.5 MG producto 7502247195226 SIN DEFINIR pieza 150.0 0.0 0.0 720.0
116 RETINIC SOFT GEL producto RET SIN DEFINIR pieza 30.0 15.0 5.0 180.0
117 MINOXIDIL DERMICO 5 MG producto 7502247153790 SIN DEFINIR pieza 130.0 0.0 0.0 450.0
118 MINOXIDIL 2.5 MG + DUTASTERIDA 0.5 MG producto 7502247195219 SIN DEFINIR pieza 60.0 0.0 0.0 670.0
119 TERBICREAM producto TERBI SIN DEFINIR pieza 70.0 15.0 8.0 150.0
120 ROLLON DUCRAY HIDROSIS CONTROL producto ROLLON DUCRAY SIN DEFINIR pieza 110.0 5.0 1.0 300.41
121 FUCIDIN UNGUENTO producto 5702191029291 SIN DEFINIR pieza 70.0 20.0 10.0 285.02
122 anthelios leche producto anthelios leche hidratante LA ROCHE POSAY pieza 20.0 5.0 3.0 507.04
123 dermatolol tx producto dermatolol tx SIN DEFINIR pieza 20.0 10.0 5.0 400.0
124 DSPOT SH producto DSPOT SH SIN DEFINIR pieza 50.0 10.0 5.0 203.0
125 EXOMEGA ACEITE DE BAÑO 500 ML producto 3282770393859 A-DERMA pieza 20.0 15.0 5.0 387.81
126 DERCOS SHAMPOO producto 3337875574358 SIN DEFINIR pieza 10.0 0.0 0.0 400.58
127 DERMALIBOUR producto 3282771057392 A-DERMA pieza 20.0 10.0 5.0 218.18
128 METROGEL producto 3499320001168 SIN DEFINIR pieza 20.0 0.0 0.0 610.0
129 MELAB3 SERUM producto 3337875890021 LA ROCHE POSAY pieza 60.0 7.0 3.0 766.86
130 ROSAE PROTECTIVE EMULSION producto 8424561008473 SIN DEFINIR pieza 110.0 10.0 5.0 420.0
131 GLUTAMEX SUN STICK producto 8809875902622 SIN DEFINIR pieza 10.0 0.0 0.0 660.0
132 ANTHELIOS FPS50+ UV MUN 400 CREMA HIDRATANTE 50 ML. producto 3337875797719 LA ROCHE POSAY pieza 10.0 0.0 0.0 348.32
133 PROTECTIVE LIP FIG producto 856901008627 SIN DEFINIR pieza 70.0 0.0 0.0 430.0
134 BOB KIDS producto 856901008764 SIN DEFINIR pieza 50.0 2.0 1.0 377.99
135 PADS producto PADS SIN DEFINIR pieza 170.0 0.0 0.0 70.0
136 TEEN DERM ALPHA PURE producto 3760269771314 SIN DEFINIR pieza 70.0 0.0 0.0 432.64
137 LACTOKEY producto 53131613 SIN DEFINIR pieza 50.0 0.0 0.0 813.38
138 SENSILIS 10D RECOVERY KIT producto 8428749927907 DERMOFARM pieza 10.0 5.0 2.0 824.0
139 ACIDO TRANEXAMICO 250MG 60 CAPSULAS producto 7506606800318 GENERAL pieza 30.0 20.0 10.0 570.0
140 CALMING CREAM producto 51241200 SKEEN pieza -70.0 0.0 0.0 200.0
141 CETAPHIL OPTIMAL HYDRATION producto 3499320011655 SIN DEFINIR pieza 10.0 0.0 0.0 308.16
142 MINERAL 89 CREMA BOOST producto 3337875831888 SIN DEFINIR pieza 70.0 0.0 0.0 412.67
143 SERUM ISOBIOTIC producto 3770009858083 MEDICEUTICS pieza 90.0 5.0 2.0 1358.0
144 ANTHELIOS UVAIR producto 3337875917810 LA ROCHE POSAY pieza 30.0 0.0 0.0 300.94
145 P-TIOX producto 3337875898485 SKIN CEUTICALS pieza 30.0 0.0 0.0 2227.97
146 GLISODIN producto 3760084965011 ISOCELL pieza 70.0 0.0 0.0 1374.0
147 INTENSIVE HYALURONIC CREME producto 3461020003001 ESTHEDERM pieza 20.0 0.0 0.0 1846.0
148 NEOSKIN STIAE producto 8437026968137 EPIOLOGY pieza 110.0 0.0 0.0 1698.4
149 TRIPEPTIDE T NECK REÁIR producto 3606000555075 SKINCEUTICALS pieza 10.0 0.0 0.0 1842.0
150 HIDROSIS CONTROL ROLL ON producto 3282770108453 DUCRAY pieza 100.0 0.0 0.0 315.0
151 YOUTH CREAM producto YOUTH CREAM SKEEN pieza 70.0 0.0 0.0 300.0
152 CALMING TONER producto CALMING TONER SKEEN pieza 80.0 0.0 0.0 170.0
153 HYDROCLEAR CREAM producto HYDROCLEAR CREAM SKEEN pieza 50.0 0.0 0.0 190.0
154 DERLUMA-G producto 7501258215909 ADVAITA pieza 30.0 0.0 0.0 0.0
155 DERLUMA UNGUENTO producto 7501258215893 ADVAITA pieza 0.0 0.0 0.0 0.0
156 ANTI-AGING LIP CARE producto 3770009858076 MEDICEUTICS pieza 10.0 0.0 0.0 431.9
157 HELIOCARE 360 producto HELIOCARE 360 SIN DEFINIR pieza 0.0 0.0 0.0 703.0
158 ULTRA FLUID OIL CONTROL INVISIBLE producto 3282770400410 AVENE pieza 100.0 0.0 0.0 425.0
159 EXOMEGA CONTROL DERMOLIMPIADOR EN ACEITE EMOLIENTE producto 3282770393842 AVENE pieza 0.0 0.0 0.0 252.0
160 EUCERIN ANTI PIGMENT DUAL SERUM producto 7319470066786 EUCERIN pieza 130.0 0.0 0.0 824.0
161 ULTRA FLUID OIL CONTROL TONO MEDIO producto 3282770400441 AVENE pieza 120.0 0.0 0.0 424.22
162 INTENSIVE PRO-COLLAGEN producto 3461020003490 ESTHEDERM pieza 10.0 0.0 0.0 1846.0
163 IRON PLUS producto IRON NUTRIADN pieza 50.0 0.0 0.0 594.82
164 STRESS SOPPORT producto STRESS SUPPORT NUTRIADN pieza 50.0 0.0 0.0 594.82
165 MAGNESIUM SUPPORT producto MAGNESIUM SUPPORT NUTRIADN pieza 50.0 0.0 0.0 500.0
166 DIGESTIVE ENZYMES producto DIGESTIVE ENZYMES NUTRIADN pieza 50.0 0.0 0.0 637.93
167 METABOLIC SUPPORT producto METABOLIC SUPPORT NUTRIADN pieza 50.0 0.0 0.0 594.82
168 INOSITOL producto INOSITOL NUTRIADN pieza 20.0 0.0 0.0 853.44
169 AKKERMANSIA producto AKKERMANSIA NUTRIADN pieza 50.0 0.0 0.0 724.13
170 OMEGA 3 producto OMEGA 3 NUTRIADN pieza 80.0 0.0 0.0 594.82
171 EFFACLAR H ISO BIOME producto 3337875777797 LA ROCHE POSAY pieza 120.0 0.0 0.0 410.56

View File

@@ -0,0 +1,864 @@
telefono,tipo,similitud_nombre,conservar,id,nombre,email,fecha_nac,visitas,ultima_visita,legacy_id,creado
11,duplicado_probable,0.82,SI,45065,Yaqubi Ayan,MSARWAR.AFG@YAHOO.COM,2022-01-06,44,2026-05-05,6034,2026-07-09 10:13:19
11,duplicado_probable,0.82,,45066,Yaqubi Ahax,MSARWAR.AFG@YAHOO.COM,2019-09-11,0,,6035,2026-07-09 10:13:19
11,duplicado_probable,0.82,,45067,Wakil Farzana,,1989-03-13,0,,6036,2026-07-09 10:13:19
15618095768,telefono_compartido,0.19,,41403,Nusbaum Jennifer,,1984-02-05,0,,1850,2026-07-09 10:12:38
15618095768,telefono_compartido,0.19,SI,41404,Robinson Andrew,DREWLAIRDROBINSON3@GMAIL.COM,1975-06-12,1,2023-05-10,1851,2026-07-09 10:12:38
16194194614,telefono_compartido,0.26,SI,42568,Jorge Sanchez Mireya,BILLMIREYA@YAHOO.COM,1978-11-26,3,2024-06-10,3154,2026-07-09 10:12:50
16194194614,telefono_compartido,0.26,,42927,Martin del Campo Alexander,BILLMIREYA@YAHOO.COM,2015-05-31,0,,3681,2026-07-09 10:12:57
17142703682,telefono_compartido,0.22,SI,40390,Monterrey Kitty,KITTYMONTERREY7@GMAIL.COM,1950-08-09,6,2024-10-14,707,2026-07-09 10:12:26
17142703682,telefono_compartido,0.22,,41614,Menz Yolanda,YOLIMENZ@GMAIL.COM,1971-11-01,0,,2089,2026-07-09 10:12:38
19497027342,telefono_compartido,0.43,,42884,Niebla Mauro,,1984-12-12,1,2024-07-08,3628,2026-07-09 10:12:57
19497027342,telefono_compartido,0.43,SI,43201,Dominguez Rodriguez Maria Francisca,FRANCISCA9ABRIL@GMAIL.COM,1956-03-10,8,2026-04-18,3995,2026-07-09 10:12:57
19497027342,telefono_compartido,0.43,,43275,Escobedo Dominguez Glenda,GLENDAYMAURO@GMAIL.COM,1987-06-15,0,,4110,2026-07-09 10:12:57
522092167938,telefono_compartido,0.71,SI,42720,Jauregui Edlyn,NO.NO@GMAIL.COM,2019-03-16,0,,3330,2026-07-09 10:12:50
522092167938,telefono_compartido,0.71,,42721,Jauregui Exzeqiel,EDLYNCOVARRUBIAS@GMAIL.COM,2021-08-30,0,,3331,2026-07-09 10:12:50
522094930605,telefono_compartido,0.64,SI,40061,Acevedo Fernando,,1997-10-10,2,2022-11-24,346,2026-07-09 10:12:20
522094930605,telefono_compartido,0.64,,40062,Acevedo Jose,,2010-12-17,0,,347,2026-07-09 10:12:20
522096206214,telefono_compartido,0.45,SI,42078,Montoya Guillermo Adriel,,2011-09-24,2,2023-10-05,2604,2026-07-09 10:12:44
522096206214,telefono_compartido,0.45,,42086,Montoya Anajanzy,,1982-06-21,0,,2613,2026-07-09 10:12:44
522096394135,telefono_compartido,0.63,SI,44908,Bautista Miguel,BAUTISTA4135@GMAIL.COM,1994-10-29,7,2025-05-09,5864,2026-07-09 10:13:19
522096394135,telefono_compartido,0.63,,44921,Bautista Christopher,BAUTISTA4135@GMAIL.COM,2010-10-06,0,,5878,2026-07-09 10:13:19
522096756807,telefono_compartido,0.58,SI,45169,Estrada María,MARIA.ARIAS7376@GMAIL.COM,1950-11-16,1,2025-04-12,6150,2026-07-09 10:13:19
522096756807,telefono_compartido,0.58,,45170,Arias María,MARIA.ARIAS7376@GMAIL.COM,1976-09-04,0,,6151,2026-07-09 10:13:19
522106322074,duplicado_probable,1.00,SI,42201,Johnston Marty,,1965-10-10,11,2025-01-23,2741,2026-07-09 10:12:44
522106322074,duplicado_probable,1.00,,43310,Johnston Marty,MARTYSD@YAHOO.COM,1965-10-10,0,,4162,2026-07-09 10:13:02
522133052163,telefono_compartido,0.77,SI,42212,Gonzalez Lucy,LUCY.GONZALEZ@LAUSD.NET,1977-02-09,7,2024-01-12,2753,2026-07-09 10:12:44
522133052163,telefono_compartido,0.77,,42249,Valdez Alexys,LUCY.GONZALEZ@LAUSD.NET,2005-09-08,0,,2794,2026-07-09 10:12:44
522133052163,telefono_compartido,0.77,,42250,Valdez Kaylee,LUCY.GONZALEZ@LAUSD.NET,2008-02-09,0,,2795,2026-07-09 10:12:44
522134474510,telefono_compartido,0.51,,42557,Figueroa Garcia Angel David,,2007-03-09,0,,3140,2026-07-09 10:12:50
522134474510,telefono_compartido,0.51,SI,42563,Garcia Ceja Gabriela,GABBYFIG14@hotmail.com,1973-09-11,4,2024-02-24,3146,2026-07-09 10:12:50
522136636605,duplicado_probable,0.91,SI,40585,Burciaga Gonzalez Arturo,,1958-09-19,5,2025-10-04,921,2026-07-09 10:12:26
522136636605,duplicado_probable,0.91,,45858,Burciaga Gonzalez Jose Arturo,BERBURCI2@GMAIL.COM,1958-09-19,0,,6870,2026-07-09 10:13:31
523105601684,telefono_compartido,0.47,,40380,Bejar Martha,,1981-06-12,0,,695,2026-07-09 10:12:26
523105601684,telefono_compartido,0.47,SI,40381,Bejar Sanchez Ezequiel,MAREZE2718@HOTMAIL.COM,1982-07-02,3,2023-01-16,696,2026-07-09 10:12:26
523106003481,duplicado_probable,1.00,SI,40273,Jauregui Santana Isabel,ISABELJAUREGUI24@GMAIL.COM,1958-02-12,29,2026-04-25,576,2026-07-09 10:12:20
523106003481,duplicado_probable,1.00,,40581,Jauregui Santana Isabel,ISABELJAUREGUI24@GMAIL.COM,1958-02-02,0,,917,2026-07-09 10:12:26
523106003481,duplicado_probable,1.00,,42963,Vargas Linares Ramon,ISABELJAUREGUI24@GMAIL.COM,1966-04-19,0,,3719,2026-07-09 10:12:57
523109880898,telefono_compartido,0.37,SI,40191,Flores Parra Ina,,1959-01-24,31,2026-06-13,489,2026-07-09 10:12:20
523109880898,telefono_compartido,0.37,,41946,Hurtado Ina,,1959-01-24,0,,2459,2026-07-09 10:12:44
523109938644,telefono_compartido,0.60,SI,40637,Tapia Raquel,,1950-07-23,12,2026-04-27,978,2026-07-09 10:12:26
523109938644,telefono_compartido,0.60,,42411,Tapia Garcia Jaime,NO@GMAIL.COM,1950-12-10,0,,2975,2026-07-09 10:12:50
523233160965,telefono_compartido,0.21,SI,42004,Ocampo Caroline,MANCHITAS_323@YAHOO.COM,2014-08-02,5,2026-01-03,2524,2026-07-09 10:12:44
523233160965,telefono_compartido,0.21,,44315,Quevedo Coren,MANCHITAS_323@YAHOO.COM,2024-08-24,0,,5241,2026-07-09 10:13:13
523235417969,telefono_compartido,0.18,SI,42471,Mendez Sosa Armando,,1956-03-20,2,2023-12-30,3043,2026-07-09 10:12:50
523235417969,telefono_compartido,0.18,,42472,Rodarte Alicia,,1966-08-19,0,,3044,2026-07-09 10:12:50
523236163630,telefono_compartido,0.47,SI,41521,Jacobo Zavala Guadalupe,,1955-12-12,3,2023-06-03,1981,2026-07-09 10:12:38
523236163630,telefono_compartido,0.47,,41522,Valverde Jacobo Lupe,,1992-02-27,0,,1982,2026-07-09 10:12:38
523237193727,telefono_compartido,0.41,,42408,Ruiz Joshua,NO@HOTMAIL.COM,2007-04-12,0,,2972,2026-07-09 10:12:50
523237193727,telefono_compartido,0.41,SI,45407,Sanchez Ruiz María,,1972-09-16,13,2025-09-18,6390,2026-07-09 10:13:25
523238330706,telefono_compartido,0.07,SI,41773,Del Toro Gloria,GLORIAYUNE@GMAIL.COM,1969-05-28,8,2025-03-08,2263,2026-07-09 10:12:38
523238330706,telefono_compartido,0.07,,41774,Yune Nicholas,GLORIAYUNE@GMAIL.COM,2006-10-07,0,,2264,2026-07-09 10:12:38
523322355714,telefono_compartido,0.51,,40681,Campos Rodriguez Alejandrina,,1981-04-11,0,,1028,2026-07-09 10:12:26
523322355714,telefono_compartido,0.51,SI,44866,Rivera Campos Estefani Anai,ESTEFANI.RIVERACAMPOS@GMAIL.COM,2009-10-06,21,2026-06-03,5815,2026-07-09 10:13:19
523421081723,telefono_compartido,0.40,,44630,Cuevaz Diaz Roman Santiago,LD666305@GMAIL.COM,2009-11-04,0,,5567,2026-07-09 10:13:13
523421081723,telefono_compartido,0.40,SI,46918,Ramirez Sanchez Cesar Octavio,ROMANDIAZ830@GMAIL.COM,1979-05-04,23,2026-06-20,8080,2026-07-09 10:13:43
523421081723,telefono_compartido,0.40,,46938,Diaz Ordunez Lourdes,LD666305@GMAIL.COM,1981-02-23,0,,8129,2026-07-09 10:13:43
523603336407,duplicado_probable,0.80,SI,46132,Martinez Zavala Maribel,,2011-08-14,11,2026-03-17,7165,2026-07-09 10:13:31
523603336407,duplicado_probable,0.80,,46283,Martinez Zavala Alicia,SHANKAZAFI8@HOTMAIL.COM,2011-03-03,0,,7325,2026-07-09 10:13:37
524084892500,telefono_compartido,0.27,SI,40600,Contreras Adriana,ADRIANA.CONTRERAS123@YAHOO.COM,1986-05-10,7,2026-02-21,938,2026-07-09 10:12:26
524084892500,telefono_compartido,0.27,,46464,Ruiz Samantha,ADRIANA.CONTRERAS123@YAHOO.COM,2009-09-22,0,,7513,2026-07-09 10:13:37
524086037942,telefono_compartido,0.76,SI,41590,Hurtado Trigos Angelica,MY2019BA@GMAIL.COM,1972-11-02,1,2025-06-27,2062,2026-07-09 10:12:38
524086037942,telefono_compartido,0.76,,45572,Hurtado Angela,,1972-11-02,0,,6560,2026-07-09 10:13:25
524242233669,telefono_compartido,0.12,SI,42246,Sanchez Jezabell,,2010-04-26,0,,2791,2026-07-09 10:12:44
524242233669,telefono_compartido,0.12,,42247,Lopez Garcia Edith,NO@GMAIL.COM,1981-08-01,0,,2792,2026-07-09 10:12:44
524422870470,telefono_compartido,0.62,SI,41369,Mendez Garcia Claudia Alejandra,ALEXTREME1980@GMAIL.COM,1980-11-05,19,2024-11-21,1808,2026-07-09 10:12:38
524422870470,telefono_compartido,0.62,,41373,Hinostrosa Mendez Hazel Alessandra,,2011-11-04,0,,1812,2026-07-09 10:12:38
525127812690,telefono_compartido,0.29,SI,42015,Gastelum Jimenez Teresita,GASTELUMTERESITA@GMAIL.COM,1972-11-03,4,2024-07-16,2537,2026-07-09 10:12:44
525127812690,telefono_compartido,0.29,,43014,Cisneros Martinez Martin,,1961-08-08,0,,3777,2026-07-09 10:12:57
525303551999,telefono_compartido,0.57,SI,41568,Wahl Suzanne,FURBABY_MOM@PROTONMAIL.COM,1962-11-11,13,2024-09-03,2037,2026-07-09 10:12:38
525303551999,telefono_compartido,0.57,,42101,Wahl John,MODESTOJOHN@SBCGLOBAL.NET,1953-04-08,0,,2630,2026-07-09 10:12:44
525554359904,telefono_compartido,0.47,SI,44182,Aranda Crestani María del Carmen,,1973-07-26,1,2023-04-19,5106,2026-07-09 10:13:08
525554359904,telefono_compartido,0.47,,44778,Garcia Aranda Annia,CARMENCRESTANI@HOTMAIL.COM,2009-03-31,0,,1162,2026-07-09 10:13:13
525563188717,telefono_compartido,0.52,SI,45735,Heranadez Maldonado Julia Maribel,JULIAMARIBEL.HERNANDEZ@GMAIL.COM,1983-06-02,5,2025-12-11,6729,2026-07-09 10:13:25
525563188717,telefono_compartido,0.52,,46066,Pina Hernandez Leonel,JULIAMARIBEL.HERNANDEZ@GMAIL.COM,2010-11-07,0,,7091,2026-07-09 10:13:31
525597138448,telefono_compartido,0.65,,42305,Villalobos Nancy,,1974-05-24,0,,2859,2026-07-09 10:12:50
525597138448,telefono_compartido,0.65,SI,42316,Villalobos Refugio,NO@GMAIL.COM,1974-11-11,4,2024-03-06,2871,2026-07-09 10:12:50
525622256068,telefono_compartido,0.36,SI,44051,Molina Evelyn,MOLINA.EVELYN.1@GMAIL.COM,1975-11-25,0,,4972,2026-07-09 10:13:08
525622256068,telefono_compartido,0.36,,44052,Chavez Ibarra Hector,,1974-05-04,0,,4973,2026-07-09 10:13:08
525622982156,telefono_compartido,0.76,SI,42373,De Anda de Anda Daira,,2001-12-17,2,2024-11-19,2931,2026-07-09 10:12:50
525622982156,telefono_compartido,0.76,,44097,De Anda Daira,DDAIRAA99@ICLOUD.COM,2001-12-17,0,,5018,2026-07-09 10:13:08
525623922976,telefono_compartido,0.69,SI,40580,Rodriguez Hernandez Fany,FRODRI0104@GMAIL.COM,1983-08-08,28,2026-06-23,916,2026-07-09 10:12:26
525623922976,telefono_compartido,0.69,,45465,Rodriguez Maria Elena,FRODRI0104@GMAIL.COM,1962-02-08,0,,6448,2026-07-09 10:13:25
525623922976,telefono_compartido,0.69,,46950,Rodriguez Abel,FANYCDS@AOL.COM,1962-04-08,0,,8143,2026-07-09 10:13:43
525627433633,telefono_compartido,0.20,SI,41427,Estrada Quezada Elvira,ELVIRA_GALLOSO@YAHOO.COM,1959-01-20,31,2026-05-30,1876,2026-07-09 10:12:38
525627433633,telefono_compartido,0.20,,43492,Tirres Garcia Maria de Jesus,ELVIRA_GALLOSO@YAHOO.COM,1945-06-07,0,,4374,2026-07-09 10:13:02
525627549883,telefono_compartido,0.40,SI,43620,Martinez Gonzalez Ma de la Luz,MARIALUZ.GONZALEZ@YAHOO.COM,1952-01-27,55,2026-07-03,4523,2026-07-09 10:13:02
525627549883,telefono_compartido,0.40,,43742,Marti Ez Christopher,,1989-10-12,0,,4653,2026-07-09 10:13:02
525629646534,duplicado_probable,1.00,SI,40318,Carrillo Leobardo,,1967-01-18,6,2023-09-28,626,2026-07-09 10:12:26
525629646534,duplicado_probable,1.00,,40682,Carrillo Leobardo,,1967-01-18,0,,1029,2026-07-09 10:12:26
526131285017,duplicado_probable,0.83,SI,41841,Ramos Chavez Blanca,,1984-12-22,6,2025-12-20,2344,2026-07-09 10:12:44
526131285017,duplicado_probable,0.83,,46114,Ramos Chavez Blanca Berence,BBRCH2212@GMAIL.COM,1984-12-22,0,,7145,2026-07-09 10:13:31
526145117937,telefono_compartido,0.48,SI,41198,Toledo Elizundia Irina,,1981-11-09,15,2023-09-26,1620,2026-07-09 10:12:32
526145117937,telefono_compartido,0.48,,41228,Comas Toledo Daniela,,2008-10-26,0,,1652,2026-07-09 10:12:32
526183027235,telefono_compartido,0.47,SI,44917,Rivas Aguilar Vanessa,,1991-09-02,11,2026-04-20,5873,2026-07-09 10:13:19
526183027235,telefono_compartido,0.47,,44918,Rivas Marylyn,,1987-06-01,0,,5874,2026-07-09 10:13:19
526192073535,telefono_compartido,0.50,SI,44996,Teran Quiñonez Leslie,LESLIIE17@HOTMAIL.COM,1992-01-27,40,2026-07-01,4370,2026-07-09 10:13:19
526192073535,telefono_compartido,0.50,,46346,Ontveros Teran Fernanda,LESLIIE17@HOTMAIL.COM,2020-01-31,0,,7390,2026-07-09 10:13:37
526192082582,telefono_compartido,0.72,SI,42393,Navarrete Azucena,,1970-08-25,2,2023-12-09,2956,2026-07-09 10:12:50
526192082582,telefono_compartido,0.72,,42395,Navarrete Ana Paula,,2005-03-31,0,,2958,2026-07-09 10:12:50
526192105498,telefono_compartido,0.64,SI,39849,Aguilar Viviana,VIVIANA.L.SERRANO@GMAIL.COM,1992-02-25,16,2023-04-17,74,2026-07-09 10:12:20
526192105498,telefono_compartido,0.64,,39850,Aguilar Ariel,,2010-11-04,0,,75,2026-07-09 10:12:20
526192591047,telefono_compartido,0.67,SI,44164,Vazquez Rocha Andrea,MAYKAPM85@GMAIL.COM,1960-11-02,0,,5088,2026-07-09 10:13:08
526192591047,telefono_compartido,0.67,,44166,Monreal Andres,MAYKAPM85@GMAIL.COM,2015-04-16,0,,5090,2026-07-09 10:13:08
526192591047,telefono_compartido,0.67,,44679,Monreal Mayka,MAYKAPM85@GMAIL.COM,1985-08-23,0,,5618,2026-07-09 10:13:13
526193194708,duplicado_probable,1.00,SI,41567,Sanchez Hernandez Adaly,ALY.HERSAN.22@GMAIL.COM,1994-09-01,7,2024-03-20,2036,2026-07-09 10:12:38
526193194708,duplicado_probable,1.00,,41656,Sanchez Hernandez Adaly,,1994-09-01,0,,2132,2026-07-09 10:12:38
526193438050,telefono_compartido,0.29,,40912,Harris Sharion,SHARION621@YAHOO.COM,1962-12-04,2,2023-09-13,1296,2026-07-09 10:12:32
526193438050,telefono_compartido,0.29,SI,41846,Henson Melanie,MELANIE@POMPEIISURGICAL.COM,1986-07-17,4,2025-06-09,2349,2026-07-09 10:12:44
526193662975,telefono_compartido,0.54,SI,45060,Reyes Ramirez Selene Violeta,,2009-12-28,19,2026-04-28,6030,2026-07-09 10:13:19
526193662975,telefono_compartido,0.54,,45838,Reyes Ramirez Javier,EMINEMA_50@HOTMAIL.COM,2011-11-06,0,,6844,2026-07-09 10:13:31
526193662975,telefono_compartido,0.54,,45898,Ramirez Flores Emma,EMINEMA_50@HOTMAIL.COM,1988-06-29,0,,6911,2026-07-09 10:13:31
526193955418,telefono_compartido,0.55,SI,39960,Aceves Callico Mia,MCALLICO@HOTMAIL.COM,2009-07-23,12,2023-06-21,222,2026-07-09 10:12:20
526193955418,telefono_compartido,0.55,,40366,Callico Maria Fernanda,MCALLICO@HOTMAIL.COM,1982-02-10,0,,680,2026-07-09 10:12:26
526194086051,telefono_compartido,0.67,SI,41142,Madrigal Toscano Diego,MIRIAMTOSKNO@GMAIL.COM,2006-08-17,5,2023-07-26,1553,2026-07-09 10:12:32
526194086051,telefono_compartido,0.67,,41189,Toscano Elizondo Miriam,MIRIAMTOSKNO@HOTMAIL.COM,1975-09-13,1,2023-01-16,1611,2026-07-09 10:12:32
526194164074,telefono_compartido,0.43,SI,40269,Lujano Garza Adriana,,1990-12-10,16,2024-09-30,571,2026-07-09 10:12:20
526194164074,telefono_compartido,0.43,,43710,Magaña Mila Aeris,LUJANOGARZA10@GMAIL.COM,2018-02-24,0,,4621,2026-07-09 10:13:02
526194181249,telefono_compartido,0.53,SI,42666,Vazquez Emilio,VAZQUEZANNA090@GMAIL.COM,2009-03-20,9,2024-07-20,3269,2026-07-09 10:12:50
526194181249,telefono_compartido,0.53,,43211,Vazquez Quezada Anna,VAZQUEZANNA090@GMAIL.COM,1990-07-08,0,,4007,2026-07-09 10:12:57
526194197072,telefono_compartido,0.22,SI,42237,Aceves Sandra,,1974-06-25,20,2024-05-07,2780,2026-07-09 10:12:44
526194197072,telefono_compartido,0.22,,42396,Soto Valentina,SANDRAACEVES05@GMAIL.COM,2010-02-21,0,,2960,2026-07-09 10:12:50
526194519546,telefono_compartido,0.79,SI,41486,Felix Santos Blanca Luz,BLANKA_1494@HOTMAIL.COM,1994-08-14,6,2023-09-18,1939,2026-07-09 10:12:38
526194519546,telefono_compartido,0.79,,41798,Frausto Felix Blanca,,1994-08-14,0,,2293,2026-07-09 10:12:44
526194960868,telefono_compartido,0.65,SI,40212,Lee Iii Archie,ARCHIE.LEE50@YAHOO.COM,2008-07-14,33,2026-05-27,510,2026-07-09 10:12:20
526194960868,telefono_compartido,0.65,,45777,Archie Lee Senior,ARCHIE.LEE@YAHOO.COM,1959-02-14,0,,6781,2026-07-09 10:13:25
526195136653,telefono_compartido,0.72,SI,40761,Garcia Rodriguez Brenda,345GERMAN@GMAIL.COM,1994-06-30,21,2026-03-28,1124,2026-07-09 10:12:26
526195136653,telefono_compartido,0.72,,42918,Garcia Brenda,,1994-06-30,0,,3672,2026-07-09 10:12:57
526195766031,telefono_compartido,0.33,SI,41322,Velazquez Gutierrez Nayely,NAYELY_VG@YAHOO.COM,1989-07-28,29,2024-07-31,1757,2026-07-09 10:12:38
526195766031,telefono_compartido,0.33,,42589,Castillo Fernandez Dylan Raul,,2009-11-13,0,,3179,2026-07-09 10:12:50
526195779037,telefono_compartido,0.33,SI,42381,Castro Alcina Michelle,MICHELLEMICHEL09@GMAIL.COM,1982-01-09,26,2026-07-01,2941,2026-07-09 10:12:50
526195779037,telefono_compartido,0.33,,46791,Michel Tiffany,MICHELLEMICHEL09@GMAIL.COM,2011-09-09,0,,7865,2026-07-09 10:13:43
526195868144,telefono_compartido,0.47,SI,40484,Cabuto Vega Berenice,ROCA1016@GMAIL.COM,1987-07-21,27,2025-05-07,812,2026-07-09 10:12:26
526195868144,telefono_compartido,0.47,,41996,Osuna Cabuto David,,2009-01-29,0,,2513,2026-07-09 10:12:44
526195868144,telefono_compartido,0.47,,42119,Robles Danna,,2012-02-21,0,,2649,2026-07-09 10:12:44
526196031606,duplicado_probable,0.90,SI,40819,Altamirano Ferra Pedro Ivan,PETEFERRA1982@GMAIL.COM,1982-09-06,2,2024-10-19,1192,2026-07-09 10:12:32
526196031606,duplicado_probable,0.90,,43721,Altamirano Ferra Pedro,PETEFERRA1982@GMAIL.COM,1982-09-06,0,,4633,2026-07-09 10:13:02
526196221084,telefono_compartido,0.58,SI,41259,Smith Debbie,CFO@POMPEIISURGICAL.COM,1964-05-07,6,2025-08-04,1684,2026-07-09 10:12:32
526196221084,telefono_compartido,0.58,,43085,Smith Olivia,CFO@POMPEIISURGICAL.COM,2008-06-07,0,,3860,2026-07-09 10:12:57
526196327657,telefono_compartido,0.42,SI,45501,Tsurumi Santillan Joaquin,NTSURUMI@GMAIL.COM,2013-03-17,16,2026-06-27,6486,2026-07-09 10:13:25
526196327657,telefono_compartido,0.42,,45542,Tsurumi Villalobos Nora,NTSURUMI@GMAIL.COM,1978-01-11,0,,6529,2026-07-09 10:13:25
526196341096,telefono_compartido,0.65,SI,42697,Araiza Marisol,MARI4ROBERT@YAHOO.COM,1981-07-04,24,2026-03-10,3305,2026-07-09 10:12:50
526196341096,telefono_compartido,0.65,,43455,Araiza Garcia Joshua,MARI4ROBERT@YAHOO.COM,2008-05-11,0,,4326,2026-07-09 10:13:02
526196491350,telefono_compartido,0.63,SI,40416,Rodriguez Martha Susana,,1975-12-09,34,2026-07-01,736,2026-07-09 10:12:26
526196491350,telefono_compartido,0.63,,40895,Rodriguez Diaz Samantha Yael,SYMARTHA74@YAHOO.COM,2010-01-08,34,2026-04-18,1278,2026-07-09 10:12:32
526196783754,duplicado_probable,0.84,SI,42845,Villegas Michel Sofia,CRISHTNA.VILLEGAS@HOTMAIL.COM,2009-12-10,2,2024-04-10,3560,2026-07-09 10:12:57
526196783754,duplicado_probable,0.84,,42850,Villegas Michel Victoria,CRISHTNA.VILLEGAS@HOTMAIL.COM,2014-01-20,0,,3578,2026-07-09 10:12:57
526197180836,telefono_compartido,0.39,SI,40808,Mayen Aviles Sylvia,ARIMAYEN14@OUTLOOK.COM,1988-09-07,19,2026-05-20,1178,2026-07-09 10:12:32
526197180836,telefono_compartido,0.39,,46196,Leon Guzman Osiel,ARIMAYEN14@OUTLOOK.COM1,2013-05-01,0,,7234,2026-07-09 10:13:31
526197192887,duplicado_probable,0.82,,42368,Arreola Ceceña Sherlyn,,2004-09-11,0,,2926,2026-07-09 10:12:50
526197192887,duplicado_probable,0.82,SI,45279,Arreola Brandon,GUADALUPE0420@GMAIL.COM,2006-11-14,15,2026-02-21,6265,2026-07-09 10:13:19
526197192887,duplicado_probable,0.82,,46285,Arreola Ceceña Giselle,GUADALUPE0420@GMAIL.COM,2001-12-04,0,,7327,2026-07-09 10:13:37
526197355777,telefono_compartido,0.18,SI,40095,Diaz Barajas Alejandro,ELI.BARAJAS.364@GMAIL.COM,2008-07-25,46,2025-09-27,384,2026-07-09 10:12:20
526197355777,telefono_compartido,0.18,,42544,Diaz Jurado Juan Manuel,ELI.BARAJAS.364@GMAIL.COM,1980-01-09,0,,3126,2026-07-09 10:12:50
526197453496,telefono_compartido,0.73,SI,41071,Salas Beltran Guadalupe,LUPITASALAS@HOTMAI0L.COM,1982-12-30,10,2025-02-08,1473,2026-07-09 10:12:32
526197453496,telefono_compartido,0.73,,43963,Salas Beltran Ines,,1982-12-30,0,,4889,2026-07-09 10:13:08
526197695556,telefono_compartido,0.51,SI,40979,Estrada Alarcón Citlati,CITLATI.21@GMAIL.COM,2001-06-21,2,2024-06-26,1370,2026-07-09 10:12:32
526197695556,telefono_compartido,0.51,,43148,Alarcon Garcia Maria Dolores,CITLATI.21@GMAIL.COM,1972-07-26,0,,3929,2026-07-09 10:12:57
526198057621,duplicado_probable,0.80,SI,41795,Martinez Aileen,AILEENIMARTINEZ@GMAIL.COM,1982-02-12,17,2026-04-28,2290,2026-07-09 10:12:44
526198057621,duplicado_probable,0.80,,41796,Martinez Camila,AILEENIMARTINEZ@GMAIL.COM,2007-12-14,6,2024-06-08,2291,2026-07-09 10:12:44
526198430000,telefono_compartido,0.47,,39808,Jimenez Maria Elena,,1948-03-15,9,2023-12-13,27,2026-07-09 10:12:20
526198430000,telefono_compartido,0.47,SI,41141,Jimenez Gonzalez Cynthia,CYNDEEJIMENEZ@YAHOO.COM,1979-03-16,13,2026-04-09,1552,2026-07-09 10:12:32
526198430000,telefono_compartido,0.47,,41899,Monzón Maximiliano,CYNDEEJIMENEZ@YAHOO.COM,2009-10-07,0,,2405,2026-07-09 10:12:44
526198502783,telefono_compartido,0.36,SI,43911,Sanchez Angel Victor Alexander,CLAUDIA.A.SANCHEZ.8@GMAIL.COM,2010-05-25,0,,4843,2026-07-09 10:13:08
526198502783,telefono_compartido,0.36,,43913,Angel C Claudia,CLAUDIA.A.SANCHEZ.8@GMAIL.COM,1976-07-08,0,,4845,2026-07-09 10:13:08
526198643126,telefono_compartido,0.49,SI,44038,Olivarez Quintero Mayra,M_OLIVAREZ@HOTMAIL.COM,1977-06-27,26,2026-03-11,613,2026-07-09 10:13:08
526198643126,telefono_compartido,0.49,,44319,Soler Olivarez Valeria,,2005-08-18,0,,684,2026-07-09 10:13:13
526198824259,telefono_compartido,0.74,SI,44883,Reyes Rosa,ROSA_REYES09@YAHOO.COM,1998-04-29,6,2025-09-27,5834,2026-07-09 10:13:19
526198824259,telefono_compartido,0.74,,45340,Reyes Rosa Elidia,,1998-04-29,0,,6324,2026-07-09 10:13:25
526199570903,telefono_compartido,0.28,SI,40124,Guerra Jimenez Genesis,GGUERRANATALIA@GMAIL.COM,1996-06-25,6,2025-09-19,415,2026-07-09 10:12:20
526199570903,telefono_compartido,0.28,,45672,Garcia Uriel Leonardo,LEONUG21@GMAIL.COM,2021-10-30,0,,6663,2026-07-09 10:13:25
526199971510,telefono_compartido,0.43,SI,40033,Ortega Benitez Olga,,1963-10-02,50,2026-07-03,316,2026-07-09 10:12:20
526199971510,telefono_compartido,0.43,,40820,Alvarado Ortega Kiana Chanel,,1990-04-16,0,,1193,2026-07-09 10:12:32
526262905575,telefono_compartido,0.41,SI,44736,Garcia U Bryan,SALGADODAISY11@GMAIL.COM,2007-01-10,0,,5680,2026-07-09 10:13:13
526262905575,telefono_compartido,0.41,,44737,Salgado U Daisy,SALGADODAISY11@GMAIL.COM,1992-07-03,0,,5681,2026-07-09 10:13:13
526263789251,telefono_compartido,0.37,SI,40641,Garcia Joanna,JOANNA.GLB2006@GMAIL.COM,1981-07-03,34,2026-06-01,982,2026-07-09 10:12:26
526263789251,telefono_compartido,0.37,,46538,León Piña Olga,,1958-03-19,0,,7593,2026-07-09 10:13:37
526264075757,telefono_compartido,0.62,SI,41462,Avellaneda Lopez Gladys,,1968-09-25,4,2023-06-17,1915,2026-07-09 10:12:38
526264075757,telefono_compartido,0.62,,41463,Avellaneda Soto Efrain,,1959-08-11,0,,1916,2026-07-09 10:12:38
526268257167,telefono_compartido,0.47,,45889,Nunez Reinaga Nicolas,,1982-10-05,0,,6902,2026-07-09 10:13:31
526268257167,telefono_compartido,0.47,SI,45927,Nunez Nallely,N121500G@GMAIL.COM,1985-06-10,24,2026-06-30,6942,2026-07-09 10:13:31
526317476272,telefono_compartido,0.64,SI,46499,Barker Joyce,RUSTYANDJOYCE539@GMAIL.COM,1950-03-23,3,2026-03-24,7554,2026-07-09 10:13:37
526317476272,telefono_compartido,0.64,,46535,Barker Curtis,RUSTYANDJOYCE539@GMAIL.COM,1955-11-12,0,,7590,2026-07-09 10:13:37
526461028015,telefono_compartido,0.63,SI,42063,Flores Palomares Cristofer Ramon,PALOMARESR82@GMAIL.COM,2006-10-20,10,2024-10-02,2587,2026-07-09 10:12:44
526461028015,telefono_compartido,0.63,,42089,Palomares Cruz Rosa Elena,PALOMARESR82@GMAIL.COM,1982-01-08,0,,2616,2026-07-09 10:12:44
526461965781,telefono_compartido,0.45,SI,40147,Hoiby Cheryl,,1955-01-24,55,2026-05-19,439,2026-07-09 10:12:20
526461965781,telefono_compartido,0.45,,42021,Hoiby Matt,MATTHOIBYART@GMAIL.COM,1968-12-31,0,,2543,2026-07-09 10:12:44
526462181115,telefono_compartido,0.67,,44494,Bugarin Castillo Yurixy,YURIXYBUGARIN@GMAIL.COM,1994-05-18,0,,856,2026-07-09 10:13:13
526462181115,telefono_compartido,0.67,SI,45191,Castillo Bugarin Maria del Refugio,CASTILLO.PROFESORA@GMAIL.COM,1963-07-07,30,2026-06-23,5688,2026-07-09 10:13:19
526502481153,duplicado_probable,1.00,SI,41218,Palafox Monique,VARELA78@HOTMAIL.COM,2023-02-26,1,2024-02-02,1642,2026-07-09 10:12:32
526502481153,duplicado_probable,1.00,,42586,Palafox Monique,VARELA78@HOTMAIL.COM,1978-02-13,0,,3176,2026-07-09 10:12:50
526504833245,telefono_compartido,0.62,SI,40470,Rojas Tapia Yadira,YADIRAALCANTAR6@OUTLOOK.COM,1978-11-29,10,2026-04-09,796,2026-07-09 10:12:26
526504833245,telefono_compartido,0.62,,42587,Rojas Alcantar Yadira,YADIRAALCANTAR6@OUTLOOK.COM,1978-11-29,0,,3177,2026-07-09 10:12:50
526505540697,telefono_compartido,0.25,SI,45441,Rubio Ylemsuy,YLEMSUYRUBIO@GMAIL.COM,1983-04-11,36,2026-06-02,6113,2026-07-09 10:13:25
526505540697,telefono_compartido,0.25,,45613,Schulz Will,WCSCHULZ@GMAIL.COM,1941-10-26,0,,6603,2026-07-09 10:13:25
526507714176,telefono_compartido,0.38,,43433,Venegas Lopez Maria de Jesus,NO@GMAIL.COM,1984-12-20,0,,4302,2026-07-09 10:13:02
526507714176,telefono_compartido,0.38,SI,45185,Lopez Reyes Virginia,,1955-01-31,2,2025-04-14,6166,2026-07-09 10:13:19
526611016296,telefono_compartido,0.77,SI,39836,Robledo Pineda Carolina,SONOGORY@GMAIL.COM,2005-05-05,15,2026-06-04,58,2026-07-09 10:12:20
526611016296,telefono_compartido,0.77,,39838,Pineda Lopez Guadalupe Elena,SONOGORY@GMAIL.COM,1976-10-08,0,,60,2026-07-09 10:12:20
526611016296,telefono_compartido,0.77,,41513,Robledo Pineda Alejandro,SONOGORY@GMAIL.COM,2007-09-07,0,,1973,2026-07-09 10:12:38
526611031330,telefono_compartido,0.51,,40643,Sanchez Partida Lizbeth Geraldine,,2009-11-22,0,,984,2026-07-09 10:12:26
526611031330,telefono_compartido,0.51,SI,40644,Partida Vazquez Blanca,PARTIDAB56@GMAIL.COM,1979-10-02,9,2023-06-20,985,2026-07-09 10:12:26
526611035281,telefono_compartido,0.42,SI,42344,Agramont Barrios Natalue Sofia,TANIAYNATALIE@GMAIL.COM,2014-03-11,4,2023-11-29,2901,2026-07-09 10:12:50
526611035281,telefono_compartido,0.42,,42345,Barrios Valencia Tania Elizabeth,TANIAYNATALIE@GMAIL.COM,1986-09-01,0,,2902,2026-07-09 10:12:50
526611039656,telefono_compartido,0.44,,43128,Charles Webb Christopher,TEAM@BAJAREHAB.COM,2004-11-02,5,2025-06-20,3907,2026-07-09 10:12:57
526611039656,telefono_compartido,0.44,,43827,Sanchez Sanchez Alberto,TEAM@BAJAREHAB.COM,1999-06-06,0,,4748,2026-07-09 10:13:08
526611039656,telefono_compartido,0.44,,44444,Lynn Salazar Breana,TEAM@BAJAREHAB.COM,1989-09-29,0,,5381,2026-07-09 10:13:13
526611039656,telefono_compartido,0.44,SI,45596,Stroud John,,2001-03-21,12,2025-10-06,6586,2026-07-09 10:13:25
526611039656,telefono_compartido,0.44,,45635,Castro Abril,,1994-04-29,0,,6626,2026-07-09 10:13:25
526611039656,telefono_compartido,0.44,,45751,Awad Feda,,1983-11-05,0,,6750,2026-07-09 10:13:25
526611039998,telefono_compartido,0.62,SI,42413,Lopez Arellanes Carolina,CAROYNOE13@GMAIL.COM,1985-05-16,5,2024-01-04,2977,2026-07-09 10:12:50
526611039998,telefono_compartido,0.62,,42419,Arellanes Arellano Maria,,1954-09-12,0,,2984,2026-07-09 10:12:50
526611050771,telefono_compartido,0.55,SI,43052,Del Angel Gonzalez Paola,PDELANGEL@GMAIL.COM,1986-05-31,10,2026-03-05,3821,2026-07-09 10:12:57
526611050771,telefono_compartido,0.55,,46299,Mayoral del Angel Lucas,PDELANGEL@GMAIL.COM,2018-04-18,0,,7343,2026-07-09 10:13:37
526611061156,telefono_compartido,0.63,SI,40324,Covarrubias Duarte Martha,,1982-09-05,3,2023-03-17,634,2026-07-09 10:12:26
526611061156,telefono_compartido,0.63,,40746,De Anda Covarrubias Mia Ailed,,2005-10-31,0,,1107,2026-07-09 10:12:26
526611065452,telefono_compartido,0.64,SI,41289,Martinez Martinez Mariana,NOTIENE@GMAIL.COM,1977-05-18,4,2023-07-24,1720,2026-07-09 10:12:38
526611065452,telefono_compartido,0.64,,41339,Diaz Martinez Arely,MTZMARIANAMTZ678@GMAIL.COM,2011-09-24,0,,1776,2026-07-09 10:12:38
526611070848,telefono_compartido,0.06,SI,46400,Araujo Morales Jessica Aide,AMJESSICADG@GMAIL.COM,1991-02-18,6,2026-03-16,7446,2026-07-09 10:13:37
526611070848,telefono_compartido,0.06,,46401,Li Yuda,AMJESSICADG@GMAIL.COM,1996-04-16,0,,7447,2026-07-09 10:13:37
526611072589,telefono_compartido,0.60,SI,40226,Sanchez Lara Cintia Lorena,CINTIA.SANCHEZ2715@HOTMAIL.COM,1971-12-19,30,2025-10-08,526,2026-07-09 10:12:20
526611072589,telefono_compartido,0.60,,41593,Estrada Sanchez Aidan,CINTIA.SANCHEZ2715@HOTMAIL.COM,2006-01-27,0,,2065,2026-07-09 10:12:38
526611074953,telefono_compartido,0.56,SI,39935,Garcia Santiago Jaqueline Yadira,JAQULINEGARCIA4@GMAIL.COM,2004-07-07,6,2024-11-21,186,2026-07-09 10:12:20
526611074953,telefono_compartido,0.56,,44114,Garcia Santiago Evelyn,GARCIALUCRECIA945@GMAIL.COM,2010-10-25,0,,5037,2026-07-09 10:13:08
526611076706,telefono_compartido,0.38,SI,39914,De Jesus de Jesus Yuritzia,GLORIADEJESUSCASTRO01@GMAIL.COM,2007-09-14,3,2022-12-22,153,2026-07-09 10:12:20
526611076706,telefono_compartido,0.38,,40153,De Jesus Castro Gloria,,1991-10-30,0,,445,2026-07-09 10:12:20
526611076917,telefono_compartido,0.16,SI,42710,Avila Torres Briana,AGUINAGAVASARAH@GMAIL.COM,2010-01-14,3,2024-11-05,3318,2026-07-09 10:12:50
526611076917,telefono_compartido,0.16,,43987,Aguiñaga Valenzuela Aleyda Sarai,,1995-01-07,0,,4907,2026-07-09 10:13:08
526611078701,telefono_compartido,0.50,,40837,Yzarraraz Tafolla Raquel,,1950-01-01,0,,1211,2026-07-09 10:12:32
526611078701,telefono_compartido,0.50,SI,41216,Alcala Izarraraz Maria Hortencia,ALIZ6901@HOTMAIL.COM,1969-01-17,6,2026-04-29,1640,2026-07-09 10:12:32
526611079992,telefono_compartido,0.41,SI,46639,Huerta Caro Ana Camila,LAURAECARO@HOTMAIL.COM,2009-01-27,4,2026-05-22,7701,2026-07-09 10:13:37
526611079992,telefono_compartido,0.41,,46640,Caro Gomez Laura Edith,LAURAECARO@HOTMAIL.COM,1979-05-26,0,,7702,2026-07-09 10:13:37
526611084511,telefono_compartido,0.48,SI,40982,Crosthwaite Resendez Carolina,CAROANDRECO52@HOTMAIL.COM,2006-07-06,4,2023-04-17,1373,2026-07-09 10:12:32
526611084511,telefono_compartido,0.48,,41240,Resendez Dominguez Ofelia,CAROANDRECO52@HOTMAIL.COM,1976-01-20,0,,1664,2026-07-09 10:12:32
526611087582,telefono_compartido,0.63,SI,40919,Cervantes Ortiz Alba Rosa,CERVANTEESALBA06@GMAIL.COM,1991-10-06,21,2023-09-27,1304,2026-07-09 10:12:32
526611087582,telefono_compartido,0.63,,41816,Cortes Cervantes Emily Andrea,CERVANTEESALBA06@GMAIL.COM,2013-04-26,0,,2316,2026-07-09 10:12:44
526611087969,telefono_compartido,0.48,,42492,Smith Ada,NOREL.ANN@GMAIL.COM,2010-10-05,0,,3067,2026-07-09 10:12:50
526611087969,telefono_compartido,0.48,SI,45531,Smith Torres Lao Tze,LAO@ALFAVID-ORGANICS.COM,1981-12-17,44,2026-06-26,6515,2026-07-09 10:13:25
526611101468,telefono_compartido,0.43,SI,40323,Peñaloza Tinoco Jose Armando,CHALINO2011@HOTMAIL.COM,2008-02-04,19,2023-08-24,633,2026-07-09 10:12:26
526611101468,telefono_compartido,0.43,,41516,Tinoco Rodriguez Yadira,,1982-03-14,0,,1976,2026-07-09 10:12:38
526611102293,telefono_compartido,0.38,SI,40322,Holguin Gonzalez Cynthia Lizeth,CYNTHIAHOLGUIN@HOTMAIL.COM,1892-07-26,10,2025-02-07,632,2026-07-09 10:12:26
526611102293,telefono_compartido,0.38,,44579,Mendoza Holguin Leonel,CYNTHIAHOLGUIN@HOTMAIL.COM,2019-06-29,0,,5513,2026-07-09 10:13:13
526611103464,telefono_compartido,0.59,SI,41395,Ruiz Mijangos Lia Yhoalibeth,RMIJANGOS@UABC.EDU.MX,2009-05-22,83,2026-07-06,1841,2026-07-09 10:12:38
526611103464,telefono_compartido,0.59,,41674,Mijangos Ortega Rosalba,RMIJANGOS@UABC.EDE,1981-09-04,0,,2150,2026-07-09 10:12:38
526611105141,duplicado_probable,0.90,SI,41000,Ordoñez Arreaga Carlos,CARLOS1SEP1980@YAHOO.COM,1981-09-02,8,2024-05-25,1391,2026-07-09 10:12:32
526611105141,duplicado_probable,0.90,,42374,Ordonez Arreaga Jose Carlos,CARLOSARREAGA900@GMAIL.COM,1981-09-01,0,,2932,2026-07-09 10:12:50
526611105858,duplicado_probable,1.00,SI,39809,Rojas Cortes Dafne Joceline,ROJASAIDA10@GMAIL.COM,2009-12-24,2,2022-11-12,28,2026-07-09 10:12:20
526611105858,duplicado_probable,1.00,,39810,Cortes Samano Aida,ROJASAIDA10@GMAIL.COM,1981-03-22,0,,29,2026-07-09 10:12:20
526611105858,duplicado_probable,1.00,,39876,Cortes Samano Aida,ROJASAIDA10@GMAIL.COM,1981-03-22,0,,104,2026-07-09 10:12:20
526611107190,telefono_compartido,0.43,SI,39986,Serrato Antunez Celindanet,RMORENOOP@OUTLOOK.COM,1981-10-07,55,2026-04-17,250,2026-07-09 10:12:20
526611107190,telefono_compartido,0.43,,42032,Moreno Serrato Maria Guadalupe,RMORENOOP@OUTLLOK.COM,2008-01-19,0,,2554,2026-07-09 10:12:44
526611107489,telefono_compartido,0.72,SI,40483,Espinoza Moreno Anabel,E.ANABEL@AOL.COM,1986-09-12,18,2026-05-06,811,2026-07-09 10:12:26
526611107489,telefono_compartido,0.72,,42790,Ramirez Espinoza Angelica,E.ANABEL@AOL.COM,2013-03-01,0,,3421,2026-07-09 10:12:57
526611107905,telefono_compartido,0.50,SI,42655,Sierra Garcia Graciela,GRACIELASIGA@GMAIL.COM,1995-06-28,9,2026-01-15,3258,2026-07-09 10:12:50
526611107905,telefono_compartido,0.50,,46176,Trujillo Sierra Aaron Isai,SIERRAGARCIAGRACIELA@GMAIL.COM,2023-09-16,0,,7211,2026-07-09 10:13:31
526611109648,telefono_compartido,0.61,SI,45111,Valenzuela Velazquez Carolina,PSIC.CAROLINA.VALENZUELA@GMAIL.COM,1990-03-27,22,2026-04-13,6085,2026-07-09 10:13:19
526611109648,telefono_compartido,0.61,,46660,Inzunza Valenzuela Victoria,PSIC.CAROLINA.VALENZUELA@GMAIL.COM,2018-12-21,0,,7727,2026-07-09 10:13:37
526611111645,telefono_compartido,0.53,SI,40250,Chavez Suarez Christofher Alan,CHAMACASGR@GMAIL.COM,2011-09-14,28,2026-05-12,551,2026-07-09 10:12:20
526611111645,telefono_compartido,0.53,,40482,Suarez Grana Claudia Rosario,CHAMACASGR@GMAIL.COM,1982-07-19,0,,809,2026-07-09 10:12:26
526611111645,telefono_compartido,0.53,,43250,Chavez Suarez Geovanni Yael,CHAMACASGR@GMAIL.COM,2008-09-18,0,,4072,2026-07-09 10:12:57
526611116797,telefono_compartido,0.55,SI,39894,Mendoza Arvizu Saul,SAULMENDOZA2305@GMAIL.COM,2005-03-23,4,2024-08-05,127,2026-07-09 10:12:20
526611116797,telefono_compartido,0.55,,44818,Arvizu Ramirez Korina,,1983-02-15,0,,5763,2026-07-09 10:13:19
526611121149,telefono_compartido,0.67,SI,41597,Jimenez de los Santos Alejandro,ALJISA2000@HOTMAIL.COM,1978-11-08,14,2025-10-01,2070,2026-07-09 10:12:38
526611121149,telefono_compartido,0.67,,41747,Jimenez Lopez Alexander,ALJISA2000@HOTMAIL.COM,2009-01-23,0,,2235,2026-07-09 10:12:38
526611121435,telefono_compartido,0.52,SI,41550,Gonzalez Velazquez Angela Aurora,ANGOURA000@GMAIL.COM,2000-11-04,19,2025-09-22,2018,2026-07-09 10:12:38
526611121435,telefono_compartido,0.52,,43894,Rodriguez Garcia Angel,,1997-01-13,0,,4820,2026-07-09 10:13:08
526611123133,telefono_compartido,0.64,SI,41181,Vera Astorga Andrea Carolina,DRACAROLINAVERA@GMAIL.COM,1995-10-18,10,2024-07-09,1598,2026-07-09 10:12:32
526611123133,telefono_compartido,0.64,,43242,Silva Vera Aria Valentina,DRACAROLINAVERA@GMAIL.COM,2018-12-03,0,,4061,2026-07-09 10:12:57
526611124200,telefono_compartido,0.48,,42908,Delgado Flores Luis Guillermo,LIZFLOCAS@GMAIL.COM,2009-07-11,0,,3658,2026-07-09 10:12:57
526611124200,telefono_compartido,0.48,SI,45521,Flores Castro Lizbeth,,1976-11-05,8,2025-08-23,6506,2026-07-09 10:13:25
526611126483,telefono_compartido,0.48,SI,39871,Jimenez Rivera Silvia,SILVIA_JR@LIVE.COM,1958-09-27,16,2026-02-07,99,2026-07-09 10:12:20
526611126483,telefono_compartido,0.48,,45353,Garcia Rivera Joselyn,JOSELYNGARCUARIVERA52@GMAIL.COM,2008-12-11,0,,6337,2026-07-09 10:13:25
526611127629,telefono_compartido,0.26,SI,41161,Vergara Vera Oriana Lizbeth,ORIVERVER97@GMAIL.COM,1997-09-27,10,2024-05-28,1576,2026-07-09 10:12:32
526611127629,telefono_compartido,0.26,,42964,Sanchez Ramirez Maria Reyna,ORIVERVER97@GMAIL.COM,1958-05-20,0,,3720,2026-07-09 10:12:57
526611128248,telefono_compartido,0.63,SI,40767,Arroyo Vera Jancy,NVERA@ROSARITO.GOB.MX,2007-11-07,20,2023-11-02,1132,2026-07-09 10:12:26
526611128248,telefono_compartido,0.63,,41295,Arroyo Vera Yareli,NVERAMZNO@GMAIL.COM,2023-05-04,0,,1726,2026-07-09 10:12:38
526611129769,telefono_compartido,0.71,SI,39786,Lara Magaña Luisabelle,LUISABELLE.LARA@UABC.EDU.MX,1995-02-28,73,2026-07-04,5,2026-07-09 10:12:20
526611129769,telefono_compartido,0.71,,42914,Lara Magaña Luis Carlos,LUISABELLE.LARA@UABC.EDU.MX,2014-08-22,0,,3664,2026-07-09 10:12:57
526611131968,telefono_compartido,0.70,SI,41024,Rodriguez Garcia Raquel,ENERORAQUEL81@GMAIL.COM,1981-01-15,74,2026-05-30,1417,2026-07-09 10:12:32
526611131968,telefono_compartido,0.70,,42729,Diaz Rodriguez Hiram,,2006-11-22,0,,3340,2026-07-09 10:12:50
526611134375,telefono_compartido,0.34,SI,42574,Leyva Mascareño Julieta Isabel,CASAELJARDIN@GMAIL.COM,1945-10-19,6,2025-01-02,3162,2026-07-09 10:12:50
526611134375,telefono_compartido,0.34,,42575,Camacho Cobos Hector Aurelio,HECTOR.CAMACHO195@GMAIL.COM,1970-08-17,0,,3163,2026-07-09 10:12:50
526611134375,telefono_compartido,0.34,,43920,Steven Larkey Gregory,CASAELJARDIN@GMAIL.COM,1948-04-01,0,,4849,2026-07-09 10:13:08
526611135155,telefono_compartido,0.60,SI,41243,Bogarin Diaz Jaden Yeray,MAMAXITA2000@GMAIL.COM,2013-09-05,5,2023-05-04,1668,2026-07-09 10:12:32
526611135155,telefono_compartido,0.60,,41244,Bogarin Diaz Dominic Aziel,MAMAXITA2000@GMAIL.COM,2015-05-23,0,,1669,2026-07-09 10:12:32
526611135759,telefono_compartido,0.48,SI,41908,Villegas Hernandez Ariana Gisell,CHIOHDZ31@GMAIL.COM,2007-12-14,70,2026-06-23,2414,2026-07-09 10:12:44
526611135759,telefono_compartido,0.48,,42043,Hernandez Tejeda Rocio,CHIOHDZ31@GMAIL.COM,1983-06-12,0,,2566,2026-07-09 10:12:44
526611137919,telefono_compartido,0.57,,42358,Hernandez Angulo Laura Yareli,LAURAHDZ2103@GMAIL.COM,1994-01-21,0,,2916,2026-07-09 10:12:50
526611137919,telefono_compartido,0.57,SI,45333,Reynoso Hernandez Renata,LAURAHDZ2103@GMAIL.COM,2018-02-06,12,2026-05-20,6317,2026-07-09 10:13:25
526611142245,duplicado_probable,0.81,,39814,Chavez Virginia,,,0,,34,2026-07-09 10:12:20
526611142245,duplicado_probable,0.81,SI,40031,Chavez Guzman Virginia,VIRGINIACHAVEZGUZMAN@GMAIL.COM,1977-04-14,2,2022-12-15,314,2026-07-09 10:12:20
526611142800,telefono_compartido,0.65,SI,41280,Ochoa Rivera Iker,ERIKARV8A@GMAIL.COM,2023-04-26,8,2025-01-07,1711,2026-07-09 10:12:32
526611142800,telefono_compartido,0.65,,41328,Rivera Sosa Erica,ERIKARV8A@GMAIL.COM,1979-08-23,0,,1764,2026-07-09 10:12:38
526611143867,telefono_compartido,0.56,SI,40229,Gomez Santiz Carmen,CARMENGS94@YAHOO.COM,1988-06-16,66,2026-06-24,529,2026-07-09 10:12:20
526611143867,telefono_compartido,0.56,,40317,Santibañez Gómez Jasmine,CLAUDIATOMAS@OUTLOOK.COM,2007-02-16,0,,625,2026-07-09 10:12:26
526611143918,telefono_compartido,0.38,SI,42646,Ortiz Lopez Maximo,,2021-10-22,2,2025-01-31,3246,2026-07-09 10:12:50
526611143918,telefono_compartido,0.38,,44674,Lopez Pulido Lorena Priscilla,PULIDOLORENA45@GMAIL.COM,1999-06-04,0,,5614,2026-07-09 10:13:13
526611147118,telefono_compartido,0.38,SI,43220,Mejia Castillo Cristina,COCO.CRISTINA.MEX@GMAIL.COM,1980-12-22,0,,4026,2026-07-09 10:12:57
526611147118,telefono_compartido,0.38,,43807,Leyva Mejia Lizbeth,COCO.CRISTINA.MEX@GMAIL.COM,2010-12-28,0,,4724,2026-07-09 10:13:08
526611148758,telefono_compartido,0.46,SI,39946,Carmona Lopez Misael Abisai,,2007-03-09,9,2023-09-07,197,2026-07-09 10:12:20
526611148758,telefono_compartido,0.46,,40608,Lopez Lorenzo Brenda Patricia,,1986-12-09,0,,946,2026-07-09 10:12:26
526611149488,telefono_compartido,0.50,SI,42153,Morales Ramirez Samuel Arturo,,2009-07-11,5,2026-05-28,2684,2026-07-09 10:12:44
526611149488,telefono_compartido,0.50,,46312,Ocampo Ramirez Martin David,OCAMPOMARTIN404@GMAIL.COM,1999-01-11,0,,7356,2026-07-09 10:13:37
526611149769,telefono_compartido,0.39,SI,46562,Estrada Flores Juan,,1950-01-27,9,2026-06-20,7619,2026-07-09 10:13:37
526611149769,telefono_compartido,0.39,,46656,Guevara Sara,,1929-10-14,0,,7723,2026-07-09 10:13:37
526611160528,telefono_compartido,0.53,SI,40668,Soberanes Eugenia,,1980-05-09,2,2025-02-19,1014,2026-07-09 10:12:26
526611160528,telefono_compartido,0.53,,44820,Soberanes Soberanes Victoria,,2010-09-11,0,,5765,2026-07-09 10:13:19
526611161063,telefono_compartido,0.55,SI,45069,Suarez Muñoz Anne Scarlet,,2013-11-22,2,2026-01-16,6038,2026-07-09 10:13:19
526611161063,telefono_compartido,0.55,,46333,Muñoz Ramirez Gabriela,G_A_VI14@HOTMAIL.COM,1994-02-27,0,,7377,2026-07-09 10:13:37
526611161736,telefono_compartido,0.31,SI,40614,Chavez Galvan Luis Fernando,CHAVEZLUIS1231@GMAIL.COM,2005-12-31,16,2025-12-03,955,2026-07-09 10:12:26
526611161736,telefono_compartido,0.31,,43390,Galvan Gomez Norma,,1981-11-15,0,,4257,2026-07-09 10:13:02
526611162228,telefono_compartido,0.29,SI,43747,Michael Marquez Alyn,,1995-02-04,23,2025-05-09,4659,2026-07-09 10:13:02
526611162228,telefono_compartido,0.29,,44638,Parra Elizabeth,,2013-06-01,0,,5576,2026-07-09 10:13:13
526611164930,telefono_compartido,0.52,SI,46924,Ramirez Robles Daniela,CINDYRA1679@GMAIL.COM,2009-05-13,3,2026-06-25,8114,2026-07-09 10:13:43
526611164930,telefono_compartido,0.52,,46948,Robles Aguirre Cindy,CINDYRA1679@GMAIL.COM,1979-01-16,0,,8140,2026-07-09 10:13:43
526611166372,duplicado_probable,0.81,SI,39995,Gomez Valenzuela Fernando,07VALENZUELAANA07@GMAIL.COM,2010-04-02,53,2026-06-10,259,2026-07-09 10:12:20
526611166372,duplicado_probable,0.81,,40351,Gomez Aneth,07VALENZUELAANA07@GMAIL.COM,2007-02-27,0,,663,2026-07-09 10:12:26
526611166372,duplicado_probable,0.81,,42026,Gomez Valenzuela Danna,07VALENZUELAANA07@GMAIL.COM,2013-02-11,0,,2548,2026-07-09 10:12:44
526611168484,telefono_compartido,0.48,SI,43384,Venegas Morales Yesika Elizabeth,JESIVENEGASA@OUTLOOK.COM,1980-01-14,8,2026-06-19,4251,2026-07-09 10:13:02
526611168484,telefono_compartido,0.48,,46846,Alvarez Venegas Maria Guadalupe,,2012-04-16,0,,7933,2026-07-09 10:13:43
526611198561,telefono_compartido,0.54,SI,43915,Parra Lepro Gael Alejandro,REBUILTSUM317@GMAIL.COM,2008-06-05,6,2025-02-06,4847,2026-07-09 10:13:08
526611198561,telefono_compartido,0.54,,43997,Lepro Galindo Veronica,VEROLEPRO@HOTMAIL.COM,1980-05-14,0,,4919,2026-07-09 10:13:08
526611230788,telefono_compartido,0.36,SI,39968,Mendoza Arambula Ana Laura,TPD.ANAMENDOZA@GMAIL.COM,1989-11-14,14,2025-08-27,230,2026-07-09 10:12:20
526611230788,telefono_compartido,0.36,,45768,Higuera Gloria Luz,,1952-12-28,0,,6772,2026-07-09 10:13:25
526611235293,telefono_compartido,0.58,SI,40594,Gomez Ambriz Denice,GOMEZDENICE071@GMAIL.COM,1985-12-27,4,2024-05-29,931,2026-07-09 10:12:26
526611235293,telefono_compartido,0.58,,43077,Cabrera Gomez Derek Emilio,DENICEGOMEZ071@GMAIL.COM,2011-11-07,0,,3851,2026-07-09 10:12:57
526611240852,telefono_compartido,0.39,SI,40045,Sandoval Osorio Orestes Octavio,YUVIAOSORIO1416@GMAIL.COM,2006-02-25,10,2026-01-27,329,2026-07-09 10:12:20
526611240852,telefono_compartido,0.39,,42583,Osorio Torres Yuvia,NO@GMAIL.COM,1979-10-23,0,,3173,2026-07-09 10:12:50
526611240852,telefono_compartido,0.39,,46326,Torres Cabrera Maria Teresa,,1961-07-31,0,,7370,2026-07-09 10:13:37
526611240933,telefono_compartido,0.27,SI,42744,Sterling Annie Marie,,1980-01-29,17,2026-04-07,3358,2026-07-09 10:12:50
526611240933,telefono_compartido,0.27,,43416,White Noah,,1990-08-10,0,,4284,2026-07-09 10:13:02
526611257754,telefono_compartido,0.22,SI,41637,Ramos Zaragosa Maria Guadalupe,NICOLAS.S.PLT@GMAIL.COM,1992-11-24,3,2025-05-23,2112,2026-07-09 10:12:38
526611257754,telefono_compartido,0.22,,45388,Santana Nicolas,,1993-10-18,0,,6370,2026-07-09 10:13:25
526611257775,telefono_compartido,0.65,SI,41451,Lara Castro Alma,AIDILLARA32@GMAIL.COM,1988-08-03,37,2025-12-10,1904,2026-07-09 10:12:38
526611257775,telefono_compartido,0.65,,41452,Moreno Lara Itzel Sirelly,ITZELSIRELLYML@ICLOUD.COM,2007-12-30,0,,1905,2026-07-09 10:12:38
526611257775,telefono_compartido,0.65,,43371,Moreno Lara Jazlyn,JAZYN2610ML@ICLOUD.COM,2010-01-26,0,,4234,2026-07-09 10:13:02
526611259077,duplicado_probable,0.82,SI,41636,Aguilar Brambila Sophie,CBRAMBILA.RUIZ@GMAIL.COM,2011-05-09,131,2026-06-30,2111,2026-07-09 10:12:38
526611259077,duplicado_probable,0.82,,42630,Aguilar Brambila Ivan,NO@GMAIL.COM,2009-07-19,0,,3227,2026-07-09 10:12:50
526611259077,duplicado_probable,0.82,,45814,Brambila Ruiz Claudia Teresa,CBRAMBILA.RUIZ@GMAIL.COM,1985-05-16,0,,6817,2026-07-09 10:13:31
526611261398,telefono_compartido,0.46,SI,42290,Howard Tapia Melanie Arlene,NO@OUTLOOK.COM,1996-03-21,10,2026-03-14,2842,2026-07-09 10:12:50
526611261398,telefono_compartido,0.46,,46450,Tapia Cortes Maria del Rosario,,1974-11-02,0,,7498,2026-07-09 10:13:37
526611262232,telefono_compartido,0.48,,44286,Benites Borbon Juana Edith,EDITH_DXH@HOTMAIL.COM,1990-11-20,0,,5213,2026-07-09 10:13:13
526611262232,telefono_compartido,0.48,SI,46357,Tronco Benites Dante,EDITH_DXH@HOTMAIL.COM,2012-10-09,31,2026-06-23,7403,2026-07-09 10:13:37
526611281981,duplicado_probable,1.00,,44479,Lopez Flores Rosa Maria,,1957-10-20,0,,5417,2026-07-09 10:13:13
526611281981,duplicado_probable,1.00,SI,46949,Lopez Flores Rosa Maria,LOPEZFLORESROSAMARIA8@GMAIL.COM,1957-10-20,2,2026-06-23,8142,2026-07-09 10:13:43
526611286717,telefono_compartido,0.58,SI,41191,Valenzuela Nieto Valeria,NIETO.JUDITH7@GMAIL.COM,2007-12-21,51,2026-01-10,1613,2026-07-09 10:12:32
526611286717,telefono_compartido,0.58,,43319,Nieto Talavera Judith,NIETO.JUDITH7@GMAIL.COM,1973-08-26,0,,4173,2026-07-09 10:13:02
526611300679,telefono_compartido,0.43,SI,40276,Vera Rodriguez Luz Maria,,1965-11-09,12,2024-12-09,579,2026-07-09 10:12:20
526611300679,telefono_compartido,0.43,,43059,Dominguez Torres Efrain,LUZM.VERA@HOTMAIL.COM,1965-12-08,0,,3830,2026-07-09 10:12:57
526611301648,telefono_compartido,0.49,SI,40604,Cortes Chavez Rosangel,,1990-01-20,72,2025-12-10,942,2026-07-09 10:12:26
526611301648,telefono_compartido,0.49,,40937,Gonzalez Cortez Dariana,,2014-01-20,0,,1325,2026-07-09 10:12:32
526611306067,telefono_compartido,0.65,,44379,Gonzalez Acosta Ismael,NALLELIACOSTA277@ICLOUD.COM,2011-05-18,0,,5309,2026-07-09 10:13:13
526611306067,telefono_compartido,0.65,SI,45202,Gonzalez Acosta Georgina,,2012-08-23,9,2025-08-27,6182,2026-07-09 10:13:19
526611306067,telefono_compartido,0.65,,45787,Gonzalez Alvarado Jorge,,1979-07-30,0,,6791,2026-07-09 10:13:31
526611307539,telefono_compartido,0.52,SI,40051,Hernandez Vidal Genesis Denisse,,2013-05-30,18,2025-02-20,335,2026-07-09 10:12:20
526611307539,telefono_compartido,0.52,,42922,Vidal Perez Cecilia,VIDALCECILIA979@GMAIL.COM,1989-12-03,0,,3676,2026-07-09 10:12:57
526611307777,telefono_compartido,0.54,SI,40742,Alba Correa Katherine Mireya,DOMINGUEZMIREYA349@GMAIL.COM,2011-06-11,19,2026-07-06,1102,2026-07-09 10:12:26
526611307777,telefono_compartido,0.54,,46534,Correa Mireya Yazmin,,1987-03-20,0,,7589,2026-07-09 10:13:37
526611312427,telefono_compartido,0.52,SI,42016,Martinez Herrera Estrella,,1982-11-30,0,,2538,2026-07-09 10:12:44
526611312427,telefono_compartido,0.52,,43983,Herrera Raquelina,,1956-07-20,0,,4903,2026-07-09 10:13:08
526611350980,telefono_compartido,0.75,SI,40590,Sanchez Balandran Ma Candelaria,CANDELS.SB@GMAIL.COM,1973-08-10,10,2024-08-22,927,2026-07-09 10:12:26
526611350980,telefono_compartido,0.75,,42627,Estrada Sanchez Isaias,CANDELS.SB@GMAIL.COM,2014-07-19,0,,3224,2026-07-09 10:12:50
526611350980,telefono_compartido,0.75,,43149,Estrada Sanchez Sofia Maiella,CANDELS.SB@GMAIL.COM,2009-04-25,0,,3930,2026-07-09 10:12:57
526611354116,telefono_compartido,0.61,SI,45553,De la Cruz Muñoz Maria de los Angeles,MARTINEZREYNA102@GMAIL.COM,1945-07-12,2,2025-10-27,6541,2026-07-09 10:13:25
526611354116,telefono_compartido,0.61,,45945,Martinez de la Cruz Alejandro,,2014-09-15,0,,6963,2026-07-09 10:13:31
526611355158,telefono_compartido,0.65,SI,40981,Hernandez Alonso Lorena,LORENAHDEZ47@YAHOO.COM,1973-10-29,14,2026-06-05,1372,2026-07-09 10:12:32
526611355158,telefono_compartido,0.65,,44008,Chavez Hernandez Jimena,CHAVEZJIMENA415@GMAIL.COM,2006-04-15,0,,4931,2026-07-09 10:13:08
526611359021,duplicado_probable,1.00,SI,39795,Gallardo Cano Denisse,NIZG722@GMAIL.COM,1999-06-10,15,2025-05-21,14,2026-07-09 10:12:20
526611359021,duplicado_probable,1.00,,40372,Gallardo Cano Denisse,NIZG723@GMAIL.COM,1999-06-10,0,,687,2026-07-09 10:12:26
526611361708,duplicado_probable,1.00,SI,40088,Canedo Rodriguez Fabrizzio,CAROFABRIZZIO@GMAIL.COM,2002-05-14,3,2023-01-10,377,2026-07-09 10:12:20
526611361708,duplicado_probable,1.00,,40421,Canedo Rodriguez Fabrizzio,CAROFABRIZZIO@GMAIL.COM,2002-05-14,0,,742,2026-07-09 10:12:26
526611362374,telefono_compartido,0.50,,40552,Corona Carmen,,1970-06-03,0,,886,2026-07-09 10:12:26
526611362374,telefono_compartido,0.50,SI,40553,Mendoza Cecilia,CECY.FICHO@GMAIL.COM,1968-04-28,2,2023-01-18,887,2026-07-09 10:12:26
526611465861,duplicado_probable,1.00,SI,39911,Villa Perez Violeta,,1977-10-12,3,2024-05-08,147,2026-07-09 10:12:20
526611465861,duplicado_probable,1.00,,42489,Villa Perez Violeta,,2004-01-05,0,,3063,2026-07-09 10:12:50
526611472087,telefono_compartido,0.49,SI,45454,Suazo Justo Paulette,,2010-02-25,3,2025-07-02,6437,2026-07-09 10:13:25
526611472087,telefono_compartido,0.49,,45523,Justo Reyes Ivone,,1991-12-07,0,,6508,2026-07-09 10:13:25
526611728774,telefono_compartido,0.38,SI,41964,Santos Ramirez Jasmin Nicole,,2023-09-06,24,2026-01-05,2478,2026-07-09 10:12:44
526611728774,telefono_compartido,0.38,,42312,Ramirez Delgado Erendira,,1986-05-08,0,,2867,2026-07-09 10:12:50
526611729992,telefono_compartido,0.27,SI,41682,Delgado Najera Maria del Refugio,BECKYRAMIREZ474@GMAIL.COM,1965-07-04,2,2024-12-05,2160,2026-07-09 10:12:38
526611729992,telefono_compartido,0.27,,43974,Silva Ramirez Andrea,BECKYRAMIREZ474@GMAIL.COM,2016-05-02,0,,4897,2026-07-09 10:13:08
526611730138,telefono_compartido,0.45,,44209,Magallanes Hernandez Merary Yireth,MARIA1848HERNDEZ@GMAIL.COM,2010-02-27,0,,5134,2026-07-09 10:13:08
526611730138,telefono_compartido,0.45,SI,46273,Hernandez Vazquez Maria Angelica,MARIA1848HERNANDEZ@GMAIL.COM,1992-06-16,19,2026-07-06,7315,2026-07-09 10:13:31
526611951442,telefono_compartido,0.27,SI,41918,Zaragoza Velazquez Denisse Lilian,ZARAGOZADENISSE323@GMAIL.COM,1998-11-06,6,2024-10-23,2424,2026-07-09 10:12:44
526611951442,telefono_compartido,0.27,,41919,Rodriguez Ayala Jose Jaime,,1997-06-07,0,,2425,2026-07-09 10:12:44
526614003328,duplicado_probable,0.95,SI,40214,Chaides Cabrales Maria,,1963-07-28,8,2023-07-04,512,2026-07-09 10:12:20
526614003328,duplicado_probable,0.95,,41630,Chaidez Cabrales Maria,,1963-07-08,0,,2105,2026-07-09 10:12:38
526614720096,telefono_compartido,0.48,SI,44416,Garcia Jaelyn,PAULINATOVAR82@GMAIL.COM,2019-04-27,2,2025-01-18,5352,2026-07-09 10:13:13
526614720096,telefono_compartido,0.48,,44417,Garcia Arley,PAULINATOVAR82@GMAIL.COM,2012-07-07,0,,5353,2026-07-09 10:13:13
526615274778,telefono_compartido,0.45,SI,41618,Chavez Garcia Renata,MARISOLGU80@GMAIL.COM,2015-09-14,7,2025-01-16,2093,2026-07-09 10:12:38
526615274778,telefono_compartido,0.45,,43810,Mayoral Garcia Sebastian,MARISOLGU80@GMAIL.COM,2008-02-02,0,,4727,2026-07-09 10:13:08
526615931311,duplicado_probable,1.00,SI,40568,Bravo Leyva Esmeralda,,1976-10-18,3,2026-02-09,902,2026-07-09 10:12:26
526615931311,duplicado_probable,1.00,,46453,Bravo Leyva Esmeralda,ESMERALDABRAVO1876@GMAIL.COM,1976-10-18,0,,7501,2026-07-09 10:13:37
526616160572,telefono_compartido,0.37,SI,43356,Gomez Alvarez Ma del Rosario,ROSARIOYSERFGIO08@GMAIL.COM,1976-01-15,4,2026-07-01,4213,2026-07-09 10:13:02
526616160572,telefono_compartido,0.37,,46977,Perez Gomez Zoe,ROSARIOZOE152024@GMAIL.COM,2009-08-06,0,,8175,2026-07-09 10:13:43
526616160771,telefono_compartido,0.57,SI,41716,Contreras Trujillo Lucia,LUCYHAROZ@LCLOUD.COM,1975-12-26,8,2024-02-14,2201,2026-07-09 10:12:38
526616160771,telefono_compartido,0.57,,41717,Haroz Contreras Alonso,LUCYHAROZ@ICLOUD.COM,2007-09-25,0,,2202,2026-07-09 10:12:38
526616164154,telefono_compartido,0.39,SI,39991,Garcia Poot Aurora Sofia,EDGAR.COTA1213@GMAIL.COM,2020-09-16,4,2026-02-27,255,2026-07-09 10:12:20
526616164154,telefono_compartido,0.39,,46396,Poot Flores Kimberly Isabel,KIMPOOT050500@GMAIL.COM,2000-05-05,0,,7442,2026-07-09 10:13:37
526617130745,telefono_compartido,0.52,SI,43429,Santana del Salto Paola,PAOLASANTANA4@GMAIL.COM,1976-09-04,8,2025-10-17,4298,2026-07-09 10:13:02
526617130745,telefono_compartido,0.52,,43430,Gonzaga Santana Ire,GONZAGAFAMILY2020@GMAIL.COM,2003-03-05,0,,4299,2026-07-09 10:13:02
526618501311,telefono_compartido,0.44,SI,40315,Gonzalez Calderon Natalia Margarita,,1989-10-29,53,2026-07-03,623,2026-07-09 10:12:26
526618501311,telefono_compartido,0.44,,43116,Esquivel Gonzalez Iris Poleth,,2012-09-27,0,,3893,2026-07-09 10:12:57
526618501719,telefono_compartido,0.43,SI,43407,Medina Eusebio Kevin Dakyru,MCEB0826@GMAIL.COM,2007-07-08,14,2024-12-10,4275,2026-07-09 10:13:02
526618501719,telefono_compartido,0.43,,43428,Eusebio Batista Mayra Cecilia,MCEB0826@GMAIL.COM,1984-02-29,0,,4297,2026-07-09 10:13:02
526618504999,telefono_compartido,0.53,,44119,Gonzalez Aguirre Isabela,VRJU@HOTMAIL.COM,2015-07-01,0,,5042,2026-07-09 10:13:08
526618504999,telefono_compartido,0.53,SI,46411,Gonzalez Salazar Veronica,VERONICA.TIGGER3@HOTMAIL.COM,1983-04-22,4,2026-02-16,7458,2026-07-09 10:13:37
526618506041,telefono_compartido,0.41,SI,40078,Campos Moreno Nora,MISSNORACAMPOS@HOTMAIL.COM,1986-02-21,43,2026-04-25,367,2026-07-09 10:12:20
526618506041,telefono_compartido,0.41,,40309,Lezama Campos Angel Jaziel,MISSNORACAMPOS@HOTMAIL.COM,1987-05-23,0,,617,2026-07-09 10:12:26
526618506922,telefono_compartido,0.35,SI,42993,Martinez Molina Cristopher Marin,DIVIAMOL301@GMAIL.COM,2010-11-03,3,2024-05-13,3751,2026-07-09 10:12:57
526618506922,telefono_compartido,0.35,,42994,Molina Vargas Divia,DIVIAMOL301@GMAIL.COM,1991-01-03,0,,3752,2026-07-09 10:12:57
526618507906,telefono_compartido,0.53,SI,40769,Perez Quezada Leticia,,1986-11-16,60,2025-12-03,1134,2026-07-09 10:12:26
526618507906,telefono_compartido,0.53,,43751,Perez Perez Karen,FERRETERIA2010SA@GMAIL.COM,2013-09-26,0,,4665,2026-07-09 10:13:02
526622021620,telefono_compartido,0.69,SI,41538,Almada Razcon Erika Fernanda,ERIKA.ALMADAR@ICLOUD.COM,1983-06-04,11,2025-06-17,2004,2026-07-09 10:12:38
526622021620,telefono_compartido,0.69,,43546,Arroyo Almada Leah Fernanda,ERIKA.ALMADAR@ICLOUD.COM,2015-05-09,0,,4440,2026-07-09 10:13:02
526631024646,telefono_compartido,0.23,SI,40701,Valencia Magana Priscilla,MM248011@ICLOUD.COM,2010-10-12,9,2025-06-05,1052,2026-07-09 10:12:26
526631024646,telefono_compartido,0.23,,45381,Magana Gutierrez Evangelina,MM248011@ICLOUD.COM,1950-01-24,0,,6363,2026-07-09 10:13:25
526631658038,telefono_compartido,0.70,SI,40246,Becerril Lopez Diana,SHOPPDI@HOTMAIL.COM,1978-03-05,71,2026-06-17,547,2026-07-09 10:12:20
526631658038,telefono_compartido,0.70,,42273,Sampayo Becerril Briana,ANASAMPAYO9@HOTMAIL.COM,2004-10-27,0,,2821,2026-07-09 10:12:44
526631994761,telefono_compartido,0.18,SI,42546,Barajas Siri Gianna,,1999-06-08,3,2024-05-31,3128,2026-07-09 10:12:50
526631994761,telefono_compartido,0.18,,43035,Thompson Scott,,1977-10-11,0,,3801,2026-07-09 10:12:57
526632010618,telefono_compartido,0.27,SI,43557,Morales Millany Valeria Patricia,MILLANYLAURA0618@GMAIL.COM,2008-01-05,7,2024-12-27,4452,2026-07-09 10:13:02
526632010618,telefono_compartido,0.27,,43719,Millany Medina Laura,MILLANYLAURA0618@GMAIL.COM,1990-07-19,0,,4630,2026-07-09 10:13:02
526632046506,telefono_compartido,0.57,SI,44483,Zuñiga Verdugo Ximena Maria,CRISTHIAN.ROCHA1988@ICLOUD.COM,2010-07-14,0,,5421,2026-07-09 10:13:13
526632046506,telefono_compartido,0.57,,44770,Zuñiga Rocha Cristhian,CRISTHIAN.ROCHA1988@ICLOUD.COM,1988-08-15,0,,5714,2026-07-09 10:13:13
526633013602,telefono_compartido,0.26,SI,42223,Rodriguez Palomares Santiago Rodolfo,FAVIOLA3086@GMAIL.COM,2011-11-08,6,2024-01-08,2765,2026-07-09 10:12:44
526633013602,telefono_compartido,0.26,,42379,Palomares Duarte Faviola Lizbeth,FAVIOLA3086@GMAIL.COM,1986-09-30,0,,2939,2026-07-09 10:12:50
526633281643,telefono_compartido,0.59,SI,40835,Valenzuela Bustamante Camila,ARLETTESV95@GMAIL.COM,2009-04-01,22,2025-08-21,1209,2026-07-09 10:12:32
526633281643,telefono_compartido,0.59,,41332,Sanchez Valenzuela Arlette,ARLETTESV95@GMAIL.COM,1995-06-15,0,,1769,2026-07-09 10:12:38
526634033230,telefono_compartido,0.52,,43176,Camargo Vazquez Sandra,CAMARGOSANDRA86@GMAIL.COM,1977-03-12,0,,3959,2026-07-09 10:12:57
526634033230,telefono_compartido,0.52,SI,45891,Cortez Camargo Keoni,CAMARGOSANDRA86@GMAIL.COM,2009-12-29,15,2025-11-01,6904,2026-07-09 10:13:31
526634380212,duplicado_probable,1.00,SI,41746,Cuen Baez Fridalexa,FRIDACUEN@GMAIL.COM,1999-05-14,5,2024-02-16,2234,2026-07-09 10:12:38
526634380212,duplicado_probable,1.00,,41913,Cuen Baez Fridalexa,FRIDACUEN@GMAIL.COM,1999-05-14,0,,2419,2026-07-09 10:12:44
526641083478,telefono_compartido,0.39,SI,46461,Sosa Carrazco Fernanda Elizabeth,FERNANDASOSAC@HOTMAIL.COM,1987-06-10,7,2026-05-22,7510,2026-07-09 10:13:37
526641083478,telefono_compartido,0.39,,46631,Dorado Sosa Jose Antonio,FERNANDASOSAC@HOTMAIL.COM,2021-04-09,0,,7693,2026-07-09 10:13:37
526641084548,telefono_compartido,0.37,SI,45265,Tafoya Cruz Karla Nayeli,TAFOYITA09@HOTMAIL.COM,1988-09-05,16,2025-09-05,6249,2026-07-09 10:13:19
526641084548,telefono_compartido,0.37,,45297,Lozano Mariana,,1994-11-07,11,2026-03-10,6280,2026-07-09 10:13:25
526641090153,telefono_compartido,0.57,SI,41456,Benetts Vivanco Damaris Estrella,D.BENETTS@ESCUELADENEGOCIOS.EDU.MX,1986-10-08,23,2026-03-07,1909,2026-07-09 10:12:38
526641090153,telefono_compartido,0.57,,45963,Barbosa Benetts Damaris Nicolle,D.BENETTS@ESCUELADENEGOCIOS.EDU.MX,2006-06-12,0,,6982,2026-07-09 10:13:31
526641177891,telefono_compartido,0.68,SI,40359,Brenes Alvarez Carlos Gustavo,PAOALVAREZ@HOTMAIL.COM,2010-07-25,32,2026-06-06,672,2026-07-09 10:12:26
526641177891,telefono_compartido,0.68,,40869,Alvarez Fitch Selma Paola,PAOALVAREZ@HOTMAIL.COM,1980-07-05,0,,1246,2026-07-09 10:12:32
526641177891,telefono_compartido,0.68,,42745,Brenes Alvarez Josemaria,PAOALVAREZ@HOTMAIL.COM,2012-07-31,0,,3359,2026-07-09 10:12:50
526641205176,telefono_compartido,0.46,SI,42356,Zamora Ortiz Cinthia Gabriela,GABRIELA_ZO@HOTMAIL.COM,1976-02-07,18,2026-05-18,2914,2026-07-09 10:12:50
526641205176,telefono_compartido,0.46,,46270,Lerma Zamora Renata,GABRIELA_ZO@HOTMAIL.COM,2011-09-20,0,,7312,2026-07-09 10:13:31
526641205455,telefono_compartido,0.70,SI,43871,Garcia Karina,,1973-10-21,0,,4792,2026-07-09 10:13:08
526641205455,telefono_compartido,0.70,,44801,Garcia Amezquita Kari Na,DRA.KGGA@GMAIL.COM,1976-03-20,0,,5747,2026-07-09 10:13:19
526641215148,telefono_compartido,0.70,SI,41769,Gonzalez Fregoso Roxana,MRRJRA10@GMAIL.COM,2009-09-08,10,2025-04-24,2259,2026-07-09 10:12:38
526641215148,telefono_compartido,0.70,,42919,Gonzalez Fregoso Analia,MRRJA10@GMAIL.COM,2009-09-08,0,,3673,2026-07-09 10:12:57
526641218831,telefono_compartido,0.47,SI,39993,Campos Morfin Tania Yolanda,TANIACAMPERS@GMAIL.COM,1988-02-18,7,2025-05-10,257,2026-07-09 10:12:20
526641218831,telefono_compartido,0.47,,45083,Uviña Campos Michael,TANIACAMPERS@GMAIL.COM,2009-10-22,0,,6053,2026-07-09 10:13:19
526641230976,telefono_compartido,0.43,SI,45474,Perez Contreras Marco Adrian,APEREZIBARRA@GMAIL.COM,2009-04-23,3,2025-07-18,6458,2026-07-09 10:13:25
526641230976,telefono_compartido,0.43,,45893,Perez Fausto Jesus,,1960-10-15,0,,6906,2026-07-09 10:13:31
526641243410,telefono_compartido,0.65,SI,44347,Morales Cisneros Emma,VAJOC86@GMAIL.COM,2007-08-08,0,,5273,2026-07-09 10:13:13
526641243410,telefono_compartido,0.65,,44794,Cisneros Lla Es Deborh,CIABNE@HOTMAIL.COM,1976-02-18,0,,5740,2026-07-09 10:13:19
526641267566,duplicado_probable,0.96,SI,40086,Nevares Michel Ana Maria,ANA.NE_87@ICLOUD.COM,1987-07-26,6,2023-05-15,375,2026-07-09 10:12:20
526641267566,duplicado_probable,0.96,,41184,Nevarez Michel Ana Maria,ANA.NE_87@ICLOUD.COM,1987-07-26,0,,1602,2026-07-09 10:12:32
526641280457,telefono_compartido,0.24,SI,45691,Hernandez Rios Francisco Javier,,1989-09-04,18,2026-03-24,6678,2026-07-09 10:13:25
526641280457,telefono_compartido,0.24,,45948,Villa Cons Mariana,MARIANAV_2202@HOTMAIL.COM,1990-02-22,0,,6966,2026-07-09 10:13:31
526641282169,telefono_compartido,0.38,SI,40331,Nuñez Escobedo Teresa,MG56870309@GMAIL.COM,2002-10-01,7,2023-10-30,642,2026-07-09 10:12:26
526641282169,telefono_compartido,0.38,,40332,Gonzalez Razo Marco Antonio,MG56870309@GMAIL.COM,1996-12-27,0,,644,2026-07-09 10:12:26
526641302653,duplicado_probable,1.00,,41863,Pacheco Castillo Lidia,LIDIA.PAC.CASTILLO@GMAIL.COM,1972-01-26,0,,2366,2026-07-09 10:12:44
526641302653,duplicado_probable,1.00,SI,45720,Pacheco Castillo Lidia,LIDIA.PAC.CASTILLO@GMAIL.COM,1972-01-26,23,2026-05-01,6711,2026-07-09 10:13:25
526641338180,telefono_compartido,0.34,SI,45118,Lugo Hernandez Nicolas Valentino,GISELL.ALEXADIAZ@GMAIL.COM,2021-10-21,5,2026-04-25,6098,2026-07-09 10:13:19
526641338180,telefono_compartido,0.34,,46432,Lugo Canobbio Jesus Alberto,ALCREFRIGERATIONSERVICES@GMAIL.COM,1996-03-26,0,,7480,2026-07-09 10:13:37
526641516570,telefono_compartido,0.38,SI,45072,Uribe Luna Valentina,ALEJANDRALUNA.ASESORIA@GMAIL.COM,2013-10-23,0,,6041,2026-07-09 10:13:19
526641516570,telefono_compartido,0.38,,45084,Luna Vazquez Alejandra,ALEJANDRALUNA.ASESORIA2@GMAIL.COM,1979-05-06,0,,6054,2026-07-09 10:13:19
526641519134,duplicado_probable,0.84,SI,45946,Romero Meza Isabel,11RELOVEDFURNITURE11@GMAIL.COM,1989-07-18,6,2026-03-27,6964,2026-07-09 10:13:31
526641519134,duplicado_probable,0.84,,46473,Romero Isabel,NO@GMAIL.COM,1989-07-18,0,,7523,2026-07-09 10:13:37
526641642104,duplicado_probable,0.90,SI,40793,Alvarado Osuna Eduardo,LALOMMA2013@GMAIL.COM,1992-10-07,1,2023-02-08,1160,2026-07-09 10:12:32
526641642104,duplicado_probable,0.90,,43852,Alvarado Osuna Jose Eduardo,TOREROALVARADO92@GMAIL.COM,1992-10-07,0,,4774,2026-07-09 10:13:08
526641665367,telefono_compartido,0.30,SI,42376,Rojas Diego,ELIZABETH.RSOSA@GMAIL.COM,2009-09-03,7,2024-01-11,2934,2026-07-09 10:12:50
526641665367,telefono_compartido,0.30,,42405,Montemayor Garcia Dora,NO@GMAIL.COM,1929-03-29,0,,2969,2026-07-09 10:12:50
526641758484,telefono_compartido,0.59,SI,43446,Rodriguez Andrade Gloria Berenice,PSIC.BERENICERODRIGUEZ@GMAIL.COM,1980-04-15,17,2026-06-13,4315,2026-07-09 10:13:02
526641758484,telefono_compartido,0.59,,44363,Cazares Rodriguez Alessandra,PSIC.BERENICERODRIGUEZ@GMAIL.COM,2012-02-22,0,,4579,2026-07-09 10:13:13
526641802915,telefono_compartido,0.62,SI,46218,Lamarque Laura,LAWIS_1702@HOTMAIL.COM,1982-12-10,8,2026-06-05,7256,2026-07-09 10:13:31
526641802915,telefono_compartido,0.62,,46219,Aguirre Sebastian,LAWIS_1702@HOTMAIL.COM,2013-03-02,0,,7257,2026-07-09 10:13:31
526641802915,telefono_compartido,0.62,,46322,Aguirre Lamarque Santiago,LAWIS_1702@HOTMAIL.COM,2008-09-13,0,,7366,2026-07-09 10:13:37
526641887222,telefono_compartido,0.61,SI,40204,Casco Benavides Annie,AICB07@HOTMAIL.COM,1978-01-07,87,2026-07-03,502,2026-07-09 10:12:20
526641887222,telefono_compartido,0.61,,40206,Parra Casco Valentina,AICB07@HOTMAIL.COM,2005-11-20,0,,504,2026-07-09 10:12:20
526641887222,telefono_compartido,0.61,,46565,Parra Renata,AICB07@HOTMAIL.COM,2014-09-20,0,,7622,2026-07-09 10:13:37
526641931585,telefono_compartido,0.54,SI,40413,Ruiz Pichardo Axel Manuel,PICHARDONORMA90@GMAIL.COM,2008-12-16,24,2026-04-23,732,2026-07-09 10:12:26
526641931585,telefono_compartido,0.54,,40706,Pichardo Flores Norma Edith,,1885-10-25,0,,1057,2026-07-09 10:12:26
526641976673,telefono_compartido,0.55,SI,40165,Trujillo Herrera Kenia Sofia,NORAELENAH510@GMAIL.COM,2011-12-08,6,2023-11-14,458,2026-07-09 10:12:20
526641976673,telefono_compartido,0.55,,40445,Herrera Magaña Nora,NORAELENAH510@GMAIL.COM,1988-06-07,0,,770,2026-07-09 10:12:26
526641985866,telefono_compartido,0.47,,43029,Orendai Arianna,P.ORENDAIN9@GMAIL.COM,2010-08-24,0,,3795,2026-07-09 10:12:57
526641985866,telefono_compartido,0.47,SI,44971,Orendain Rodriguez Perla Manuela,P.ORENDAIN9@GMAIL.COM,1990-08-09,6,2025-03-19,5933,2026-07-09 10:13:19
526641995186,telefono_compartido,0.41,SI,43324,Avila Martinez Emiliano,BERE_16_@HOTMAIL.COM,2915-05-02,4,2024-12-27,4178,2026-07-09 10:13:02
526641995186,telefono_compartido,0.41,,44298,Martinez Medina Ofelia Berenice,BERE_16_@HOTMAIL.COM,1986-01-27,0,,5225,2026-07-09 10:13:13
526641999762,telefono_compartido,0.66,SI,45586,Quijano Rodriguez Regina,OSCARQUIJANODELMAR@GMAIL.COM,2015-01-27,3,2025-11-12,6574,2026-07-09 10:13:25
526641999762,telefono_compartido,0.66,,45760,Rodriguez Gallego Claudia Josefina,CRG1486@ICLOUD.COM,1986-11-14,0,,6761,2026-07-09 10:13:25
526642014162,telefono_compartido,0.38,,40948,Gaona Reglado Ramiro,,1965-09-23,3,2023-05-03,1337,2026-07-09 10:12:32
526642014162,telefono_compartido,0.38,SI,41011,Talavera Velazquez Esmeralda,,1971-08-28,7,2026-05-27,1402,2026-07-09 10:12:32
526642047529,telefono_compartido,0.44,SI,45182,Navar Bojorquez Aleyda,ALEYDA.NAVAR@GMAIL.COM,1987-03-05,11,2026-01-12,5960,2026-07-09 10:13:19
526642047529,telefono_compartido,0.44,,45482,Navarro Ivanka,IVANKANN77@GMAIL.COM,2011-07-07,0,,6467,2026-07-09 10:13:25
526642047602,telefono_compartido,0.45,,44018,Ortega Quiñonez Ernesto,,1979-05-05,0,,4942,2026-07-09 10:13:08
526642047602,telefono_compartido,0.45,SI,45792,Quiñones Serrano Irma Eloy,,1948-12-01,1,2025-08-15,6796,2026-07-09 10:13:31
526642175802,telefono_compartido,0.64,,43345,Valenzuela Rojo Serafina,MAR_ROJO1301@HOTMAIL.COM,1958-08-23,0,,4202,2026-07-09 10:13:02
526642175802,telefono_compartido,0.64,SI,45850,Valenzuela Rojo Martina,MAR_ROJO1301@HOTMAIL.COM,1964-09-15,23,2026-03-21,6862,2026-07-09 10:13:31
526642175802,telefono_compartido,0.64,,46510,Chavez Cabanillas Guillermina,MAR_ROJO1301@HOTMAIL.COM,1969-06-25,0,,7565,2026-07-09 10:13:37
526642182279,telefono_compartido,0.75,SI,42330,Mendoza Guerrero Samantha,MELY15MX@HOTMAIL.COM,2010-03-30,24,2025-09-26,2887,2026-07-09 10:12:50
526642182279,telefono_compartido,0.75,,42454,Mendoza Guerrero Alan,ALAN16MX@GMAIL.COM,2006-09-20,0,,3022,2026-07-09 10:12:50
526642182279,telefono_compartido,0.75,,42585,Mendoza Guerrero Ximena,MELY15MX@HOTMAIL.COM,2011-06-06,0,,3175,2026-07-09 10:12:50
526642182279,telefono_compartido,0.75,,44031,Guerrero Amarillas Meliza,MELY15MX@HOTMAIL.COM,1982-11-05,0,,4954,2026-07-09 10:13:08
526642258050,telefono_compartido,0.53,SI,43066,Ramirez Soler Elias,ITZEL.SOLER@GMAIL.COM,2017-03-24,14,2024-12-06,3838,2026-07-09 10:12:57
526642258050,telefono_compartido,0.53,,43068,Soler Itzel,ITZEL.SOLER@GMAIL.COM,1988-07-09,0,,3840,2026-07-09 10:12:57
526642288589,telefono_compartido,0.57,SI,46227,Santos Heinecke Mariel Abigahil,BECKYHEINECKE@HOTMAIL.COM,2013-11-30,7,2026-02-27,7265,2026-07-09 10:13:31
526642288589,telefono_compartido,0.57,,46228,Heinecke Saldaña Rebeca Arely,BECKYHEINECKE@HOTMAIL.COM,1982-02-23,0,,7266,2026-07-09 10:13:31
526642323443,telefono_compartido,0.37,,42075,Perez Medrano Shantelle,BIANEYLOVECRAFT@GMAIL.COM,2010-11-05,0,,2601,2026-07-09 10:12:44
526642323443,telefono_compartido,0.37,SI,45328,Ceballlos Hid Hester,NO@GMAIL.COM,1969-08-14,5,2025-12-06,6312,2026-07-09 10:13:25
526642323899,telefono_compartido,0.39,SI,40418,Ramirez Machado Diego Alfonzo,FERNANDO@LUXORINT.COM,1952-11-13,11,2025-02-20,738,2026-07-09 10:12:26
526642323899,telefono_compartido,0.39,,40739,Vera Hernandez Alejandra,,1978-06-20,0,,1099,2026-07-09 10:12:26
526642323899,telefono_compartido,0.39,,41639,Villarreal Vera Danna Emyli,JOCELYNLEYVA16@GMAIL.COM,2011-02-21,0,,2114,2026-07-09 10:12:38
526642331765,telefono_compartido,0.68,,42663,Barajas Martinez Paulina,BARAJAS_PAOX87@HOTMAIL.COM,1987-07-28,0,,3266,2026-07-09 10:12:50
526642331765,telefono_compartido,0.68,,43205,Torres Barajas Erick,BARAJAS_PAOX87@HOTMAIL.COM,2008-04-19,0,,4000,2026-07-09 10:12:57
526642331765,telefono_compartido,0.68,SI,45058,Torres Barajas Ayleen,BARAJAS_PAOX87@HOTMAIL.COM,2012-01-08,23,2026-05-13,6028,2026-07-09 10:13:19
526642339634,telefono_compartido,0.60,,45312,Romero Sonia,,1979-08-23,0,,6297,2026-07-09 10:13:25
526642339634,telefono_compartido,0.60,SI,45408,Romero Arzapalo Sonia Yudith,SYRAS3999@GMAIL.COM,1979-08-23,4,2025-05-27,6391,2026-07-09 10:13:25
526642401717,telefono_compartido,0.29,SI,44702,Mercado Rojas Victor Manuel,NO@GMAIL.COM,1972-12-04,18,2026-06-29,5642,2026-07-09 10:13:13
526642401717,telefono_compartido,0.29,,45006,Lora Ana,,1980-08-13,2,2025-03-19,5972,2026-07-09 10:13:19
526642519054,telefono_compartido,0.57,SI,46216,Fuentes Gutierrez Carlos,ROX_SELENE03@HOTMAIL.COM,2010-08-05,8,2026-06-24,7254,2026-07-09 10:13:31
526642519054,telefono_compartido,0.57,,46378,Gutierrez Gallegos Roxana,ROX_SELENE03@HOTMAIL.COM,1983-11-05,0,,7424,2026-07-09 10:13:37
526642521950,telefono_compartido,0.44,SI,41412,Rivera Cota Veronica,VERONICARIVERACOTA@HOTMAIL.COM,1981-01-05,8,2026-07-01,1859,2026-07-09 10:12:38
526642521950,telefono_compartido,0.44,,43106,Castaneda Isaias,ISA@NUVIEWPKUS.COM,1981-01-12,0,,3882,2026-07-09 10:12:57
526642568619,duplicado_probable,1.00,SI,46022,Zepeda Fernandez Maria Guadalupe,,1962-12-11,6,2026-07-01,7046,2026-07-09 10:13:31
526642568619,duplicado_probable,1.00,,46834,Zepeda Fernandez Maria Guadalupe,LUPITA.ZEPEDAF@GMAIL.COM,1962-12-11,0,,7917,2026-07-09 10:13:43
526642571654,telefono_compartido,0.63,SI,40300,Espinoza Juarez Milca Marcela,,2008-04-30,20,2026-05-23,604,2026-07-09 10:12:26
526642571654,telefono_compartido,0.63,,40908,Juarez Juarez Olivia Delif,OLIVIADELIF@HOTMAIL.COM,1978-04-20,0,,1291,2026-07-09 10:12:32
526642571654,telefono_compartido,0.63,,41928,Espinoza Juarez Luis Obed,OLIVIADELIF@HOTMAIL.COM,2009-05-13,0,,2438,2026-07-09 10:12:44
526642638146,telefono_compartido,0.57,SI,39922,Rodriguez Gastelum Erika,GASTELUM.E@HOTMAIL.COM,1984-08-30,23,2023-11-22,163,2026-07-09 10:12:20
526642638146,telefono_compartido,0.57,,40731,Verjan Rodriguez Isabella,GASTELUM.E@hotmail.com,2011-10-03,0,,1091,2026-07-09 10:12:26
526642810515,telefono_compartido,0.44,SI,42999,Mendoza Lopez Jesus Ramon,JESUS.MENDLOP@GMAIL.COM,1991-07-19,20,2026-04-10,3757,2026-07-09 10:12:57
526642810515,telefono_compartido,0.44,,46539,Lopez Ulloa Gabriela,,1972-07-29,4,2026-07-06,7594,2026-07-09 10:13:37
526642874699,telefono_compartido,0.33,SI,41643,Castillo Madrigal Isabella,ISACAMA27@YAHOO.COM,2006-12-27,26,2025-09-12,2118,2026-07-09 10:12:38
526642874699,telefono_compartido,0.33,,43918,Madrigal Zugasti Nadia,,1977-11-17,0,,62,2026-07-09 10:13:08
526642916418,telefono_compartido,0.60,SI,42872,Villarreal Villanes Grisel Aracely,DANIAV@GMAIL.COM,2007-12-12,51,2026-07-01,3608,2026-07-09 10:12:57
526642916418,telefono_compartido,0.60,,43244,Villanes Estrada Dania Grisel,DANIAV@GMAIL.COM,1979-03-19,0,,4063,2026-07-09 10:12:57
526642964659,telefono_compartido,0.75,SI,45951,Menchaca Olvera Lia Kamila,FLOROLVERA92@ICLOUD.COM,2016-07-30,4,2025-12-01,6969,2026-07-09 10:13:31
526642964659,telefono_compartido,0.75,,45952,Menchaca Olvera Lily Mailen,FLOROLVERA92@ICLOUD.COM,2017-10-20,0,,6970,2026-07-09 10:13:31
526643014407,telefono_compartido,0.72,SI,42620,Hernandez Navarro Maria Guadalupe,,1958-01-24,17,2025-06-28,3215,2026-07-09 10:12:50
526643014407,telefono_compartido,0.72,,45153,Navarro Andrade Maria Guadalupe,,1958-01-24,6,2025-08-26,6136,2026-07-09 10:13:19
526643047307,telefono_compartido,0.53,SI,42406,Bejarano Paola,PAOLA26@GMAIL.COM,1979-02-26,39,2026-04-14,2970,2026-07-09 10:12:50
526643047307,telefono_compartido,0.53,,43193,Villareal Bejarano Maite Andrea,PAOLA26@GMAIL.COM,2007-05-03,0,,3985,2026-07-09 10:12:57
526643097726,telefono_compartido,0.47,SI,40443,Fagoaga Mora Clara Judith,JEGONFA@GMAIL.COM,1939-08-12,5,2024-12-07,768,2026-07-09 10:12:26
526643097726,telefono_compartido,0.47,,43904,Gonzalez Fagoaga Jesus Eduardo,JEGONFA@GMAIL.COM,1974-03-18,0,,4835,2026-07-09 10:13:08
526643114776,telefono_compartido,0.65,,41902,Ramirez Castillo Isabella,,2008-01-14,0,,2408,2026-07-09 10:12:44
526643114776,telefono_compartido,0.65,SI,41995,Ramirez Castillo Valeria,LULU.CASTILLO@HOTMAIL.COM,2003-09-14,11,2025-11-22,2512,2026-07-09 10:12:44
526643154097,duplicado_probable,1.00,SI,40631,Lopez Alfaro Dayra Elizabeth,DAYRAELA615@gmail.com,2006-05-25,15,2026-06-24,972,2026-07-09 10:12:26
526643154097,duplicado_probable,1.00,,42339,Lopez Alfaro Dayra Elizabeth,DAYRAELA615@GMAIL.COM,2006-05-25,0,,2896,2026-07-09 10:12:50
526643169201,duplicado_probable,1.00,SI,41807,Diego Alvarez Rosario,,1979-10-20,21,2025-12-13,2306,2026-07-09 10:12:44
526643169201,duplicado_probable,1.00,,45895,Diego Álvarez Alejandra de Jesús,ROSARIODIEGOALVAREZ@GMAIL.COM,2011-04-09,0,,6908,2026-07-09 10:13:31
526643169201,duplicado_probable,1.00,,45897,Diego Alvarez Rosario,,1979-10-20,0,,6910,2026-07-09 10:13:31
526643184370,telefono_compartido,0.50,SI,40346,Gonzalez Ruiz Maria Rogelia,GLEZM3415@GMAIL.COM,1990-10-04,11,2023-03-03,658,2026-07-09 10:12:26
526643184370,telefono_compartido,0.50,,40348,Gonzalez Nicole Guadalupe,GLEZM3415@GMAIL.COM,2010-07-09,0,,660,2026-07-09 10:12:26
526643270516,telefono_compartido,0.46,SI,40591,Romero Lara Brenda Laura,BRENDALRL29@GMAIL.COM,1984-02-27,18,2026-01-13,928,2026-07-09 10:12:26
526643270516,telefono_compartido,0.46,,44049,Olmos Romero Niza Ximena,BRENDA_LRL@HOTMAIL.COM,2008-01-28,0,,4970,2026-07-09 10:13:08
526643308936,telefono_compartido,0.49,SI,40497,Nava Vazquez Alejandra,RUBEN95ALE@GMAIL.COM,1973-12-28,37,2026-04-20,826,2026-07-09 10:12:26
526643308936,telefono_compartido,0.49,,41502,Hernandez Nava Kayla,RUBEN95ALE@GMAIL.COM,2008-03-07,0,,1959,2026-07-09 10:12:38
526643308936,telefono_compartido,0.49,,42824,Hernandez Gutierrez Ruben,,1971-10-27,0,,3518,2026-07-09 10:12:57
526643312097,telefono_compartido,0.41,SI,42985,Aviles Ortiz Emmanuel Jaime,RAKE.ORTIZ.LEON@GMAIL.COM,2007-01-10,11,2026-06-16,3742,2026-07-09 10:12:57
526643312097,telefono_compartido,0.41,,46362,Ortiz Leon Raquel,RAKE_ORTIZ@HOTMAIL.COM,1976-02-14,0,,7408,2026-07-09 10:13:37
526643314910,telefono_compartido,0.40,SI,43216,Corrales Delgado Ivana Victoria,ZULL_37@HOTMAIL.COM,2008-02-29,0,,4014,2026-07-09 10:12:57
526643314910,telefono_compartido,0.40,,44115,Delgado Zulema,,1970-02-26,0,,5038,2026-07-09 10:13:08
526643317692,telefono_compartido,0.51,SI,45385,Leyva Orozco Yaqueline,,1976-03-08,5,2026-03-02,6367,2026-07-09 10:13:25
526643317692,telefono_compartido,0.51,,46465,Negrete Leyva Melissa,,2008-01-01,0,,7514,2026-07-09 10:13:37
526643330312,telefono_compartido,0.36,,43870,Villazana Quintero Valeria,MAQUIORTIZ90@GMAIL.COM,2010-12-15,0,,4791,2026-07-09 10:13:08
526643330312,telefono_compartido,0.36,SI,46250,Quintero Ortiz Mara,MAQUIORTIZ90@GMAIL.COM,1990-04-20,5,2026-05-05,7289,2026-07-09 10:13:31
526643331107,telefono_compartido,0.32,SI,41387,Montelongo Rios Margarita,ROSALBA9393@GMAIL.COM,1946-06-10,4,2023-06-08,1829,2026-07-09 10:12:38
526643331107,telefono_compartido,0.32,,41478,Muñoz Rosalba,ROSAMARIA33DIDI@GMAIL.COM,1967-08-31,0,,1931,2026-07-09 10:12:38
526643344347,telefono_compartido,0.50,SI,43290,Sosa Montemayor Elizabeth,ELIZABETTAMONTEMAYOR@GMAIL.COM,1966-08-30,5,2024-11-19,4128,2026-07-09 10:13:02
526643344347,telefono_compartido,0.50,,46936,Montemayor Garcia Dora Irma,ELIZABETTAMONTEMAYOR@GMAIL.COM,1929-03-29,2,2026-07-04,8127,2026-07-09 10:13:43
526643366324,telefono_compartido,0.26,SI,41801,Partida Garcia Rogelio,ROGELIOROSARITOGARCIA.4@GMAIL.COM,1973-08-12,8,2024-10-07,2297,2026-07-09 10:12:44
526643366324,telefono_compartido,0.26,,43332,Tec Cortes Jesus Antonio,ANTONIOTEC0528@GMAIL.COM,1980-05-28,2,2024-10-07,4187,2026-07-09 10:13:02
526643416777,telefono_compartido,0.12,SI,40897,Reyes Nicole,SHARDESAHAGUN@GMAIL.COM,2022-12-30,4,2023-04-27,1280,2026-07-09 10:12:32
526643416777,telefono_compartido,0.12,,45616,Garcia Sahagun Sharde,SHARDESAHAGUN@GMAIL.COM,1993-10-04,2,2025-09-06,6606,2026-07-09 10:13:25
526643483151,telefono_compartido,0.24,,41858,Vazquez Nina,,2017-04-11,0,,2361,2026-07-09 10:12:44
526643483151,telefono_compartido,0.24,SI,41859,Sandoval Yara,YARAZET.VAZQUEZ11@GMAIL.COM,1986-10-06,3,2023-11-14,2362,2026-07-09 10:12:44
526643490274,telefono_compartido,0.50,SI,42754,Montero Reyes Bibiana Patricia,PATY.MONTERE@GMAIL.COM,1986-03-17,5,2025-05-28,3368,2026-07-09 10:12:50
526643490274,telefono_compartido,0.50,,44167,Romo Montero Ailyn,,2015-02-11,0,,5091,2026-07-09 10:13:08
526643574311,telefono_compartido,0.73,SI,40196,Perez Ortega Carmen Leticia,LETYPO.ENF@HOTMAIL.COM,1963-03-08,36,2025-12-19,494,2026-07-09 10:12:20
526643574311,telefono_compartido,0.73,,44078,Perez Ortega Elsa,EMEPO_24@HOTMAIL.COM,1969-12-24,0,,5001,2026-07-09 10:13:08
526643638366,telefono_compartido,0.33,SI,44281,Arce Hernandez Aide,AURA_ARCE@HOTMAIL.COM,1977-12-23,0,,5207,2026-07-09 10:13:08
526643638366,telefono_compartido,0.33,,44284,Felix Juan Carlos,,1983-08-02,0,,5210,2026-07-09 10:13:13
526643641839,telefono_compartido,0.59,SI,40341,Diego Acosta Marla,BIAG2813@GMAIL.COM,2010-02-20,65,2026-05-13,653,2026-07-09 10:12:26
526643641839,telefono_compartido,0.59,,41929,Acosta Gallo Blanca Isaura,BIAG2813@GMAIL.COM,1983-03-28,0,,2440,2026-07-09 10:12:44
526643684241,telefono_compartido,0.55,,42906,Gonzalez Guadalupe,LUPITA.SANTAMARIA@YAHOO.COM.MX,1962-12-21,11,2024-08-09,3656,2026-07-09 10:12:57
526643684241,telefono_compartido,0.55,SI,43916,Negrete Gonzalez Karla,KARLAKALLIO@HOTMAIL.COM,1985-01-11,26,2025-12-08,3601,2026-07-09 10:13:08
526643684320,duplicado_probable,1.00,SI,41729,Herrera Machuca Carlos,CARLOS.HERRERA@ASICMEXICO.COM,1956-12-16,3,2025-02-12,2217,2026-07-09 10:12:38
526643684320,duplicado_probable,1.00,,44516,Herrera Machuca Carlos,CARLOS.HERRERA@ASICMEXICO.COM,1956-12-16,0,,5448,2026-07-09 10:13:13
526643687659,telefono_compartido,0.55,SI,44077,Diaz Amador Victoria,ROCIO_AN@HOTMAIL.COM,2008-08-07,0,,5000,2026-07-09 10:13:08
526643687659,telefono_compartido,0.55,,44251,Amador Noriega Rocio,ROCIO_AN@HOTMAIL.COM,1984-11-08,0,,5181,2026-07-09 10:13:08
526643689362,telefono_compartido,0.27,SI,43414,Nora Celia Gonzalez Villalobos,VILLANORA66@GMAIL.COM,1966-02-10,3,2024-09-20,4282,2026-07-09 10:13:02
526643689362,telefono_compartido,0.27,,43601,Rasmussen Egil,VILLANORA66@GMAIL.COM,1966-04-25,0,,4502,2026-07-09 10:13:02
526643689735,telefono_compartido,0.55,,41314,Oropeza Gutierrez Humberto,EDITH.GR788@GMAIL.COM,2006-05-10,0,,1748,2026-07-09 10:12:38
526643689735,telefono_compartido,0.55,SI,44813,Gutierrez Rodriguez Edith,NO@gmail.com,1988-07-24,30,2026-05-13,3151,2026-07-09 10:13:19
526643694332,telefono_compartido,0.27,SI,39909,Mercado Gracia Olivia Leticia,OLIVIAJ1971@GMAIL.COM,1971-06-19,44,2026-02-23,144,2026-07-09 10:12:20
526643694332,telefono_compartido,0.27,,40857,Manousakis Manny,OLIVIAJ1971@GMAIL.COM,1960-06-06,0,,1233,2026-07-09 10:12:32
526643702018,telefono_compartido,0.78,,42398,Orihuela Moreno Arcinoe,AMOREYA77@GMAIL.COM,2023-12-09,0,,2962,2026-07-09 10:12:50
526643702018,telefono_compartido,0.78,,42506,Orihuela Moreno Yael,AMOREYA77@GMAIL.COM,2012-08-22,0,,3081,2026-07-09 10:12:50
526643702018,telefono_compartido,0.78,SI,45738,Orihuela Moreno Miranda,AMOREYA77@GMAIL.COM,2018-05-28,7,2025-09-16,6733,2026-07-09 10:13:25
526643702018,telefono_compartido,0.78,,45793,Moreno Montoya Aurora,AMOREYA77@GMAIL.COM,1977-07-23,0,,6797,2026-07-09 10:13:31
526643715000,telefono_compartido,0.60,SI,42242,Flores Gutierrez Damian,ABRIL11LOCA@GMAIL.COM,2009-05-14,6,2024-06-05,2787,2026-07-09 10:12:44
526643715000,telefono_compartido,0.60,,42497,Gutierrez Cota Abril,ABRIL11LOCA@GMAIL.COM,1987-05-11,0,,3072,2026-07-09 10:12:50
526643852703,telefono_compartido,0.37,SI,40827,Guerrero Lopez Karen Melissa,KAREN.MELIGRO7@GMAIL.COM,1997-06-07,29,2025-10-22,1201,2026-07-09 10:12:32
526643852703,telefono_compartido,0.37,,44042,Guerrero Dayana,KARENMELIGRO7@GMAIL.COM,2007-11-16,0,,3203,2026-07-09 10:13:08
526643865393,telefono_compartido,0.41,SI,45502,Mercado Austin Regina,MERKKO3@YAHOO.COM,1987-08-06,5,2026-02-23,6487,2026-07-09 10:13:25
526643865393,telefono_compartido,0.41,,46393,Duran Rios Rodrigo,RODRIGODURAN75@HOTMAIL.COM,1975-09-02,0,,7439,2026-07-09 10:13:37
526643896467,telefono_compartido,0.51,SI,42602,Velarde Vega Monica,MONICA.VELARDE@HOTMAIL.COM,1973-08-31,47,2024-12-06,3194,2026-07-09 10:12:50
526643896467,telefono_compartido,0.51,,42713,Dominguez Velarde Aithana Nicole,,2009-08-11,0,,3321,2026-07-09 10:12:50
526643897005,telefono_compartido,0.33,SI,45611,Gonzalez Isabel,ISAGGZ010489@HOTMAIL.COM,1989-04-01,7,2025-11-11,6601,2026-07-09 10:13:25
526643897005,telefono_compartido,0.33,,45612,Armas Alejandro,,2008-09-02,0,,6602,2026-07-09 10:13:25
526643897988,telefono_compartido,0.58,SI,42131,Ruiz Felix Nataly Guadalupe,TALY.RUIZ17@GMAIL.COM,1995-11-17,6,2026-06-06,2661,2026-07-09 10:12:44
526643897988,telefono_compartido,0.58,,46146,Ruiz Nataly,TALY.RUIZ17@GMAIL.COM,1995-11-17,0,,7182,2026-07-09 10:13:31
526643993372,telefono_compartido,0.42,SI,42349,Moreno Ortega Magdelis,MADGDELISMO79@GMAIL.COM,1979-08-14,6,2024-11-02,2907,2026-07-09 10:12:50
526643993372,telefono_compartido,0.42,,43900,Ortega Ortega Eladia Sofia,MAGDELISMO79@GMAIL.COM,1952-09-18,0,,4829,2026-07-09 10:13:08
526644042457,telefono_compartido,0.32,,43372,Bernal Leonel,,1978-02-20,0,,4235,2026-07-09 10:13:02
526644042457,telefono_compartido,0.32,SI,45894,Nuñez Dozal Karina,LENORIOSDALYBERNALNUNEZ29@GMAIL.COM,1983-01-31,4,2026-01-20,6907,2026-07-09 10:13:31
526644044790,telefono_compartido,0.34,SI,42983,Orta Martinez Consuelo,DZGG7703@GMAIL.COM,1924-08-29,81,2026-07-01,3740,2026-07-09 10:12:57
526644044790,telefono_compartido,0.34,,43921,Godinez Gutierrez Dulce Zuleyka,DZGG7703@GMAIL.COM,1977-02-03,0,,884,2026-07-09 10:13:08
526644047227,telefono_compartido,0.49,SI,40646,Chavez Pelayo Laura Alida,LAURA_ALY17@hotmail.com,1975-08-02,19,2026-04-22,988,2026-07-09 10:12:26
526644047227,telefono_compartido,0.49,,42702,Salazar Chavez Sofia,LAURA_ALY17@HOTMAIL.COM,2010-11-30,0,,3310,2026-07-09 10:12:50
526644058906,telefono_compartido,0.58,SI,43369,Oronoz Gonzalez Miranda,TETEOROGONZA@GMAIL.COM,2011-06-09,24,2025-05-21,4231,2026-07-09 10:13:02
526644058906,telefono_compartido,0.58,,44262,Gonzalez Rubalcava Teresa,TETEOROGONZA@GMAIL.COM,2024-03-02,0,,3302,2026-07-09 10:13:08
526644063990,telefono_compartido,0.48,,43626,Aguilar Gutierrez Jose,JOSEGAGUILAR1@HOTMAIL.COM,1980-07-14,0,,4530,2026-07-09 10:13:02
526644063990,telefono_compartido,0.48,SI,45262,Aguilar Moreno Tahilyn Monserrat,JOSEGAGUILAR1@HOTMAIL.COM,2011-11-11,3,2025-05-05,6246,2026-07-09 10:13:19
526644079616,duplicado_probable,0.87,SI,39791,Flores Romero Marco Antonio,ADI_ARQ.MARCOANTONIO@HOTMAIL.COM,1987-09-13,14,2024-09-23,10,2026-07-09 10:12:20
526644079616,duplicado_probable,0.87,,42750,Flores Rodarte Marco Antonio,NO.NO@GMAIL.COM,1987-09-13,0,,3364,2026-07-09 10:12:50
526644083858,duplicado_probable,0.85,SI,45372,Guerrero B Blanca,BLANCAGUERRERO59@GMAIL.COM,1959-08-26,26,2026-05-08,6354,2026-07-09 10:13:25
526644083858,duplicado_probable,0.85,,46287,Guerrero Benitez Blanca,BLANCAGUERRERO59@GMAIL.COM,1959-08-26,0,,7329,2026-07-09 10:13:37
526644133549,duplicado_probable,0.84,SI,42856,Rodriguez Garcia Diego,EGARCIARODRIGUEZ18@GMAIL.COM,2009-01-18,13,2026-05-28,3590,2026-07-09 10:12:57
526644133549,duplicado_probable,0.84,,46860,Rodriguez Garcia Emilio,,2015-06-18,0,,7947,2026-07-09 10:13:43
526644158987,telefono_compartido,0.50,SI,40534,Jimenez Beltran Tiffany,SILVIA.BELTRAN.ORTEGA@hotmail.com,2008-05-23,18,2026-01-19,867,2026-07-09 10:12:26
526644158987,telefono_compartido,0.50,,42463,Beltran Ortega Silvia,,1973-09-21,0,,3032,2026-07-09 10:12:50
526644216244,telefono_compartido,0.59,SI,40732,De Anda Carrera Luz Noemi,NOEMIDEANDAA@GMAIL.COM,1989-02-03,67,2026-06-20,1092,2026-07-09 10:12:26
526644216244,telefono_compartido,0.59,,40733,Lepe de Anda Leonardo,,2009-06-01,0,,1093,2026-07-09 10:12:26
526644216244,telefono_compartido,0.59,,46488,De Anda Carolina,,1951-01-13,0,,7542,2026-07-09 10:13:37
526644250801,telefono_compartido,0.39,SI,43439,Sanchez Ramos David,LORENASANCHEZR97@GMAIL.COM,2010-09-09,19,2026-04-06,4308,2026-07-09 10:13:02
526644250801,telefono_compartido,0.39,,44241,Ramos Medina Felicitas,FELIX.RAMOSMEDINA@HOTMAIL.COM,1973-05-20,0,,5171,2026-07-09 10:13:08
526644375276,telefono_compartido,0.62,SI,40134,Ayala Ainsworth Beatriz,,1954-06-30,23,2026-03-25,425,2026-07-09 10:12:20
526644375276,telefono_compartido,0.62,,40438,Gomez Ayala Beatriz,MDBETTY3001@GMAIL.COM,1974-01-30,1,2022-12-05,762,2026-07-09 10:12:26
526644375460,duplicado_probable,0.83,SI,43725,Valdez Acosta Ian,GUADALUPEACOSTAESPINOZA@GMAIL.COM,2016-02-02,0,,4637,2026-07-09 10:13:02
526644375460,duplicado_probable,0.83,,43726,Valdez Castel Carlos,GUADALUPEAACOSTAESPINOZA@GMAIL.COM,1978-02-24,0,,4638,2026-07-09 10:13:02
526644375460,duplicado_probable,0.83,,43989,Acosta Espinoza Guadalupe,GUADALUPEACOSTAESPINOZA@GMAIL.COM,1987-12-06,0,,4909,2026-07-09 10:13:08
526644375460,duplicado_probable,0.83,,44480,Valdez Acosta Karla,,2013-01-04,0,,5418,2026-07-09 10:13:13
526644389554,telefono_compartido,0.40,SI,42157,Sanchez Martinez Elizabeth,ELIUNK@HOTMAIL.COM,1981-12-13,19,2026-06-11,2690,2026-07-09 10:12:44
526644389554,telefono_compartido,0.40,,45802,Pardini Gaxiola Gilberto,,1976-08-04,0,,6805,2026-07-09 10:13:31
526644409265,telefono_compartido,0.13,,41626,Anaya Sanchez Lidia Naomi,GRACIELASANCHEX1988@GMAIL.COMPOR,2007-03-22,0,,2101,2026-07-09 10:12:38
526644409265,telefono_compartido,0.13,SI,45565,Mata Mendoza Severina,GRACIELASANCHEX1988@GMAIL.COM,1952-11-30,16,2025-08-04,6553,2026-07-09 10:13:25
526644473608,telefono_compartido,0.79,SI,39869,Perez Reyes America,AME_ALEJANDRA@HOTMAIL.COM,1994-09-05,36,2026-06-06,97,2026-07-09 10:12:20
526644473608,telefono_compartido,0.79,,40090,Perez Reyes America Alejandra,AME_ALEJANDRA@HOTMAIL.COM,1994-09-06,0,,379,2026-07-09 10:12:20
526644517536,telefono_compartido,0.37,SI,46814,Moreno Calzada Jessica Dayani,JACONRAMIREZ0@GMAIL.COM,2004-04-07,4,2026-06-15,7894,2026-07-09 10:13:43
526644517536,telefono_compartido,0.37,,46819,Sevilla Moreno Kendall Lailony,,2023-04-25,0,,7900,2026-07-09 10:13:43
526644591861,duplicado_probable,1.00,SI,43150,Buelna Buelna Mitzi Jael,BUELNABUELNAJ@GMAIL.COM,1999-01-06,1,2024-06-13,3931,2026-07-09 10:12:57
526644591861,duplicado_probable,1.00,,43697,Buelna Buelna Mitzi Jael,BUELNABUELNAJ@GMAIL.COM,1999-01-06,0,,4607,2026-07-09 10:13:02
526644771033,telefono_compartido,0.44,SI,39820,Aguirre Granados Maria Concepcion,TA_OSCAR@HOTMAIL.COM,1979-03-12,83,2026-06-10,40,2026-07-09 10:12:20
526644771033,telefono_compartido,0.44,,41900,Islas Aguirre Arianna,CONCHITA_OSCAR@HOTMAIL.COM,2015-12-26,0,,2406,2026-07-09 10:12:44
526644793526,duplicado_probable,1.00,,40890,Matuz Ortiz Gael Antonio,VOZ1980170612@GMAIL.COM,2012-06-17,0,,1271,2026-07-09 10:12:32
526644793526,duplicado_probable,1.00,SI,44861,Matuz Ortiz Gael Antonio,VOZ1980170612@GMAIL.COM,2012-06-17,22,2026-03-17,5810,2026-07-09 10:13:19
526644793526,duplicado_probable,1.00,,45161,Ortiz Zamudio Veronica,,1980-11-07,0,,6144,2026-07-09 10:13:19
526644929111,telefono_compartido,0.63,,43112,Espinoza Lopez Dennise Mariel,DENNISEBARRERA2013@GMAIL.COM,1987-12-20,0,,3888,2026-07-09 10:12:57
526644929111,telefono_compartido,0.63,,43667,Barrera Espinoza Jade,DENNISEBARRERA2013@GMAIL.COM,2013-04-05,0,,4575,2026-07-09 10:13:02
526644929111,telefono_compartido,0.63,SI,45373,Barrera Espinoza Ambar Dennise,,2007-04-10,12,2025-10-03,6355,2026-07-09 10:13:25
526644936861,telefono_compartido,0.59,SI,41385,Gamez Valdes Renata,KARINAVALDESM123@GMAIL.COM,2012-04-18,16,2026-03-26,1825,2026-07-09 10:12:38
526644936861,telefono_compartido,0.59,,42564,Valdes Moreno Karina Angelica,NO@gmail.com,1988-07-29,0,,3147,2026-07-09 10:12:50
526644936861,telefono_compartido,0.59,,42584,Gamez Valdes Bennjamin,KARINAVALDESM123@GMAIL.COM,2007-07-21,0,,3174,2026-07-09 10:12:50
526644981243,telefono_compartido,0.31,SI,39885,Zapata Garcia Patricia,ANACG.NOH@GMAIL.COM,1996-05-27,21,2026-05-16,117,2026-07-09 10:12:20
526644981243,telefono_compartido,0.31,,45816,Garcia Noh Ana Cristina,,1996-05-27,0,,6819,2026-07-09 10:13:31
526645062937,telefono_compartido,0.61,SI,43606,López Guerra Siboney,,2013-12-13,37,2026-06-20,4507,2026-07-09 10:13:02
526645062937,telefono_compartido,0.61,,44493,Guerra Montes de Oca Rosario Siboney,ROSARIO880904@GMAIL.COM,1988-09-04,0,,5431,2026-07-09 10:13:13
526645085112,telefono_compartido,0.30,SI,42707,Angel Cristina,FRAMBUESA_1027@HOTMAIL.COM,1985-10-27,7,2026-01-06,3315,2026-07-09 10:12:50
526645085112,telefono_compartido,0.30,,46070,Brown Derrick,,2017-06-14,0,,7095,2026-07-09 10:13:31
526645102100,telefono_compartido,0.35,,42704,Rodas Jason,JR21213@AOL.COM,1985-08-08,0,,3312,2026-07-09 10:12:50
526645102100,telefono_compartido,0.35,SI,45994,Stewart Jane,IANFROMTIJUANA@GMAIL.COM,1938-12-23,8,2026-06-01,7016,2026-07-09 10:13:31
526645232696,duplicado_probable,0.98,SI,40304,Ibarra Gutierrez Karla Gabriela,GABYIBA28@LIVE.COM,1971-02-28,8,2025-02-03,610,2026-07-09 10:12:26
526645232696,duplicado_probable,0.98,,44327,Ibarra Gutierrrez Karla Gabriela,GABYIBA28@LIVE.COM,1971-02-28,0,,5250,2026-07-09 10:13:13
526645306350,telefono_compartido,0.39,SI,42464,Marmolejo Garcia Sophia,GUSTAVOMARMOLLOP@GMAIL.COM,2008-04-15,15,2026-01-09,3033,2026-07-09 10:12:50
526645306350,telefono_compartido,0.39,,43163,Garcia Balderas Doris Yanira,SAMANTHA.GARCIA@UABC.EDU.MX,1976-10-20,0,,3946,2026-07-09 10:12:57
526645337992,telefono_compartido,0.31,SI,41423,Hernandez Morales Ma Dolores,VENECIA_MUCINO@HOTMAIL.COM,1950-06-22,13,2025-01-10,1871,2026-07-09 10:12:38
526645337992,telefono_compartido,0.31,,41658,Muciño Espinosa Venecia,VENECIA_MUCINO@HOTMAIL.COM,1989-03-23,0,,2134,2026-07-09 10:12:38
526645507583,telefono_compartido,0.36,SI,41677,Aldaco Lepe Erendida Abigail,,1973-03-07,7,2024-12-20,2154,2026-07-09 10:12:38
526645507583,telefono_compartido,0.36,,41880,Cervantes Alondra,ALORUBY@ICLOUD.COM,1998-03-23,0,,2383,2026-07-09 10:12:44
526645556699,duplicado_probable,1.00,SI,45456,Rodriguez Leal Alba,,2002-11-22,1,2025-06-10,6439,2026-07-09 10:13:25
526645556699,duplicado_probable,1.00,,45481,Rodriguez Leal Alba,,2002-11-22,0,,6466,2026-07-09 10:13:25
526645756189,telefono_compartido,0.42,SI,45998,Osuna Angulo Maida Regina,MAIDA.REGIOS@GMAIL.COM,2003-11-27,1,2025-11-15,7020,2026-07-09 10:13:31
526645756189,telefono_compartido,0.42,,46137,Quijano Angulo Michelle,MICHELLEQUIJANO421@GMAIL.COM,2005-04-21,0,,7171,2026-07-09 10:13:31
526645768726,telefono_compartido,0.29,,41865,Lopez Paredes Ana Elizabeth,,1982-10-22,0,,2368,2026-07-09 10:12:44
526645768726,telefono_compartido,0.29,SI,41866,Mesta Lopez Samantha Yocelyn,SAM.MESTA2009@GMAIL.COM,2009-06-21,4,2023-11-21,2369,2026-07-09 10:12:44
526645833336,telefono_compartido,0.41,SI,45973,Jacobo Gomez Diana,DIANA_JACOBO@YAHOO.COM,1978-08-14,4,2026-04-27,6994,2026-07-09 10:13:31
526645833336,telefono_compartido,0.41,,46728,Rangel Jaboco Azalea Aimee,DIANA_JACOBO@YAHOO.CON,2015-12-21,0,,7799,2026-07-09 10:13:37
526645878363,telefono_compartido,0.24,SI,46704,Garcia Pimentel Diego,THEOUTSIDER.TTV@GMAIL.COM,2008-07-18,5,2026-05-15,7774,2026-07-09 10:13:37
526645878363,telefono_compartido,0.24,,46707,Ozuna Lugo Francisca,CPMARYPIMENTEL1980@GMAIL.COM,1939-11-29,0,,7777,2026-07-09 10:13:37
526645899429,telefono_compartido,0.55,,43745,Lopez Huaracha Aurora,,1964-04-20,0,,4657,2026-07-09 10:13:02
526645899429,telefono_compartido,0.55,SI,45650,Garcia Lopez Maria Eugenia,MARU.GL22@GMAIL.COM,1987-06-18,22,2026-06-12,6640,2026-07-09 10:13:25
526645977788,telefono_compartido,0.48,SI,41706,Avalos Lopez Naian Lourdes,ALEJANDRAMENDOZA0611@GMAIL.COM,2009-07-24,28,2025-06-25,2190,2026-07-09 10:12:38
526645977788,telefono_compartido,0.48,,41712,Lopez Vergara Dulce Alejandra,ALEJANDRAMENDOZA0611@GMAIL.COM,1981-11-06,0,,2197,2026-07-09 10:12:38
526645977788,telefono_compartido,0.48,,41921,Avalos Lopez Ian Donovan,ALEJANDRAMENDOZA0611@GMAIL.COM,2008-07-07,0,,2428,2026-07-09 10:12:44
526646032445,telefono_compartido,0.48,SI,44652,Lopez Ortiz Vania America,MARIAORTIZH@EDUBC.MX,2011-02-25,34,2026-05-09,5593,2026-07-09 10:13:13
526646032445,telefono_compartido,0.48,,44653,Ortiz Huerta Maria de Lourdes,MARIAORTIZH@EDUBC.MX,1984-12-20,0,,5594,2026-07-09 10:13:13
526646036820,duplicado_probable,1.00,SI,44194,Mosqueda Tostado Laura Diana,LAURADIANA1487@HOTMAIL.COM,1990-12-07,11,2025-05-16,5119,2026-07-09 10:13:08
526646036820,duplicado_probable,1.00,,44543,Mosqueda Tostado Laura Diana,LAURADIANA1487@HOTMAIL.COM,1990-12-07,0,,178,2026-07-09 10:13:13
526646113515,telefono_compartido,0.29,SI,40279,Ibarra Erenas Alicia,,1981-06-23,39,2025-04-23,582,2026-07-09 10:12:20
526646113515,telefono_compartido,0.29,,43530,Sevilla Ibarra Isabel,ALICIB26@GMAIL.COM,2010-07-12,0,,4420,2026-07-09 10:13:02
526646346062,telefono_compartido,0.68,SI,46293,Lomeli Martinez Evelyn,KOHKCOY18@HOTMAIL.COM,1983-07-06,2,2026-01-05,7337,2026-07-09 10:13:37
526646346062,telefono_compartido,0.68,,46294,Flores Martinez Emilio,KOHKCOY18@HOTMAIL.COM,2012-07-06,0,,7338,2026-07-09 10:13:37
526646403342,telefono_compartido,0.46,SI,42172,Gomez Lujan Sebastian,21GOMEZ@ATT.NET,2003-02-02,70,2026-06-23,2707,2026-07-09 10:12:44
526646403342,telefono_compartido,0.46,,43358,Lujan Medina Rosa,,1967-10-06,0,,4217,2026-07-09 10:13:02
526646403342,telefono_compartido,0.46,,46026,Medina Amador Herminia,RGOMEZRN@SBCGLOBAL.NET,1948-03-18,0,,7050,2026-07-09 10:13:31
526646403342,telefono_compartido,0.46,,46340,Gomez Cruz Ricardo,RGMHOBBY@GMAIL.COM,1971-05-02,0,,7384,2026-07-09 10:13:37
526646489182,telefono_compartido,0.43,SI,40994,Romandia Jacobo Rene,JACOBOMONIK@GMAIL.COM,2004-03-26,19,2025-07-30,1385,2026-07-09 10:12:32
526646489182,telefono_compartido,0.43,,44158,Jacobo Cerrillo Monica,JACOBOMONIK@GMAIL.COM,1978-03-10,0,,1176,2026-07-09 10:13:08
526646938548,telefono_compartido,0.60,SI,39943,Ramirez Luna Yaned,RAMIREZYANED33@GMAIL.COM,1987-04-28,26,2026-04-27,194,2026-07-09 10:12:20
526646938548,telefono_compartido,0.60,,40063,Calderon Ramirez Bianca Vanessa,,2023-02-03,0,,349,2026-07-09 10:12:20
526646938548,telefono_compartido,0.60,,44396,Calderon Ramirez Dayro,RAMIREZLUNAYANED@GMAIL.COM,2015-05-01,0,,5332,2026-07-09 10:13:13
526646955946,telefono_compartido,0.31,,43537,Gonzales Briana,BRYSHER.COMGONZALEZ@ICLOUD.COM,2006-03-18,0,,4430,2026-07-09 10:13:02
526646955946,telefono_compartido,0.31,SI,45097,Gutierrez Garcia Renata Mariel,,2018-07-03,11,2025-05-06,6069,2026-07-09 10:13:19
526646991107,telefono_compartido,0.26,SI,40398,Castillo Ponce Cleotilde,,1960-06-18,12,2026-04-10,717,2026-07-09 10:12:26
526646991107,telefono_compartido,0.26,,40804,Antonella Morillo Chan,CAROLINACHAN.C@GMAIL.COM,2016-12-29,0,,1173,2026-07-09 10:12:32
526647091216,telefono_compartido,0.43,SI,43832,Ventura Chisnas Viviana,VIVIVENTURAC10@GMAIL.COM,1987-08-29,0,,4753,2026-07-09 10:13:08
526647091216,telefono_compartido,0.43,,43833,Martinez Ventura Ilyana Monserrat,VIVIVENTURAC10@GMAIL.COM,2008-07-29,0,,4754,2026-07-09 10:13:08
526647101910,telefono_compartido,0.49,SI,40018,Urbina Vargas Ivanna Kirle,MERCEDEZ1407@HOTMAIL.COM,2005-02-07,8,2023-02-23,300,2026-07-09 10:12:20
526647101910,telefono_compartido,0.49,,40444,Vargas Avila Matia Mercedes,,1984-09-24,0,,769,2026-07-09 10:12:26
526647301977,duplicado_probable,1.00,,41344,Montalvo Dominguez Jonathan,,2000-02-11,0,,1782,2026-07-09 10:12:38
526647301977,duplicado_probable,1.00,SI,45163,Montalvo Dominguez Jonathan,JONATHANMONTALVO112000@GMAIL.COM,2000-02-11,24,2026-05-08,3973,2026-07-09 10:13:19
526647500373,telefono_compartido,0.68,SI,40407,Lopez Carrillo Allison,ALNIPIS26@GMAIL.COM,2006-10-26,11,2024-05-07,726,2026-07-09 10:12:26
526647500373,telefono_compartido,0.68,,46635,Lopez Carrillo Ismael Alexander,LOPEZCARRILLOISMAELALEXANDER@GMAIL.COM,2011-03-09,3,2026-05-21,7697,2026-07-09 10:13:37
526647634186,telefono_compartido,0.54,SI,42795,Ascencio Sotelo Susana,SUSANAASCENCIOSOTELO@GMAIL.COM,1985-09-03,0,,3434,2026-07-09 10:12:57
526647634186,telefono_compartido,0.54,,43957,Saucedo Ascencio Maria Carlota,SUSANAASCENCIOSOTELO@GMAIL.COM,2012-08-24,0,,4884,2026-07-09 10:13:08
526647658484,telefono_compartido,0.55,SI,39811,Cuen Baez Renata,,2008-06-29,8,2026-06-02,31,2026-07-09 10:12:20
526647658484,telefono_compartido,0.55,,43923,Baez Cuen Claudia,CLAUBAEZ1479@GMAIL.COM,1979-09-14,0,,4852,2026-07-09 10:13:08
526647792406,telefono_compartido,0.43,SI,43111,Guevara Hinojoza Dafne Yamileth,MONICAHINOJOZA2018@HOTMAIL.COM,2009-09-20,58,2026-05-21,3887,2026-07-09 10:12:57
526647792406,telefono_compartido,0.43,,43881,Hinojoza Peraza Monica Lizeth,,1980-02-09,0,,4804,2026-07-09 10:13:08
526647792406,telefono_compartido,0.43,,46598,Guevara Hernandez Melanie Kristel,MONICAHIJOZA2018@HOTMAIL.COM,2012-05-10,0,,7659,2026-07-09 10:13:37
526648022645,telefono_compartido,0.45,SI,40906,Soto Vega Lilia,,1978-10-11,30,2025-01-18,1289,2026-07-09 10:12:32
526648022645,telefono_compartido,0.45,,41353,Perez Soto Katia,LILISOTO78@GMAIL.COM,2010-03-20,0,,1792,2026-07-09 10:12:38
526653924802,telefono_compartido,0.64,SI,44997,Castro Jonathan,LIC.GLORIACASTRO@HOTMAIL.COM,2004-04-23,3,2025-09-09,5963,2026-07-09 10:13:19
526653924802,telefono_compartido,0.64,,44998,Castro Gloria,LIC.GLORIACASTRO@HOTMAIL.COM,1978-09-23,0,,5964,2026-07-09 10:13:19
526672103348,telefono_compartido,0.36,,40327,Gastelum Aviles Claudia,GASTELUMAVILES.CLAUDIA@GMAIL.COM,1982-10-09,0,,637,2026-07-09 10:12:26
526672103348,telefono_compartido,0.36,SI,44915,Teran Gastelum Luciana,GASTELUMAVILES.CLAUDIA@GMAIL.COM,2018-09-18,14,2025-06-11,5871,2026-07-09 10:13:19
526675019509,telefono_compartido,0.65,SI,44201,Ahumada Erives Miriam Zulema,SAN_1390@HOTMAIL.ES,1990-03-13,8,2026-06-03,5126,2026-07-09 10:13:08
526675019509,telefono_compartido,0.65,,44697,Daylin Ahumada Miriam,DAYLINAHUMADA@GMAIL.COM,2011-06-26,0,,5637,2026-07-09 10:13:13
526691233980,telefono_compartido,0.27,SI,44551,Corrales Rodriguez Karely Jazmin,CORRALESKARELY2@GMAIL.COM,1996-08-06,0,,5489,2026-07-09 10:13:13
526691233980,telefono_compartido,0.27,,45125,Robles Junior,JS_R_B@HOTMAIL.COM,1998-09-12,0,,6105,2026-07-09 10:13:19
526751121945,telefono_compartido,0.32,SI,46701,Vasquez Vargas Karla,KARDEM347@GMAIL.COM,2002-11-04,5,2026-06-30,7771,2026-07-09 10:13:37
526751121945,telefono_compartido,0.32,,46974,Leyva Rueda Manuel,KARDEM347@GMAIL.COM,1972-10-22,0,,8172,2026-07-09 10:13:43
526862212806,telefono_compartido,0.56,SI,40599,Franco Parra Cynthia,CYNTHIAFRANCO.PARRA@GMAIL.COM,1983-10-24,27,2026-02-25,937,2026-07-09 10:12:26
526862212806,telefono_compartido,0.56,,43294,Diaz Franco Maximiliano,CYNTHIAFRANCO.PARRA@GMAIL.COM,2013-09-15,0,,4134,2026-07-09 10:13:02
526863221603,telefono_compartido,0.33,SI,42003,Verduzco Obeso Mitchel,VERDUZCOM@HOTMAIL.COM,1996-06-19,19,2025-12-23,2523,2026-07-09 10:12:44
526863221603,telefono_compartido,0.33,,43229,Roe Axl Matteo,VERDUZCOM@HOTMAIL.COM,2009-05-13,0,,4040,2026-07-09 10:12:57
526864060446,telefono_compartido,0.26,,41673,Gomez Ma Valeria Isabel,VALERIAISABELGM@GMAIL.COM,1995-08-20,1,2023-07-11,2149,2026-07-09 10:12:38
526864060446,telefono_compartido,0.26,SI,41731,Gonzalez Mendoza Adriana,VALERIAISABELGM@GMAIL.COM,1995-08-20,4,2025-02-19,2219,2026-07-09 10:12:38
526871584464,telefono_compartido,0.14,SI,42503,Gamez Rubio Mary Janeth,GAMEZRUBIO@ICLOUD.COM,1991-11-05,10,2024-05-29,3078,2026-07-09 10:12:50
526871584464,telefono_compartido,0.14,,42992,Andrade Cruz Gabriel,GAMEZRUBIO@ICLOUD.COM,2009-03-07,0,,3750,2026-07-09 10:12:57
527076161506,telefono_compartido,0.27,SI,41602,Robinson Gloria,GLOW1220@YAHOO.CO,1983-12-20,23,2024-10-14,2076,2026-07-09 10:12:38
527076161506,telefono_compartido,0.27,,42334,Treibek Westphal Phillip,PTWPTW88@GMAIL.COM,1980-12-08,0,,2891,2026-07-09 10:12:50
527076161506,telefono_compartido,0.27,,43640,Hover Jesseca,JHOVER24@HOTMAIL.COM,1973-06-24,0,,4545,2026-07-09 10:13:02
527143008671,telefono_compartido,0.48,SI,46063,Dillard Harold,PERIPATETIC@NYM.HUSH.COM,1961-11-13,6,2026-07-06,7088,2026-07-09 10:13:31
527143008671,telefono_compartido,0.48,,46942,Pham Lauren,LARIMARSTONE@HUSHMAIL.COM,1968-06-11,0,,8133,2026-07-09 10:13:43
527144768562,telefono_compartido,0.55,SI,40809,Gentile Liliana,GENTILE.LILIANA98@GMAIL.COM,1998-01-28,2,2026-04-07,1179,2026-07-09 10:12:32
527144768562,telefono_compartido,0.55,,46652,Villa Lara Liliana,GENTILE.LILIANA98@GMAIL.COM,1998-01-28,0,,7717,2026-07-09 10:13:37
527144832514,telefono_compartido,0.77,SI,42740,Alcantara Gonzalez Roberto,ROBERTOALCANTARA2003@GMAIL.COM,2003-07-23,13,2025-08-19,3354,2026-07-09 10:12:50
527144832514,telefono_compartido,0.77,,43307,Alcantar Bojorquez Roberto,,1959-10-03,0,,4158,2026-07-09 10:13:02
527144998430,telefono_compartido,0.58,SI,41439,Westrick Oliver William,WILLIAMWESTRICK100@GMAIL.COM,1958-06-05,6,2025-03-10,1891,2026-07-09 10:12:38
527144998430,telefono_compartido,0.58,,44429,Westrick Teresa,WILLIAMWESTRICK10@GMAIL.COM,1962-10-15,0,,5367,2026-07-09 10:13:13
527145612812,telefono_compartido,0.53,,45638,Zavala Virginia,,1969-10-05,0,,6629,2026-07-09 10:13:25
527145612812,telefono_compartido,0.53,SI,45639,Zavala Baltazar,VIRGINIAZAVALA1005@GMAIL.COM,1966-02-19,3,2025-07-09,6630,2026-07-09 10:13:25
527148013518,telefono_compartido,0.36,SI,40776,Garcia Gabriela,GGBETTYBOOP@YAHOO.COM,1978-11-06,6,2024-12-04,1141,2026-07-09 10:12:26
527148013518,telefono_compartido,0.36,,42888,Lopez Garcia Isaac,GGBETTYBOOP@YAHOO.COM,2007-03-11,0,,3632,2026-07-09 10:12:57
527451232667,duplicado_probable,1.00,SI,41594,Roldan Bernal Jose Manuel,MANUELROLDAN360@GMAIL.COM,2001-04-11,38,2026-06-27,2066,2026-07-09 10:12:38
527451232667,duplicado_probable,1.00,,42311,Roldan Bernal Jose Manuel,MANUELROLDAN360@GMAIL.COM,2001-04-11,0,,2865,2026-07-09 10:12:50
527472622662,telefono_compartido,0.19,SI,42722,Hernandez Ballesteros Jande,JANDE_HERNANDEZ@HOTMAIL.COM,1982-05-22,5,2024-03-11,3332,2026-07-09 10:12:50
527472622662,telefono_compartido,0.19,,42723,Jauregui Juliana,NO.NO@GMAIL.COM,2019-03-16,0,,3333,2026-07-09 10:12:50
527603107878,telefono_compartido,0.75,SI,40973,Rivera Arribeño Alma,,1957-12-08,16,2025-11-15,1364,2026-07-09 10:12:32
527603107878,telefono_compartido,0.75,,45466,Espinoza Rivera Alma,ALMAE1957@GMAIL.COM,1957-12-08,0,,6449,2026-07-09 10:13:25
527605743678,telefono_compartido,0.52,SI,43965,Chaidez Reyes Lourdes,REYESLOURDES19@GMAIL.COM,1983-01-22,0,,4891,2026-07-09 10:13:08
527605743678,telefono_compartido,0.52,,43966,Higuera Chaidez Sebastian,LCKITTOS@AOL.COM,2005-12-03,0,,4892,2026-07-09 10:13:08
527606455166,telefono_compartido,0.68,SI,40671,Hernandez -ontiveros Patricia,PATRICIAWORK2016@GMAIL.COM,1968-01-15,32,2026-05-22,1017,2026-07-09 10:12:26
527606455166,telefono_compartido,0.68,,42156,Hernandez Ontiveris Ester,,1944-04-10,0,,2689,2026-07-09 10:12:44
527607042252,telefono_compartido,0.67,SI,42343,Vargas Arreola Ana,,1993-10-07,1,2023-11-29,2900,2026-07-09 10:12:50
527607042252,telefono_compartido,0.67,,44675,Vargas Arreola Esmeralda,AVARGAS_939@YAHOO.COM,1993-10-07,0,,2515,2026-07-09 10:13:13
527756218831,telefono_compartido,0.26,SI,44361,Sahagun Samantha Melissa,SAMY121612@ICLOUD.COM,2012-12-16,0,,5295,2026-07-09 10:13:13
527756218831,telefono_compartido,0.26,,44953,Salazar Garcia Norma Alejandra,,1979-05-21,0,,5917,2026-07-09 10:13:19
528186069066,telefono_compartido,0.58,SI,45980,Sandoval Legaspi Roberto,LUZSAND@SBCGLOBAL.NET,1943-01-24,4,2025-11-04,7001,2026-07-09 10:13:31
528186069066,telefono_compartido,0.58,,46060,Castillo Sandoval Luz,LUZSAND@SBCGLOBAL.NET,1953-05-20,0,,7085,2026-07-09 10:13:31
528189419769,telefono_compartido,0.65,SI,42831,Carrillo Soto Brianna,BRIANNA.CARRILLO.05@GMAIL.COM,2005-10-19,17,2024-11-30,3526,2026-07-09 10:12:57
528189419769,telefono_compartido,0.65,,42899,Soto Lopez Brisa,BRISA86@ICLOUD.COM,1986-04-08,0,,3648,2026-07-09 10:12:57
528312628969,telefono_compartido,0.47,SI,43230,Gamino Emily,MEMO6_2@YAHOO.COM,2006-02-13,19,2025-08-22,4041,2026-07-09 10:12:57
528312628969,telefono_compartido,0.47,,43252,Alanis Ramos Olga,MEMO6_2@YAHOO.COM,1982-09-22,0,,4074,2026-07-09 10:12:57
528312628969,telefono_compartido,0.47,,44752,Alvarez Alanis Sophia,MEMO6_2@YAHOO.COM,2017-02-11,0,,5693,2026-07-09 10:13:13
528586926163,telefono_compartido,0.47,,44727,Webb Lesley,LESWEBB@GMAIL.COM,1977-03-10,0,,5672,2026-07-09 10:13:13
528586926163,telefono_compartido,0.47,SI,45619,Webb Mariscal Sofia,LESWEBB@GMAIL.COM,2013-05-13,8,2026-06-06,6610,2026-07-09 10:13:25
528588291364,telefono_compartido,0.30,SI,43999,Jones Michael Gary,LAURITAANGIE@LIVE.COM,1955-06-21,0,,4921,2026-07-09 10:13:08
528588291364,telefono_compartido,0.30,,44000,Rodriguez Garcia Laura,LAURITAANGIE@LIVE.COM,1964-10-13,0,,4922,2026-07-09 10:13:08
529092319931,telefono_compartido,0.44,SI,45116,Montes Paulina,,1990-10-10,27,2026-06-27,6089,2026-07-09 10:13:19
529092319931,telefono_compartido,0.44,,46820,Montes Johana,NO@GMAIL.COM,1962-09-14,0,,7901,2026-07-09 10:13:43
529092350069,duplicado_probable,1.00,SI,40624,Perez Joanna,,1991-11-23,9,2026-02-20,965,2026-07-09 10:12:26
529092350069,duplicado_probable,1.00,,44730,Perez Joanna,JOANPEREZ1468@YAHOO.COM,1991-11-23,0,,5675,2026-07-09 10:13:13
529092950258,duplicado_probable,0.83,SI,41036,Sanchez Raygoza Alina,,2008-04-25,39,2024-06-27,1430,2026-07-09 10:12:32
529092950258,duplicado_probable,0.83,,41361,Sanchez Raygoza Jose,,2007-03-27,0,,1800,2026-07-09 10:12:38
529092950258,duplicado_probable,0.83,,41362,Sanchez Raygoza Diego,,2008-04-28,0,,1801,2026-07-09 10:12:38
529093324318,telefono_compartido,0.46,SI,40278,Alvarez Vasquez Jorgeandres,,2006-01-14,7,2023-04-15,581,2026-07-09 10:12:20
529093324318,telefono_compartido,0.46,,40579,Vasquez Rodriguez Lourdes,,1974-11-22,0,,914,2026-07-09 10:12:26
529093793305,duplicado_probable,0.84,SI,45404,Padilla Ramirez Alek,COKLXRAYMARKERS@ME.COM,2005-07-26,3,2025-07-30,6387,2026-07-09 10:13:25
529093793305,duplicado_probable,0.84,,45405,Padilla Ramirez Alandra,COOLXRAYMARKERS@ME.COM,2005-11-30,0,,6388,2026-07-09 10:13:25
529093793305,duplicado_probable,0.84,,45406,Padilla Bautista Jesus,,1976-10-05,0,,6389,2026-07-09 10:13:25
529094897416,telefono_compartido,0.19,SI,45133,Del Rio Guadalupe,LUPITA_DELRIO@YAHOOO.COM,1987-09-10,13,2025-10-10,5187,2026-07-09 10:13:19
529094897416,telefono_compartido,0.19,,45330,Ornelas Ariana,,2008-08-09,0,,6314,2026-07-09 10:13:25
529095597351,telefono_compartido,0.26,SI,40181,Zapata Rocha Armando,,1996-03-14,7,2025-10-18,479,2026-07-09 10:12:20
529095597351,telefono_compartido,0.26,,40462,Gutierrez Hernandez Adriana,ADRIANAGTZ.0331@GMAIL.COM,1971-03-31,6,2025-09-05,788,2026-07-09 10:12:26
529097706953,duplicado_probable,0.88,SI,41527,Hernandez Islas Ximena Sofia,SELENEISLAS95@GMAIL.COM,2016-05-24,3,2026-04-17,1989,2026-07-09 10:12:38
529097706953,duplicado_probable,0.88,,46570,Hernandez Islas Ximena,,2016-05-24,0,,7627,2026-07-09 10:13:37
529098370029,telefono_compartido,0.64,SI,43005,Ramirez Raul,RAUL6419@VERIZON.NET,1964-01-26,9,2025-02-22,3764,2026-07-09 10:12:57
529098370029,telefono_compartido,0.64,,43007,Ramirez Samantha,RAUL6419@VERIZO.NET,2016-07-26,0,,3766,2026-07-09 10:12:57
529099001217,telefono_compartido,0.33,SI,46822,Castaneda Gama Viviana,VIVI.USA.1202@GMAIL.COM,2002-02-12,2,2026-05-16,7903,2026-07-09 10:13:43
529099001217,telefono_compartido,0.33,,46823,Mosqueda Acosta Maria,,1973-04-05,0,,7904,2026-07-09 10:13:43
529253397117,telefono_compartido,0.36,SI,46156,Harris Karen,,1960-11-12,6,2026-06-16,7191,2026-07-09 10:13:31
529253397117,telefono_compartido,0.36,,46169,Jackson Caroline,,1942-10-23,0,,7204,2026-07-09 10:13:31
529282469046,duplicado_probable,0.80,SI,39832,Ruiz Lopez Ana,ANNA.B.PLAZA@MSN.COM,1955-08-27,12,2026-05-27,54,2026-07-09 10:12:20
529282469046,duplicado_probable,0.80,,43579,Ruiz Lopez Ana Bertha,ANNA.B.PLAZA@MSN.COM,1955-08-27,0,,4476,2026-07-09 10:13:02
529282469046,duplicado_probable,0.80,,46288,Espinoza Plaza Daniel,DBOYGG77@GMAIL.COM,2007-05-09,0,,7330,2026-07-09 10:13:37
529283232486,telefono_compartido,0.31,SI,45985,Sonner Ivette,,1974-12-17,2,2025-10-04,7006,2026-07-09 10:13:31
529283232486,telefono_compartido,0.31,,45986,Jhonston Lara,,1967-06-24,0,,7007,2026-07-09 10:13:31
529498387369,telefono_compartido,0.49,SI,44557,Gallegos Contreras Madison,EVELYNGALLEGOS@LIVE.COM,2006-11-10,0,,5496,2026-07-09 10:13:13
529498387369,telefono_compartido,0.49,,44562,Contreras Carpio Evelyn,EVELYNGALLEGOS@LUVE.COM,1978-12-12,0,,5501,2026-07-09 10:13:13
529514134835,telefono_compartido,0.46,SI,44447,Urriarte Gonzalez Lidia,HEAVENEXPRESS@VERIZON.NET,1935-08-03,5,2025-01-25,5384,2026-07-09 10:13:13
529514134835,telefono_compartido,0.46,,44615,Hernandez Uriarte Elva Eneyda,,1970-08-15,0,,5552,2026-07-09 10:13:13
529514875162,telefono_compartido,0.39,SI,40894,Ceja Torres Karol,KAROLCEJA_1990@GMAIL.COM,1990-06-22,23,2026-07-03,1277,2026-07-09 10:12:32
529514875162,telefono_compartido,0.39,,44254,Romero Isabela,NO@GMAIL.COM,2023-09-18,0,,5184,2026-07-09 10:13:08
529515266441,telefono_compartido,0.35,SI,39872,Davila-chase Cynyhia,,1997-08-08,12,2023-11-11,100,2026-07-09 10:12:20
529515266441,telefono_compartido,0.35,,39873,Davila Maritza,,1969-10-24,0,,101,2026-07-09 10:12:20
529515507600,telefono_compartido,0.30,SI,45627,Osorio Sergio,DENISEPJIMENEZ@OUTLOOK.COM,1976-03-22,2,2025-07-07,6618,2026-07-09 10:13:25
529515507600,telefono_compartido,0.30,,45628,Jimenez Denise,DENISEPJIMENEZ@OUTLOOK.COM,1980-07-28,0,,6619,2026-07-09 10:13:25
529516349207,telefono_compartido,0.67,SI,40898,Murillo Isabella,IRMA28LEONY@GMAIL.COM,2003-05-22,8,2024-09-23,1281,2026-07-09 10:12:32
529516349207,telefono_compartido,0.67,,43138,Murillo Leon Irma,IRMA28LEONY@GMAIL.COM,1969-12-28,0,,3917,2026-07-09 10:12:57
529516604446,telefono_compartido,0.26,SI,44015,Ramos Lopez Miguel,SINALOA_1958@HOTMAIL.COM,2012-10-18,9,2025-10-18,4939,2026-07-09 10:13:08
529516604446,telefono_compartido,0.26,,44017,Garcia Muñoz Patricia,,1958-03-17,0,,4941,2026-07-09 10:13:08
529518408033,telefono_compartido,0.29,SI,43146,Arreola Alexa,STARLENEARREOLA@LIVE.COM,2007-03-11,4,2024-11-14,3927,2026-07-09 10:12:57
529518408033,telefono_compartido,0.29,,43152,Orosco Starlene,STARLENEARREOLA@LIVE.COM,1984-02-12,0,,3934,2026-07-09 10:12:57
529518521349,telefono_compartido,0.62,SI,40693,Sobie Griselda,GRACIESOBIEZ@yahoo.com,1968-03-05,36,2025-10-31,1041,2026-07-09 10:12:26
529518521349,telefono_compartido,0.62,,42468,Sobie Caelyn,GRACIESOBIE@GMAIL.COM,2000-12-14,0,,3040,2026-07-09 10:12:50
529518521349,telefono_compartido,0.62,,44502,Rivas Bobadilla Griselda,GRACIESOBIE@GMAIL.COM,1968-03-05,0,,566,2026-07-09 10:13:13
529519062579,telefono_compartido,0.36,SI,43666,Contreras Cinthia,,1979-03-30,2,2025-01-25,4573,2026-07-09 10:13:02
529519062579,telefono_compartido,0.36,,44020,Damian Drew,,2010-04-04,0,,4944,2026-07-09 10:13:08
529622843006,telefono_compartido,0.38,,44571,Fuentes Donna,,1972-07-30,0,,5507,2026-07-09 10:13:13
529622843006,telefono_compartido,0.38,SI,45686,Quigley Glenn,,1967-01-05,11,2026-05-09,6673,2026-07-09 10:13:25
529641089175,telefono_compartido,0.37,SI,41725,Escobar Toledo Christopher Emilio,LAURAPATRICIATOLEDO@GOOGLE.COM,2010-08-12,5,2025-03-08,2212,2026-07-09 10:12:38
529641089175,telefono_compartido,0.37,,44631,Toledo Ruiz Laura Patricia,LAURAPATRICIATOLEDO27@GMAIL.COM,1978-12-27,0,,5568,2026-07-09 10:13:13
529706440513,duplicado_probable,0.80,,44061,Lucero Jimmy,LUCEROSTAN@AOL.COM,1967-05-31,0,,4983,2026-07-09 10:13:08
529706440513,duplicado_probable,0.80,SI,44958,Lucero Jimari,LUCEROSTAN@AOL.COM,2001-09-04,13,2025-09-08,5919,2026-07-09 10:13:19
425414654,mismo_nombre_distinto_telefono,1.00,,39859,Tostado Avelar Jennifer,JENTOSTADO@GMAIL.COM,1995-12-23,0,,84,2026-07-09 10:12:20
524254146054,mismo_nombre_distinto_telefono,1.00,SI,46946,Tostado Avelar Jennifer,JENTOSTADO@GMAIL.COM,1995-12-23,2,2026-06-22,8137,2026-07-09 10:13:43
526611722513,mismo_nombre_distinto_telefono,1.00,,40157,Perez Rodriguez Miguel Angel,,2012-05-15,1,2022-12-01,450,2026-07-09 10:12:20
527354040018,mismo_nombre_distinto_telefono,1.00,SI,42332,Perez Rodriguez Miguel Angel,MIGUEL00APRZ@GMAIL.COM,2000-05-02,3,2025-12-12,2889,2026-07-09 10:12:50
526612395332,mismo_nombre_distinto_telefono,1.00,SI,41281,Garcia Mendez Jesus Osvaldo,ROSAMENDEZAVALOS@GMAIL.COM,2008-03-12,7,2024-11-29,1712,2026-07-09 10:12:32
526645289303,mismo_nombre_distinto_telefono,1.00,,44195,Garcia Mendez Jesus Osvaldo,JOSVGAME@GMAIL.COM,2008-03-12,4,2025-04-05,5120,2026-07-09 10:13:08
19494674609,mismo_nombre_distinto_telefono,1.00,,41468,Thomas Patti,PATTILOVESYA@GMAIL.COM,1953-07-20,4,2026-06-08,1921,2026-07-09 10:12:38
529494674609,mismo_nombre_distinto_telefono,1.00,SI,45754,Thomas Patti,PATTILOVESYA@GMAIL.COM,1953-07-20,5,2025-11-04,6754,2026-07-09 10:13:25
526864060446,mismo_nombre_distinto_telefono,1.00,SI,41731,Gonzalez Mendoza Adriana,VALERIAISABELGM@GMAIL.COM,1995-08-20,4,2025-02-19,2219,2026-07-09 10:12:38
527252369141,mismo_nombre_distinto_telefono,1.00,,42197,Gonzalez Mendoza Adriana,,1990-05-17,1,2023-10-21,2737,2026-07-09 10:12:44
526192194154,mismo_nombre_distinto_telefono,1.00,,42891,Vega Guzman Elga Minerva,DR.ADRIANVEGA89@GMAIL.COM,1978-08-08,2,2026-02-11,3636,2026-07-09 10:12:57
526643873091,mismo_nombre_distinto_telefono,1.00,SI,44445,Vega Guzman Elga Minerva,ELGAVEGA@YAHOO.COM.MX,1978-08-09,17,2026-02-18,5382,2026-07-09 10:13:13
526283027235,mismo_nombre_distinto_telefono,1.00,,43259,Rivas Aguilar Vanessa,,1991-09-02,0,,4083,2026-07-09 10:12:57
526183027235,mismo_nombre_distinto_telefono,1.00,SI,44917,Rivas Aguilar Vanessa,,1991-09-02,11,2026-04-20,5873,2026-07-09 10:13:19
526641412732,mismo_nombre_distinto_telefono,1.00,SI,44960,Aldaz Adolfo,CHELO_ALDAZ@HOTMAIL.ES,1950-09-27,17,2026-06-29,5921,2026-07-09 10:13:19
523232155454,mismo_nombre_distinto_telefono,1.00,,45960,Aldaz Adolfo,YAMAHAIE@YAHOO.COM,1979-01-16,1,2025-09-26,6979,2026-07-09 10:13:31
12145978774,mismo_nombre_distinto_telefono,1.00,,45431,Rosales Aguilar Elizabeth,EROSALES.AGUILAR@GMAIL.COM,1984-06-22,1,2025-06-02,6414,2026-07-09 10:13:25
522145978774,mismo_nombre_distinto_telefono,1.00,SI,46715,Rosales Aguilar Elizabeth,EROSALES.AGUILAR@GMAIL.COM,1984-06-22,4,2026-05-30,7786,2026-07-09 10:13:37
1 telefono tipo similitud_nombre conservar id nombre email fecha_nac visitas ultima_visita legacy_id creado
2 11 duplicado_probable 0.82 SI 45065 Yaqubi Ayan MSARWAR.AFG@YAHOO.COM 2022-01-06 44 2026-05-05 6034 2026-07-09 10:13:19
3 11 duplicado_probable 0.82 45066 Yaqubi Ahax MSARWAR.AFG@YAHOO.COM 2019-09-11 0 6035 2026-07-09 10:13:19
4 11 duplicado_probable 0.82 45067 Wakil Farzana 1989-03-13 0 6036 2026-07-09 10:13:19
5 15618095768 telefono_compartido 0.19 41403 Nusbaum Jennifer 1984-02-05 0 1850 2026-07-09 10:12:38
6 15618095768 telefono_compartido 0.19 SI 41404 Robinson Andrew DREWLAIRDROBINSON3@GMAIL.COM 1975-06-12 1 2023-05-10 1851 2026-07-09 10:12:38
7 16194194614 telefono_compartido 0.26 SI 42568 Jorge Sanchez Mireya BILLMIREYA@YAHOO.COM 1978-11-26 3 2024-06-10 3154 2026-07-09 10:12:50
8 16194194614 telefono_compartido 0.26 42927 Martin del Campo Alexander BILLMIREYA@YAHOO.COM 2015-05-31 0 3681 2026-07-09 10:12:57
9 17142703682 telefono_compartido 0.22 SI 40390 Monterrey Kitty KITTYMONTERREY7@GMAIL.COM 1950-08-09 6 2024-10-14 707 2026-07-09 10:12:26
10 17142703682 telefono_compartido 0.22 41614 Menz Yolanda YOLIMENZ@GMAIL.COM 1971-11-01 0 2089 2026-07-09 10:12:38
11 19497027342 telefono_compartido 0.43 42884 Niebla Mauro 1984-12-12 1 2024-07-08 3628 2026-07-09 10:12:57
12 19497027342 telefono_compartido 0.43 SI 43201 Dominguez Rodriguez Maria Francisca FRANCISCA9ABRIL@GMAIL.COM 1956-03-10 8 2026-04-18 3995 2026-07-09 10:12:57
13 19497027342 telefono_compartido 0.43 43275 Escobedo Dominguez Glenda GLENDAYMAURO@GMAIL.COM 1987-06-15 0 4110 2026-07-09 10:12:57
14 522092167938 telefono_compartido 0.71 SI 42720 Jauregui Edlyn NO.NO@GMAIL.COM 2019-03-16 0 3330 2026-07-09 10:12:50
15 522092167938 telefono_compartido 0.71 42721 Jauregui Exzeqiel EDLYNCOVARRUBIAS@GMAIL.COM 2021-08-30 0 3331 2026-07-09 10:12:50
16 522094930605 telefono_compartido 0.64 SI 40061 Acevedo Fernando 1997-10-10 2 2022-11-24 346 2026-07-09 10:12:20
17 522094930605 telefono_compartido 0.64 40062 Acevedo Jose 2010-12-17 0 347 2026-07-09 10:12:20
18 522096206214 telefono_compartido 0.45 SI 42078 Montoya Guillermo Adriel 2011-09-24 2 2023-10-05 2604 2026-07-09 10:12:44
19 522096206214 telefono_compartido 0.45 42086 Montoya Anajanzy 1982-06-21 0 2613 2026-07-09 10:12:44
20 522096394135 telefono_compartido 0.63 SI 44908 Bautista Miguel BAUTISTA4135@GMAIL.COM 1994-10-29 7 2025-05-09 5864 2026-07-09 10:13:19
21 522096394135 telefono_compartido 0.63 44921 Bautista Christopher BAUTISTA4135@GMAIL.COM 2010-10-06 0 5878 2026-07-09 10:13:19
22 522096756807 telefono_compartido 0.58 SI 45169 Estrada María MARIA.ARIAS7376@GMAIL.COM 1950-11-16 1 2025-04-12 6150 2026-07-09 10:13:19
23 522096756807 telefono_compartido 0.58 45170 Arias María MARIA.ARIAS7376@GMAIL.COM 1976-09-04 0 6151 2026-07-09 10:13:19
24 522106322074 duplicado_probable 1.00 SI 42201 Johnston Marty 1965-10-10 11 2025-01-23 2741 2026-07-09 10:12:44
25 522106322074 duplicado_probable 1.00 43310 Johnston Marty MARTYSD@YAHOO.COM 1965-10-10 0 4162 2026-07-09 10:13:02
26 522133052163 telefono_compartido 0.77 SI 42212 Gonzalez Lucy LUCY.GONZALEZ@LAUSD.NET 1977-02-09 7 2024-01-12 2753 2026-07-09 10:12:44
27 522133052163 telefono_compartido 0.77 42249 Valdez Alexys LUCY.GONZALEZ@LAUSD.NET 2005-09-08 0 2794 2026-07-09 10:12:44
28 522133052163 telefono_compartido 0.77 42250 Valdez Kaylee LUCY.GONZALEZ@LAUSD.NET 2008-02-09 0 2795 2026-07-09 10:12:44
29 522134474510 telefono_compartido 0.51 42557 Figueroa Garcia Angel David 2007-03-09 0 3140 2026-07-09 10:12:50
30 522134474510 telefono_compartido 0.51 SI 42563 Garcia Ceja Gabriela GABBYFIG14@hotmail.com 1973-09-11 4 2024-02-24 3146 2026-07-09 10:12:50
31 522136636605 duplicado_probable 0.91 SI 40585 Burciaga Gonzalez Arturo 1958-09-19 5 2025-10-04 921 2026-07-09 10:12:26
32 522136636605 duplicado_probable 0.91 45858 Burciaga Gonzalez Jose Arturo BERBURCI2@GMAIL.COM 1958-09-19 0 6870 2026-07-09 10:13:31
33 523105601684 telefono_compartido 0.47 40380 Bejar Martha 1981-06-12 0 695 2026-07-09 10:12:26
34 523105601684 telefono_compartido 0.47 SI 40381 Bejar Sanchez Ezequiel MAREZE2718@HOTMAIL.COM 1982-07-02 3 2023-01-16 696 2026-07-09 10:12:26
35 523106003481 duplicado_probable 1.00 SI 40273 Jauregui Santana Isabel ISABELJAUREGUI24@GMAIL.COM 1958-02-12 29 2026-04-25 576 2026-07-09 10:12:20
36 523106003481 duplicado_probable 1.00 40581 Jauregui Santana Isabel ISABELJAUREGUI24@GMAIL.COM 1958-02-02 0 917 2026-07-09 10:12:26
37 523106003481 duplicado_probable 1.00 42963 Vargas Linares Ramon ISABELJAUREGUI24@GMAIL.COM 1966-04-19 0 3719 2026-07-09 10:12:57
38 523109880898 telefono_compartido 0.37 SI 40191 Flores Parra Ina 1959-01-24 31 2026-06-13 489 2026-07-09 10:12:20
39 523109880898 telefono_compartido 0.37 41946 Hurtado Ina 1959-01-24 0 2459 2026-07-09 10:12:44
40 523109938644 telefono_compartido 0.60 SI 40637 Tapia Raquel 1950-07-23 12 2026-04-27 978 2026-07-09 10:12:26
41 523109938644 telefono_compartido 0.60 42411 Tapia Garcia Jaime NO@GMAIL.COM 1950-12-10 0 2975 2026-07-09 10:12:50
42 523233160965 telefono_compartido 0.21 SI 42004 Ocampo Caroline MANCHITAS_323@YAHOO.COM 2014-08-02 5 2026-01-03 2524 2026-07-09 10:12:44
43 523233160965 telefono_compartido 0.21 44315 Quevedo Coren MANCHITAS_323@YAHOO.COM 2024-08-24 0 5241 2026-07-09 10:13:13
44 523235417969 telefono_compartido 0.18 SI 42471 Mendez Sosa Armando 1956-03-20 2 2023-12-30 3043 2026-07-09 10:12:50
45 523235417969 telefono_compartido 0.18 42472 Rodarte Alicia 1966-08-19 0 3044 2026-07-09 10:12:50
46 523236163630 telefono_compartido 0.47 SI 41521 Jacobo Zavala Guadalupe 1955-12-12 3 2023-06-03 1981 2026-07-09 10:12:38
47 523236163630 telefono_compartido 0.47 41522 Valverde Jacobo Lupe 1992-02-27 0 1982 2026-07-09 10:12:38
48 523237193727 telefono_compartido 0.41 42408 Ruiz Joshua NO@HOTMAIL.COM 2007-04-12 0 2972 2026-07-09 10:12:50
49 523237193727 telefono_compartido 0.41 SI 45407 Sanchez Ruiz María 1972-09-16 13 2025-09-18 6390 2026-07-09 10:13:25
50 523238330706 telefono_compartido 0.07 SI 41773 Del Toro Gloria GLORIAYUNE@GMAIL.COM 1969-05-28 8 2025-03-08 2263 2026-07-09 10:12:38
51 523238330706 telefono_compartido 0.07 41774 Yune Nicholas GLORIAYUNE@GMAIL.COM 2006-10-07 0 2264 2026-07-09 10:12:38
52 523322355714 telefono_compartido 0.51 40681 Campos Rodriguez Alejandrina 1981-04-11 0 1028 2026-07-09 10:12:26
53 523322355714 telefono_compartido 0.51 SI 44866 Rivera Campos Estefani Anai ESTEFANI.RIVERACAMPOS@GMAIL.COM 2009-10-06 21 2026-06-03 5815 2026-07-09 10:13:19
54 523421081723 telefono_compartido 0.40 44630 Cuevaz Diaz Roman Santiago LD666305@GMAIL.COM 2009-11-04 0 5567 2026-07-09 10:13:13
55 523421081723 telefono_compartido 0.40 SI 46918 Ramirez Sanchez Cesar Octavio ROMANDIAZ830@GMAIL.COM 1979-05-04 23 2026-06-20 8080 2026-07-09 10:13:43
56 523421081723 telefono_compartido 0.40 46938 Diaz Ordunez Lourdes LD666305@GMAIL.COM 1981-02-23 0 8129 2026-07-09 10:13:43
57 523603336407 duplicado_probable 0.80 SI 46132 Martinez Zavala Maribel 2011-08-14 11 2026-03-17 7165 2026-07-09 10:13:31
58 523603336407 duplicado_probable 0.80 46283 Martinez Zavala Alicia SHANKAZAFI8@HOTMAIL.COM 2011-03-03 0 7325 2026-07-09 10:13:37
59 524084892500 telefono_compartido 0.27 SI 40600 Contreras Adriana ADRIANA.CONTRERAS123@YAHOO.COM 1986-05-10 7 2026-02-21 938 2026-07-09 10:12:26
60 524084892500 telefono_compartido 0.27 46464 Ruiz Samantha ADRIANA.CONTRERAS123@YAHOO.COM 2009-09-22 0 7513 2026-07-09 10:13:37
61 524086037942 telefono_compartido 0.76 SI 41590 Hurtado Trigos Angelica MY2019BA@GMAIL.COM 1972-11-02 1 2025-06-27 2062 2026-07-09 10:12:38
62 524086037942 telefono_compartido 0.76 45572 Hurtado Angela 1972-11-02 0 6560 2026-07-09 10:13:25
63 524242233669 telefono_compartido 0.12 SI 42246 Sanchez Jezabell 2010-04-26 0 2791 2026-07-09 10:12:44
64 524242233669 telefono_compartido 0.12 42247 Lopez Garcia Edith NO@GMAIL.COM 1981-08-01 0 2792 2026-07-09 10:12:44
65 524422870470 telefono_compartido 0.62 SI 41369 Mendez Garcia Claudia Alejandra ALEXTREME1980@GMAIL.COM 1980-11-05 19 2024-11-21 1808 2026-07-09 10:12:38
66 524422870470 telefono_compartido 0.62 41373 Hinostrosa Mendez Hazel Alessandra 2011-11-04 0 1812 2026-07-09 10:12:38
67 525127812690 telefono_compartido 0.29 SI 42015 Gastelum Jimenez Teresita GASTELUMTERESITA@GMAIL.COM 1972-11-03 4 2024-07-16 2537 2026-07-09 10:12:44
68 525127812690 telefono_compartido 0.29 43014 Cisneros Martinez Martin 1961-08-08 0 3777 2026-07-09 10:12:57
69 525303551999 telefono_compartido 0.57 SI 41568 Wahl Suzanne FURBABY_MOM@PROTONMAIL.COM 1962-11-11 13 2024-09-03 2037 2026-07-09 10:12:38
70 525303551999 telefono_compartido 0.57 42101 Wahl John MODESTOJOHN@SBCGLOBAL.NET 1953-04-08 0 2630 2026-07-09 10:12:44
71 525554359904 telefono_compartido 0.47 SI 44182 Aranda Crestani María del Carmen 1973-07-26 1 2023-04-19 5106 2026-07-09 10:13:08
72 525554359904 telefono_compartido 0.47 44778 Garcia Aranda Annia CARMENCRESTANI@HOTMAIL.COM 2009-03-31 0 1162 2026-07-09 10:13:13
73 525563188717 telefono_compartido 0.52 SI 45735 Heranadez Maldonado Julia Maribel JULIAMARIBEL.HERNANDEZ@GMAIL.COM 1983-06-02 5 2025-12-11 6729 2026-07-09 10:13:25
74 525563188717 telefono_compartido 0.52 46066 Pina Hernandez Leonel JULIAMARIBEL.HERNANDEZ@GMAIL.COM 2010-11-07 0 7091 2026-07-09 10:13:31
75 525597138448 telefono_compartido 0.65 42305 Villalobos Nancy 1974-05-24 0 2859 2026-07-09 10:12:50
76 525597138448 telefono_compartido 0.65 SI 42316 Villalobos Refugio NO@GMAIL.COM 1974-11-11 4 2024-03-06 2871 2026-07-09 10:12:50
77 525622256068 telefono_compartido 0.36 SI 44051 Molina Evelyn MOLINA.EVELYN.1@GMAIL.COM 1975-11-25 0 4972 2026-07-09 10:13:08
78 525622256068 telefono_compartido 0.36 44052 Chavez Ibarra Hector 1974-05-04 0 4973 2026-07-09 10:13:08
79 525622982156 telefono_compartido 0.76 SI 42373 De Anda de Anda Daira 2001-12-17 2 2024-11-19 2931 2026-07-09 10:12:50
80 525622982156 telefono_compartido 0.76 44097 De Anda Daira DDAIRAA99@ICLOUD.COM 2001-12-17 0 5018 2026-07-09 10:13:08
81 525623922976 telefono_compartido 0.69 SI 40580 Rodriguez Hernandez Fany FRODRI0104@GMAIL.COM 1983-08-08 28 2026-06-23 916 2026-07-09 10:12:26
82 525623922976 telefono_compartido 0.69 45465 Rodriguez Maria Elena FRODRI0104@GMAIL.COM 1962-02-08 0 6448 2026-07-09 10:13:25
83 525623922976 telefono_compartido 0.69 46950 Rodriguez Abel FANYCDS@AOL.COM 1962-04-08 0 8143 2026-07-09 10:13:43
84 525627433633 telefono_compartido 0.20 SI 41427 Estrada Quezada Elvira ELVIRA_GALLOSO@YAHOO.COM 1959-01-20 31 2026-05-30 1876 2026-07-09 10:12:38
85 525627433633 telefono_compartido 0.20 43492 Tirres Garcia Maria de Jesus ELVIRA_GALLOSO@YAHOO.COM 1945-06-07 0 4374 2026-07-09 10:13:02
86 525627549883 telefono_compartido 0.40 SI 43620 Martinez Gonzalez Ma de la Luz MARIALUZ.GONZALEZ@YAHOO.COM 1952-01-27 55 2026-07-03 4523 2026-07-09 10:13:02
87 525627549883 telefono_compartido 0.40 43742 Marti Ez Christopher 1989-10-12 0 4653 2026-07-09 10:13:02
88 525629646534 duplicado_probable 1.00 SI 40318 Carrillo Leobardo 1967-01-18 6 2023-09-28 626 2026-07-09 10:12:26
89 525629646534 duplicado_probable 1.00 40682 Carrillo Leobardo 1967-01-18 0 1029 2026-07-09 10:12:26
90 526131285017 duplicado_probable 0.83 SI 41841 Ramos Chavez Blanca 1984-12-22 6 2025-12-20 2344 2026-07-09 10:12:44
91 526131285017 duplicado_probable 0.83 46114 Ramos Chavez Blanca Berence BBRCH2212@GMAIL.COM 1984-12-22 0 7145 2026-07-09 10:13:31
92 526145117937 telefono_compartido 0.48 SI 41198 Toledo Elizundia Irina 1981-11-09 15 2023-09-26 1620 2026-07-09 10:12:32
93 526145117937 telefono_compartido 0.48 41228 Comas Toledo Daniela 2008-10-26 0 1652 2026-07-09 10:12:32
94 526183027235 telefono_compartido 0.47 SI 44917 Rivas Aguilar Vanessa 1991-09-02 11 2026-04-20 5873 2026-07-09 10:13:19
95 526183027235 telefono_compartido 0.47 44918 Rivas Marylyn 1987-06-01 0 5874 2026-07-09 10:13:19
96 526192073535 telefono_compartido 0.50 SI 44996 Teran Quiñonez Leslie LESLIIE17@HOTMAIL.COM 1992-01-27 40 2026-07-01 4370 2026-07-09 10:13:19
97 526192073535 telefono_compartido 0.50 46346 Ontveros Teran Fernanda LESLIIE17@HOTMAIL.COM 2020-01-31 0 7390 2026-07-09 10:13:37
98 526192082582 telefono_compartido 0.72 SI 42393 Navarrete Azucena 1970-08-25 2 2023-12-09 2956 2026-07-09 10:12:50
99 526192082582 telefono_compartido 0.72 42395 Navarrete Ana Paula 2005-03-31 0 2958 2026-07-09 10:12:50
100 526192105498 telefono_compartido 0.64 SI 39849 Aguilar Viviana VIVIANA.L.SERRANO@GMAIL.COM 1992-02-25 16 2023-04-17 74 2026-07-09 10:12:20
101 526192105498 telefono_compartido 0.64 39850 Aguilar Ariel 2010-11-04 0 75 2026-07-09 10:12:20
102 526192591047 telefono_compartido 0.67 SI 44164 Vazquez Rocha Andrea MAYKAPM85@GMAIL.COM 1960-11-02 0 5088 2026-07-09 10:13:08
103 526192591047 telefono_compartido 0.67 44166 Monreal Andres MAYKAPM85@GMAIL.COM 2015-04-16 0 5090 2026-07-09 10:13:08
104 526192591047 telefono_compartido 0.67 44679 Monreal Mayka MAYKAPM85@GMAIL.COM 1985-08-23 0 5618 2026-07-09 10:13:13
105 526193194708 duplicado_probable 1.00 SI 41567 Sanchez Hernandez Adaly ALY.HERSAN.22@GMAIL.COM 1994-09-01 7 2024-03-20 2036 2026-07-09 10:12:38
106 526193194708 duplicado_probable 1.00 41656 Sanchez Hernandez Adaly 1994-09-01 0 2132 2026-07-09 10:12:38
107 526193438050 telefono_compartido 0.29 40912 Harris Sharion SHARION621@YAHOO.COM 1962-12-04 2 2023-09-13 1296 2026-07-09 10:12:32
108 526193438050 telefono_compartido 0.29 SI 41846 Henson Melanie MELANIE@POMPEIISURGICAL.COM 1986-07-17 4 2025-06-09 2349 2026-07-09 10:12:44
109 526193662975 telefono_compartido 0.54 SI 45060 Reyes Ramirez Selene Violeta 2009-12-28 19 2026-04-28 6030 2026-07-09 10:13:19
110 526193662975 telefono_compartido 0.54 45838 Reyes Ramirez Javier EMINEMA_50@HOTMAIL.COM 2011-11-06 0 6844 2026-07-09 10:13:31
111 526193662975 telefono_compartido 0.54 45898 Ramirez Flores Emma EMINEMA_50@HOTMAIL.COM 1988-06-29 0 6911 2026-07-09 10:13:31
112 526193955418 telefono_compartido 0.55 SI 39960 Aceves Callico Mia MCALLICO@HOTMAIL.COM 2009-07-23 12 2023-06-21 222 2026-07-09 10:12:20
113 526193955418 telefono_compartido 0.55 40366 Callico Maria Fernanda MCALLICO@HOTMAIL.COM 1982-02-10 0 680 2026-07-09 10:12:26
114 526194086051 telefono_compartido 0.67 SI 41142 Madrigal Toscano Diego MIRIAMTOSKNO@GMAIL.COM 2006-08-17 5 2023-07-26 1553 2026-07-09 10:12:32
115 526194086051 telefono_compartido 0.67 41189 Toscano Elizondo Miriam MIRIAMTOSKNO@HOTMAIL.COM 1975-09-13 1 2023-01-16 1611 2026-07-09 10:12:32
116 526194164074 telefono_compartido 0.43 SI 40269 Lujano Garza Adriana 1990-12-10 16 2024-09-30 571 2026-07-09 10:12:20
117 526194164074 telefono_compartido 0.43 43710 Magaña Mila Aeris LUJANOGARZA10@GMAIL.COM 2018-02-24 0 4621 2026-07-09 10:13:02
118 526194181249 telefono_compartido 0.53 SI 42666 Vazquez Emilio VAZQUEZANNA090@GMAIL.COM 2009-03-20 9 2024-07-20 3269 2026-07-09 10:12:50
119 526194181249 telefono_compartido 0.53 43211 Vazquez Quezada Anna VAZQUEZANNA090@GMAIL.COM 1990-07-08 0 4007 2026-07-09 10:12:57
120 526194197072 telefono_compartido 0.22 SI 42237 Aceves Sandra 1974-06-25 20 2024-05-07 2780 2026-07-09 10:12:44
121 526194197072 telefono_compartido 0.22 42396 Soto Valentina SANDRAACEVES05@GMAIL.COM 2010-02-21 0 2960 2026-07-09 10:12:50
122 526194519546 telefono_compartido 0.79 SI 41486 Felix Santos Blanca Luz BLANKA_1494@HOTMAIL.COM 1994-08-14 6 2023-09-18 1939 2026-07-09 10:12:38
123 526194519546 telefono_compartido 0.79 41798 Frausto Felix Blanca 1994-08-14 0 2293 2026-07-09 10:12:44
124 526194960868 telefono_compartido 0.65 SI 40212 Lee Iii Archie ARCHIE.LEE50@YAHOO.COM 2008-07-14 33 2026-05-27 510 2026-07-09 10:12:20
125 526194960868 telefono_compartido 0.65 45777 Archie Lee Senior ARCHIE.LEE@YAHOO.COM 1959-02-14 0 6781 2026-07-09 10:13:25
126 526195136653 telefono_compartido 0.72 SI 40761 Garcia Rodriguez Brenda 345GERMAN@GMAIL.COM 1994-06-30 21 2026-03-28 1124 2026-07-09 10:12:26
127 526195136653 telefono_compartido 0.72 42918 Garcia Brenda 1994-06-30 0 3672 2026-07-09 10:12:57
128 526195766031 telefono_compartido 0.33 SI 41322 Velazquez Gutierrez Nayely NAYELY_VG@YAHOO.COM 1989-07-28 29 2024-07-31 1757 2026-07-09 10:12:38
129 526195766031 telefono_compartido 0.33 42589 Castillo Fernandez Dylan Raul 2009-11-13 0 3179 2026-07-09 10:12:50
130 526195779037 telefono_compartido 0.33 SI 42381 Castro Alcina Michelle MICHELLEMICHEL09@GMAIL.COM 1982-01-09 26 2026-07-01 2941 2026-07-09 10:12:50
131 526195779037 telefono_compartido 0.33 46791 Michel Tiffany MICHELLEMICHEL09@GMAIL.COM 2011-09-09 0 7865 2026-07-09 10:13:43
132 526195868144 telefono_compartido 0.47 SI 40484 Cabuto Vega Berenice ROCA1016@GMAIL.COM 1987-07-21 27 2025-05-07 812 2026-07-09 10:12:26
133 526195868144 telefono_compartido 0.47 41996 Osuna Cabuto David 2009-01-29 0 2513 2026-07-09 10:12:44
134 526195868144 telefono_compartido 0.47 42119 Robles Danna 2012-02-21 0 2649 2026-07-09 10:12:44
135 526196031606 duplicado_probable 0.90 SI 40819 Altamirano Ferra Pedro Ivan PETEFERRA1982@GMAIL.COM 1982-09-06 2 2024-10-19 1192 2026-07-09 10:12:32
136 526196031606 duplicado_probable 0.90 43721 Altamirano Ferra Pedro PETEFERRA1982@GMAIL.COM 1982-09-06 0 4633 2026-07-09 10:13:02
137 526196221084 telefono_compartido 0.58 SI 41259 Smith Debbie CFO@POMPEIISURGICAL.COM 1964-05-07 6 2025-08-04 1684 2026-07-09 10:12:32
138 526196221084 telefono_compartido 0.58 43085 Smith Olivia CFO@POMPEIISURGICAL.COM 2008-06-07 0 3860 2026-07-09 10:12:57
139 526196327657 telefono_compartido 0.42 SI 45501 Tsurumi Santillan Joaquin NTSURUMI@GMAIL.COM 2013-03-17 16 2026-06-27 6486 2026-07-09 10:13:25
140 526196327657 telefono_compartido 0.42 45542 Tsurumi Villalobos Nora NTSURUMI@GMAIL.COM 1978-01-11 0 6529 2026-07-09 10:13:25
141 526196341096 telefono_compartido 0.65 SI 42697 Araiza Marisol MARI4ROBERT@YAHOO.COM 1981-07-04 24 2026-03-10 3305 2026-07-09 10:12:50
142 526196341096 telefono_compartido 0.65 43455 Araiza Garcia Joshua MARI4ROBERT@YAHOO.COM 2008-05-11 0 4326 2026-07-09 10:13:02
143 526196491350 telefono_compartido 0.63 SI 40416 Rodriguez Martha Susana 1975-12-09 34 2026-07-01 736 2026-07-09 10:12:26
144 526196491350 telefono_compartido 0.63 40895 Rodriguez Diaz Samantha Yael SYMARTHA74@YAHOO.COM 2010-01-08 34 2026-04-18 1278 2026-07-09 10:12:32
145 526196783754 duplicado_probable 0.84 SI 42845 Villegas Michel Sofia CRISHTNA.VILLEGAS@HOTMAIL.COM 2009-12-10 2 2024-04-10 3560 2026-07-09 10:12:57
146 526196783754 duplicado_probable 0.84 42850 Villegas Michel Victoria CRISHTNA.VILLEGAS@HOTMAIL.COM 2014-01-20 0 3578 2026-07-09 10:12:57
147 526197180836 telefono_compartido 0.39 SI 40808 Mayen Aviles Sylvia ARIMAYEN14@OUTLOOK.COM 1988-09-07 19 2026-05-20 1178 2026-07-09 10:12:32
148 526197180836 telefono_compartido 0.39 46196 Leon Guzman Osiel ARIMAYEN14@OUTLOOK.COM1 2013-05-01 0 7234 2026-07-09 10:13:31
149 526197192887 duplicado_probable 0.82 42368 Arreola Ceceña Sherlyn 2004-09-11 0 2926 2026-07-09 10:12:50
150 526197192887 duplicado_probable 0.82 SI 45279 Arreola Brandon GUADALUPE0420@GMAIL.COM 2006-11-14 15 2026-02-21 6265 2026-07-09 10:13:19
151 526197192887 duplicado_probable 0.82 46285 Arreola Ceceña Giselle GUADALUPE0420@GMAIL.COM 2001-12-04 0 7327 2026-07-09 10:13:37
152 526197355777 telefono_compartido 0.18 SI 40095 Diaz Barajas Alejandro ELI.BARAJAS.364@GMAIL.COM 2008-07-25 46 2025-09-27 384 2026-07-09 10:12:20
153 526197355777 telefono_compartido 0.18 42544 Diaz Jurado Juan Manuel ELI.BARAJAS.364@GMAIL.COM 1980-01-09 0 3126 2026-07-09 10:12:50
154 526197453496 telefono_compartido 0.73 SI 41071 Salas Beltran Guadalupe LUPITASALAS@HOTMAI0L.COM 1982-12-30 10 2025-02-08 1473 2026-07-09 10:12:32
155 526197453496 telefono_compartido 0.73 43963 Salas Beltran Ines 1982-12-30 0 4889 2026-07-09 10:13:08
156 526197695556 telefono_compartido 0.51 SI 40979 Estrada Alarcón Citlati CITLATI.21@GMAIL.COM 2001-06-21 2 2024-06-26 1370 2026-07-09 10:12:32
157 526197695556 telefono_compartido 0.51 43148 Alarcon Garcia Maria Dolores CITLATI.21@GMAIL.COM 1972-07-26 0 3929 2026-07-09 10:12:57
158 526198057621 duplicado_probable 0.80 SI 41795 Martinez Aileen AILEENIMARTINEZ@GMAIL.COM 1982-02-12 17 2026-04-28 2290 2026-07-09 10:12:44
159 526198057621 duplicado_probable 0.80 41796 Martinez Camila AILEENIMARTINEZ@GMAIL.COM 2007-12-14 6 2024-06-08 2291 2026-07-09 10:12:44
160 526198430000 telefono_compartido 0.47 39808 Jimenez Maria Elena 1948-03-15 9 2023-12-13 27 2026-07-09 10:12:20
161 526198430000 telefono_compartido 0.47 SI 41141 Jimenez Gonzalez Cynthia CYNDEEJIMENEZ@YAHOO.COM 1979-03-16 13 2026-04-09 1552 2026-07-09 10:12:32
162 526198430000 telefono_compartido 0.47 41899 Monzón Maximiliano CYNDEEJIMENEZ@YAHOO.COM 2009-10-07 0 2405 2026-07-09 10:12:44
163 526198502783 telefono_compartido 0.36 SI 43911 Sanchez Angel Victor Alexander CLAUDIA.A.SANCHEZ.8@GMAIL.COM 2010-05-25 0 4843 2026-07-09 10:13:08
164 526198502783 telefono_compartido 0.36 43913 Angel C Claudia CLAUDIA.A.SANCHEZ.8@GMAIL.COM 1976-07-08 0 4845 2026-07-09 10:13:08
165 526198643126 telefono_compartido 0.49 SI 44038 Olivarez Quintero Mayra M_OLIVAREZ@HOTMAIL.COM 1977-06-27 26 2026-03-11 613 2026-07-09 10:13:08
166 526198643126 telefono_compartido 0.49 44319 Soler Olivarez Valeria 2005-08-18 0 684 2026-07-09 10:13:13
167 526198824259 telefono_compartido 0.74 SI 44883 Reyes Rosa ROSA_REYES09@YAHOO.COM 1998-04-29 6 2025-09-27 5834 2026-07-09 10:13:19
168 526198824259 telefono_compartido 0.74 45340 Reyes Rosa Elidia 1998-04-29 0 6324 2026-07-09 10:13:25
169 526199570903 telefono_compartido 0.28 SI 40124 Guerra Jimenez Genesis GGUERRANATALIA@GMAIL.COM 1996-06-25 6 2025-09-19 415 2026-07-09 10:12:20
170 526199570903 telefono_compartido 0.28 45672 Garcia Uriel Leonardo LEONUG21@GMAIL.COM 2021-10-30 0 6663 2026-07-09 10:13:25
171 526199971510 telefono_compartido 0.43 SI 40033 Ortega Benitez Olga 1963-10-02 50 2026-07-03 316 2026-07-09 10:12:20
172 526199971510 telefono_compartido 0.43 40820 Alvarado Ortega Kiana Chanel 1990-04-16 0 1193 2026-07-09 10:12:32
173 526262905575 telefono_compartido 0.41 SI 44736 Garcia U Bryan SALGADODAISY11@GMAIL.COM 2007-01-10 0 5680 2026-07-09 10:13:13
174 526262905575 telefono_compartido 0.41 44737 Salgado U Daisy SALGADODAISY11@GMAIL.COM 1992-07-03 0 5681 2026-07-09 10:13:13
175 526263789251 telefono_compartido 0.37 SI 40641 Garcia Joanna JOANNA.GLB2006@GMAIL.COM 1981-07-03 34 2026-06-01 982 2026-07-09 10:12:26
176 526263789251 telefono_compartido 0.37 46538 León Piña Olga 1958-03-19 0 7593 2026-07-09 10:13:37
177 526264075757 telefono_compartido 0.62 SI 41462 Avellaneda Lopez Gladys 1968-09-25 4 2023-06-17 1915 2026-07-09 10:12:38
178 526264075757 telefono_compartido 0.62 41463 Avellaneda Soto Efrain 1959-08-11 0 1916 2026-07-09 10:12:38
179 526268257167 telefono_compartido 0.47 45889 Nunez Reinaga Nicolas 1982-10-05 0 6902 2026-07-09 10:13:31
180 526268257167 telefono_compartido 0.47 SI 45927 Nunez Nallely N121500G@GMAIL.COM 1985-06-10 24 2026-06-30 6942 2026-07-09 10:13:31
181 526317476272 telefono_compartido 0.64 SI 46499 Barker Joyce RUSTYANDJOYCE539@GMAIL.COM 1950-03-23 3 2026-03-24 7554 2026-07-09 10:13:37
182 526317476272 telefono_compartido 0.64 46535 Barker Curtis RUSTYANDJOYCE539@GMAIL.COM 1955-11-12 0 7590 2026-07-09 10:13:37
183 526461028015 telefono_compartido 0.63 SI 42063 Flores Palomares Cristofer Ramon PALOMARESR82@GMAIL.COM 2006-10-20 10 2024-10-02 2587 2026-07-09 10:12:44
184 526461028015 telefono_compartido 0.63 42089 Palomares Cruz Rosa Elena PALOMARESR82@GMAIL.COM 1982-01-08 0 2616 2026-07-09 10:12:44
185 526461965781 telefono_compartido 0.45 SI 40147 Hoiby Cheryl 1955-01-24 55 2026-05-19 439 2026-07-09 10:12:20
186 526461965781 telefono_compartido 0.45 42021 Hoiby Matt MATTHOIBYART@GMAIL.COM 1968-12-31 0 2543 2026-07-09 10:12:44
187 526462181115 telefono_compartido 0.67 44494 Bugarin Castillo Yurixy YURIXYBUGARIN@GMAIL.COM 1994-05-18 0 856 2026-07-09 10:13:13
188 526462181115 telefono_compartido 0.67 SI 45191 Castillo Bugarin Maria del Refugio CASTILLO.PROFESORA@GMAIL.COM 1963-07-07 30 2026-06-23 5688 2026-07-09 10:13:19
189 526502481153 duplicado_probable 1.00 SI 41218 Palafox Monique VARELA78@HOTMAIL.COM 2023-02-26 1 2024-02-02 1642 2026-07-09 10:12:32
190 526502481153 duplicado_probable 1.00 42586 Palafox Monique VARELA78@HOTMAIL.COM 1978-02-13 0 3176 2026-07-09 10:12:50
191 526504833245 telefono_compartido 0.62 SI 40470 Rojas Tapia Yadira YADIRAALCANTAR6@OUTLOOK.COM 1978-11-29 10 2026-04-09 796 2026-07-09 10:12:26
192 526504833245 telefono_compartido 0.62 42587 Rojas Alcantar Yadira YADIRAALCANTAR6@OUTLOOK.COM 1978-11-29 0 3177 2026-07-09 10:12:50
193 526505540697 telefono_compartido 0.25 SI 45441 Rubio Ylemsuy YLEMSUYRUBIO@GMAIL.COM 1983-04-11 36 2026-06-02 6113 2026-07-09 10:13:25
194 526505540697 telefono_compartido 0.25 45613 Schulz Will WCSCHULZ@GMAIL.COM 1941-10-26 0 6603 2026-07-09 10:13:25
195 526507714176 telefono_compartido 0.38 43433 Venegas Lopez Maria de Jesus NO@GMAIL.COM 1984-12-20 0 4302 2026-07-09 10:13:02
196 526507714176 telefono_compartido 0.38 SI 45185 Lopez Reyes Virginia 1955-01-31 2 2025-04-14 6166 2026-07-09 10:13:19
197 526611016296 telefono_compartido 0.77 SI 39836 Robledo Pineda Carolina SONOGORY@GMAIL.COM 2005-05-05 15 2026-06-04 58 2026-07-09 10:12:20
198 526611016296 telefono_compartido 0.77 39838 Pineda Lopez Guadalupe Elena SONOGORY@GMAIL.COM 1976-10-08 0 60 2026-07-09 10:12:20
199 526611016296 telefono_compartido 0.77 41513 Robledo Pineda Alejandro SONOGORY@GMAIL.COM 2007-09-07 0 1973 2026-07-09 10:12:38
200 526611031330 telefono_compartido 0.51 40643 Sanchez Partida Lizbeth Geraldine 2009-11-22 0 984 2026-07-09 10:12:26
201 526611031330 telefono_compartido 0.51 SI 40644 Partida Vazquez Blanca PARTIDAB56@GMAIL.COM 1979-10-02 9 2023-06-20 985 2026-07-09 10:12:26
202 526611035281 telefono_compartido 0.42 SI 42344 Agramont Barrios Natalue Sofia TANIAYNATALIE@GMAIL.COM 2014-03-11 4 2023-11-29 2901 2026-07-09 10:12:50
203 526611035281 telefono_compartido 0.42 42345 Barrios Valencia Tania Elizabeth TANIAYNATALIE@GMAIL.COM 1986-09-01 0 2902 2026-07-09 10:12:50
204 526611039656 telefono_compartido 0.44 43128 Charles Webb Christopher TEAM@BAJAREHAB.COM 2004-11-02 5 2025-06-20 3907 2026-07-09 10:12:57
205 526611039656 telefono_compartido 0.44 43827 Sanchez Sanchez Alberto TEAM@BAJAREHAB.COM 1999-06-06 0 4748 2026-07-09 10:13:08
206 526611039656 telefono_compartido 0.44 44444 Lynn Salazar Breana TEAM@BAJAREHAB.COM 1989-09-29 0 5381 2026-07-09 10:13:13
207 526611039656 telefono_compartido 0.44 SI 45596 Stroud John 2001-03-21 12 2025-10-06 6586 2026-07-09 10:13:25
208 526611039656 telefono_compartido 0.44 45635 Castro Abril 1994-04-29 0 6626 2026-07-09 10:13:25
209 526611039656 telefono_compartido 0.44 45751 Awad Feda 1983-11-05 0 6750 2026-07-09 10:13:25
210 526611039998 telefono_compartido 0.62 SI 42413 Lopez Arellanes Carolina CAROYNOE13@GMAIL.COM 1985-05-16 5 2024-01-04 2977 2026-07-09 10:12:50
211 526611039998 telefono_compartido 0.62 42419 Arellanes Arellano Maria 1954-09-12 0 2984 2026-07-09 10:12:50
212 526611050771 telefono_compartido 0.55 SI 43052 Del Angel Gonzalez Paola PDELANGEL@GMAIL.COM 1986-05-31 10 2026-03-05 3821 2026-07-09 10:12:57
213 526611050771 telefono_compartido 0.55 46299 Mayoral del Angel Lucas PDELANGEL@GMAIL.COM 2018-04-18 0 7343 2026-07-09 10:13:37
214 526611061156 telefono_compartido 0.63 SI 40324 Covarrubias Duarte Martha 1982-09-05 3 2023-03-17 634 2026-07-09 10:12:26
215 526611061156 telefono_compartido 0.63 40746 De Anda Covarrubias Mia Ailed 2005-10-31 0 1107 2026-07-09 10:12:26
216 526611065452 telefono_compartido 0.64 SI 41289 Martinez Martinez Mariana NOTIENE@GMAIL.COM 1977-05-18 4 2023-07-24 1720 2026-07-09 10:12:38
217 526611065452 telefono_compartido 0.64 41339 Diaz Martinez Arely MTZMARIANAMTZ678@GMAIL.COM 2011-09-24 0 1776 2026-07-09 10:12:38
218 526611070848 telefono_compartido 0.06 SI 46400 Araujo Morales Jessica Aide AMJESSICADG@GMAIL.COM 1991-02-18 6 2026-03-16 7446 2026-07-09 10:13:37
219 526611070848 telefono_compartido 0.06 46401 Li Yuda AMJESSICADG@GMAIL.COM 1996-04-16 0 7447 2026-07-09 10:13:37
220 526611072589 telefono_compartido 0.60 SI 40226 Sanchez Lara Cintia Lorena CINTIA.SANCHEZ2715@HOTMAIL.COM 1971-12-19 30 2025-10-08 526 2026-07-09 10:12:20
221 526611072589 telefono_compartido 0.60 41593 Estrada Sanchez Aidan CINTIA.SANCHEZ2715@HOTMAIL.COM 2006-01-27 0 2065 2026-07-09 10:12:38
222 526611074953 telefono_compartido 0.56 SI 39935 Garcia Santiago Jaqueline Yadira JAQULINEGARCIA4@GMAIL.COM 2004-07-07 6 2024-11-21 186 2026-07-09 10:12:20
223 526611074953 telefono_compartido 0.56 44114 Garcia Santiago Evelyn GARCIALUCRECIA945@GMAIL.COM 2010-10-25 0 5037 2026-07-09 10:13:08
224 526611076706 telefono_compartido 0.38 SI 39914 De Jesus de Jesus Yuritzia GLORIADEJESUSCASTRO01@GMAIL.COM 2007-09-14 3 2022-12-22 153 2026-07-09 10:12:20
225 526611076706 telefono_compartido 0.38 40153 De Jesus Castro Gloria 1991-10-30 0 445 2026-07-09 10:12:20
226 526611076917 telefono_compartido 0.16 SI 42710 Avila Torres Briana AGUINAGAVASARAH@GMAIL.COM 2010-01-14 3 2024-11-05 3318 2026-07-09 10:12:50
227 526611076917 telefono_compartido 0.16 43987 Aguiñaga Valenzuela Aleyda Sarai 1995-01-07 0 4907 2026-07-09 10:13:08
228 526611078701 telefono_compartido 0.50 40837 Yzarraraz Tafolla Raquel 1950-01-01 0 1211 2026-07-09 10:12:32
229 526611078701 telefono_compartido 0.50 SI 41216 Alcala Izarraraz Maria Hortencia ALIZ6901@HOTMAIL.COM 1969-01-17 6 2026-04-29 1640 2026-07-09 10:12:32
230 526611079992 telefono_compartido 0.41 SI 46639 Huerta Caro Ana Camila LAURAECARO@HOTMAIL.COM 2009-01-27 4 2026-05-22 7701 2026-07-09 10:13:37
231 526611079992 telefono_compartido 0.41 46640 Caro Gomez Laura Edith LAURAECARO@HOTMAIL.COM 1979-05-26 0 7702 2026-07-09 10:13:37
232 526611084511 telefono_compartido 0.48 SI 40982 Crosthwaite Resendez Carolina CAROANDRECO52@HOTMAIL.COM 2006-07-06 4 2023-04-17 1373 2026-07-09 10:12:32
233 526611084511 telefono_compartido 0.48 41240 Resendez Dominguez Ofelia CAROANDRECO52@HOTMAIL.COM 1976-01-20 0 1664 2026-07-09 10:12:32
234 526611087582 telefono_compartido 0.63 SI 40919 Cervantes Ortiz Alba Rosa CERVANTEESALBA06@GMAIL.COM 1991-10-06 21 2023-09-27 1304 2026-07-09 10:12:32
235 526611087582 telefono_compartido 0.63 41816 Cortes Cervantes Emily Andrea CERVANTEESALBA06@GMAIL.COM 2013-04-26 0 2316 2026-07-09 10:12:44
236 526611087969 telefono_compartido 0.48 42492 Smith Ada NOREL.ANN@GMAIL.COM 2010-10-05 0 3067 2026-07-09 10:12:50
237 526611087969 telefono_compartido 0.48 SI 45531 Smith Torres Lao Tze LAO@ALFAVID-ORGANICS.COM 1981-12-17 44 2026-06-26 6515 2026-07-09 10:13:25
238 526611101468 telefono_compartido 0.43 SI 40323 Peñaloza Tinoco Jose Armando CHALINO2011@HOTMAIL.COM 2008-02-04 19 2023-08-24 633 2026-07-09 10:12:26
239 526611101468 telefono_compartido 0.43 41516 Tinoco Rodriguez Yadira 1982-03-14 0 1976 2026-07-09 10:12:38
240 526611102293 telefono_compartido 0.38 SI 40322 Holguin Gonzalez Cynthia Lizeth CYNTHIAHOLGUIN@HOTMAIL.COM 1892-07-26 10 2025-02-07 632 2026-07-09 10:12:26
241 526611102293 telefono_compartido 0.38 44579 Mendoza Holguin Leonel CYNTHIAHOLGUIN@HOTMAIL.COM 2019-06-29 0 5513 2026-07-09 10:13:13
242 526611103464 telefono_compartido 0.59 SI 41395 Ruiz Mijangos Lia Yhoalibeth RMIJANGOS@UABC.EDU.MX 2009-05-22 83 2026-07-06 1841 2026-07-09 10:12:38
243 526611103464 telefono_compartido 0.59 41674 Mijangos Ortega Rosalba RMIJANGOS@UABC.EDE 1981-09-04 0 2150 2026-07-09 10:12:38
244 526611105141 duplicado_probable 0.90 SI 41000 Ordoñez Arreaga Carlos CARLOS1SEP1980@YAHOO.COM 1981-09-02 8 2024-05-25 1391 2026-07-09 10:12:32
245 526611105141 duplicado_probable 0.90 42374 Ordonez Arreaga Jose Carlos CARLOSARREAGA900@GMAIL.COM 1981-09-01 0 2932 2026-07-09 10:12:50
246 526611105858 duplicado_probable 1.00 SI 39809 Rojas Cortes Dafne Joceline ROJASAIDA10@GMAIL.COM 2009-12-24 2 2022-11-12 28 2026-07-09 10:12:20
247 526611105858 duplicado_probable 1.00 39810 Cortes Samano Aida ROJASAIDA10@GMAIL.COM 1981-03-22 0 29 2026-07-09 10:12:20
248 526611105858 duplicado_probable 1.00 39876 Cortes Samano Aida ROJASAIDA10@GMAIL.COM 1981-03-22 0 104 2026-07-09 10:12:20
249 526611107190 telefono_compartido 0.43 SI 39986 Serrato Antunez Celindanet RMORENOOP@OUTLOOK.COM 1981-10-07 55 2026-04-17 250 2026-07-09 10:12:20
250 526611107190 telefono_compartido 0.43 42032 Moreno Serrato Maria Guadalupe RMORENOOP@OUTLLOK.COM 2008-01-19 0 2554 2026-07-09 10:12:44
251 526611107489 telefono_compartido 0.72 SI 40483 Espinoza Moreno Anabel E.ANABEL@AOL.COM 1986-09-12 18 2026-05-06 811 2026-07-09 10:12:26
252 526611107489 telefono_compartido 0.72 42790 Ramirez Espinoza Angelica E.ANABEL@AOL.COM 2013-03-01 0 3421 2026-07-09 10:12:57
253 526611107905 telefono_compartido 0.50 SI 42655 Sierra Garcia Graciela GRACIELASIGA@GMAIL.COM 1995-06-28 9 2026-01-15 3258 2026-07-09 10:12:50
254 526611107905 telefono_compartido 0.50 46176 Trujillo Sierra Aaron Isai SIERRAGARCIAGRACIELA@GMAIL.COM 2023-09-16 0 7211 2026-07-09 10:13:31
255 526611109648 telefono_compartido 0.61 SI 45111 Valenzuela Velazquez Carolina PSIC.CAROLINA.VALENZUELA@GMAIL.COM 1990-03-27 22 2026-04-13 6085 2026-07-09 10:13:19
256 526611109648 telefono_compartido 0.61 46660 Inzunza Valenzuela Victoria PSIC.CAROLINA.VALENZUELA@GMAIL.COM 2018-12-21 0 7727 2026-07-09 10:13:37
257 526611111645 telefono_compartido 0.53 SI 40250 Chavez Suarez Christofher Alan CHAMACASGR@GMAIL.COM 2011-09-14 28 2026-05-12 551 2026-07-09 10:12:20
258 526611111645 telefono_compartido 0.53 40482 Suarez Grana Claudia Rosario CHAMACASGR@GMAIL.COM 1982-07-19 0 809 2026-07-09 10:12:26
259 526611111645 telefono_compartido 0.53 43250 Chavez Suarez Geovanni Yael CHAMACASGR@GMAIL.COM 2008-09-18 0 4072 2026-07-09 10:12:57
260 526611116797 telefono_compartido 0.55 SI 39894 Mendoza Arvizu Saul SAULMENDOZA2305@GMAIL.COM 2005-03-23 4 2024-08-05 127 2026-07-09 10:12:20
261 526611116797 telefono_compartido 0.55 44818 Arvizu Ramirez Korina 1983-02-15 0 5763 2026-07-09 10:13:19
262 526611121149 telefono_compartido 0.67 SI 41597 Jimenez de los Santos Alejandro ALJISA2000@HOTMAIL.COM 1978-11-08 14 2025-10-01 2070 2026-07-09 10:12:38
263 526611121149 telefono_compartido 0.67 41747 Jimenez Lopez Alexander ALJISA2000@HOTMAIL.COM 2009-01-23 0 2235 2026-07-09 10:12:38
264 526611121435 telefono_compartido 0.52 SI 41550 Gonzalez Velazquez Angela Aurora ANGOURA000@GMAIL.COM 2000-11-04 19 2025-09-22 2018 2026-07-09 10:12:38
265 526611121435 telefono_compartido 0.52 43894 Rodriguez Garcia Angel 1997-01-13 0 4820 2026-07-09 10:13:08
266 526611123133 telefono_compartido 0.64 SI 41181 Vera Astorga Andrea Carolina DRACAROLINAVERA@GMAIL.COM 1995-10-18 10 2024-07-09 1598 2026-07-09 10:12:32
267 526611123133 telefono_compartido 0.64 43242 Silva Vera Aria Valentina DRACAROLINAVERA@GMAIL.COM 2018-12-03 0 4061 2026-07-09 10:12:57
268 526611124200 telefono_compartido 0.48 42908 Delgado Flores Luis Guillermo LIZFLOCAS@GMAIL.COM 2009-07-11 0 3658 2026-07-09 10:12:57
269 526611124200 telefono_compartido 0.48 SI 45521 Flores Castro Lizbeth 1976-11-05 8 2025-08-23 6506 2026-07-09 10:13:25
270 526611126483 telefono_compartido 0.48 SI 39871 Jimenez Rivera Silvia SILVIA_JR@LIVE.COM 1958-09-27 16 2026-02-07 99 2026-07-09 10:12:20
271 526611126483 telefono_compartido 0.48 45353 Garcia Rivera Joselyn JOSELYNGARCUARIVERA52@GMAIL.COM 2008-12-11 0 6337 2026-07-09 10:13:25
272 526611127629 telefono_compartido 0.26 SI 41161 Vergara Vera Oriana Lizbeth ORIVERVER97@GMAIL.COM 1997-09-27 10 2024-05-28 1576 2026-07-09 10:12:32
273 526611127629 telefono_compartido 0.26 42964 Sanchez Ramirez Maria Reyna ORIVERVER97@GMAIL.COM 1958-05-20 0 3720 2026-07-09 10:12:57
274 526611128248 telefono_compartido 0.63 SI 40767 Arroyo Vera Jancy NVERA@ROSARITO.GOB.MX 2007-11-07 20 2023-11-02 1132 2026-07-09 10:12:26
275 526611128248 telefono_compartido 0.63 41295 Arroyo Vera Yareli NVERAMZNO@GMAIL.COM 2023-05-04 0 1726 2026-07-09 10:12:38
276 526611129769 telefono_compartido 0.71 SI 39786 Lara Magaña Luisabelle LUISABELLE.LARA@UABC.EDU.MX 1995-02-28 73 2026-07-04 5 2026-07-09 10:12:20
277 526611129769 telefono_compartido 0.71 42914 Lara Magaña Luis Carlos LUISABELLE.LARA@UABC.EDU.MX 2014-08-22 0 3664 2026-07-09 10:12:57
278 526611131968 telefono_compartido 0.70 SI 41024 Rodriguez Garcia Raquel ENERORAQUEL81@GMAIL.COM 1981-01-15 74 2026-05-30 1417 2026-07-09 10:12:32
279 526611131968 telefono_compartido 0.70 42729 Diaz Rodriguez Hiram 2006-11-22 0 3340 2026-07-09 10:12:50
280 526611134375 telefono_compartido 0.34 SI 42574 Leyva Mascareño Julieta Isabel CASAELJARDIN@GMAIL.COM 1945-10-19 6 2025-01-02 3162 2026-07-09 10:12:50
281 526611134375 telefono_compartido 0.34 42575 Camacho Cobos Hector Aurelio HECTOR.CAMACHO195@GMAIL.COM 1970-08-17 0 3163 2026-07-09 10:12:50
282 526611134375 telefono_compartido 0.34 43920 Steven Larkey Gregory CASAELJARDIN@GMAIL.COM 1948-04-01 0 4849 2026-07-09 10:13:08
283 526611135155 telefono_compartido 0.60 SI 41243 Bogarin Diaz Jaden Yeray MAMAXITA2000@GMAIL.COM 2013-09-05 5 2023-05-04 1668 2026-07-09 10:12:32
284 526611135155 telefono_compartido 0.60 41244 Bogarin Diaz Dominic Aziel MAMAXITA2000@GMAIL.COM 2015-05-23 0 1669 2026-07-09 10:12:32
285 526611135759 telefono_compartido 0.48 SI 41908 Villegas Hernandez Ariana Gisell CHIOHDZ31@GMAIL.COM 2007-12-14 70 2026-06-23 2414 2026-07-09 10:12:44
286 526611135759 telefono_compartido 0.48 42043 Hernandez Tejeda Rocio CHIOHDZ31@GMAIL.COM 1983-06-12 0 2566 2026-07-09 10:12:44
287 526611137919 telefono_compartido 0.57 42358 Hernandez Angulo Laura Yareli LAURAHDZ2103@GMAIL.COM 1994-01-21 0 2916 2026-07-09 10:12:50
288 526611137919 telefono_compartido 0.57 SI 45333 Reynoso Hernandez Renata LAURAHDZ2103@GMAIL.COM 2018-02-06 12 2026-05-20 6317 2026-07-09 10:13:25
289 526611142245 duplicado_probable 0.81 39814 Chavez Virginia 0 34 2026-07-09 10:12:20
290 526611142245 duplicado_probable 0.81 SI 40031 Chavez Guzman Virginia VIRGINIACHAVEZGUZMAN@GMAIL.COM 1977-04-14 2 2022-12-15 314 2026-07-09 10:12:20
291 526611142800 telefono_compartido 0.65 SI 41280 Ochoa Rivera Iker ERIKARV8A@GMAIL.COM 2023-04-26 8 2025-01-07 1711 2026-07-09 10:12:32
292 526611142800 telefono_compartido 0.65 41328 Rivera Sosa Erica ERIKARV8A@GMAIL.COM 1979-08-23 0 1764 2026-07-09 10:12:38
293 526611143867 telefono_compartido 0.56 SI 40229 Gomez Santiz Carmen CARMENGS94@YAHOO.COM 1988-06-16 66 2026-06-24 529 2026-07-09 10:12:20
294 526611143867 telefono_compartido 0.56 40317 Santibañez Gómez Jasmine CLAUDIATOMAS@OUTLOOK.COM 2007-02-16 0 625 2026-07-09 10:12:26
295 526611143918 telefono_compartido 0.38 SI 42646 Ortiz Lopez Maximo 2021-10-22 2 2025-01-31 3246 2026-07-09 10:12:50
296 526611143918 telefono_compartido 0.38 44674 Lopez Pulido Lorena Priscilla PULIDOLORENA45@GMAIL.COM 1999-06-04 0 5614 2026-07-09 10:13:13
297 526611147118 telefono_compartido 0.38 SI 43220 Mejia Castillo Cristina COCO.CRISTINA.MEX@GMAIL.COM 1980-12-22 0 4026 2026-07-09 10:12:57
298 526611147118 telefono_compartido 0.38 43807 Leyva Mejia Lizbeth COCO.CRISTINA.MEX@GMAIL.COM 2010-12-28 0 4724 2026-07-09 10:13:08
299 526611148758 telefono_compartido 0.46 SI 39946 Carmona Lopez Misael Abisai 2007-03-09 9 2023-09-07 197 2026-07-09 10:12:20
300 526611148758 telefono_compartido 0.46 40608 Lopez Lorenzo Brenda Patricia 1986-12-09 0 946 2026-07-09 10:12:26
301 526611149488 telefono_compartido 0.50 SI 42153 Morales Ramirez Samuel Arturo 2009-07-11 5 2026-05-28 2684 2026-07-09 10:12:44
302 526611149488 telefono_compartido 0.50 46312 Ocampo Ramirez Martin David OCAMPOMARTIN404@GMAIL.COM 1999-01-11 0 7356 2026-07-09 10:13:37
303 526611149769 telefono_compartido 0.39 SI 46562 Estrada Flores Juan 1950-01-27 9 2026-06-20 7619 2026-07-09 10:13:37
304 526611149769 telefono_compartido 0.39 46656 Guevara Sara 1929-10-14 0 7723 2026-07-09 10:13:37
305 526611160528 telefono_compartido 0.53 SI 40668 Soberanes Eugenia 1980-05-09 2 2025-02-19 1014 2026-07-09 10:12:26
306 526611160528 telefono_compartido 0.53 44820 Soberanes Soberanes Victoria 2010-09-11 0 5765 2026-07-09 10:13:19
307 526611161063 telefono_compartido 0.55 SI 45069 Suarez Muñoz Anne Scarlet 2013-11-22 2 2026-01-16 6038 2026-07-09 10:13:19
308 526611161063 telefono_compartido 0.55 46333 Muñoz Ramirez Gabriela G_A_VI14@HOTMAIL.COM 1994-02-27 0 7377 2026-07-09 10:13:37
309 526611161736 telefono_compartido 0.31 SI 40614 Chavez Galvan Luis Fernando CHAVEZLUIS1231@GMAIL.COM 2005-12-31 16 2025-12-03 955 2026-07-09 10:12:26
310 526611161736 telefono_compartido 0.31 43390 Galvan Gomez Norma 1981-11-15 0 4257 2026-07-09 10:13:02
311 526611162228 telefono_compartido 0.29 SI 43747 Michael Marquez Alyn 1995-02-04 23 2025-05-09 4659 2026-07-09 10:13:02
312 526611162228 telefono_compartido 0.29 44638 Parra Elizabeth 2013-06-01 0 5576 2026-07-09 10:13:13
313 526611164930 telefono_compartido 0.52 SI 46924 Ramirez Robles Daniela CINDYRA1679@GMAIL.COM 2009-05-13 3 2026-06-25 8114 2026-07-09 10:13:43
314 526611164930 telefono_compartido 0.52 46948 Robles Aguirre Cindy CINDYRA1679@GMAIL.COM 1979-01-16 0 8140 2026-07-09 10:13:43
315 526611166372 duplicado_probable 0.81 SI 39995 Gomez Valenzuela Fernando 07VALENZUELAANA07@GMAIL.COM 2010-04-02 53 2026-06-10 259 2026-07-09 10:12:20
316 526611166372 duplicado_probable 0.81 40351 Gomez Aneth 07VALENZUELAANA07@GMAIL.COM 2007-02-27 0 663 2026-07-09 10:12:26
317 526611166372 duplicado_probable 0.81 42026 Gomez Valenzuela Danna 07VALENZUELAANA07@GMAIL.COM 2013-02-11 0 2548 2026-07-09 10:12:44
318 526611168484 telefono_compartido 0.48 SI 43384 Venegas Morales Yesika Elizabeth JESIVENEGASA@OUTLOOK.COM 1980-01-14 8 2026-06-19 4251 2026-07-09 10:13:02
319 526611168484 telefono_compartido 0.48 46846 Alvarez Venegas Maria Guadalupe 2012-04-16 0 7933 2026-07-09 10:13:43
320 526611198561 telefono_compartido 0.54 SI 43915 Parra Lepro Gael Alejandro REBUILTSUM317@GMAIL.COM 2008-06-05 6 2025-02-06 4847 2026-07-09 10:13:08
321 526611198561 telefono_compartido 0.54 43997 Lepro Galindo Veronica VEROLEPRO@HOTMAIL.COM 1980-05-14 0 4919 2026-07-09 10:13:08
322 526611230788 telefono_compartido 0.36 SI 39968 Mendoza Arambula Ana Laura TPD.ANAMENDOZA@GMAIL.COM 1989-11-14 14 2025-08-27 230 2026-07-09 10:12:20
323 526611230788 telefono_compartido 0.36 45768 Higuera Gloria Luz 1952-12-28 0 6772 2026-07-09 10:13:25
324 526611235293 telefono_compartido 0.58 SI 40594 Gomez Ambriz Denice GOMEZDENICE071@GMAIL.COM 1985-12-27 4 2024-05-29 931 2026-07-09 10:12:26
325 526611235293 telefono_compartido 0.58 43077 Cabrera Gomez Derek Emilio DENICEGOMEZ071@GMAIL.COM 2011-11-07 0 3851 2026-07-09 10:12:57
326 526611240852 telefono_compartido 0.39 SI 40045 Sandoval Osorio Orestes Octavio YUVIAOSORIO1416@GMAIL.COM 2006-02-25 10 2026-01-27 329 2026-07-09 10:12:20
327 526611240852 telefono_compartido 0.39 42583 Osorio Torres Yuvia NO@GMAIL.COM 1979-10-23 0 3173 2026-07-09 10:12:50
328 526611240852 telefono_compartido 0.39 46326 Torres Cabrera Maria Teresa 1961-07-31 0 7370 2026-07-09 10:13:37
329 526611240933 telefono_compartido 0.27 SI 42744 Sterling Annie Marie 1980-01-29 17 2026-04-07 3358 2026-07-09 10:12:50
330 526611240933 telefono_compartido 0.27 43416 White Noah 1990-08-10 0 4284 2026-07-09 10:13:02
331 526611257754 telefono_compartido 0.22 SI 41637 Ramos Zaragosa Maria Guadalupe NICOLAS.S.PLT@GMAIL.COM 1992-11-24 3 2025-05-23 2112 2026-07-09 10:12:38
332 526611257754 telefono_compartido 0.22 45388 Santana Nicolas 1993-10-18 0 6370 2026-07-09 10:13:25
333 526611257775 telefono_compartido 0.65 SI 41451 Lara Castro Alma AIDILLARA32@GMAIL.COM 1988-08-03 37 2025-12-10 1904 2026-07-09 10:12:38
334 526611257775 telefono_compartido 0.65 41452 Moreno Lara Itzel Sirelly ITZELSIRELLYML@ICLOUD.COM 2007-12-30 0 1905 2026-07-09 10:12:38
335 526611257775 telefono_compartido 0.65 43371 Moreno Lara Jazlyn JAZYN2610ML@ICLOUD.COM 2010-01-26 0 4234 2026-07-09 10:13:02
336 526611259077 duplicado_probable 0.82 SI 41636 Aguilar Brambila Sophie CBRAMBILA.RUIZ@GMAIL.COM 2011-05-09 131 2026-06-30 2111 2026-07-09 10:12:38
337 526611259077 duplicado_probable 0.82 42630 Aguilar Brambila Ivan NO@GMAIL.COM 2009-07-19 0 3227 2026-07-09 10:12:50
338 526611259077 duplicado_probable 0.82 45814 Brambila Ruiz Claudia Teresa CBRAMBILA.RUIZ@GMAIL.COM 1985-05-16 0 6817 2026-07-09 10:13:31
339 526611261398 telefono_compartido 0.46 SI 42290 Howard Tapia Melanie Arlene NO@OUTLOOK.COM 1996-03-21 10 2026-03-14 2842 2026-07-09 10:12:50
340 526611261398 telefono_compartido 0.46 46450 Tapia Cortes Maria del Rosario 1974-11-02 0 7498 2026-07-09 10:13:37
341 526611262232 telefono_compartido 0.48 44286 Benites Borbon Juana Edith EDITH_DXH@HOTMAIL.COM 1990-11-20 0 5213 2026-07-09 10:13:13
342 526611262232 telefono_compartido 0.48 SI 46357 Tronco Benites Dante EDITH_DXH@HOTMAIL.COM 2012-10-09 31 2026-06-23 7403 2026-07-09 10:13:37
343 526611281981 duplicado_probable 1.00 44479 Lopez Flores Rosa Maria 1957-10-20 0 5417 2026-07-09 10:13:13
344 526611281981 duplicado_probable 1.00 SI 46949 Lopez Flores Rosa Maria LOPEZFLORESROSAMARIA8@GMAIL.COM 1957-10-20 2 2026-06-23 8142 2026-07-09 10:13:43
345 526611286717 telefono_compartido 0.58 SI 41191 Valenzuela Nieto Valeria NIETO.JUDITH7@GMAIL.COM 2007-12-21 51 2026-01-10 1613 2026-07-09 10:12:32
346 526611286717 telefono_compartido 0.58 43319 Nieto Talavera Judith NIETO.JUDITH7@GMAIL.COM 1973-08-26 0 4173 2026-07-09 10:13:02
347 526611300679 telefono_compartido 0.43 SI 40276 Vera Rodriguez Luz Maria 1965-11-09 12 2024-12-09 579 2026-07-09 10:12:20
348 526611300679 telefono_compartido 0.43 43059 Dominguez Torres Efrain LUZM.VERA@HOTMAIL.COM 1965-12-08 0 3830 2026-07-09 10:12:57
349 526611301648 telefono_compartido 0.49 SI 40604 Cortes Chavez Rosangel 1990-01-20 72 2025-12-10 942 2026-07-09 10:12:26
350 526611301648 telefono_compartido 0.49 40937 Gonzalez Cortez Dariana 2014-01-20 0 1325 2026-07-09 10:12:32
351 526611306067 telefono_compartido 0.65 44379 Gonzalez Acosta Ismael NALLELIACOSTA277@ICLOUD.COM 2011-05-18 0 5309 2026-07-09 10:13:13
352 526611306067 telefono_compartido 0.65 SI 45202 Gonzalez Acosta Georgina 2012-08-23 9 2025-08-27 6182 2026-07-09 10:13:19
353 526611306067 telefono_compartido 0.65 45787 Gonzalez Alvarado Jorge 1979-07-30 0 6791 2026-07-09 10:13:31
354 526611307539 telefono_compartido 0.52 SI 40051 Hernandez Vidal Genesis Denisse 2013-05-30 18 2025-02-20 335 2026-07-09 10:12:20
355 526611307539 telefono_compartido 0.52 42922 Vidal Perez Cecilia VIDALCECILIA979@GMAIL.COM 1989-12-03 0 3676 2026-07-09 10:12:57
356 526611307777 telefono_compartido 0.54 SI 40742 Alba Correa Katherine Mireya DOMINGUEZMIREYA349@GMAIL.COM 2011-06-11 19 2026-07-06 1102 2026-07-09 10:12:26
357 526611307777 telefono_compartido 0.54 46534 Correa Mireya Yazmin 1987-03-20 0 7589 2026-07-09 10:13:37
358 526611312427 telefono_compartido 0.52 SI 42016 Martinez Herrera Estrella 1982-11-30 0 2538 2026-07-09 10:12:44
359 526611312427 telefono_compartido 0.52 43983 Herrera Raquelina 1956-07-20 0 4903 2026-07-09 10:13:08
360 526611350980 telefono_compartido 0.75 SI 40590 Sanchez Balandran Ma Candelaria CANDELS.SB@GMAIL.COM 1973-08-10 10 2024-08-22 927 2026-07-09 10:12:26
361 526611350980 telefono_compartido 0.75 42627 Estrada Sanchez Isaias CANDELS.SB@GMAIL.COM 2014-07-19 0 3224 2026-07-09 10:12:50
362 526611350980 telefono_compartido 0.75 43149 Estrada Sanchez Sofia Maiella CANDELS.SB@GMAIL.COM 2009-04-25 0 3930 2026-07-09 10:12:57
363 526611354116 telefono_compartido 0.61 SI 45553 De la Cruz Muñoz Maria de los Angeles MARTINEZREYNA102@GMAIL.COM 1945-07-12 2 2025-10-27 6541 2026-07-09 10:13:25
364 526611354116 telefono_compartido 0.61 45945 Martinez de la Cruz Alejandro 2014-09-15 0 6963 2026-07-09 10:13:31
365 526611355158 telefono_compartido 0.65 SI 40981 Hernandez Alonso Lorena LORENAHDEZ47@YAHOO.COM 1973-10-29 14 2026-06-05 1372 2026-07-09 10:12:32
366 526611355158 telefono_compartido 0.65 44008 Chavez Hernandez Jimena CHAVEZJIMENA415@GMAIL.COM 2006-04-15 0 4931 2026-07-09 10:13:08
367 526611359021 duplicado_probable 1.00 SI 39795 Gallardo Cano Denisse NIZG722@GMAIL.COM 1999-06-10 15 2025-05-21 14 2026-07-09 10:12:20
368 526611359021 duplicado_probable 1.00 40372 Gallardo Cano Denisse NIZG723@GMAIL.COM 1999-06-10 0 687 2026-07-09 10:12:26
369 526611361708 duplicado_probable 1.00 SI 40088 Canedo Rodriguez Fabrizzio CAROFABRIZZIO@GMAIL.COM 2002-05-14 3 2023-01-10 377 2026-07-09 10:12:20
370 526611361708 duplicado_probable 1.00 40421 Canedo Rodriguez Fabrizzio CAROFABRIZZIO@GMAIL.COM 2002-05-14 0 742 2026-07-09 10:12:26
371 526611362374 telefono_compartido 0.50 40552 Corona Carmen 1970-06-03 0 886 2026-07-09 10:12:26
372 526611362374 telefono_compartido 0.50 SI 40553 Mendoza Cecilia CECY.FICHO@GMAIL.COM 1968-04-28 2 2023-01-18 887 2026-07-09 10:12:26
373 526611465861 duplicado_probable 1.00 SI 39911 Villa Perez Violeta 1977-10-12 3 2024-05-08 147 2026-07-09 10:12:20
374 526611465861 duplicado_probable 1.00 42489 Villa Perez Violeta 2004-01-05 0 3063 2026-07-09 10:12:50
375 526611472087 telefono_compartido 0.49 SI 45454 Suazo Justo Paulette 2010-02-25 3 2025-07-02 6437 2026-07-09 10:13:25
376 526611472087 telefono_compartido 0.49 45523 Justo Reyes Ivone 1991-12-07 0 6508 2026-07-09 10:13:25
377 526611728774 telefono_compartido 0.38 SI 41964 Santos Ramirez Jasmin Nicole 2023-09-06 24 2026-01-05 2478 2026-07-09 10:12:44
378 526611728774 telefono_compartido 0.38 42312 Ramirez Delgado Erendira 1986-05-08 0 2867 2026-07-09 10:12:50
379 526611729992 telefono_compartido 0.27 SI 41682 Delgado Najera Maria del Refugio BECKYRAMIREZ474@GMAIL.COM 1965-07-04 2 2024-12-05 2160 2026-07-09 10:12:38
380 526611729992 telefono_compartido 0.27 43974 Silva Ramirez Andrea BECKYRAMIREZ474@GMAIL.COM 2016-05-02 0 4897 2026-07-09 10:13:08
381 526611730138 telefono_compartido 0.45 44209 Magallanes Hernandez Merary Yireth MARIA1848HERNDEZ@GMAIL.COM 2010-02-27 0 5134 2026-07-09 10:13:08
382 526611730138 telefono_compartido 0.45 SI 46273 Hernandez Vazquez Maria Angelica MARIA1848HERNANDEZ@GMAIL.COM 1992-06-16 19 2026-07-06 7315 2026-07-09 10:13:31
383 526611951442 telefono_compartido 0.27 SI 41918 Zaragoza Velazquez Denisse Lilian ZARAGOZADENISSE323@GMAIL.COM 1998-11-06 6 2024-10-23 2424 2026-07-09 10:12:44
384 526611951442 telefono_compartido 0.27 41919 Rodriguez Ayala Jose Jaime 1997-06-07 0 2425 2026-07-09 10:12:44
385 526614003328 duplicado_probable 0.95 SI 40214 Chaides Cabrales Maria 1963-07-28 8 2023-07-04 512 2026-07-09 10:12:20
386 526614003328 duplicado_probable 0.95 41630 Chaidez Cabrales Maria 1963-07-08 0 2105 2026-07-09 10:12:38
387 526614720096 telefono_compartido 0.48 SI 44416 Garcia Jaelyn PAULINATOVAR82@GMAIL.COM 2019-04-27 2 2025-01-18 5352 2026-07-09 10:13:13
388 526614720096 telefono_compartido 0.48 44417 Garcia Arley PAULINATOVAR82@GMAIL.COM 2012-07-07 0 5353 2026-07-09 10:13:13
389 526615274778 telefono_compartido 0.45 SI 41618 Chavez Garcia Renata MARISOLGU80@GMAIL.COM 2015-09-14 7 2025-01-16 2093 2026-07-09 10:12:38
390 526615274778 telefono_compartido 0.45 43810 Mayoral Garcia Sebastian MARISOLGU80@GMAIL.COM 2008-02-02 0 4727 2026-07-09 10:13:08
391 526615931311 duplicado_probable 1.00 SI 40568 Bravo Leyva Esmeralda 1976-10-18 3 2026-02-09 902 2026-07-09 10:12:26
392 526615931311 duplicado_probable 1.00 46453 Bravo Leyva Esmeralda ESMERALDABRAVO1876@GMAIL.COM 1976-10-18 0 7501 2026-07-09 10:13:37
393 526616160572 telefono_compartido 0.37 SI 43356 Gomez Alvarez Ma del Rosario ROSARIOYSERFGIO08@GMAIL.COM 1976-01-15 4 2026-07-01 4213 2026-07-09 10:13:02
394 526616160572 telefono_compartido 0.37 46977 Perez Gomez Zoe ROSARIOZOE152024@GMAIL.COM 2009-08-06 0 8175 2026-07-09 10:13:43
395 526616160771 telefono_compartido 0.57 SI 41716 Contreras Trujillo Lucia LUCYHAROZ@LCLOUD.COM 1975-12-26 8 2024-02-14 2201 2026-07-09 10:12:38
396 526616160771 telefono_compartido 0.57 41717 Haroz Contreras Alonso LUCYHAROZ@ICLOUD.COM 2007-09-25 0 2202 2026-07-09 10:12:38
397 526616164154 telefono_compartido 0.39 SI 39991 Garcia Poot Aurora Sofia EDGAR.COTA1213@GMAIL.COM 2020-09-16 4 2026-02-27 255 2026-07-09 10:12:20
398 526616164154 telefono_compartido 0.39 46396 Poot Flores Kimberly Isabel KIMPOOT050500@GMAIL.COM 2000-05-05 0 7442 2026-07-09 10:13:37
399 526617130745 telefono_compartido 0.52 SI 43429 Santana del Salto Paola PAOLASANTANA4@GMAIL.COM 1976-09-04 8 2025-10-17 4298 2026-07-09 10:13:02
400 526617130745 telefono_compartido 0.52 43430 Gonzaga Santana Ire GONZAGAFAMILY2020@GMAIL.COM 2003-03-05 0 4299 2026-07-09 10:13:02
401 526618501311 telefono_compartido 0.44 SI 40315 Gonzalez Calderon Natalia Margarita 1989-10-29 53 2026-07-03 623 2026-07-09 10:12:26
402 526618501311 telefono_compartido 0.44 43116 Esquivel Gonzalez Iris Poleth 2012-09-27 0 3893 2026-07-09 10:12:57
403 526618501719 telefono_compartido 0.43 SI 43407 Medina Eusebio Kevin Dakyru MCEB0826@GMAIL.COM 2007-07-08 14 2024-12-10 4275 2026-07-09 10:13:02
404 526618501719 telefono_compartido 0.43 43428 Eusebio Batista Mayra Cecilia MCEB0826@GMAIL.COM 1984-02-29 0 4297 2026-07-09 10:13:02
405 526618504999 telefono_compartido 0.53 44119 Gonzalez Aguirre Isabela VRJU@HOTMAIL.COM 2015-07-01 0 5042 2026-07-09 10:13:08
406 526618504999 telefono_compartido 0.53 SI 46411 Gonzalez Salazar Veronica VERONICA.TIGGER3@HOTMAIL.COM 1983-04-22 4 2026-02-16 7458 2026-07-09 10:13:37
407 526618506041 telefono_compartido 0.41 SI 40078 Campos Moreno Nora MISSNORACAMPOS@HOTMAIL.COM 1986-02-21 43 2026-04-25 367 2026-07-09 10:12:20
408 526618506041 telefono_compartido 0.41 40309 Lezama Campos Angel Jaziel MISSNORACAMPOS@HOTMAIL.COM 1987-05-23 0 617 2026-07-09 10:12:26
409 526618506922 telefono_compartido 0.35 SI 42993 Martinez Molina Cristopher Marin DIVIAMOL301@GMAIL.COM 2010-11-03 3 2024-05-13 3751 2026-07-09 10:12:57
410 526618506922 telefono_compartido 0.35 42994 Molina Vargas Divia DIVIAMOL301@GMAIL.COM 1991-01-03 0 3752 2026-07-09 10:12:57
411 526618507906 telefono_compartido 0.53 SI 40769 Perez Quezada Leticia 1986-11-16 60 2025-12-03 1134 2026-07-09 10:12:26
412 526618507906 telefono_compartido 0.53 43751 Perez Perez Karen FERRETERIA2010SA@GMAIL.COM 2013-09-26 0 4665 2026-07-09 10:13:02
413 526622021620 telefono_compartido 0.69 SI 41538 Almada Razcon Erika Fernanda ERIKA.ALMADAR@ICLOUD.COM 1983-06-04 11 2025-06-17 2004 2026-07-09 10:12:38
414 526622021620 telefono_compartido 0.69 43546 Arroyo Almada Leah Fernanda ERIKA.ALMADAR@ICLOUD.COM 2015-05-09 0 4440 2026-07-09 10:13:02
415 526631024646 telefono_compartido 0.23 SI 40701 Valencia Magana Priscilla MM248011@ICLOUD.COM 2010-10-12 9 2025-06-05 1052 2026-07-09 10:12:26
416 526631024646 telefono_compartido 0.23 45381 Magana Gutierrez Evangelina MM248011@ICLOUD.COM 1950-01-24 0 6363 2026-07-09 10:13:25
417 526631658038 telefono_compartido 0.70 SI 40246 Becerril Lopez Diana SHOPPDI@HOTMAIL.COM 1978-03-05 71 2026-06-17 547 2026-07-09 10:12:20
418 526631658038 telefono_compartido 0.70 42273 Sampayo Becerril Briana ANASAMPAYO9@HOTMAIL.COM 2004-10-27 0 2821 2026-07-09 10:12:44
419 526631994761 telefono_compartido 0.18 SI 42546 Barajas Siri Gianna 1999-06-08 3 2024-05-31 3128 2026-07-09 10:12:50
420 526631994761 telefono_compartido 0.18 43035 Thompson Scott 1977-10-11 0 3801 2026-07-09 10:12:57
421 526632010618 telefono_compartido 0.27 SI 43557 Morales Millany Valeria Patricia MILLANYLAURA0618@GMAIL.COM 2008-01-05 7 2024-12-27 4452 2026-07-09 10:13:02
422 526632010618 telefono_compartido 0.27 43719 Millany Medina Laura MILLANYLAURA0618@GMAIL.COM 1990-07-19 0 4630 2026-07-09 10:13:02
423 526632046506 telefono_compartido 0.57 SI 44483 Zuñiga Verdugo Ximena Maria CRISTHIAN.ROCHA1988@ICLOUD.COM 2010-07-14 0 5421 2026-07-09 10:13:13
424 526632046506 telefono_compartido 0.57 44770 Zuñiga Rocha Cristhian CRISTHIAN.ROCHA1988@ICLOUD.COM 1988-08-15 0 5714 2026-07-09 10:13:13
425 526633013602 telefono_compartido 0.26 SI 42223 Rodriguez Palomares Santiago Rodolfo FAVIOLA3086@GMAIL.COM 2011-11-08 6 2024-01-08 2765 2026-07-09 10:12:44
426 526633013602 telefono_compartido 0.26 42379 Palomares Duarte Faviola Lizbeth FAVIOLA3086@GMAIL.COM 1986-09-30 0 2939 2026-07-09 10:12:50
427 526633281643 telefono_compartido 0.59 SI 40835 Valenzuela Bustamante Camila ARLETTESV95@GMAIL.COM 2009-04-01 22 2025-08-21 1209 2026-07-09 10:12:32
428 526633281643 telefono_compartido 0.59 41332 Sanchez Valenzuela Arlette ARLETTESV95@GMAIL.COM 1995-06-15 0 1769 2026-07-09 10:12:38
429 526634033230 telefono_compartido 0.52 43176 Camargo Vazquez Sandra CAMARGOSANDRA86@GMAIL.COM 1977-03-12 0 3959 2026-07-09 10:12:57
430 526634033230 telefono_compartido 0.52 SI 45891 Cortez Camargo Keoni CAMARGOSANDRA86@GMAIL.COM 2009-12-29 15 2025-11-01 6904 2026-07-09 10:13:31
431 526634380212 duplicado_probable 1.00 SI 41746 Cuen Baez Fridalexa FRIDACUEN@GMAIL.COM 1999-05-14 5 2024-02-16 2234 2026-07-09 10:12:38
432 526634380212 duplicado_probable 1.00 41913 Cuen Baez Fridalexa FRIDACUEN@GMAIL.COM 1999-05-14 0 2419 2026-07-09 10:12:44
433 526641083478 telefono_compartido 0.39 SI 46461 Sosa Carrazco Fernanda Elizabeth FERNANDASOSAC@HOTMAIL.COM 1987-06-10 7 2026-05-22 7510 2026-07-09 10:13:37
434 526641083478 telefono_compartido 0.39 46631 Dorado Sosa Jose Antonio FERNANDASOSAC@HOTMAIL.COM 2021-04-09 0 7693 2026-07-09 10:13:37
435 526641084548 telefono_compartido 0.37 SI 45265 Tafoya Cruz Karla Nayeli TAFOYITA09@HOTMAIL.COM 1988-09-05 16 2025-09-05 6249 2026-07-09 10:13:19
436 526641084548 telefono_compartido 0.37 45297 Lozano Mariana 1994-11-07 11 2026-03-10 6280 2026-07-09 10:13:25
437 526641090153 telefono_compartido 0.57 SI 41456 Benetts Vivanco Damaris Estrella D.BENETTS@ESCUELADENEGOCIOS.EDU.MX 1986-10-08 23 2026-03-07 1909 2026-07-09 10:12:38
438 526641090153 telefono_compartido 0.57 45963 Barbosa Benetts Damaris Nicolle D.BENETTS@ESCUELADENEGOCIOS.EDU.MX 2006-06-12 0 6982 2026-07-09 10:13:31
439 526641177891 telefono_compartido 0.68 SI 40359 Brenes Alvarez Carlos Gustavo PAOALVAREZ@HOTMAIL.COM 2010-07-25 32 2026-06-06 672 2026-07-09 10:12:26
440 526641177891 telefono_compartido 0.68 40869 Alvarez Fitch Selma Paola PAOALVAREZ@HOTMAIL.COM 1980-07-05 0 1246 2026-07-09 10:12:32
441 526641177891 telefono_compartido 0.68 42745 Brenes Alvarez Josemaria PAOALVAREZ@HOTMAIL.COM 2012-07-31 0 3359 2026-07-09 10:12:50
442 526641205176 telefono_compartido 0.46 SI 42356 Zamora Ortiz Cinthia Gabriela GABRIELA_ZO@HOTMAIL.COM 1976-02-07 18 2026-05-18 2914 2026-07-09 10:12:50
443 526641205176 telefono_compartido 0.46 46270 Lerma Zamora Renata GABRIELA_ZO@HOTMAIL.COM 2011-09-20 0 7312 2026-07-09 10:13:31
444 526641205455 telefono_compartido 0.70 SI 43871 Garcia Karina 1973-10-21 0 4792 2026-07-09 10:13:08
445 526641205455 telefono_compartido 0.70 44801 Garcia Amezquita Kari Na DRA.KGGA@GMAIL.COM 1976-03-20 0 5747 2026-07-09 10:13:19
446 526641215148 telefono_compartido 0.70 SI 41769 Gonzalez Fregoso Roxana MRRJRA10@GMAIL.COM 2009-09-08 10 2025-04-24 2259 2026-07-09 10:12:38
447 526641215148 telefono_compartido 0.70 42919 Gonzalez Fregoso Analia MRRJA10@GMAIL.COM 2009-09-08 0 3673 2026-07-09 10:12:57
448 526641218831 telefono_compartido 0.47 SI 39993 Campos Morfin Tania Yolanda TANIACAMPERS@GMAIL.COM 1988-02-18 7 2025-05-10 257 2026-07-09 10:12:20
449 526641218831 telefono_compartido 0.47 45083 Uviña Campos Michael TANIACAMPERS@GMAIL.COM 2009-10-22 0 6053 2026-07-09 10:13:19
450 526641230976 telefono_compartido 0.43 SI 45474 Perez Contreras Marco Adrian APEREZIBARRA@GMAIL.COM 2009-04-23 3 2025-07-18 6458 2026-07-09 10:13:25
451 526641230976 telefono_compartido 0.43 45893 Perez Fausto Jesus 1960-10-15 0 6906 2026-07-09 10:13:31
452 526641243410 telefono_compartido 0.65 SI 44347 Morales Cisneros Emma VAJOC86@GMAIL.COM 2007-08-08 0 5273 2026-07-09 10:13:13
453 526641243410 telefono_compartido 0.65 44794 Cisneros Lla Es Deborh CIABNE@HOTMAIL.COM 1976-02-18 0 5740 2026-07-09 10:13:19
454 526641267566 duplicado_probable 0.96 SI 40086 Nevares Michel Ana Maria ANA.NE_87@ICLOUD.COM 1987-07-26 6 2023-05-15 375 2026-07-09 10:12:20
455 526641267566 duplicado_probable 0.96 41184 Nevarez Michel Ana Maria ANA.NE_87@ICLOUD.COM 1987-07-26 0 1602 2026-07-09 10:12:32
456 526641280457 telefono_compartido 0.24 SI 45691 Hernandez Rios Francisco Javier 1989-09-04 18 2026-03-24 6678 2026-07-09 10:13:25
457 526641280457 telefono_compartido 0.24 45948 Villa Cons Mariana MARIANAV_2202@HOTMAIL.COM 1990-02-22 0 6966 2026-07-09 10:13:31
458 526641282169 telefono_compartido 0.38 SI 40331 Nuñez Escobedo Teresa MG56870309@GMAIL.COM 2002-10-01 7 2023-10-30 642 2026-07-09 10:12:26
459 526641282169 telefono_compartido 0.38 40332 Gonzalez Razo Marco Antonio MG56870309@GMAIL.COM 1996-12-27 0 644 2026-07-09 10:12:26
460 526641302653 duplicado_probable 1.00 41863 Pacheco Castillo Lidia LIDIA.PAC.CASTILLO@GMAIL.COM 1972-01-26 0 2366 2026-07-09 10:12:44
461 526641302653 duplicado_probable 1.00 SI 45720 Pacheco Castillo Lidia LIDIA.PAC.CASTILLO@GMAIL.COM 1972-01-26 23 2026-05-01 6711 2026-07-09 10:13:25
462 526641338180 telefono_compartido 0.34 SI 45118 Lugo Hernandez Nicolas Valentino GISELL.ALEXADIAZ@GMAIL.COM 2021-10-21 5 2026-04-25 6098 2026-07-09 10:13:19
463 526641338180 telefono_compartido 0.34 46432 Lugo Canobbio Jesus Alberto ALCREFRIGERATIONSERVICES@GMAIL.COM 1996-03-26 0 7480 2026-07-09 10:13:37
464 526641516570 telefono_compartido 0.38 SI 45072 Uribe Luna Valentina ALEJANDRALUNA.ASESORIA@GMAIL.COM 2013-10-23 0 6041 2026-07-09 10:13:19
465 526641516570 telefono_compartido 0.38 45084 Luna Vazquez Alejandra ALEJANDRALUNA.ASESORIA2@GMAIL.COM 1979-05-06 0 6054 2026-07-09 10:13:19
466 526641519134 duplicado_probable 0.84 SI 45946 Romero Meza Isabel 11RELOVEDFURNITURE11@GMAIL.COM 1989-07-18 6 2026-03-27 6964 2026-07-09 10:13:31
467 526641519134 duplicado_probable 0.84 46473 Romero Isabel NO@GMAIL.COM 1989-07-18 0 7523 2026-07-09 10:13:37
468 526641642104 duplicado_probable 0.90 SI 40793 Alvarado Osuna Eduardo LALOMMA2013@GMAIL.COM 1992-10-07 1 2023-02-08 1160 2026-07-09 10:12:32
469 526641642104 duplicado_probable 0.90 43852 Alvarado Osuna Jose Eduardo TOREROALVARADO92@GMAIL.COM 1992-10-07 0 4774 2026-07-09 10:13:08
470 526641665367 telefono_compartido 0.30 SI 42376 Rojas Diego ELIZABETH.RSOSA@GMAIL.COM 2009-09-03 7 2024-01-11 2934 2026-07-09 10:12:50
471 526641665367 telefono_compartido 0.30 42405 Montemayor Garcia Dora NO@GMAIL.COM 1929-03-29 0 2969 2026-07-09 10:12:50
472 526641758484 telefono_compartido 0.59 SI 43446 Rodriguez Andrade Gloria Berenice PSIC.BERENICERODRIGUEZ@GMAIL.COM 1980-04-15 17 2026-06-13 4315 2026-07-09 10:13:02
473 526641758484 telefono_compartido 0.59 44363 Cazares Rodriguez Alessandra PSIC.BERENICERODRIGUEZ@GMAIL.COM 2012-02-22 0 4579 2026-07-09 10:13:13
474 526641802915 telefono_compartido 0.62 SI 46218 Lamarque Laura LAWIS_1702@HOTMAIL.COM 1982-12-10 8 2026-06-05 7256 2026-07-09 10:13:31
475 526641802915 telefono_compartido 0.62 46219 Aguirre Sebastian LAWIS_1702@HOTMAIL.COM 2013-03-02 0 7257 2026-07-09 10:13:31
476 526641802915 telefono_compartido 0.62 46322 Aguirre Lamarque Santiago LAWIS_1702@HOTMAIL.COM 2008-09-13 0 7366 2026-07-09 10:13:37
477 526641887222 telefono_compartido 0.61 SI 40204 Casco Benavides Annie AICB07@HOTMAIL.COM 1978-01-07 87 2026-07-03 502 2026-07-09 10:12:20
478 526641887222 telefono_compartido 0.61 40206 Parra Casco Valentina AICB07@HOTMAIL.COM 2005-11-20 0 504 2026-07-09 10:12:20
479 526641887222 telefono_compartido 0.61 46565 Parra Renata AICB07@HOTMAIL.COM 2014-09-20 0 7622 2026-07-09 10:13:37
480 526641931585 telefono_compartido 0.54 SI 40413 Ruiz Pichardo Axel Manuel PICHARDONORMA90@GMAIL.COM 2008-12-16 24 2026-04-23 732 2026-07-09 10:12:26
481 526641931585 telefono_compartido 0.54 40706 Pichardo Flores Norma Edith 1885-10-25 0 1057 2026-07-09 10:12:26
482 526641976673 telefono_compartido 0.55 SI 40165 Trujillo Herrera Kenia Sofia NORAELENAH510@GMAIL.COM 2011-12-08 6 2023-11-14 458 2026-07-09 10:12:20
483 526641976673 telefono_compartido 0.55 40445 Herrera Magaña Nora NORAELENAH510@GMAIL.COM 1988-06-07 0 770 2026-07-09 10:12:26
484 526641985866 telefono_compartido 0.47 43029 Orendai Arianna P.ORENDAIN9@GMAIL.COM 2010-08-24 0 3795 2026-07-09 10:12:57
485 526641985866 telefono_compartido 0.47 SI 44971 Orendain Rodriguez Perla Manuela P.ORENDAIN9@GMAIL.COM 1990-08-09 6 2025-03-19 5933 2026-07-09 10:13:19
486 526641995186 telefono_compartido 0.41 SI 43324 Avila Martinez Emiliano BERE_16_@HOTMAIL.COM 2915-05-02 4 2024-12-27 4178 2026-07-09 10:13:02
487 526641995186 telefono_compartido 0.41 44298 Martinez Medina Ofelia Berenice BERE_16_@HOTMAIL.COM 1986-01-27 0 5225 2026-07-09 10:13:13
488 526641999762 telefono_compartido 0.66 SI 45586 Quijano Rodriguez Regina OSCARQUIJANODELMAR@GMAIL.COM 2015-01-27 3 2025-11-12 6574 2026-07-09 10:13:25
489 526641999762 telefono_compartido 0.66 45760 Rodriguez Gallego Claudia Josefina CRG1486@ICLOUD.COM 1986-11-14 0 6761 2026-07-09 10:13:25
490 526642014162 telefono_compartido 0.38 40948 Gaona Reglado Ramiro 1965-09-23 3 2023-05-03 1337 2026-07-09 10:12:32
491 526642014162 telefono_compartido 0.38 SI 41011 Talavera Velazquez Esmeralda 1971-08-28 7 2026-05-27 1402 2026-07-09 10:12:32
492 526642047529 telefono_compartido 0.44 SI 45182 Navar Bojorquez Aleyda ALEYDA.NAVAR@GMAIL.COM 1987-03-05 11 2026-01-12 5960 2026-07-09 10:13:19
493 526642047529 telefono_compartido 0.44 45482 Navarro Ivanka IVANKANN77@GMAIL.COM 2011-07-07 0 6467 2026-07-09 10:13:25
494 526642047602 telefono_compartido 0.45 44018 Ortega Quiñonez Ernesto 1979-05-05 0 4942 2026-07-09 10:13:08
495 526642047602 telefono_compartido 0.45 SI 45792 Quiñones Serrano Irma Eloy 1948-12-01 1 2025-08-15 6796 2026-07-09 10:13:31
496 526642175802 telefono_compartido 0.64 43345 Valenzuela Rojo Serafina MAR_ROJO1301@HOTMAIL.COM 1958-08-23 0 4202 2026-07-09 10:13:02
497 526642175802 telefono_compartido 0.64 SI 45850 Valenzuela Rojo Martina MAR_ROJO1301@HOTMAIL.COM 1964-09-15 23 2026-03-21 6862 2026-07-09 10:13:31
498 526642175802 telefono_compartido 0.64 46510 Chavez Cabanillas Guillermina MAR_ROJO1301@HOTMAIL.COM 1969-06-25 0 7565 2026-07-09 10:13:37
499 526642182279 telefono_compartido 0.75 SI 42330 Mendoza Guerrero Samantha MELY15MX@HOTMAIL.COM 2010-03-30 24 2025-09-26 2887 2026-07-09 10:12:50
500 526642182279 telefono_compartido 0.75 42454 Mendoza Guerrero Alan ALAN16MX@GMAIL.COM 2006-09-20 0 3022 2026-07-09 10:12:50
501 526642182279 telefono_compartido 0.75 42585 Mendoza Guerrero Ximena MELY15MX@HOTMAIL.COM 2011-06-06 0 3175 2026-07-09 10:12:50
502 526642182279 telefono_compartido 0.75 44031 Guerrero Amarillas Meliza MELY15MX@HOTMAIL.COM 1982-11-05 0 4954 2026-07-09 10:13:08
503 526642258050 telefono_compartido 0.53 SI 43066 Ramirez Soler Elias ITZEL.SOLER@GMAIL.COM 2017-03-24 14 2024-12-06 3838 2026-07-09 10:12:57
504 526642258050 telefono_compartido 0.53 43068 Soler Itzel ITZEL.SOLER@GMAIL.COM 1988-07-09 0 3840 2026-07-09 10:12:57
505 526642288589 telefono_compartido 0.57 SI 46227 Santos Heinecke Mariel Abigahil BECKYHEINECKE@HOTMAIL.COM 2013-11-30 7 2026-02-27 7265 2026-07-09 10:13:31
506 526642288589 telefono_compartido 0.57 46228 Heinecke Saldaña Rebeca Arely BECKYHEINECKE@HOTMAIL.COM 1982-02-23 0 7266 2026-07-09 10:13:31
507 526642323443 telefono_compartido 0.37 42075 Perez Medrano Shantelle BIANEYLOVECRAFT@GMAIL.COM 2010-11-05 0 2601 2026-07-09 10:12:44
508 526642323443 telefono_compartido 0.37 SI 45328 Ceballlos Hid Hester NO@GMAIL.COM 1969-08-14 5 2025-12-06 6312 2026-07-09 10:13:25
509 526642323899 telefono_compartido 0.39 SI 40418 Ramirez Machado Diego Alfonzo FERNANDO@LUXORINT.COM 1952-11-13 11 2025-02-20 738 2026-07-09 10:12:26
510 526642323899 telefono_compartido 0.39 40739 Vera Hernandez Alejandra 1978-06-20 0 1099 2026-07-09 10:12:26
511 526642323899 telefono_compartido 0.39 41639 Villarreal Vera Danna Emyli JOCELYNLEYVA16@GMAIL.COM 2011-02-21 0 2114 2026-07-09 10:12:38
512 526642331765 telefono_compartido 0.68 42663 Barajas Martinez Paulina BARAJAS_PAOX87@HOTMAIL.COM 1987-07-28 0 3266 2026-07-09 10:12:50
513 526642331765 telefono_compartido 0.68 43205 Torres Barajas Erick BARAJAS_PAOX87@HOTMAIL.COM 2008-04-19 0 4000 2026-07-09 10:12:57
514 526642331765 telefono_compartido 0.68 SI 45058 Torres Barajas Ayleen BARAJAS_PAOX87@HOTMAIL.COM 2012-01-08 23 2026-05-13 6028 2026-07-09 10:13:19
515 526642339634 telefono_compartido 0.60 45312 Romero Sonia 1979-08-23 0 6297 2026-07-09 10:13:25
516 526642339634 telefono_compartido 0.60 SI 45408 Romero Arzapalo Sonia Yudith SYRAS3999@GMAIL.COM 1979-08-23 4 2025-05-27 6391 2026-07-09 10:13:25
517 526642401717 telefono_compartido 0.29 SI 44702 Mercado Rojas Victor Manuel NO@GMAIL.COM 1972-12-04 18 2026-06-29 5642 2026-07-09 10:13:13
518 526642401717 telefono_compartido 0.29 45006 Lora Ana 1980-08-13 2 2025-03-19 5972 2026-07-09 10:13:19
519 526642519054 telefono_compartido 0.57 SI 46216 Fuentes Gutierrez Carlos ROX_SELENE03@HOTMAIL.COM 2010-08-05 8 2026-06-24 7254 2026-07-09 10:13:31
520 526642519054 telefono_compartido 0.57 46378 Gutierrez Gallegos Roxana ROX_SELENE03@HOTMAIL.COM 1983-11-05 0 7424 2026-07-09 10:13:37
521 526642521950 telefono_compartido 0.44 SI 41412 Rivera Cota Veronica VERONICARIVERACOTA@HOTMAIL.COM 1981-01-05 8 2026-07-01 1859 2026-07-09 10:12:38
522 526642521950 telefono_compartido 0.44 43106 Castaneda Isaias ISA@NUVIEWPKUS.COM 1981-01-12 0 3882 2026-07-09 10:12:57
523 526642568619 duplicado_probable 1.00 SI 46022 Zepeda Fernandez Maria Guadalupe 1962-12-11 6 2026-07-01 7046 2026-07-09 10:13:31
524 526642568619 duplicado_probable 1.00 46834 Zepeda Fernandez Maria Guadalupe LUPITA.ZEPEDAF@GMAIL.COM 1962-12-11 0 7917 2026-07-09 10:13:43
525 526642571654 telefono_compartido 0.63 SI 40300 Espinoza Juarez Milca Marcela 2008-04-30 20 2026-05-23 604 2026-07-09 10:12:26
526 526642571654 telefono_compartido 0.63 40908 Juarez Juarez Olivia Delif OLIVIADELIF@HOTMAIL.COM 1978-04-20 0 1291 2026-07-09 10:12:32
527 526642571654 telefono_compartido 0.63 41928 Espinoza Juarez Luis Obed OLIVIADELIF@HOTMAIL.COM 2009-05-13 0 2438 2026-07-09 10:12:44
528 526642638146 telefono_compartido 0.57 SI 39922 Rodriguez Gastelum Erika GASTELUM.E@HOTMAIL.COM 1984-08-30 23 2023-11-22 163 2026-07-09 10:12:20
529 526642638146 telefono_compartido 0.57 40731 Verjan Rodriguez Isabella GASTELUM.E@hotmail.com 2011-10-03 0 1091 2026-07-09 10:12:26
530 526642810515 telefono_compartido 0.44 SI 42999 Mendoza Lopez Jesus Ramon JESUS.MENDLOP@GMAIL.COM 1991-07-19 20 2026-04-10 3757 2026-07-09 10:12:57
531 526642810515 telefono_compartido 0.44 46539 Lopez Ulloa Gabriela 1972-07-29 4 2026-07-06 7594 2026-07-09 10:13:37
532 526642874699 telefono_compartido 0.33 SI 41643 Castillo Madrigal Isabella ISACAMA27@YAHOO.COM 2006-12-27 26 2025-09-12 2118 2026-07-09 10:12:38
533 526642874699 telefono_compartido 0.33 43918 Madrigal Zugasti Nadia 1977-11-17 0 62 2026-07-09 10:13:08
534 526642916418 telefono_compartido 0.60 SI 42872 Villarreal Villanes Grisel Aracely DANIAV@GMAIL.COM 2007-12-12 51 2026-07-01 3608 2026-07-09 10:12:57
535 526642916418 telefono_compartido 0.60 43244 Villanes Estrada Dania Grisel DANIAV@GMAIL.COM 1979-03-19 0 4063 2026-07-09 10:12:57
536 526642964659 telefono_compartido 0.75 SI 45951 Menchaca Olvera Lia Kamila FLOROLVERA92@ICLOUD.COM 2016-07-30 4 2025-12-01 6969 2026-07-09 10:13:31
537 526642964659 telefono_compartido 0.75 45952 Menchaca Olvera Lily Mailen FLOROLVERA92@ICLOUD.COM 2017-10-20 0 6970 2026-07-09 10:13:31
538 526643014407 telefono_compartido 0.72 SI 42620 Hernandez Navarro Maria Guadalupe 1958-01-24 17 2025-06-28 3215 2026-07-09 10:12:50
539 526643014407 telefono_compartido 0.72 45153 Navarro Andrade Maria Guadalupe 1958-01-24 6 2025-08-26 6136 2026-07-09 10:13:19
540 526643047307 telefono_compartido 0.53 SI 42406 Bejarano Paola PAOLA26@GMAIL.COM 1979-02-26 39 2026-04-14 2970 2026-07-09 10:12:50
541 526643047307 telefono_compartido 0.53 43193 Villareal Bejarano Maite Andrea PAOLA26@GMAIL.COM 2007-05-03 0 3985 2026-07-09 10:12:57
542 526643097726 telefono_compartido 0.47 SI 40443 Fagoaga Mora Clara Judith JEGONFA@GMAIL.COM 1939-08-12 5 2024-12-07 768 2026-07-09 10:12:26
543 526643097726 telefono_compartido 0.47 43904 Gonzalez Fagoaga Jesus Eduardo JEGONFA@GMAIL.COM 1974-03-18 0 4835 2026-07-09 10:13:08
544 526643114776 telefono_compartido 0.65 41902 Ramirez Castillo Isabella 2008-01-14 0 2408 2026-07-09 10:12:44
545 526643114776 telefono_compartido 0.65 SI 41995 Ramirez Castillo Valeria LULU.CASTILLO@HOTMAIL.COM 2003-09-14 11 2025-11-22 2512 2026-07-09 10:12:44
546 526643154097 duplicado_probable 1.00 SI 40631 Lopez Alfaro Dayra Elizabeth DAYRAELA615@gmail.com 2006-05-25 15 2026-06-24 972 2026-07-09 10:12:26
547 526643154097 duplicado_probable 1.00 42339 Lopez Alfaro Dayra Elizabeth DAYRAELA615@GMAIL.COM 2006-05-25 0 2896 2026-07-09 10:12:50
548 526643169201 duplicado_probable 1.00 SI 41807 Diego Alvarez Rosario 1979-10-20 21 2025-12-13 2306 2026-07-09 10:12:44
549 526643169201 duplicado_probable 1.00 45895 Diego Álvarez Alejandra de Jesús ROSARIODIEGOALVAREZ@GMAIL.COM 2011-04-09 0 6908 2026-07-09 10:13:31
550 526643169201 duplicado_probable 1.00 45897 Diego Alvarez Rosario 1979-10-20 0 6910 2026-07-09 10:13:31
551 526643184370 telefono_compartido 0.50 SI 40346 Gonzalez Ruiz Maria Rogelia GLEZM3415@GMAIL.COM 1990-10-04 11 2023-03-03 658 2026-07-09 10:12:26
552 526643184370 telefono_compartido 0.50 40348 Gonzalez Nicole Guadalupe GLEZM3415@GMAIL.COM 2010-07-09 0 660 2026-07-09 10:12:26
553 526643270516 telefono_compartido 0.46 SI 40591 Romero Lara Brenda Laura BRENDALRL29@GMAIL.COM 1984-02-27 18 2026-01-13 928 2026-07-09 10:12:26
554 526643270516 telefono_compartido 0.46 44049 Olmos Romero Niza Ximena BRENDA_LRL@HOTMAIL.COM 2008-01-28 0 4970 2026-07-09 10:13:08
555 526643308936 telefono_compartido 0.49 SI 40497 Nava Vazquez Alejandra RUBEN95ALE@GMAIL.COM 1973-12-28 37 2026-04-20 826 2026-07-09 10:12:26
556 526643308936 telefono_compartido 0.49 41502 Hernandez Nava Kayla RUBEN95ALE@GMAIL.COM 2008-03-07 0 1959 2026-07-09 10:12:38
557 526643308936 telefono_compartido 0.49 42824 Hernandez Gutierrez Ruben 1971-10-27 0 3518 2026-07-09 10:12:57
558 526643312097 telefono_compartido 0.41 SI 42985 Aviles Ortiz Emmanuel Jaime RAKE.ORTIZ.LEON@GMAIL.COM 2007-01-10 11 2026-06-16 3742 2026-07-09 10:12:57
559 526643312097 telefono_compartido 0.41 46362 Ortiz Leon Raquel RAKE_ORTIZ@HOTMAIL.COM 1976-02-14 0 7408 2026-07-09 10:13:37
560 526643314910 telefono_compartido 0.40 SI 43216 Corrales Delgado Ivana Victoria ZULL_37@HOTMAIL.COM 2008-02-29 0 4014 2026-07-09 10:12:57
561 526643314910 telefono_compartido 0.40 44115 Delgado Zulema 1970-02-26 0 5038 2026-07-09 10:13:08
562 526643317692 telefono_compartido 0.51 SI 45385 Leyva Orozco Yaqueline 1976-03-08 5 2026-03-02 6367 2026-07-09 10:13:25
563 526643317692 telefono_compartido 0.51 46465 Negrete Leyva Melissa 2008-01-01 0 7514 2026-07-09 10:13:37
564 526643330312 telefono_compartido 0.36 43870 Villazana Quintero Valeria MAQUIORTIZ90@GMAIL.COM 2010-12-15 0 4791 2026-07-09 10:13:08
565 526643330312 telefono_compartido 0.36 SI 46250 Quintero Ortiz Mara MAQUIORTIZ90@GMAIL.COM 1990-04-20 5 2026-05-05 7289 2026-07-09 10:13:31
566 526643331107 telefono_compartido 0.32 SI 41387 Montelongo Rios Margarita ROSALBA9393@GMAIL.COM 1946-06-10 4 2023-06-08 1829 2026-07-09 10:12:38
567 526643331107 telefono_compartido 0.32 41478 Muñoz Rosalba ROSAMARIA33DIDI@GMAIL.COM 1967-08-31 0 1931 2026-07-09 10:12:38
568 526643344347 telefono_compartido 0.50 SI 43290 Sosa Montemayor Elizabeth ELIZABETTAMONTEMAYOR@GMAIL.COM 1966-08-30 5 2024-11-19 4128 2026-07-09 10:13:02
569 526643344347 telefono_compartido 0.50 46936 Montemayor Garcia Dora Irma ELIZABETTAMONTEMAYOR@GMAIL.COM 1929-03-29 2 2026-07-04 8127 2026-07-09 10:13:43
570 526643366324 telefono_compartido 0.26 SI 41801 Partida Garcia Rogelio ROGELIOROSARITOGARCIA.4@GMAIL.COM 1973-08-12 8 2024-10-07 2297 2026-07-09 10:12:44
571 526643366324 telefono_compartido 0.26 43332 Tec Cortes Jesus Antonio ANTONIOTEC0528@GMAIL.COM 1980-05-28 2 2024-10-07 4187 2026-07-09 10:13:02
572 526643416777 telefono_compartido 0.12 SI 40897 Reyes Nicole SHARDESAHAGUN@GMAIL.COM 2022-12-30 4 2023-04-27 1280 2026-07-09 10:12:32
573 526643416777 telefono_compartido 0.12 45616 Garcia Sahagun Sharde SHARDESAHAGUN@GMAIL.COM 1993-10-04 2 2025-09-06 6606 2026-07-09 10:13:25
574 526643483151 telefono_compartido 0.24 41858 Vazquez Nina 2017-04-11 0 2361 2026-07-09 10:12:44
575 526643483151 telefono_compartido 0.24 SI 41859 Sandoval Yara YARAZET.VAZQUEZ11@GMAIL.COM 1986-10-06 3 2023-11-14 2362 2026-07-09 10:12:44
576 526643490274 telefono_compartido 0.50 SI 42754 Montero Reyes Bibiana Patricia PATY.MONTERE@GMAIL.COM 1986-03-17 5 2025-05-28 3368 2026-07-09 10:12:50
577 526643490274 telefono_compartido 0.50 44167 Romo Montero Ailyn 2015-02-11 0 5091 2026-07-09 10:13:08
578 526643574311 telefono_compartido 0.73 SI 40196 Perez Ortega Carmen Leticia LETYPO.ENF@HOTMAIL.COM 1963-03-08 36 2025-12-19 494 2026-07-09 10:12:20
579 526643574311 telefono_compartido 0.73 44078 Perez Ortega Elsa EMEPO_24@HOTMAIL.COM 1969-12-24 0 5001 2026-07-09 10:13:08
580 526643638366 telefono_compartido 0.33 SI 44281 Arce Hernandez Aide AURA_ARCE@HOTMAIL.COM 1977-12-23 0 5207 2026-07-09 10:13:08
581 526643638366 telefono_compartido 0.33 44284 Felix Juan Carlos 1983-08-02 0 5210 2026-07-09 10:13:13
582 526643641839 telefono_compartido 0.59 SI 40341 Diego Acosta Marla BIAG2813@GMAIL.COM 2010-02-20 65 2026-05-13 653 2026-07-09 10:12:26
583 526643641839 telefono_compartido 0.59 41929 Acosta Gallo Blanca Isaura BIAG2813@GMAIL.COM 1983-03-28 0 2440 2026-07-09 10:12:44
584 526643684241 telefono_compartido 0.55 42906 Gonzalez Guadalupe LUPITA.SANTAMARIA@YAHOO.COM.MX 1962-12-21 11 2024-08-09 3656 2026-07-09 10:12:57
585 526643684241 telefono_compartido 0.55 SI 43916 Negrete Gonzalez Karla KARLAKALLIO@HOTMAIL.COM 1985-01-11 26 2025-12-08 3601 2026-07-09 10:13:08
586 526643684320 duplicado_probable 1.00 SI 41729 Herrera Machuca Carlos CARLOS.HERRERA@ASICMEXICO.COM 1956-12-16 3 2025-02-12 2217 2026-07-09 10:12:38
587 526643684320 duplicado_probable 1.00 44516 Herrera Machuca Carlos CARLOS.HERRERA@ASICMEXICO.COM 1956-12-16 0 5448 2026-07-09 10:13:13
588 526643687659 telefono_compartido 0.55 SI 44077 Diaz Amador Victoria ROCIO_AN@HOTMAIL.COM 2008-08-07 0 5000 2026-07-09 10:13:08
589 526643687659 telefono_compartido 0.55 44251 Amador Noriega Rocio ROCIO_AN@HOTMAIL.COM 1984-11-08 0 5181 2026-07-09 10:13:08
590 526643689362 telefono_compartido 0.27 SI 43414 Nora Celia Gonzalez Villalobos VILLANORA66@GMAIL.COM 1966-02-10 3 2024-09-20 4282 2026-07-09 10:13:02
591 526643689362 telefono_compartido 0.27 43601 Rasmussen Egil VILLANORA66@GMAIL.COM 1966-04-25 0 4502 2026-07-09 10:13:02
592 526643689735 telefono_compartido 0.55 41314 Oropeza Gutierrez Humberto EDITH.GR788@GMAIL.COM 2006-05-10 0 1748 2026-07-09 10:12:38
593 526643689735 telefono_compartido 0.55 SI 44813 Gutierrez Rodriguez Edith NO@gmail.com 1988-07-24 30 2026-05-13 3151 2026-07-09 10:13:19
594 526643694332 telefono_compartido 0.27 SI 39909 Mercado Gracia Olivia Leticia OLIVIAJ1971@GMAIL.COM 1971-06-19 44 2026-02-23 144 2026-07-09 10:12:20
595 526643694332 telefono_compartido 0.27 40857 Manousakis Manny OLIVIAJ1971@GMAIL.COM 1960-06-06 0 1233 2026-07-09 10:12:32
596 526643702018 telefono_compartido 0.78 42398 Orihuela Moreno Arcinoe AMOREYA77@GMAIL.COM 2023-12-09 0 2962 2026-07-09 10:12:50
597 526643702018 telefono_compartido 0.78 42506 Orihuela Moreno Yael AMOREYA77@GMAIL.COM 2012-08-22 0 3081 2026-07-09 10:12:50
598 526643702018 telefono_compartido 0.78 SI 45738 Orihuela Moreno Miranda AMOREYA77@GMAIL.COM 2018-05-28 7 2025-09-16 6733 2026-07-09 10:13:25
599 526643702018 telefono_compartido 0.78 45793 Moreno Montoya Aurora AMOREYA77@GMAIL.COM 1977-07-23 0 6797 2026-07-09 10:13:31
600 526643715000 telefono_compartido 0.60 SI 42242 Flores Gutierrez Damian ABRIL11LOCA@GMAIL.COM 2009-05-14 6 2024-06-05 2787 2026-07-09 10:12:44
601 526643715000 telefono_compartido 0.60 42497 Gutierrez Cota Abril ABRIL11LOCA@GMAIL.COM 1987-05-11 0 3072 2026-07-09 10:12:50
602 526643852703 telefono_compartido 0.37 SI 40827 Guerrero Lopez Karen Melissa KAREN.MELIGRO7@GMAIL.COM 1997-06-07 29 2025-10-22 1201 2026-07-09 10:12:32
603 526643852703 telefono_compartido 0.37 44042 Guerrero Dayana KARENMELIGRO7@GMAIL.COM 2007-11-16 0 3203 2026-07-09 10:13:08
604 526643865393 telefono_compartido 0.41 SI 45502 Mercado Austin Regina MERKKO3@YAHOO.COM 1987-08-06 5 2026-02-23 6487 2026-07-09 10:13:25
605 526643865393 telefono_compartido 0.41 46393 Duran Rios Rodrigo RODRIGODURAN75@HOTMAIL.COM 1975-09-02 0 7439 2026-07-09 10:13:37
606 526643896467 telefono_compartido 0.51 SI 42602 Velarde Vega Monica MONICA.VELARDE@HOTMAIL.COM 1973-08-31 47 2024-12-06 3194 2026-07-09 10:12:50
607 526643896467 telefono_compartido 0.51 42713 Dominguez Velarde Aithana Nicole 2009-08-11 0 3321 2026-07-09 10:12:50
608 526643897005 telefono_compartido 0.33 SI 45611 Gonzalez Isabel ISAGGZ010489@HOTMAIL.COM 1989-04-01 7 2025-11-11 6601 2026-07-09 10:13:25
609 526643897005 telefono_compartido 0.33 45612 Armas Alejandro 2008-09-02 0 6602 2026-07-09 10:13:25
610 526643897988 telefono_compartido 0.58 SI 42131 Ruiz Felix Nataly Guadalupe TALY.RUIZ17@GMAIL.COM 1995-11-17 6 2026-06-06 2661 2026-07-09 10:12:44
611 526643897988 telefono_compartido 0.58 46146 Ruiz Nataly TALY.RUIZ17@GMAIL.COM 1995-11-17 0 7182 2026-07-09 10:13:31
612 526643993372 telefono_compartido 0.42 SI 42349 Moreno Ortega Magdelis MADGDELISMO79@GMAIL.COM 1979-08-14 6 2024-11-02 2907 2026-07-09 10:12:50
613 526643993372 telefono_compartido 0.42 43900 Ortega Ortega Eladia Sofia MAGDELISMO79@GMAIL.COM 1952-09-18 0 4829 2026-07-09 10:13:08
614 526644042457 telefono_compartido 0.32 43372 Bernal Leonel 1978-02-20 0 4235 2026-07-09 10:13:02
615 526644042457 telefono_compartido 0.32 SI 45894 Nuñez Dozal Karina LENORIOSDALYBERNALNUNEZ29@GMAIL.COM 1983-01-31 4 2026-01-20 6907 2026-07-09 10:13:31
616 526644044790 telefono_compartido 0.34 SI 42983 Orta Martinez Consuelo DZGG7703@GMAIL.COM 1924-08-29 81 2026-07-01 3740 2026-07-09 10:12:57
617 526644044790 telefono_compartido 0.34 43921 Godinez Gutierrez Dulce Zuleyka DZGG7703@GMAIL.COM 1977-02-03 0 884 2026-07-09 10:13:08
618 526644047227 telefono_compartido 0.49 SI 40646 Chavez Pelayo Laura Alida LAURA_ALY17@hotmail.com 1975-08-02 19 2026-04-22 988 2026-07-09 10:12:26
619 526644047227 telefono_compartido 0.49 42702 Salazar Chavez Sofia LAURA_ALY17@HOTMAIL.COM 2010-11-30 0 3310 2026-07-09 10:12:50
620 526644058906 telefono_compartido 0.58 SI 43369 Oronoz Gonzalez Miranda TETEOROGONZA@GMAIL.COM 2011-06-09 24 2025-05-21 4231 2026-07-09 10:13:02
621 526644058906 telefono_compartido 0.58 44262 Gonzalez Rubalcava Teresa TETEOROGONZA@GMAIL.COM 2024-03-02 0 3302 2026-07-09 10:13:08
622 526644063990 telefono_compartido 0.48 43626 Aguilar Gutierrez Jose JOSEGAGUILAR1@HOTMAIL.COM 1980-07-14 0 4530 2026-07-09 10:13:02
623 526644063990 telefono_compartido 0.48 SI 45262 Aguilar Moreno Tahilyn Monserrat JOSEGAGUILAR1@HOTMAIL.COM 2011-11-11 3 2025-05-05 6246 2026-07-09 10:13:19
624 526644079616 duplicado_probable 0.87 SI 39791 Flores Romero Marco Antonio ADI_ARQ.MARCOANTONIO@HOTMAIL.COM 1987-09-13 14 2024-09-23 10 2026-07-09 10:12:20
625 526644079616 duplicado_probable 0.87 42750 Flores Rodarte Marco Antonio NO.NO@GMAIL.COM 1987-09-13 0 3364 2026-07-09 10:12:50
626 526644083858 duplicado_probable 0.85 SI 45372 Guerrero B Blanca BLANCAGUERRERO59@GMAIL.COM 1959-08-26 26 2026-05-08 6354 2026-07-09 10:13:25
627 526644083858 duplicado_probable 0.85 46287 Guerrero Benitez Blanca BLANCAGUERRERO59@GMAIL.COM 1959-08-26 0 7329 2026-07-09 10:13:37
628 526644133549 duplicado_probable 0.84 SI 42856 Rodriguez Garcia Diego EGARCIARODRIGUEZ18@GMAIL.COM 2009-01-18 13 2026-05-28 3590 2026-07-09 10:12:57
629 526644133549 duplicado_probable 0.84 46860 Rodriguez Garcia Emilio 2015-06-18 0 7947 2026-07-09 10:13:43
630 526644158987 telefono_compartido 0.50 SI 40534 Jimenez Beltran Tiffany SILVIA.BELTRAN.ORTEGA@hotmail.com 2008-05-23 18 2026-01-19 867 2026-07-09 10:12:26
631 526644158987 telefono_compartido 0.50 42463 Beltran Ortega Silvia 1973-09-21 0 3032 2026-07-09 10:12:50
632 526644216244 telefono_compartido 0.59 SI 40732 De Anda Carrera Luz Noemi NOEMIDEANDAA@GMAIL.COM 1989-02-03 67 2026-06-20 1092 2026-07-09 10:12:26
633 526644216244 telefono_compartido 0.59 40733 Lepe de Anda Leonardo 2009-06-01 0 1093 2026-07-09 10:12:26
634 526644216244 telefono_compartido 0.59 46488 De Anda Carolina 1951-01-13 0 7542 2026-07-09 10:13:37
635 526644250801 telefono_compartido 0.39 SI 43439 Sanchez Ramos David LORENASANCHEZR97@GMAIL.COM 2010-09-09 19 2026-04-06 4308 2026-07-09 10:13:02
636 526644250801 telefono_compartido 0.39 44241 Ramos Medina Felicitas FELIX.RAMOSMEDINA@HOTMAIL.COM 1973-05-20 0 5171 2026-07-09 10:13:08
637 526644375276 telefono_compartido 0.62 SI 40134 Ayala Ainsworth Beatriz 1954-06-30 23 2026-03-25 425 2026-07-09 10:12:20
638 526644375276 telefono_compartido 0.62 40438 Gomez Ayala Beatriz MDBETTY3001@GMAIL.COM 1974-01-30 1 2022-12-05 762 2026-07-09 10:12:26
639 526644375460 duplicado_probable 0.83 SI 43725 Valdez Acosta Ian GUADALUPEACOSTAESPINOZA@GMAIL.COM 2016-02-02 0 4637 2026-07-09 10:13:02
640 526644375460 duplicado_probable 0.83 43726 Valdez Castel Carlos GUADALUPEAACOSTAESPINOZA@GMAIL.COM 1978-02-24 0 4638 2026-07-09 10:13:02
641 526644375460 duplicado_probable 0.83 43989 Acosta Espinoza Guadalupe GUADALUPEACOSTAESPINOZA@GMAIL.COM 1987-12-06 0 4909 2026-07-09 10:13:08
642 526644375460 duplicado_probable 0.83 44480 Valdez Acosta Karla 2013-01-04 0 5418 2026-07-09 10:13:13
643 526644389554 telefono_compartido 0.40 SI 42157 Sanchez Martinez Elizabeth ELIUNK@HOTMAIL.COM 1981-12-13 19 2026-06-11 2690 2026-07-09 10:12:44
644 526644389554 telefono_compartido 0.40 45802 Pardini Gaxiola Gilberto 1976-08-04 0 6805 2026-07-09 10:13:31
645 526644409265 telefono_compartido 0.13 41626 Anaya Sanchez Lidia Naomi GRACIELASANCHEX1988@GMAIL.COMPOR 2007-03-22 0 2101 2026-07-09 10:12:38
646 526644409265 telefono_compartido 0.13 SI 45565 Mata Mendoza Severina GRACIELASANCHEX1988@GMAIL.COM 1952-11-30 16 2025-08-04 6553 2026-07-09 10:13:25
647 526644473608 telefono_compartido 0.79 SI 39869 Perez Reyes America AME_ALEJANDRA@HOTMAIL.COM 1994-09-05 36 2026-06-06 97 2026-07-09 10:12:20
648 526644473608 telefono_compartido 0.79 40090 Perez Reyes America Alejandra AME_ALEJANDRA@HOTMAIL.COM 1994-09-06 0 379 2026-07-09 10:12:20
649 526644517536 telefono_compartido 0.37 SI 46814 Moreno Calzada Jessica Dayani JACONRAMIREZ0@GMAIL.COM 2004-04-07 4 2026-06-15 7894 2026-07-09 10:13:43
650 526644517536 telefono_compartido 0.37 46819 Sevilla Moreno Kendall Lailony 2023-04-25 0 7900 2026-07-09 10:13:43
651 526644591861 duplicado_probable 1.00 SI 43150 Buelna Buelna Mitzi Jael BUELNABUELNAJ@GMAIL.COM 1999-01-06 1 2024-06-13 3931 2026-07-09 10:12:57
652 526644591861 duplicado_probable 1.00 43697 Buelna Buelna Mitzi Jael BUELNABUELNAJ@GMAIL.COM 1999-01-06 0 4607 2026-07-09 10:13:02
653 526644771033 telefono_compartido 0.44 SI 39820 Aguirre Granados Maria Concepcion TA_OSCAR@HOTMAIL.COM 1979-03-12 83 2026-06-10 40 2026-07-09 10:12:20
654 526644771033 telefono_compartido 0.44 41900 Islas Aguirre Arianna CONCHITA_OSCAR@HOTMAIL.COM 2015-12-26 0 2406 2026-07-09 10:12:44
655 526644793526 duplicado_probable 1.00 40890 Matuz Ortiz Gael Antonio VOZ1980170612@GMAIL.COM 2012-06-17 0 1271 2026-07-09 10:12:32
656 526644793526 duplicado_probable 1.00 SI 44861 Matuz Ortiz Gael Antonio VOZ1980170612@GMAIL.COM 2012-06-17 22 2026-03-17 5810 2026-07-09 10:13:19
657 526644793526 duplicado_probable 1.00 45161 Ortiz Zamudio Veronica 1980-11-07 0 6144 2026-07-09 10:13:19
658 526644929111 telefono_compartido 0.63 43112 Espinoza Lopez Dennise Mariel DENNISEBARRERA2013@GMAIL.COM 1987-12-20 0 3888 2026-07-09 10:12:57
659 526644929111 telefono_compartido 0.63 43667 Barrera Espinoza Jade DENNISEBARRERA2013@GMAIL.COM 2013-04-05 0 4575 2026-07-09 10:13:02
660 526644929111 telefono_compartido 0.63 SI 45373 Barrera Espinoza Ambar Dennise 2007-04-10 12 2025-10-03 6355 2026-07-09 10:13:25
661 526644936861 telefono_compartido 0.59 SI 41385 Gamez Valdes Renata KARINAVALDESM123@GMAIL.COM 2012-04-18 16 2026-03-26 1825 2026-07-09 10:12:38
662 526644936861 telefono_compartido 0.59 42564 Valdes Moreno Karina Angelica NO@gmail.com 1988-07-29 0 3147 2026-07-09 10:12:50
663 526644936861 telefono_compartido 0.59 42584 Gamez Valdes Bennjamin KARINAVALDESM123@GMAIL.COM 2007-07-21 0 3174 2026-07-09 10:12:50
664 526644981243 telefono_compartido 0.31 SI 39885 Zapata Garcia Patricia ANACG.NOH@GMAIL.COM 1996-05-27 21 2026-05-16 117 2026-07-09 10:12:20
665 526644981243 telefono_compartido 0.31 45816 Garcia Noh Ana Cristina 1996-05-27 0 6819 2026-07-09 10:13:31
666 526645062937 telefono_compartido 0.61 SI 43606 López Guerra Siboney 2013-12-13 37 2026-06-20 4507 2026-07-09 10:13:02
667 526645062937 telefono_compartido 0.61 44493 Guerra Montes de Oca Rosario Siboney ROSARIO880904@GMAIL.COM 1988-09-04 0 5431 2026-07-09 10:13:13
668 526645085112 telefono_compartido 0.30 SI 42707 Angel Cristina FRAMBUESA_1027@HOTMAIL.COM 1985-10-27 7 2026-01-06 3315 2026-07-09 10:12:50
669 526645085112 telefono_compartido 0.30 46070 Brown Derrick 2017-06-14 0 7095 2026-07-09 10:13:31
670 526645102100 telefono_compartido 0.35 42704 Rodas Jason JR21213@AOL.COM 1985-08-08 0 3312 2026-07-09 10:12:50
671 526645102100 telefono_compartido 0.35 SI 45994 Stewart Jane IANFROMTIJUANA@GMAIL.COM 1938-12-23 8 2026-06-01 7016 2026-07-09 10:13:31
672 526645232696 duplicado_probable 0.98 SI 40304 Ibarra Gutierrez Karla Gabriela GABYIBA28@LIVE.COM 1971-02-28 8 2025-02-03 610 2026-07-09 10:12:26
673 526645232696 duplicado_probable 0.98 44327 Ibarra Gutierrrez Karla Gabriela GABYIBA28@LIVE.COM 1971-02-28 0 5250 2026-07-09 10:13:13
674 526645306350 telefono_compartido 0.39 SI 42464 Marmolejo Garcia Sophia GUSTAVOMARMOLLOP@GMAIL.COM 2008-04-15 15 2026-01-09 3033 2026-07-09 10:12:50
675 526645306350 telefono_compartido 0.39 43163 Garcia Balderas Doris Yanira SAMANTHA.GARCIA@UABC.EDU.MX 1976-10-20 0 3946 2026-07-09 10:12:57
676 526645337992 telefono_compartido 0.31 SI 41423 Hernandez Morales Ma Dolores VENECIA_MUCINO@HOTMAIL.COM 1950-06-22 13 2025-01-10 1871 2026-07-09 10:12:38
677 526645337992 telefono_compartido 0.31 41658 Muciño Espinosa Venecia VENECIA_MUCINO@HOTMAIL.COM 1989-03-23 0 2134 2026-07-09 10:12:38
678 526645507583 telefono_compartido 0.36 SI 41677 Aldaco Lepe Erendida Abigail 1973-03-07 7 2024-12-20 2154 2026-07-09 10:12:38
679 526645507583 telefono_compartido 0.36 41880 Cervantes Alondra ALORUBY@ICLOUD.COM 1998-03-23 0 2383 2026-07-09 10:12:44
680 526645556699 duplicado_probable 1.00 SI 45456 Rodriguez Leal Alba 2002-11-22 1 2025-06-10 6439 2026-07-09 10:13:25
681 526645556699 duplicado_probable 1.00 45481 Rodriguez Leal Alba 2002-11-22 0 6466 2026-07-09 10:13:25
682 526645756189 telefono_compartido 0.42 SI 45998 Osuna Angulo Maida Regina MAIDA.REGIOS@GMAIL.COM 2003-11-27 1 2025-11-15 7020 2026-07-09 10:13:31
683 526645756189 telefono_compartido 0.42 46137 Quijano Angulo Michelle MICHELLEQUIJANO421@GMAIL.COM 2005-04-21 0 7171 2026-07-09 10:13:31
684 526645768726 telefono_compartido 0.29 41865 Lopez Paredes Ana Elizabeth 1982-10-22 0 2368 2026-07-09 10:12:44
685 526645768726 telefono_compartido 0.29 SI 41866 Mesta Lopez Samantha Yocelyn SAM.MESTA2009@GMAIL.COM 2009-06-21 4 2023-11-21 2369 2026-07-09 10:12:44
686 526645833336 telefono_compartido 0.41 SI 45973 Jacobo Gomez Diana DIANA_JACOBO@YAHOO.COM 1978-08-14 4 2026-04-27 6994 2026-07-09 10:13:31
687 526645833336 telefono_compartido 0.41 46728 Rangel Jaboco Azalea Aimee DIANA_JACOBO@YAHOO.CON 2015-12-21 0 7799 2026-07-09 10:13:37
688 526645878363 telefono_compartido 0.24 SI 46704 Garcia Pimentel Diego THEOUTSIDER.TTV@GMAIL.COM 2008-07-18 5 2026-05-15 7774 2026-07-09 10:13:37
689 526645878363 telefono_compartido 0.24 46707 Ozuna Lugo Francisca CPMARYPIMENTEL1980@GMAIL.COM 1939-11-29 0 7777 2026-07-09 10:13:37
690 526645899429 telefono_compartido 0.55 43745 Lopez Huaracha Aurora 1964-04-20 0 4657 2026-07-09 10:13:02
691 526645899429 telefono_compartido 0.55 SI 45650 Garcia Lopez Maria Eugenia MARU.GL22@GMAIL.COM 1987-06-18 22 2026-06-12 6640 2026-07-09 10:13:25
692 526645977788 telefono_compartido 0.48 SI 41706 Avalos Lopez Naian Lourdes ALEJANDRAMENDOZA0611@GMAIL.COM 2009-07-24 28 2025-06-25 2190 2026-07-09 10:12:38
693 526645977788 telefono_compartido 0.48 41712 Lopez Vergara Dulce Alejandra ALEJANDRAMENDOZA0611@GMAIL.COM 1981-11-06 0 2197 2026-07-09 10:12:38
694 526645977788 telefono_compartido 0.48 41921 Avalos Lopez Ian Donovan ALEJANDRAMENDOZA0611@GMAIL.COM 2008-07-07 0 2428 2026-07-09 10:12:44
695 526646032445 telefono_compartido 0.48 SI 44652 Lopez Ortiz Vania America MARIAORTIZH@EDUBC.MX 2011-02-25 34 2026-05-09 5593 2026-07-09 10:13:13
696 526646032445 telefono_compartido 0.48 44653 Ortiz Huerta Maria de Lourdes MARIAORTIZH@EDUBC.MX 1984-12-20 0 5594 2026-07-09 10:13:13
697 526646036820 duplicado_probable 1.00 SI 44194 Mosqueda Tostado Laura Diana LAURADIANA1487@HOTMAIL.COM 1990-12-07 11 2025-05-16 5119 2026-07-09 10:13:08
698 526646036820 duplicado_probable 1.00 44543 Mosqueda Tostado Laura Diana LAURADIANA1487@HOTMAIL.COM 1990-12-07 0 178 2026-07-09 10:13:13
699 526646113515 telefono_compartido 0.29 SI 40279 Ibarra Erenas Alicia 1981-06-23 39 2025-04-23 582 2026-07-09 10:12:20
700 526646113515 telefono_compartido 0.29 43530 Sevilla Ibarra Isabel ALICIB26@GMAIL.COM 2010-07-12 0 4420 2026-07-09 10:13:02
701 526646346062 telefono_compartido 0.68 SI 46293 Lomeli Martinez Evelyn KOHKCOY18@HOTMAIL.COM 1983-07-06 2 2026-01-05 7337 2026-07-09 10:13:37
702 526646346062 telefono_compartido 0.68 46294 Flores Martinez Emilio KOHKCOY18@HOTMAIL.COM 2012-07-06 0 7338 2026-07-09 10:13:37
703 526646403342 telefono_compartido 0.46 SI 42172 Gomez Lujan Sebastian 21GOMEZ@ATT.NET 2003-02-02 70 2026-06-23 2707 2026-07-09 10:12:44
704 526646403342 telefono_compartido 0.46 43358 Lujan Medina Rosa 1967-10-06 0 4217 2026-07-09 10:13:02
705 526646403342 telefono_compartido 0.46 46026 Medina Amador Herminia RGOMEZRN@SBCGLOBAL.NET 1948-03-18 0 7050 2026-07-09 10:13:31
706 526646403342 telefono_compartido 0.46 46340 Gomez Cruz Ricardo RGMHOBBY@GMAIL.COM 1971-05-02 0 7384 2026-07-09 10:13:37
707 526646489182 telefono_compartido 0.43 SI 40994 Romandia Jacobo Rene JACOBOMONIK@GMAIL.COM 2004-03-26 19 2025-07-30 1385 2026-07-09 10:12:32
708 526646489182 telefono_compartido 0.43 44158 Jacobo Cerrillo Monica JACOBOMONIK@GMAIL.COM 1978-03-10 0 1176 2026-07-09 10:13:08
709 526646938548 telefono_compartido 0.60 SI 39943 Ramirez Luna Yaned RAMIREZYANED33@GMAIL.COM 1987-04-28 26 2026-04-27 194 2026-07-09 10:12:20
710 526646938548 telefono_compartido 0.60 40063 Calderon Ramirez Bianca Vanessa 2023-02-03 0 349 2026-07-09 10:12:20
711 526646938548 telefono_compartido 0.60 44396 Calderon Ramirez Dayro RAMIREZLUNAYANED@GMAIL.COM 2015-05-01 0 5332 2026-07-09 10:13:13
712 526646955946 telefono_compartido 0.31 43537 Gonzales Briana BRYSHER.COMGONZALEZ@ICLOUD.COM 2006-03-18 0 4430 2026-07-09 10:13:02
713 526646955946 telefono_compartido 0.31 SI 45097 Gutierrez Garcia Renata Mariel 2018-07-03 11 2025-05-06 6069 2026-07-09 10:13:19
714 526646991107 telefono_compartido 0.26 SI 40398 Castillo Ponce Cleotilde 1960-06-18 12 2026-04-10 717 2026-07-09 10:12:26
715 526646991107 telefono_compartido 0.26 40804 Antonella Morillo Chan CAROLINACHAN.C@GMAIL.COM 2016-12-29 0 1173 2026-07-09 10:12:32
716 526647091216 telefono_compartido 0.43 SI 43832 Ventura Chisnas Viviana VIVIVENTURAC10@GMAIL.COM 1987-08-29 0 4753 2026-07-09 10:13:08
717 526647091216 telefono_compartido 0.43 43833 Martinez Ventura Ilyana Monserrat VIVIVENTURAC10@GMAIL.COM 2008-07-29 0 4754 2026-07-09 10:13:08
718 526647101910 telefono_compartido 0.49 SI 40018 Urbina Vargas Ivanna Kirle MERCEDEZ1407@HOTMAIL.COM 2005-02-07 8 2023-02-23 300 2026-07-09 10:12:20
719 526647101910 telefono_compartido 0.49 40444 Vargas Avila Matia Mercedes 1984-09-24 0 769 2026-07-09 10:12:26
720 526647301977 duplicado_probable 1.00 41344 Montalvo Dominguez Jonathan 2000-02-11 0 1782 2026-07-09 10:12:38
721 526647301977 duplicado_probable 1.00 SI 45163 Montalvo Dominguez Jonathan JONATHANMONTALVO112000@GMAIL.COM 2000-02-11 24 2026-05-08 3973 2026-07-09 10:13:19
722 526647500373 telefono_compartido 0.68 SI 40407 Lopez Carrillo Allison ALNIPIS26@GMAIL.COM 2006-10-26 11 2024-05-07 726 2026-07-09 10:12:26
723 526647500373 telefono_compartido 0.68 46635 Lopez Carrillo Ismael Alexander LOPEZCARRILLOISMAELALEXANDER@GMAIL.COM 2011-03-09 3 2026-05-21 7697 2026-07-09 10:13:37
724 526647634186 telefono_compartido 0.54 SI 42795 Ascencio Sotelo Susana SUSANAASCENCIOSOTELO@GMAIL.COM 1985-09-03 0 3434 2026-07-09 10:12:57
725 526647634186 telefono_compartido 0.54 43957 Saucedo Ascencio Maria Carlota SUSANAASCENCIOSOTELO@GMAIL.COM 2012-08-24 0 4884 2026-07-09 10:13:08
726 526647658484 telefono_compartido 0.55 SI 39811 Cuen Baez Renata 2008-06-29 8 2026-06-02 31 2026-07-09 10:12:20
727 526647658484 telefono_compartido 0.55 43923 Baez Cuen Claudia CLAUBAEZ1479@GMAIL.COM 1979-09-14 0 4852 2026-07-09 10:13:08
728 526647792406 telefono_compartido 0.43 SI 43111 Guevara Hinojoza Dafne Yamileth MONICAHINOJOZA2018@HOTMAIL.COM 2009-09-20 58 2026-05-21 3887 2026-07-09 10:12:57
729 526647792406 telefono_compartido 0.43 43881 Hinojoza Peraza Monica Lizeth 1980-02-09 0 4804 2026-07-09 10:13:08
730 526647792406 telefono_compartido 0.43 46598 Guevara Hernandez Melanie Kristel MONICAHIJOZA2018@HOTMAIL.COM 2012-05-10 0 7659 2026-07-09 10:13:37
731 526648022645 telefono_compartido 0.45 SI 40906 Soto Vega Lilia 1978-10-11 30 2025-01-18 1289 2026-07-09 10:12:32
732 526648022645 telefono_compartido 0.45 41353 Perez Soto Katia LILISOTO78@GMAIL.COM 2010-03-20 0 1792 2026-07-09 10:12:38
733 526653924802 telefono_compartido 0.64 SI 44997 Castro Jonathan LIC.GLORIACASTRO@HOTMAIL.COM 2004-04-23 3 2025-09-09 5963 2026-07-09 10:13:19
734 526653924802 telefono_compartido 0.64 44998 Castro Gloria LIC.GLORIACASTRO@HOTMAIL.COM 1978-09-23 0 5964 2026-07-09 10:13:19
735 526672103348 telefono_compartido 0.36 40327 Gastelum Aviles Claudia GASTELUMAVILES.CLAUDIA@GMAIL.COM 1982-10-09 0 637 2026-07-09 10:12:26
736 526672103348 telefono_compartido 0.36 SI 44915 Teran Gastelum Luciana GASTELUMAVILES.CLAUDIA@GMAIL.COM 2018-09-18 14 2025-06-11 5871 2026-07-09 10:13:19
737 526675019509 telefono_compartido 0.65 SI 44201 Ahumada Erives Miriam Zulema SAN_1390@HOTMAIL.ES 1990-03-13 8 2026-06-03 5126 2026-07-09 10:13:08
738 526675019509 telefono_compartido 0.65 44697 Daylin Ahumada Miriam DAYLINAHUMADA@GMAIL.COM 2011-06-26 0 5637 2026-07-09 10:13:13
739 526691233980 telefono_compartido 0.27 SI 44551 Corrales Rodriguez Karely Jazmin CORRALESKARELY2@GMAIL.COM 1996-08-06 0 5489 2026-07-09 10:13:13
740 526691233980 telefono_compartido 0.27 45125 Robles Junior JS_R_B@HOTMAIL.COM 1998-09-12 0 6105 2026-07-09 10:13:19
741 526751121945 telefono_compartido 0.32 SI 46701 Vasquez Vargas Karla KARDEM347@GMAIL.COM 2002-11-04 5 2026-06-30 7771 2026-07-09 10:13:37
742 526751121945 telefono_compartido 0.32 46974 Leyva Rueda Manuel KARDEM347@GMAIL.COM 1972-10-22 0 8172 2026-07-09 10:13:43
743 526862212806 telefono_compartido 0.56 SI 40599 Franco Parra Cynthia CYNTHIAFRANCO.PARRA@GMAIL.COM 1983-10-24 27 2026-02-25 937 2026-07-09 10:12:26
744 526862212806 telefono_compartido 0.56 43294 Diaz Franco Maximiliano CYNTHIAFRANCO.PARRA@GMAIL.COM 2013-09-15 0 4134 2026-07-09 10:13:02
745 526863221603 telefono_compartido 0.33 SI 42003 Verduzco Obeso Mitchel VERDUZCOM@HOTMAIL.COM 1996-06-19 19 2025-12-23 2523 2026-07-09 10:12:44
746 526863221603 telefono_compartido 0.33 43229 Roe Axl Matteo VERDUZCOM@HOTMAIL.COM 2009-05-13 0 4040 2026-07-09 10:12:57
747 526864060446 telefono_compartido 0.26 41673 Gomez Ma Valeria Isabel VALERIAISABELGM@GMAIL.COM 1995-08-20 1 2023-07-11 2149 2026-07-09 10:12:38
748 526864060446 telefono_compartido 0.26 SI 41731 Gonzalez Mendoza Adriana VALERIAISABELGM@GMAIL.COM 1995-08-20 4 2025-02-19 2219 2026-07-09 10:12:38
749 526871584464 telefono_compartido 0.14 SI 42503 Gamez Rubio Mary Janeth GAMEZRUBIO@ICLOUD.COM 1991-11-05 10 2024-05-29 3078 2026-07-09 10:12:50
750 526871584464 telefono_compartido 0.14 42992 Andrade Cruz Gabriel GAMEZRUBIO@ICLOUD.COM 2009-03-07 0 3750 2026-07-09 10:12:57
751 527076161506 telefono_compartido 0.27 SI 41602 Robinson Gloria GLOW1220@YAHOO.CO 1983-12-20 23 2024-10-14 2076 2026-07-09 10:12:38
752 527076161506 telefono_compartido 0.27 42334 Treibek Westphal Phillip PTWPTW88@GMAIL.COM 1980-12-08 0 2891 2026-07-09 10:12:50
753 527076161506 telefono_compartido 0.27 43640 Hover Jesseca JHOVER24@HOTMAIL.COM 1973-06-24 0 4545 2026-07-09 10:13:02
754 527143008671 telefono_compartido 0.48 SI 46063 Dillard Harold PERIPATETIC@NYM.HUSH.COM 1961-11-13 6 2026-07-06 7088 2026-07-09 10:13:31
755 527143008671 telefono_compartido 0.48 46942 Pham Lauren LARIMARSTONE@HUSHMAIL.COM 1968-06-11 0 8133 2026-07-09 10:13:43
756 527144768562 telefono_compartido 0.55 SI 40809 Gentile Liliana GENTILE.LILIANA98@GMAIL.COM 1998-01-28 2 2026-04-07 1179 2026-07-09 10:12:32
757 527144768562 telefono_compartido 0.55 46652 Villa Lara Liliana GENTILE.LILIANA98@GMAIL.COM 1998-01-28 0 7717 2026-07-09 10:13:37
758 527144832514 telefono_compartido 0.77 SI 42740 Alcantara Gonzalez Roberto ROBERTOALCANTARA2003@GMAIL.COM 2003-07-23 13 2025-08-19 3354 2026-07-09 10:12:50
759 527144832514 telefono_compartido 0.77 43307 Alcantar Bojorquez Roberto 1959-10-03 0 4158 2026-07-09 10:13:02
760 527144998430 telefono_compartido 0.58 SI 41439 Westrick Oliver William WILLIAMWESTRICK100@GMAIL.COM 1958-06-05 6 2025-03-10 1891 2026-07-09 10:12:38
761 527144998430 telefono_compartido 0.58 44429 Westrick Teresa WILLIAMWESTRICK10@GMAIL.COM 1962-10-15 0 5367 2026-07-09 10:13:13
762 527145612812 telefono_compartido 0.53 45638 Zavala Virginia 1969-10-05 0 6629 2026-07-09 10:13:25
763 527145612812 telefono_compartido 0.53 SI 45639 Zavala Baltazar VIRGINIAZAVALA1005@GMAIL.COM 1966-02-19 3 2025-07-09 6630 2026-07-09 10:13:25
764 527148013518 telefono_compartido 0.36 SI 40776 Garcia Gabriela GGBETTYBOOP@YAHOO.COM 1978-11-06 6 2024-12-04 1141 2026-07-09 10:12:26
765 527148013518 telefono_compartido 0.36 42888 Lopez Garcia Isaac GGBETTYBOOP@YAHOO.COM 2007-03-11 0 3632 2026-07-09 10:12:57
766 527451232667 duplicado_probable 1.00 SI 41594 Roldan Bernal Jose Manuel MANUELROLDAN360@GMAIL.COM 2001-04-11 38 2026-06-27 2066 2026-07-09 10:12:38
767 527451232667 duplicado_probable 1.00 42311 Roldan Bernal Jose Manuel MANUELROLDAN360@GMAIL.COM 2001-04-11 0 2865 2026-07-09 10:12:50
768 527472622662 telefono_compartido 0.19 SI 42722 Hernandez Ballesteros Jande JANDE_HERNANDEZ@HOTMAIL.COM 1982-05-22 5 2024-03-11 3332 2026-07-09 10:12:50
769 527472622662 telefono_compartido 0.19 42723 Jauregui Juliana NO.NO@GMAIL.COM 2019-03-16 0 3333 2026-07-09 10:12:50
770 527603107878 telefono_compartido 0.75 SI 40973 Rivera Arribeño Alma 1957-12-08 16 2025-11-15 1364 2026-07-09 10:12:32
771 527603107878 telefono_compartido 0.75 45466 Espinoza Rivera Alma ALMAE1957@GMAIL.COM 1957-12-08 0 6449 2026-07-09 10:13:25
772 527605743678 telefono_compartido 0.52 SI 43965 Chaidez Reyes Lourdes REYESLOURDES19@GMAIL.COM 1983-01-22 0 4891 2026-07-09 10:13:08
773 527605743678 telefono_compartido 0.52 43966 Higuera Chaidez Sebastian LCKITTOS@AOL.COM 2005-12-03 0 4892 2026-07-09 10:13:08
774 527606455166 telefono_compartido 0.68 SI 40671 Hernandez -ontiveros Patricia PATRICIAWORK2016@GMAIL.COM 1968-01-15 32 2026-05-22 1017 2026-07-09 10:12:26
775 527606455166 telefono_compartido 0.68 42156 Hernandez Ontiveris Ester 1944-04-10 0 2689 2026-07-09 10:12:44
776 527607042252 telefono_compartido 0.67 SI 42343 Vargas Arreola Ana 1993-10-07 1 2023-11-29 2900 2026-07-09 10:12:50
777 527607042252 telefono_compartido 0.67 44675 Vargas Arreola Esmeralda AVARGAS_939@YAHOO.COM 1993-10-07 0 2515 2026-07-09 10:13:13
778 527756218831 telefono_compartido 0.26 SI 44361 Sahagun Samantha Melissa SAMY121612@ICLOUD.COM 2012-12-16 0 5295 2026-07-09 10:13:13
779 527756218831 telefono_compartido 0.26 44953 Salazar Garcia Norma Alejandra 1979-05-21 0 5917 2026-07-09 10:13:19
780 528186069066 telefono_compartido 0.58 SI 45980 Sandoval Legaspi Roberto LUZSAND@SBCGLOBAL.NET 1943-01-24 4 2025-11-04 7001 2026-07-09 10:13:31
781 528186069066 telefono_compartido 0.58 46060 Castillo Sandoval Luz LUZSAND@SBCGLOBAL.NET 1953-05-20 0 7085 2026-07-09 10:13:31
782 528189419769 telefono_compartido 0.65 SI 42831 Carrillo Soto Brianna BRIANNA.CARRILLO.05@GMAIL.COM 2005-10-19 17 2024-11-30 3526 2026-07-09 10:12:57
783 528189419769 telefono_compartido 0.65 42899 Soto Lopez Brisa BRISA86@ICLOUD.COM 1986-04-08 0 3648 2026-07-09 10:12:57
784 528312628969 telefono_compartido 0.47 SI 43230 Gamino Emily MEMO6_2@YAHOO.COM 2006-02-13 19 2025-08-22 4041 2026-07-09 10:12:57
785 528312628969 telefono_compartido 0.47 43252 Alanis Ramos Olga MEMO6_2@YAHOO.COM 1982-09-22 0 4074 2026-07-09 10:12:57
786 528312628969 telefono_compartido 0.47 44752 Alvarez Alanis Sophia MEMO6_2@YAHOO.COM 2017-02-11 0 5693 2026-07-09 10:13:13
787 528586926163 telefono_compartido 0.47 44727 Webb Lesley LESWEBB@GMAIL.COM 1977-03-10 0 5672 2026-07-09 10:13:13
788 528586926163 telefono_compartido 0.47 SI 45619 Webb Mariscal Sofia LESWEBB@GMAIL.COM 2013-05-13 8 2026-06-06 6610 2026-07-09 10:13:25
789 528588291364 telefono_compartido 0.30 SI 43999 Jones Michael Gary LAURITAANGIE@LIVE.COM 1955-06-21 0 4921 2026-07-09 10:13:08
790 528588291364 telefono_compartido 0.30 44000 Rodriguez Garcia Laura LAURITAANGIE@LIVE.COM 1964-10-13 0 4922 2026-07-09 10:13:08
791 529092319931 telefono_compartido 0.44 SI 45116 Montes Paulina 1990-10-10 27 2026-06-27 6089 2026-07-09 10:13:19
792 529092319931 telefono_compartido 0.44 46820 Montes Johana NO@GMAIL.COM 1962-09-14 0 7901 2026-07-09 10:13:43
793 529092350069 duplicado_probable 1.00 SI 40624 Perez Joanna 1991-11-23 9 2026-02-20 965 2026-07-09 10:12:26
794 529092350069 duplicado_probable 1.00 44730 Perez Joanna JOANPEREZ1468@YAHOO.COM 1991-11-23 0 5675 2026-07-09 10:13:13
795 529092950258 duplicado_probable 0.83 SI 41036 Sanchez Raygoza Alina 2008-04-25 39 2024-06-27 1430 2026-07-09 10:12:32
796 529092950258 duplicado_probable 0.83 41361 Sanchez Raygoza Jose 2007-03-27 0 1800 2026-07-09 10:12:38
797 529092950258 duplicado_probable 0.83 41362 Sanchez Raygoza Diego 2008-04-28 0 1801 2026-07-09 10:12:38
798 529093324318 telefono_compartido 0.46 SI 40278 Alvarez Vasquez Jorgeandres 2006-01-14 7 2023-04-15 581 2026-07-09 10:12:20
799 529093324318 telefono_compartido 0.46 40579 Vasquez Rodriguez Lourdes 1974-11-22 0 914 2026-07-09 10:12:26
800 529093793305 duplicado_probable 0.84 SI 45404 Padilla Ramirez Alek COKLXRAYMARKERS@ME.COM 2005-07-26 3 2025-07-30 6387 2026-07-09 10:13:25
801 529093793305 duplicado_probable 0.84 45405 Padilla Ramirez Alandra COOLXRAYMARKERS@ME.COM 2005-11-30 0 6388 2026-07-09 10:13:25
802 529093793305 duplicado_probable 0.84 45406 Padilla Bautista Jesus 1976-10-05 0 6389 2026-07-09 10:13:25
803 529094897416 telefono_compartido 0.19 SI 45133 Del Rio Guadalupe LUPITA_DELRIO@YAHOOO.COM 1987-09-10 13 2025-10-10 5187 2026-07-09 10:13:19
804 529094897416 telefono_compartido 0.19 45330 Ornelas Ariana 2008-08-09 0 6314 2026-07-09 10:13:25
805 529095597351 telefono_compartido 0.26 SI 40181 Zapata Rocha Armando 1996-03-14 7 2025-10-18 479 2026-07-09 10:12:20
806 529095597351 telefono_compartido 0.26 40462 Gutierrez Hernandez Adriana ADRIANAGTZ.0331@GMAIL.COM 1971-03-31 6 2025-09-05 788 2026-07-09 10:12:26
807 529097706953 duplicado_probable 0.88 SI 41527 Hernandez Islas Ximena Sofia SELENEISLAS95@GMAIL.COM 2016-05-24 3 2026-04-17 1989 2026-07-09 10:12:38
808 529097706953 duplicado_probable 0.88 46570 Hernandez Islas Ximena 2016-05-24 0 7627 2026-07-09 10:13:37
809 529098370029 telefono_compartido 0.64 SI 43005 Ramirez Raul RAUL6419@VERIZON.NET 1964-01-26 9 2025-02-22 3764 2026-07-09 10:12:57
810 529098370029 telefono_compartido 0.64 43007 Ramirez Samantha RAUL6419@VERIZO.NET 2016-07-26 0 3766 2026-07-09 10:12:57
811 529099001217 telefono_compartido 0.33 SI 46822 Castaneda Gama Viviana VIVI.USA.1202@GMAIL.COM 2002-02-12 2 2026-05-16 7903 2026-07-09 10:13:43
812 529099001217 telefono_compartido 0.33 46823 Mosqueda Acosta Maria 1973-04-05 0 7904 2026-07-09 10:13:43
813 529253397117 telefono_compartido 0.36 SI 46156 Harris Karen 1960-11-12 6 2026-06-16 7191 2026-07-09 10:13:31
814 529253397117 telefono_compartido 0.36 46169 Jackson Caroline 1942-10-23 0 7204 2026-07-09 10:13:31
815 529282469046 duplicado_probable 0.80 SI 39832 Ruiz Lopez Ana ANNA.B.PLAZA@MSN.COM 1955-08-27 12 2026-05-27 54 2026-07-09 10:12:20
816 529282469046 duplicado_probable 0.80 43579 Ruiz Lopez Ana Bertha ANNA.B.PLAZA@MSN.COM 1955-08-27 0 4476 2026-07-09 10:13:02
817 529282469046 duplicado_probable 0.80 46288 Espinoza Plaza Daniel DBOYGG77@GMAIL.COM 2007-05-09 0 7330 2026-07-09 10:13:37
818 529283232486 telefono_compartido 0.31 SI 45985 Sonner Ivette 1974-12-17 2 2025-10-04 7006 2026-07-09 10:13:31
819 529283232486 telefono_compartido 0.31 45986 Jhonston Lara 1967-06-24 0 7007 2026-07-09 10:13:31
820 529498387369 telefono_compartido 0.49 SI 44557 Gallegos Contreras Madison EVELYNGALLEGOS@LIVE.COM 2006-11-10 0 5496 2026-07-09 10:13:13
821 529498387369 telefono_compartido 0.49 44562 Contreras Carpio Evelyn EVELYNGALLEGOS@LUVE.COM 1978-12-12 0 5501 2026-07-09 10:13:13
822 529514134835 telefono_compartido 0.46 SI 44447 Urriarte Gonzalez Lidia HEAVENEXPRESS@VERIZON.NET 1935-08-03 5 2025-01-25 5384 2026-07-09 10:13:13
823 529514134835 telefono_compartido 0.46 44615 Hernandez Uriarte Elva Eneyda 1970-08-15 0 5552 2026-07-09 10:13:13
824 529514875162 telefono_compartido 0.39 SI 40894 Ceja Torres Karol KAROLCEJA_1990@GMAIL.COM 1990-06-22 23 2026-07-03 1277 2026-07-09 10:12:32
825 529514875162 telefono_compartido 0.39 44254 Romero Isabela NO@GMAIL.COM 2023-09-18 0 5184 2026-07-09 10:13:08
826 529515266441 telefono_compartido 0.35 SI 39872 Davila-chase Cynyhia 1997-08-08 12 2023-11-11 100 2026-07-09 10:12:20
827 529515266441 telefono_compartido 0.35 39873 Davila Maritza 1969-10-24 0 101 2026-07-09 10:12:20
828 529515507600 telefono_compartido 0.30 SI 45627 Osorio Sergio DENISEPJIMENEZ@OUTLOOK.COM 1976-03-22 2 2025-07-07 6618 2026-07-09 10:13:25
829 529515507600 telefono_compartido 0.30 45628 Jimenez Denise DENISEPJIMENEZ@OUTLOOK.COM 1980-07-28 0 6619 2026-07-09 10:13:25
830 529516349207 telefono_compartido 0.67 SI 40898 Murillo Isabella IRMA28LEONY@GMAIL.COM 2003-05-22 8 2024-09-23 1281 2026-07-09 10:12:32
831 529516349207 telefono_compartido 0.67 43138 Murillo Leon Irma IRMA28LEONY@GMAIL.COM 1969-12-28 0 3917 2026-07-09 10:12:57
832 529516604446 telefono_compartido 0.26 SI 44015 Ramos Lopez Miguel SINALOA_1958@HOTMAIL.COM 2012-10-18 9 2025-10-18 4939 2026-07-09 10:13:08
833 529516604446 telefono_compartido 0.26 44017 Garcia Muñoz Patricia 1958-03-17 0 4941 2026-07-09 10:13:08
834 529518408033 telefono_compartido 0.29 SI 43146 Arreola Alexa STARLENEARREOLA@LIVE.COM 2007-03-11 4 2024-11-14 3927 2026-07-09 10:12:57
835 529518408033 telefono_compartido 0.29 43152 Orosco Starlene STARLENEARREOLA@LIVE.COM 1984-02-12 0 3934 2026-07-09 10:12:57
836 529518521349 telefono_compartido 0.62 SI 40693 Sobie Griselda GRACIESOBIEZ@yahoo.com 1968-03-05 36 2025-10-31 1041 2026-07-09 10:12:26
837 529518521349 telefono_compartido 0.62 42468 Sobie Caelyn GRACIESOBIE@GMAIL.COM 2000-12-14 0 3040 2026-07-09 10:12:50
838 529518521349 telefono_compartido 0.62 44502 Rivas Bobadilla Griselda GRACIESOBIE@GMAIL.COM 1968-03-05 0 566 2026-07-09 10:13:13
839 529519062579 telefono_compartido 0.36 SI 43666 Contreras Cinthia 1979-03-30 2 2025-01-25 4573 2026-07-09 10:13:02
840 529519062579 telefono_compartido 0.36 44020 Damian Drew 2010-04-04 0 4944 2026-07-09 10:13:08
841 529622843006 telefono_compartido 0.38 44571 Fuentes Donna 1972-07-30 0 5507 2026-07-09 10:13:13
842 529622843006 telefono_compartido 0.38 SI 45686 Quigley Glenn 1967-01-05 11 2026-05-09 6673 2026-07-09 10:13:25
843 529641089175 telefono_compartido 0.37 SI 41725 Escobar Toledo Christopher Emilio LAURAPATRICIATOLEDO@GOOGLE.COM 2010-08-12 5 2025-03-08 2212 2026-07-09 10:12:38
844 529641089175 telefono_compartido 0.37 44631 Toledo Ruiz Laura Patricia LAURAPATRICIATOLEDO27@GMAIL.COM 1978-12-27 0 5568 2026-07-09 10:13:13
845 529706440513 duplicado_probable 0.80 44061 Lucero Jimmy LUCEROSTAN@AOL.COM 1967-05-31 0 4983 2026-07-09 10:13:08
846 529706440513 duplicado_probable 0.80 SI 44958 Lucero Jimari LUCEROSTAN@AOL.COM 2001-09-04 13 2025-09-08 5919 2026-07-09 10:13:19
847 425414654 mismo_nombre_distinto_telefono 1.00 39859 Tostado Avelar Jennifer JENTOSTADO@GMAIL.COM 1995-12-23 0 84 2026-07-09 10:12:20
848 524254146054 mismo_nombre_distinto_telefono 1.00 SI 46946 Tostado Avelar Jennifer JENTOSTADO@GMAIL.COM 1995-12-23 2 2026-06-22 8137 2026-07-09 10:13:43
849 526611722513 mismo_nombre_distinto_telefono 1.00 40157 Perez Rodriguez Miguel Angel 2012-05-15 1 2022-12-01 450 2026-07-09 10:12:20
850 527354040018 mismo_nombre_distinto_telefono 1.00 SI 42332 Perez Rodriguez Miguel Angel MIGUEL00APRZ@GMAIL.COM 2000-05-02 3 2025-12-12 2889 2026-07-09 10:12:50
851 526612395332 mismo_nombre_distinto_telefono 1.00 SI 41281 Garcia Mendez Jesus Osvaldo ROSAMENDEZAVALOS@GMAIL.COM 2008-03-12 7 2024-11-29 1712 2026-07-09 10:12:32
852 526645289303 mismo_nombre_distinto_telefono 1.00 44195 Garcia Mendez Jesus Osvaldo JOSVGAME@GMAIL.COM 2008-03-12 4 2025-04-05 5120 2026-07-09 10:13:08
853 19494674609 mismo_nombre_distinto_telefono 1.00 41468 Thomas Patti PATTILOVESYA@GMAIL.COM 1953-07-20 4 2026-06-08 1921 2026-07-09 10:12:38
854 529494674609 mismo_nombre_distinto_telefono 1.00 SI 45754 Thomas Patti PATTILOVESYA@GMAIL.COM 1953-07-20 5 2025-11-04 6754 2026-07-09 10:13:25
855 526864060446 mismo_nombre_distinto_telefono 1.00 SI 41731 Gonzalez Mendoza Adriana VALERIAISABELGM@GMAIL.COM 1995-08-20 4 2025-02-19 2219 2026-07-09 10:12:38
856 527252369141 mismo_nombre_distinto_telefono 1.00 42197 Gonzalez Mendoza Adriana 1990-05-17 1 2023-10-21 2737 2026-07-09 10:12:44
857 526192194154 mismo_nombre_distinto_telefono 1.00 42891 Vega Guzman Elga Minerva DR.ADRIANVEGA89@GMAIL.COM 1978-08-08 2 2026-02-11 3636 2026-07-09 10:12:57
858 526643873091 mismo_nombre_distinto_telefono 1.00 SI 44445 Vega Guzman Elga Minerva ELGAVEGA@YAHOO.COM.MX 1978-08-09 17 2026-02-18 5382 2026-07-09 10:13:13
859 526283027235 mismo_nombre_distinto_telefono 1.00 43259 Rivas Aguilar Vanessa 1991-09-02 0 4083 2026-07-09 10:12:57
860 526183027235 mismo_nombre_distinto_telefono 1.00 SI 44917 Rivas Aguilar Vanessa 1991-09-02 11 2026-04-20 5873 2026-07-09 10:13:19
861 526641412732 mismo_nombre_distinto_telefono 1.00 SI 44960 Aldaz Adolfo CHELO_ALDAZ@HOTMAIL.ES 1950-09-27 17 2026-06-29 5921 2026-07-09 10:13:19
862 523232155454 mismo_nombre_distinto_telefono 1.00 45960 Aldaz Adolfo YAMAHAIE@YAHOO.COM 1979-01-16 1 2025-09-26 6979 2026-07-09 10:13:31
863 12145978774 mismo_nombre_distinto_telefono 1.00 45431 Rosales Aguilar Elizabeth EROSALES.AGUILAR@GMAIL.COM 1984-06-22 1 2025-06-02 6414 2026-07-09 10:13:25
864 522145978774 mismo_nombre_distinto_telefono 1.00 SI 46715 Rosales Aguilar Elizabeth EROSALES.AGUILAR@GMAIL.COM 1984-06-22 4 2026-05-30 7786 2026-07-09 10:13:37

View File

@@ -15,6 +15,7 @@
'security/ir.model.access.csv',
'data/sequences.xml',
'views/cita_views.xml',
'views/bloqueo_views.xml',
'views/menu_views.xml',
],
'installable': True,

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Secuencia para citas -->
<record id="seq_skeen_cita" model="ir.sequence">
<field name="name">Cita SKEEN</field>
@@ -19,4 +20,5 @@
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import cita
from . import bloqueo

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class SkeenCitaBloqueo(models.Model):
_name = 'skeen.cita.bloqueo'
_description = 'Bloqueo de Agenda por Médico'
_order = 'date desc'
doctor_id = fields.Many2one('hr.employee', string='Médico', required=True)
date = fields.Date(string='Fecha', required=True, index=True)
all_day = fields.Boolean(string='Todo el día', default=False)
time_from = fields.Float(string='Desde', default=9.0)
time_to = fields.Float(string='Hasta', default=10.0)
motivo = fields.Char(string='Motivo')
@api.constrains('time_from', 'time_to', 'all_day')
def _check_horas(self):
for b in self:
if not b.all_day and b.time_to <= b.time_from:
raise ValidationError(_('La hora final debe ser mayor a la inicial'))

View File

@@ -232,6 +232,23 @@ class SkeenCita(models.Model):
overlapping = self.search(domain)
if overlapping:
raise ValidationError(_('Ya existe una cita en ese horario!'))
@api.constrains('date', 'time', 'doctor_id', 'servicio_id')
def _check_bloqueos(self):
"""Rechaza citas que se solapan con un bloqueo de agenda del médico"""
for cita in self:
if cita.state in ('cancelled', 'no_show') or not cita.doctor_id or not cita.date:
continue
dur = ((cita.servicio_id.duration_min if cita.servicio_id else 30) or 30) / 60.0
ini = cita.time
fin = ini + dur
bloqueos = self.env['skeen.cita.bloqueo'].search([
('doctor_id', '=', cita.doctor_id.id),
('date', '=', cita.date),
])
for b in bloqueos:
if b.all_day or (ini < b.time_to and fin > b.time_from):
raise ValidationError(_('El médico tiene un bloqueo (%s)') % (b.motivo or 'agenda bloqueada'))
def get_available_slots(self, date, servicio_id):
"""Retorna slots disponibles para una fecha y servicio"""

View File

@@ -2,3 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_servicio_user,Acceso a Servicios SKEEN,model_skeen_servicio,base.group_user,1,1,1,1
access_skeen_service_tag_user,Acceso a Etiquetas de Servicio SKEEN,model_skeen_service_tag,base.group_user,1,1,1,1
access_skeen_cita_user,Acceso a Citas SKEEN,model_skeen_cita,base.group_user,1,1,1,1
access_skeen_cita_bloqueo_user,Acceso a Bloqueos de Agenda SKEEN,model_skeen_cita_bloqueo,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_servicio_user Acceso a Servicios SKEEN model_skeen_servicio base.group_user 1 1 1 1
3 access_skeen_service_tag_user Acceso a Etiquetas de Servicio SKEEN model_skeen_service_tag base.group_user 1 1 1 1
4 access_skeen_cita_user Acceso a Citas SKEEN model_skeen_cita base.group_user 1 1 1 1
5 access_skeen_cita_bloqueo_user Acceso a Bloqueos de Agenda SKEEN model_skeen_cita_bloqueo base.group_user 1 1 1 1

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vista de lista de bloqueos -->
<record id="view_skeen_cita_bloqueo_tree" model="ir.ui.view">
<field name="name">skeen.cita.bloqueo.tree</field>
<field name="model">skeen.cita.bloqueo</field>
<field name="arch" type="xml">
<tree>
<field name="date"/>
<field name="doctor_id"/>
<field name="all_day"/>
<field name="time_from" widget="float_time"/>
<field name="time_to" widget="float_time"/>
<field name="motivo"/>
</tree>
</field>
</record>
<!-- Vista de formulario de bloqueos -->
<record id="view_skeen_cita_bloqueo_form" model="ir.ui.view">
<field name="name">skeen.cita.bloqueo.form</field>
<field name="model">skeen.cita.bloqueo</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="doctor_id"/>
<field name="date"/>
<field name="motivo"/>
</group>
<group>
<field name="all_day"/>
<field name="time_from" widget="float_time" invisible="all_day"/>
<field name="time_to" widget="float_time" invisible="all_day"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Acción para bloqueos -->
<record id="action_skeen_cita_bloqueo_tree" model="ir.actions.act_window">
<field name="name">Bloqueos de Agenda</field>
<field name="res_model">skeen.cita.bloqueo</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menú Bloqueos (bajo el menú Citas) -->
<menuitem id="menu_skeen_citas_bloqueos" name="Bloqueos de Agenda" parent="menu_skeen_citas"
action="action_skeen_cita_bloqueo_tree" sequence="20"/>
</odoo>

View File

@@ -6,9 +6,11 @@
'summary': 'Inventario de productos y consumibles con niveles y movimientos',
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base'],
'depends': ['base', 'skeen_citas'],
'data': [
'security/ir.model.access.csv',
'views/inventario_views.xml',
'data/cron.xml',
],
'installable': True,
'application': True,

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="cron_descontar_caducados" model="ir.cron">
<field name="name">SKEEN Inventario: descontar artículos caducados</field>
<field name="model_id" ref="model_skeen_inventario_item"/>
<field name="state">code</field>
<field name="code">model._cron_descontar_caducados()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -55,6 +55,32 @@ class SkeenInventarioItem(models.Model):
else:
rec.stock_level = 'optimal'
@api.model
def _cron_descontar_caducados(self):
"""Descuenta (baja a cero) los artículos caducados con existencias.
Corre diario vía ir.cron; la baja queda como movimiento tipo 'baja'."""
hoy = fields.Date.today()
caducados = self.search([
('expiry_date', '!=', False),
('expiry_date', '<', hoy),
('qty', '>', 0),
('active', '=', True),
])
Move = self.env['skeen.inventario.move'].sudo()
for item in caducados:
Move.create({
'item_id': item.id,
'type': 'baja',
'qty': item.qty,
'reference': 'CADUCIDAD',
'notes': f'Descuento automático por caducidad {item.expiry_date}',
})
if caducados:
import logging
logging.getLogger(__name__).info(
'Inventario: %s artículos caducados descontados', len(caducados))
return len(caducados)
class SkeenInventarioMove(models.Model):
_name = 'skeen.inventario.move'

View File

@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vista de lista de items de inventario -->
<record id="view_skeen_inventario_item_tree" model="ir.ui.view">
<field name="name">skeen.inventario.item.tree</field>
<field name="model">skeen.inventario.item</field>
<field name="arch" type="xml">
<tree decoration-danger="stock_level=='out'" decoration-warning="stock_level in ('critical','low')" decoration-success="stock_level=='optimal'">
<field name="name"/>
<field name="sku"/>
<field name="kind"/>
<field name="category"/>
<field name="unit"/>
<field name="qty"/>
<field name="qty_min"/>
<field name="qty_optimal"/>
<field name="cost"/>
<field name="inventory_value" sum="Total"/>
<field name="stock_level" widget="badge"
decoration-danger="stock_level=='out'"
decoration-warning="stock_level in ('critical','low')"
decoration-success="stock_level=='optimal'"/>
<field name="expiry_date"/>
</tree>
</field>
</record>
<!-- Vista de formulario de item -->
<record id="view_skeen_inventario_item_form" model="ir.ui.view">
<field name="name">skeen.inventario.item.form</field>
<field name="model">skeen.inventario.item</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="name"/>
<field name="sku"/>
<field name="kind"/>
<field name="category"/>
<field name="unit"/>
<field name="active"/>
</group>
<group>
<field name="qty"/>
<field name="qty_min"/>
<field name="qty_optimal"/>
<field name="cost"/>
<field name="inventory_value" readonly="1"/>
<field name="stock_level" readonly="1"/>
<field name="expiry_date"/>
<field name="last_count_date" readonly="1"/>
</group>
</group>
<notebook>
<page string="Movimientos">
<field name="move_ids" readonly="1">
<tree>
<field name="date"/>
<field name="type"/>
<field name="qty"/>
<field name="before_qty"/>
<field name="after_qty"/>
<field name="reference"/>
<field name="notes"/>
</tree>
</field>
</page>
<page string="Notas">
<field name="notes"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Filtros de búsqueda -->
<record id="view_skeen_inventario_item_search" model="ir.ui.view">
<field name="name">skeen.inventario.item.search</field>
<field name="model">skeen.inventario.item</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="sku"/>
<field name="category"/>
<filter name="producto" string="Productos" domain="[('kind','=','producto')]"/>
<filter name="consumible" string="Consumibles" domain="[('kind','=','consumible')]"/>
<separator/>
<filter name="out" string="Sin existencias" domain="[('stock_level','=','out')]"/>
<filter name="low" string="Bajo mínimo" domain="[('stock_level','in',('critical','low'))]"/>
<group expand="0" string="Agrupar por">
<filter name="group_category" string="Línea / Categoría" context="{'group_by':'category'}"/>
<filter name="group_kind" string="Tipo" context="{'group_by':'kind'}"/>
</group>
</search>
</field>
</record>
<!-- Vista de lista de movimientos -->
<record id="view_skeen_inventario_move_tree" model="ir.ui.view">
<field name="name">skeen.inventario.move.tree</field>
<field name="model">skeen.inventario.move</field>
<field name="arch" type="xml">
<tree>
<field name="date"/>
<field name="item_id"/>
<field name="type" widget="badge"/>
<field name="qty"/>
<field name="before_qty"/>
<field name="after_qty"/>
<field name="reference"/>
<field name="notes"/>
</tree>
</field>
</record>
<!-- Acciones -->
<record id="action_skeen_inventario_item" model="ir.actions.act_window">
<field name="name">Artículos</field>
<field name="res_model">skeen.inventario.item</field>
<field name="view_mode">tree,form</field>
</record>
<record id="action_skeen_inventario_move" model="ir.actions.act_window">
<field name="name">Movimientos</field>
<field name="res_model">skeen.inventario.move</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menús (bajo el menú raíz SKEEN) -->
<menuitem id="menu_skeen_inventario" name="Inventario" parent="skeen_citas.menu_skeen_root" sequence="40"/>
<menuitem id="menu_skeen_inventario_items" name="Artículos" parent="menu_skeen_inventario"
action="action_skeen_inventario_item" sequence="10"/>
<menuitem id="menu_skeen_inventario_moves" name="Movimientos" parent="menu_skeen_inventario"
action="action_skeen_inventario_move" sequence="20"/>
</odoo>

View File

@@ -14,6 +14,8 @@
'data': [
'security/ir.model.access.csv',
'views/paciente_views.xml',
'views/receta_views.xml',
'views/catalogos_views.xml',
],
'installable': True,
'application': True,

View File

@@ -2,3 +2,6 @@
from . import res_partner
from . import hr_employee
from . import patient_adjunto
from . import receta
from . import catalogos

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenDiagnostico(models.Model):
_name = 'skeen.diagnostico'
_description = 'Catálogo de Diagnósticos SKEEN'
_order = 'name'
name = fields.Char(string='Nombre', required=True)
categoria = fields.Char(string='Categoría')
descripcion = fields.Text(string='Descripción')
active = fields.Boolean(string='Activo', default=True)
class SkeenProcedimiento(models.Model):
_name = 'skeen.procedimiento'
_description = 'Catálogo de Procedimientos Médicos SKEEN'
_order = 'name'
name = fields.Char(string='Nombre', required=True)
categoria = fields.Char(string='Categoría')
descripcion = fields.Text(string='Descripción')
active = fields.Boolean(string='Activo', default=True)

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenPatientAdjunto(models.Model):
_name = 'skeen.patient.adjunto'
_description = 'Adjunto de Paciente (expediente escaneado o imagen)'
_order = 'create_date desc'
partner_id = fields.Many2one('res.partner', string='Paciente', required=True, ondelete='cascade',
domain=[('is_patient', '=', True)])
kind = fields.Selection([
('expediente', 'Expediente Escaneado'),
('imagen', 'Imagen'),
], string='Tipo', required=True)
name = fields.Char(string='Archivo')
file = fields.Binary(string='Contenido', attachment=True, required=True)
mimetype = fields.Char(string='Tipo MIME')
notes = fields.Char(string='Notas')

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenReceta(models.Model):
_name = 'skeen.receta'
_description = 'Plantilla de Receta SKEEN'
_order = 'name'
name = fields.Char(string='Nombre', required=True)
categoria = fields.Char(string='Categoría')
contenido = fields.Text(string='Contenido', required=True)
active = fields.Boolean(string='Activa', default=True)

View File

@@ -53,6 +53,7 @@ class ResPartner(models.Model):
emergency_phone = fields.Char(string='Teléfono de Emergencia')
home_phone = fields.Char(string='Teléfono Casa')
mobile = fields.Char(string='Celular')
whatsapp = fields.Char(string='WhatsApp')
address_notes = fields.Text(string='Dirección Completa')
referred_by = fields.Char(string='Recomendado Por')
patient_comments = fields.Text(string='Comentarios del Paciente')
@@ -96,6 +97,13 @@ class ResPartner(models.Model):
total_visits = fields.Integer(string='Total Visitas', default=0)
total_spent = fields.Float(string='Total Gastado', default=0.0)
# Adjuntos (expediente escaneado y galería de imágenes)
adjunto_ids = fields.One2many('skeen.patient.adjunto', 'partner_id', string='Documentos')
# Completitud de expediente
expediente_completion = fields.Integer(string='Completitud Expediente', compute='_compute_expediente_completion', store=True)
expediente_missing = fields.Text(string='Datos Faltantes Expediente', compute='_compute_expediente_completion', store=True)
# Fuente de captación
source = fields.Selection([
('whatsapp', 'WhatsApp'),
@@ -118,6 +126,47 @@ class ResPartner(models.Model):
else:
rec.age = 0
CLINICAL_BOOL_FIELDS = [
'is_pregnant', 'is_breastfeeding', 'uses_contraceptives',
'kidney_problems', 'back_pain', 'heart_disease', 'respiratory_problems',
'blood_pressure', 'diabetes', 'thyroid', 'colitis', 'constipation',
'liver_problems', 'surgeries', 'varicose_veins', 'migraine',
'faints_with_needles',
]
@api.depends('email', 'birth_date', 'gender', 'mobile', 'home_phone', 'address_notes',
'emergency_contact', 'emergency_phone', 'occupation', 'marital_status',
'blood_type', 'allergies', 'current_medication', 'medical_history',
'medical_notes', 'is_pregnant', 'is_breastfeeding', 'uses_contraceptives',
'kidney_problems', 'back_pain', 'heart_disease', 'respiratory_problems',
'blood_pressure', 'diabetes', 'thyroid', 'colitis', 'constipation',
'liver_problems', 'surgeries', 'varicose_veins', 'migraine',
'faints_with_needles')
def _compute_expediente_completion(self):
for rec in self:
checks = [
('Email', bool(rec.email)),
('Fecha de nacimiento', bool(rec.birth_date)),
('Género', bool(rec.gender)),
('Teléfono', bool(rec.mobile or rec.home_phone)),
('Dirección', bool(rec.address_notes and rec.address_notes.strip())),
('Contacto de emergencia', bool(
rec.emergency_contact and rec.emergency_contact.strip()
and rec.emergency_phone and rec.emergency_phone.strip())),
('Ocupación', bool(rec.occupation and rec.occupation.strip())),
('Estado civil', bool(rec.marital_status)),
('Tipo de sangre', bool(rec.blood_type)),
('Alergias', bool(rec.allergies and rec.allergies.strip())),
('Medicación actual', bool(rec.current_medication and rec.current_medication.strip())),
('Cuestionario clínico', bool(
any(rec[fname] for fname in self.CLINICAL_BOOL_FIELDS)
or (rec.medical_history and rec.medical_history.strip())
or (rec.medical_notes and rec.medical_notes.strip()))),
]
puntos = sum(1 for _, ok in checks if ok)
rec.expediente_completion = round(puntos * 100 / len(checks))
rec.expediente_missing = '|'.join(label for label, ok in checks if not ok)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:

View File

@@ -1,2 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_patient_tag_user,Acceso a Etiquetas Paciente SKEEN,model_skeen_patient_tag,base.group_user,1,1,1,1
access_skeen_patient_adjunto_user,Acceso a Adjuntos de Paciente SKEEN,model_skeen_patient_adjunto,base.group_user,1,1,1,1
access_skeen_receta_user,Acceso a Recetas SKEEN,model_skeen_receta,base.group_user,1,1,1,1
access_skeen_diagnostico_user,Acceso a Diagnósticos SKEEN,model_skeen_diagnostico,base.group_user,1,1,1,1
access_skeen_procedimiento_user,Acceso a Procedimientos SKEEN,model_skeen_procedimiento,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_patient_tag_user Acceso a Etiquetas Paciente SKEEN model_skeen_patient_tag base.group_user 1 1 1 1
3 access_skeen_patient_adjunto_user Acceso a Adjuntos de Paciente SKEEN model_skeen_patient_adjunto base.group_user 1 1 1 1
4 access_skeen_receta_user Acceso a Recetas SKEEN model_skeen_receta base.group_user 1 1 1 1
5 access_skeen_diagnostico_user Acceso a Diagnósticos SKEEN model_skeen_diagnostico base.group_user 1 1 1 1
6 access_skeen_procedimiento_user Acceso a Procedimientos SKEEN model_skeen_procedimiento base.group_user 1 1 1 1

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Diagnósticos -->
<record id="view_skeen_diagnostico_tree" model="ir.ui.view">
<field name="name">skeen.diagnostico.tree</field>
<field name="model">skeen.diagnostico</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="categoria"/>
<field name="active"/>
</tree>
</field>
</record>
<record id="view_skeen_diagnostico_form" model="ir.ui.view">
<field name="name">skeen.diagnostico.form</field>
<field name="model">skeen.diagnostico</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="categoria"/>
<field name="active"/>
</group>
<field name="descripcion"/>
</sheet>
</form>
</field>
</record>
<record id="action_skeen_diagnostico_tree" model="ir.actions.act_window">
<field name="name">Diagnósticos</field>
<field name="res_model">skeen.diagnostico</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Procedimientos -->
<record id="view_skeen_procedimiento_tree" model="ir.ui.view">
<field name="name">skeen.procedimiento.tree</field>
<field name="model">skeen.procedimiento</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="categoria"/>
<field name="active"/>
</tree>
</field>
</record>
<record id="view_skeen_procedimiento_form" model="ir.ui.view">
<field name="name">skeen.procedimiento.form</field>
<field name="model">skeen.procedimiento</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="categoria"/>
<field name="active"/>
</group>
<field name="descripcion"/>
</sheet>
</form>
</field>
</record>
<record id="action_skeen_procedimiento_tree" model="ir.actions.act_window">
<field name="name">Procedimientos</field>
<field name="res_model">skeen.procedimiento</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menús (bajo el menú raíz SKEEN de skeen_citas) -->
<menuitem id="menu_skeen_diagnosticos" name="Diagnósticos" parent="skeen_citas.menu_skeen_root"
action="action_skeen_diagnostico_tree" sequence="17"/>
<menuitem id="menu_skeen_procedimientos" name="Procedimientos" parent="skeen_citas.menu_skeen_root"
action="action_skeen_procedimiento_tree" sequence="18"/>
</odoo>

View File

@@ -18,6 +18,7 @@
<field name="occupation" invisible="not is_patient" />
<field name="home_phone" invisible="not is_patient" />
<field name="mobile" invisible="not is_patient" />
<field name="whatsapp" invisible="not is_patient" />
<field name="emergency_contact" invisible="not is_patient" />
<field name="emergency_phone" invisible="not is_patient" />
<field name="address_notes" invisible="not is_patient" />
@@ -63,6 +64,17 @@
</group>
</group>
</page>
<page string="Documentos" invisible="not is_patient">
<field name="adjunto_ids">
<tree editable="bottom">
<field name="kind"/>
<field name="name"/>
<field name="file" filename="name"/>
<field name="notes"/>
<field name="create_date"/>
</tree>
</field>
</page>
<page string="Estadísticas SKEEN" invisible="not is_patient">
<group>
<field name="last_visit" />

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Vista de lista de recetas -->
<record id="view_skeen_receta_tree" model="ir.ui.view">
<field name="name">skeen.receta.tree</field>
<field name="model">skeen.receta</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="categoria"/>
<field name="active"/>
</tree>
</field>
</record>
<!-- Vista de formulario de recetas -->
<record id="view_skeen_receta_form" model="ir.ui.view">
<field name="name">skeen.receta.form</field>
<field name="model">skeen.receta</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="categoria"/>
<field name="active"/>
</group>
<field name="contenido"/>
</sheet>
</form>
</field>
</record>
<!-- Acción para recetas -->
<record id="action_skeen_receta_tree" model="ir.actions.act_window">
<field name="name">Recetas</field>
<field name="res_model">skeen.receta</field>
<field name="view_mode">tree,form</field>
</record>
<!-- Menú Recetas (bajo el menú raíz SKEEN de skeen_citas) -->
<menuitem id="menu_skeen_recetas" name="Recetas" parent="skeen_citas.menu_skeen_root"
action="action_skeen_receta_tree" sequence="16"/>
</odoo>

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Visitas',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Registro de visitas clínicas para SKEEN Derma Experts',
'description': """
Registro de visitas clínicas (motivo, diagnóstico, tratamiento).
Se crea una visita automáticamente cuando el paciente llega a su cita.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base', 'skeen_citas', 'skeen_inventario'],
'data': [
'security/ir.model.access.csv',
'data/sequences.xml',
'views/visita_views.xml',
'views/menu_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Secuencia para visitas (noupdate: no reiniciar el contador en upgrades) -->
<data noupdate="1">
<record id="seq_skeen_visita" model="ir.sequence">
<field name="name">Visita SKEEN</field>
<field name="code">skeen.visita</field>
<field name="prefix">VIS-</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from . import visita
from . import insumo
from . import adjunto
from . import cita

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class SkeenVisitaAdjunto(models.Model):
_name = 'skeen.visita.adjunto'
_description = 'Adjunto de Visita (foto o documento)'
_order = 'create_date asc, id asc'
visita_id = fields.Many2one('skeen.visita', string='Visita', required=True, ondelete='cascade')
kind = fields.Selection([
('antes', 'Foto Antes'),
('despues', 'Foto Después'),
('documento', 'Documento'),
], string='Tipo', required=True)
name = fields.Char(string='Archivo')
file = fields.Binary(string='Contenido', attachment=True, required=True)
mimetype = fields.Char(string='Tipo MIME')
notes = fields.Char(string='Notas')

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
from odoo import models
class SkeenCita(models.Model):
_inherit = 'skeen.cita'
def _get_visita_en_curso(self):
self.ensure_one()
return self.env['skeen.visita'].search([
('cita_id', '=', self.id),
('state', '=', 'en_curso'),
], limit=1)
def action_arrive(self):
res = super(SkeenCita, self).action_arrive()
for cita in self:
# Crear la visita clínica al llegar el paciente (una por cita)
if not cita._get_visita_en_curso():
self.env['skeen.visita'].create({
'cita_id': cita.id,
'partner_id': cita.partner_id.id,
'doctor_id': cita.doctor_id.id if cita.doctor_id else False,
'servicio_id': cita.servicio_id.id if cita.servicio_id else False,
'motivo': cita.servicio_id.name if cita.servicio_id else '',
})
return res
def action_done(self):
res = super(SkeenCita, self).action_done()
for cita in self:
visita = cita._get_visita_en_curso()
if visita:
visita.action_complete()
return res
def action_cancel(self):
res = super(SkeenCita, self).action_cancel()
for cita in self:
visita = cita._get_visita_en_curso()
if visita:
visita.action_cancel()
return res
def action_no_show(self):
res = super(SkeenCita, self).action_no_show()
for cita in self:
visita = cita._get_visita_en_curso()
if visita:
visita.action_cancel()
return res

Some files were not shown because too many files have changed in this diff Show More