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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,3 +62,4 @@ datos/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
visitas_legacy_*.json
|
||||
|
||||
@@ -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>
|
||||
|
||||
984
frontend/package-lock.json
generated
984
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
220
frontend/src/components/CatalogoPanel.tsx
Normal file
220
frontend/src/components/CatalogoPanel.tsx
Normal 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;
|
||||
569
frontend/src/components/ClasicoLayout.tsx
Normal file
569
frontend/src/components/ClasicoLayout.tsx
Normal 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;
|
||||
160
frontend/src/components/GlobalSearch.tsx
Normal file
160
frontend/src/components/GlobalSearch.tsx
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
|
||||
122
frontend/src/components/NotificationBell.tsx
Normal file
122
frontend/src/components/NotificationBell.tsx
Normal 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;
|
||||
185
frontend/src/components/PatientAdjuntos.tsx
Normal file
185
frontend/src/components/PatientAdjuntos.tsx
Normal 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;
|
||||
145
frontend/src/components/QuickLinksDock.tsx
Normal file
145
frontend/src/components/QuickLinksDock.tsx
Normal 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;
|
||||
139
frontend/src/components/RecetaPrint.tsx
Normal file
139
frontend/src/components/RecetaPrint.tsx
Normal 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;
|
||||
210
frontend/src/components/RecetasPanel.tsx
Normal file
210
frontend/src/components/RecetasPanel.tsx
Normal 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;
|
||||
@@ -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)}
|
||||
|
||||
71
frontend/src/components/ThemePicker.tsx
Normal file
71
frontend/src/components/ThemePicker.tsx
Normal 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;
|
||||
375
frontend/src/components/UsuariosPanel.tsx
Normal file
375
frontend/src/components/UsuariosPanel.tsx
Normal 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;
|
||||
178
frontend/src/components/inventario/AlertasPanel.tsx
Normal file
178
frontend/src/components/inventario/AlertasPanel.tsx
Normal 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;
|
||||
255
frontend/src/components/inventario/MovimientosPanel.tsx
Normal file
255
frontend/src/components/inventario/MovimientosPanel.tsx
Normal 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;
|
||||
130
frontend/src/components/reportes/AdeudosReport.tsx
Normal file
130
frontend/src/components/reportes/AdeudosReport.tsx
Normal 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;
|
||||
136
frontend/src/components/reportes/ComisionesReport.tsx
Normal file
136
frontend/src/components/reportes/ComisionesReport.tsx
Normal 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;
|
||||
191
frontend/src/components/reportes/ConcentradoReport.tsx
Normal file
191
frontend/src/components/reportes/ConcentradoReport.tsx
Normal 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;
|
||||
154
frontend/src/components/reportes/CortesReport.tsx
Normal file
154
frontend/src/components/reportes/CortesReport.tsx
Normal 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;
|
||||
287
frontend/src/components/reportes/DailyReport.tsx
Normal file
287
frontend/src/components/reportes/DailyReport.tsx
Normal 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;
|
||||
129
frontend/src/components/reportes/DevolucionesReport.tsx
Normal file
129
frontend/src/components/reportes/DevolucionesReport.tsx
Normal 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;
|
||||
149
frontend/src/components/reportes/ExportarReport.tsx
Normal file
149
frontend/src/components/reportes/ExportarReport.tsx
Normal 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;
|
||||
162
frontend/src/components/reportes/HorasAgendaReport.tsx
Normal file
162
frontend/src/components/reportes/HorasAgendaReport.tsx
Normal 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;
|
||||
209
frontend/src/components/reportes/IngresosReport.tsx
Normal file
209
frontend/src/components/reportes/IngresosReport.tsx
Normal 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;
|
||||
187
frontend/src/components/reportes/InventarioReport.tsx
Normal file
187
frontend/src/components/reportes/InventarioReport.tsx
Normal 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;
|
||||
244
frontend/src/components/reportes/KpisReport.tsx
Normal file
244
frontend/src/components/reportes/KpisReport.tsx
Normal 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;
|
||||
118
frontend/src/components/reportes/PagosClientesReport.tsx
Normal file
118
frontend/src/components/reportes/PagosClientesReport.tsx
Normal 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;
|
||||
169
frontend/src/components/reportes/PagosServiciosReport.tsx
Normal file
169
frontend/src/components/reportes/PagosServiciosReport.tsx
Normal 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;
|
||||
106
frontend/src/components/reportes/PaquetesReport.tsx
Normal file
106
frontend/src/components/reportes/PaquetesReport.tsx
Normal 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;
|
||||
104
frontend/src/components/reportes/RecomendacionesReport.tsx
Normal file
104
frontend/src/components/reportes/RecomendacionesReport.tsx
Normal 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;
|
||||
113
frontend/src/components/reportes/TopClientesReport.tsx
Normal file
113
frontend/src/components/reportes/TopClientesReport.tsx
Normal 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;
|
||||
117
frontend/src/components/reportes/VendedoresReport.tsx
Normal file
117
frontend/src/components/reportes/VendedoresReport.tsx
Normal 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;
|
||||
18
frontend/src/components/ui/ExportButton.tsx
Normal file
18
frontend/src/components/ui/ExportButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
148
frontend/src/lib/exporter.ts
Normal file
148
frontend/src/lib/exporter.ts
Normal 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);
|
||||
};
|
||||
29
frontend/src/lib/menu-items.ts
Normal file
29
frontend/src/lib/menu-items.ts
Normal 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' },
|
||||
];
|
||||
143
frontend/src/lib/quicklinks.tsx
Normal file
143
frontend/src/lib/quicklinks.tsx
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
395
frontend/src/pages/Consultas.tsx
Normal file
395
frontend/src/pages/Consultas.tsx
Normal 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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
601
frontend/src/pages/Pos.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
324
frontend/src/pages/UltimasVisitas.tsx
Normal file
324
frontend/src/pages/UltimasVisitas.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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 = [
|
||||
|
||||
614
frontend/src/pages/Visitas.tsx
Normal file
614
frontend/src/pages/Visitas.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -22,6 +22,6 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
})
|
||||
|
||||
357
migracion/delta_import.py
Normal file
357
migracion/delta_import.py
Normal 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()
|
||||
137
migracion/detectar_duplicados.py
Normal file
137
migracion/detectar_duplicados.py
Normal 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']})")
|
||||
2212
migracion/extraccion/delta_citas_20260810_041212.csv
Normal file
2212
migracion/extraccion/delta_citas_20260810_041212.csv
Normal file
File diff suppressed because it is too large
Load Diff
238
migracion/importar_visitas.py
Normal file
238
migracion/importar_visitas.py
Normal 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()
|
||||
171
migracion/inventario_real.csv
Normal file
171
migracion/inventario_real.csv
Normal 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,,
|
||||
|
864
migracion/reporte_duplicados.csv
Normal file
864
migracion/reporte_duplicados.csv
Normal 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
|
||||
|
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import cita
|
||||
from . import bloqueo
|
||||
|
||||
23
odoo-addons/skeen_citas/models/bloqueo.py
Normal file
23
odoo-addons/skeen_citas/models/bloqueo.py
Normal 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'))
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
53
odoo-addons/skeen_citas/views/bloqueo_views.xml
Normal file
53
odoo-addons/skeen_citas/views/bloqueo_views.xml
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
15
odoo-addons/skeen_inventario/data/cron.xml
Normal file
15
odoo-addons/skeen_inventario/data/cron.xml
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
137
odoo-addons/skeen_inventario/views/inventario_views.xml
Normal file
137
odoo-addons/skeen_inventario/views/inventario_views.xml
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
|
||||
from . import res_partner
|
||||
from . import hr_employee
|
||||
from . import patient_adjunto
|
||||
from . import receta
|
||||
from . import catalogos
|
||||
|
||||
25
odoo-addons/skeen_pacientes/models/catalogos.py
Normal file
25
odoo-addons/skeen_pacientes/models/catalogos.py
Normal 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)
|
||||
20
odoo-addons/skeen_pacientes/models/patient_adjunto.py
Normal file
20
odoo-addons/skeen_pacientes/models/patient_adjunto.py
Normal 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')
|
||||
14
odoo-addons/skeen_pacientes/models/receta.py
Normal file
14
odoo-addons/skeen_pacientes/models/receta.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
76
odoo-addons/skeen_pacientes/views/catalogos_views.xml
Normal file
76
odoo-addons/skeen_pacientes/views/catalogos_views.xml
Normal 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>
|
||||
@@ -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" />
|
||||
|
||||
44
odoo-addons/skeen_pacientes/views/receta_views.xml
Normal file
44
odoo-addons/skeen_pacientes/views/receta_views.xml
Normal 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>
|
||||
3
odoo-addons/skeen_visitas/__init__.py
Normal file
3
odoo-addons/skeen_visitas/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
24
odoo-addons/skeen_visitas/__manifest__.py
Normal file
24
odoo-addons/skeen_visitas/__manifest__.py
Normal 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',
|
||||
}
|
||||
14
odoo-addons/skeen_visitas/data/sequences.xml
Normal file
14
odoo-addons/skeen_visitas/data/sequences.xml
Normal 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>
|
||||
6
odoo-addons/skeen_visitas/models/__init__.py
Normal file
6
odoo-addons/skeen_visitas/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import visita
|
||||
from . import insumo
|
||||
from . import adjunto
|
||||
from . import cita
|
||||
20
odoo-addons/skeen_visitas/models/adjunto.py
Normal file
20
odoo-addons/skeen_visitas/models/adjunto.py
Normal 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')
|
||||
52
odoo-addons/skeen_visitas/models/cita.py
Normal file
52
odoo-addons/skeen_visitas/models/cita.py
Normal 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
Reference in New Issue
Block a user