diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79b772b..67400af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import Medicos from './pages/Medicos'; import Monedero from './pages/Monedero'; import Pagos from './pages/Pagos'; import Ventas from './pages/Ventas'; +import Tickets from './pages/Tickets'; import Cortes from './pages/Cortes'; import Reportes from './pages/Reportes'; import Configuracion from './pages/Configuracion'; @@ -102,6 +103,7 @@ const App: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ClasicoLayout.tsx b/frontend/src/components/ClasicoLayout.tsx index cb39aee..a675f78 100644 --- a/frontend/src/components/ClasicoLayout.tsx +++ b/frontend/src/components/ClasicoLayout.tsx @@ -29,6 +29,7 @@ import { ChevronDown, DollarSign, CircleDollarSign, + TicketCheck, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; @@ -132,6 +133,7 @@ const menuTree: MenuEntry[] = [ ], }, { label: 'Ventas del día', icon: ShoppingCart, href: '/ventas' }, + { label: 'Tickets', icon: TicketCheck, href: '/tickets' }, { label: 'Punto de Venta', icon: Store, href: '/pos' }, { label: 'Clientes', icon: Users, href: '/pacientes' }, { label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' }, diff --git a/frontend/src/components/HomeNestLayout.tsx b/frontend/src/components/HomeNestLayout.tsx index be2e865..7b37a20 100644 --- a/frontend/src/components/HomeNestLayout.tsx +++ b/frontend/src/components/HomeNestLayout.tsx @@ -33,6 +33,7 @@ import { ChevronUp, MessageCircle, CircleDollarSign, + TicketCheck, } from 'lucide-react'; import { Button, Modal, Input } from './ui'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; @@ -77,6 +78,7 @@ const menuSections = [ items: [ { label: 'Punto de Venta', icon: Store, href: '/pos' }, { label: 'Ventas', icon: ShoppingCart, href: '/ventas' }, + { label: 'Tickets', icon: TicketCheck, href: '/tickets' }, { label: 'Pagos', icon: CreditCard, href: '/pagos' }, { label: 'Adeudos', icon: Banknote, href: '/reportes?tab=adeudos' }, { label: 'Monedero', icon: Wallet, href: '/monedero' }, diff --git a/frontend/src/components/PatientDrawer.tsx b/frontend/src/components/PatientDrawer.tsx index 95c2e1a..769e884 100644 --- a/frontend/src/components/PatientDrawer.tsx +++ b/frontend/src/components/PatientDrawer.tsx @@ -3,10 +3,12 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } import { useNavigate } from 'react-router-dom'; import { X, Star, AlertTriangle, Phone, MessageCircle, Mail, Wallet, Stethoscope, ArrowRight, - Calendar, ClipboardList, ShoppingBag, CreditCard, History, + Calendar, ClipboardList, ShoppingBag, CreditCard, History, Plus, } from 'lucide-react'; -import { odooApi, type Patient, type Visita, type EstadoCuenta, type Appointment, type Sale, type Payment } from '../services/odoo'; +import { odooApi, type Patient, type Visita, type EstadoCuenta, type PatientWallet, type ReceiptData, type Appointment, type Sale, type Payment } from '../services/odoo'; import { Badge, Button, Skeleton } from './ui'; +import RecargarSaldoModal from './RecargarSaldoModal'; +import ReciboPrint from './ReciboPrint'; import { waLink } from '../lib/whatsapp'; import { badgeForAppointmentState, badgeForSaleState } from './ui'; @@ -67,6 +69,10 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa const [patient, setPatient] = useState(null); const [visitas, setVisitas] = useState([]); const [cuenta, setCuenta] = useState(null); + // Saldo a favor (monedero: 1 pt = $1 MXN) y recarga de anticipos + const [wallet, setWallet] = useState(null); + const [recargaOpen, setRecargaOpen] = useState(false); + const [reciboDeposito, setReciboDeposito] = useState(null); const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[]; @@ -82,6 +88,7 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa setLoading(true); setPatient(null); setCuenta(null); + setWallet(null); setVisitas([]); setHistory(null); setTab('resumen'); @@ -93,6 +100,10 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa if (hRes.status === 'success') { setVisitas((hRes.visitas || []).slice(0, 5)); setCuenta(hRes.estado_cuenta); + odooApi.getPatientWallet(patientId) + .then((w) => { if (w.status === 'success') setWallet(w); }) + .catch(() => {}); + setHistory({ appointments: hRes.appointments || [], sales: hRes.sales || [], @@ -358,6 +369,46 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa + {/* Saldo a favor (monedero) */} +
+

+ Saldo a favor +

+
+
+
+

+ {fmtMoney(wallet?.equivalent_mxn ?? cuenta?.puntos_monedero ?? 0)} +

+

+ {wallet?.equivalent_usd != null + ? `≈ ${wallet.equivalent_usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} USD` + : 'Sin tipo de cambio'} + {(wallet?.depositado ?? 0) > 0 ? ` · recargado ${fmtMoney(wallet?.depositado ?? 0)}` : ''} +

+
+ +
+ {(wallet?.movimientos || []).length > 0 && ( +
+ {wallet!.movimientos.slice(0, 20).map((m) => ( +
+ + {m.narration} + {m.date}{m.operator ? ` · ${m.operator}` : ''} + + 0 ? 'text-emerald-700' : 'text-rose-600'}`}> + {m.points > 0 ? '+' : ''}{m.points} + +
+ ))} +
+ )} +
+
+ {/* Visitas recientes */}

@@ -507,6 +558,21 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa

+ + setRecargaOpen(false)} + patientId={patientId} + patientName={patient?.name} + saldoActual={wallet?.equivalent_mxn ?? 0} + onDone={(receipt) => { + setReciboDeposito(receipt); + odooApi.getPatientWallet(patientId) + .then((w) => { if (w.status === 'success') setWallet(w); }) + .catch(() => {}); + }} + /> + setReciboDeposito(null)} /> ); }; diff --git a/frontend/src/components/RecargarSaldoModal.tsx b/frontend/src/components/RecargarSaldoModal.tsx new file mode 100644 index 0000000..07717af --- /dev/null +++ b/frontend/src/components/RecargarSaldoModal.tsx @@ -0,0 +1,146 @@ +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import { Wallet, Plus } from 'lucide-react'; +import { Button, Input, Modal, Select, TextArea, toast } from './ui'; +import { odooApi, type ReceiptData } from '../services/odoo'; + +const METODOS = [ + { value: 'cash', label: 'Efectivo' }, + { value: 'card', label: 'Tarjeta' }, + { value: 'transfer', label: 'Transferencia' }, + { value: 'stripe', label: 'Stripe' }, + { value: 'mercadopago', label: 'MercadoPago' }, +]; + +const money = (v: number | null | undefined) => + '$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +interface Props { + isOpen: boolean; + onClose: () => void; + patientId: number | null; + patientName?: string; + /** Cita de origen (cuando se recarga desde la agenda) */ + citaId?: number | null; + /** Saldo actual, para mostrar antes/después */ + saldoActual?: number | null; + /** Se avisa cuando la recarga se aplicó, para refrescar contadores */ + onDone?: (receipt: ReceiptData) => void; +} + +/** Recarga de saldo a favor (anticipo). Compartido por expediente y agenda. */ +const RecargarSaldoModal: FC = ({ isOpen, onClose, patientId, patientName, citaId, saldoActual, onDone }) => { + const [monto, setMonto] = useState(''); + const [metodo, setMetodo] = useState('cash'); + const [nota, setNota] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!isOpen) return; + setMonto(''); + setMetodo('cash'); + setNota(citaId ? 'Anticipo de cita' : ''); + }, [isOpen, citaId]); + + const guardar = async () => { + if (!patientId) return; + const value = parseFloat(monto); + if (Number.isNaN(value) || value <= 0) { + toast.error('Ingresa un monto mayor a 0'); + return; + } + if (!metodo) { + toast.error('Selecciona el método de pago'); + return; + } + try { + setSaving(true); + const res = await odooApi.depositPatientWallet(patientId, { + amount: value, + payment_method: metodo, + notes: nota.trim(), + ...(citaId ? { cita_id: citaId } : {}), + }); + if (res.status === 'success') { + toast.success(`Saldo a favor actualizado: ${money(res.wallet.equivalent_mxn)}`); + onDone?.(res.receipt); + onClose(); + } + } catch (err) { + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'No se pudo recargar el saldo'); + } finally { + setSaving(false); + } + }; + + const saldo = saldoActual ?? 0; + const nuevo = saldo + (parseFloat(monto) || 0); + + return ( + + + + + } + > +
+

+ El monto queda a favor del paciente y puede usarse + como forma de pago en el POS. Se registra como depósito con tu usuario y genera recibo imprimible. +

+ +
+
+

Saldo actual

+

{money(saldo)}

+
+
+

Saldo tras la recarga

+

{money(nuevo)}

+
+
+ + setMonto(e.target.value)} + placeholder="500" + /> +
+ {[500, 1000, 1500, 2000].map((v) => ( + + ))} +
+ +