Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy

- Módulo Visitas completo (auto desde agenda, insumos con descargo de
  inventario, fotos antes/después y documentos, receta imprimible)
- Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con
  puntos monedero, ticket imprimible)
- WACRM: leads automáticos desde WhatsApp, asignación de conversaciones
  y leads a agentes, conversión lead→paciente, ficha del paciente en chat
- Pacientes: completitud de expediente, alertas clínicas, historial
  unificado con detalle, foto, WhatsApp, estado de cuenta, filtros
  rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería)
- Agenda: vistas por médico y por hora, filtros rápidos (libres, primera
  vez, check-in, no-show), modal de acciones, bloqueos por médico,
  drag&drop para mover citas
- Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos,
  comisiones, pagos, devoluciones, top clientes, horas, paquetes,
  vendedores, concentrado, recomendaciones, KPIs) con exportación Excel
- Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo
  treeview, selector de tema; accesos rápidos personalizables con 3
  presentaciones; búsqueda global; notificaciones reales
- Configuración: secciones (clínica, usuarios con permisos por sección,
  recetas, catálogos de diagnósticos y procedimientos)
- Inventario: alertas de caducidad y sugerencia de compra, cron diario
  que descuenta artículos caducados, compras/bajas
- Consultas Médicas, página Expedientes, importadores delta
  (citas/visitas legacy idempotentes), depuración de duplicados
- Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias
  Odoo corregidas (noupdate, company_id), rollback en validaciones
This commit is contained in:
2026-08-13 23:30:08 +00:00
parent a718592291
commit 01f6007e30
109 changed files with 19067 additions and 931 deletions

View File

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