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