POS vista lista, modulo Tickets para gerencia con cancelacion auditada, y saldo a favor (recargas de anticipo desde expediente y agenda, canje en POS)
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<Patient | null>(null);
|
||||
const [visitas, setVisitas] = useState<Visita[]>([]);
|
||||
const [cuenta, setCuenta] = useState<EstadoCuenta | null>(null);
|
||||
// Saldo a favor (monedero: 1 pt = $1 MXN) y recarga de anticipos
|
||||
const [wallet, setWallet] = useState<PatientWallet | null>(null);
|
||||
const [recargaOpen, setRecargaOpen] = useState(false);
|
||||
const [reciboDeposito, setReciboDeposito] = useState<ReceiptData | null>(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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saldo a favor (monedero) */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2 flex items-center gap-1">
|
||||
<Wallet size={12} /> Saldo a favor
|
||||
</p>
|
||||
<div className="p-3 rounded-xl border border-emerald-200 bg-emerald-50/60">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="text-2xl font-heading font-semibold text-theme-heading">
|
||||
{fmtMoney(wallet?.equivalent_mxn ?? cuenta?.puntos_monedero ?? 0)}
|
||||
</p>
|
||||
<p className="text-xs text-theme-muted">
|
||||
{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)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setRecargaOpen(true)}>
|
||||
<Plus size={14} className="mr-1.5" /> Recargar saldo
|
||||
</Button>
|
||||
</div>
|
||||
{(wallet?.movimientos || []).length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-emerald-200 max-h-48 overflow-y-auto space-y-1.5">
|
||||
{wallet!.movimientos.slice(0, 20).map((m) => (
|
||||
<div key={m.id} className="flex items-start justify-between gap-2 text-[11px]">
|
||||
<span className="text-theme-muted min-w-0">
|
||||
<span className="block text-theme-heading">{m.narration}</span>
|
||||
<span className="block">{m.date}{m.operator ? ` · ${m.operator}` : ''}</span>
|
||||
</span>
|
||||
<span className={`shrink-0 font-semibold ${m.points > 0 ? 'text-emerald-700' : 'text-rose-600'}`}>
|
||||
{m.points > 0 ? '+' : ''}{m.points}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visitas recientes */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2 flex items-center gap-1">
|
||||
@@ -507,6 +558,21 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<RecargarSaldoModal
|
||||
isOpen={recargaOpen}
|
||||
onClose={() => 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(() => {});
|
||||
}}
|
||||
/>
|
||||
<ReciboPrint receipt={reciboDeposito} onClose={() => setReciboDeposito(null)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
146
frontend/src/components/RecargarSaldoModal.tsx
Normal file
146
frontend/src/components/RecargarSaldoModal.tsx
Normal file
@@ -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<Props> = ({ 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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={patientName ? `Recargar saldo a favor · ${patientName}` : 'Recargar saldo a favor'}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Cancelar</Button>
|
||||
<Button onClick={guardar} loading={saving}>
|
||||
<Wallet size={14} className="mr-1.5" />
|
||||
Recargar
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-theme-muted">
|
||||
El monto queda <span className="font-medium text-theme-heading">a favor del paciente</span> y puede usarse
|
||||
como forma de pago en el POS. Se registra como depósito con tu usuario y genera recibo imprimible.
|
||||
</p>
|
||||
|
||||
<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">Saldo actual</p>
|
||||
<p className="text-lg font-semibold text-theme-heading">{money(saldo)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Saldo tras la recarga</p>
|
||||
<p className="text-lg font-semibold text-emerald-700">{money(nuevo)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Monto (MXN) *"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={monto}
|
||||
onChange={(e) => setMonto(e.target.value)}
|
||||
placeholder="500"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[500, 1000, 1500, 2000].map((v) => (
|
||||
<Button key={v} size="sm" variant="outline" onClick={() => setMonto(String(v))}>
|
||||
<Plus size={12} className="mr-1" /> {money(v)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Select label="Método de pago *" options={METODOS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
|
||||
<TextArea
|
||||
label="Nota (opcional)"
|
||||
rows={2}
|
||||
value={nota}
|
||||
onChange={(e) => setNota(e.target.value)}
|
||||
placeholder="Ej. anticipo de consulta, pago a cuenta..."
|
||||
/>
|
||||
{citaId ? (
|
||||
<p className="text-[11px] text-theme-muted">Se registrará como anticipo de la cita seleccionada.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecargarSaldoModal;
|
||||
@@ -35,6 +35,9 @@ const clinicSettings = (): ClinicSettings => {
|
||||
export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void }> = ({ receipt, onClose }) => {
|
||||
if (!receipt) return null;
|
||||
const clinic = clinicSettings();
|
||||
// Recibo de recarga de saldo a favor (anticipo): mismo formato, sin el
|
||||
// desglose de adeudo y mostrando el saldo a favor resultante.
|
||||
const esDeposito = receipt.tipo === 'deposito';
|
||||
|
||||
return (
|
||||
<Modal isOpen={!!receipt} onClose={onClose} title={`Recibo de pago ${receipt.folio}`} maxWidth="md">
|
||||
@@ -43,7 +46,7 @@ export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void
|
||||
<p className="font-bold text-[15px]">{clinic.name}</p>
|
||||
{clinic.address && <p className="text-[11px] text-[#555]">{clinic.address}</p>}
|
||||
{clinic.phone && <p className="text-[11px] text-[#555]">{clinic.phone}</p>}
|
||||
<p className="font-semibold mt-2 tracking-wide">RECIBO DE PAGO</p>
|
||||
<p className="font-semibold mt-2 tracking-wide">{esDeposito ? 'RECIBO DE SALDO A FAVOR' : 'RECIBO DE PAGO'}</p>
|
||||
<p className="text-[11px] text-[#555]">Folio {receipt.folio} · {receipt.date}</p>
|
||||
</div>
|
||||
|
||||
@@ -101,20 +104,29 @@ export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void
|
||||
|
||||
<table className="w-full text-[13px]">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-1 pr-2">Adeudo anterior</td>
|
||||
<td className="py-1 text-right">{money(receipt.adeudo_anterior)}</td>
|
||||
</tr>
|
||||
{!esDeposito && (
|
||||
<tr>
|
||||
<td className="py-1 pr-2">Adeudo anterior</td>
|
||||
<td className="py-1 text-right">{money(receipt.adeudo_anterior)}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="font-bold border-y border-[#1A1A1A]">
|
||||
<td className="py-1.5 pr-2">Total recibido</td>
|
||||
<td className="py-1.5 pr-2">{esDeposito ? 'Recarga recibida' : 'Total recibido'}</td>
|
||||
<td className="py-1.5 text-right">{money(receipt.amount)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1 pr-2">Saldo restante</td>
|
||||
<td className={`py-1 text-right font-semibold ${receipt.saldo_restante > 0 ? 'text-rose-700' : 'text-emerald-700'}`}>
|
||||
{money(receipt.saldo_restante)}
|
||||
</td>
|
||||
</tr>
|
||||
{esDeposito ? (
|
||||
<tr>
|
||||
<td className="py-1 pr-2">Saldo a favor</td>
|
||||
<td className="py-1 text-right font-semibold text-emerald-700">{money(receipt.saldo_favor ?? 0)}</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-1 pr-2">Saldo restante</td>
|
||||
<td className={`py-1 text-right font-semibold ${receipt.saldo_restante > 0 ? 'text-rose-700' : 'text-emerald-700'}`}>
|
||||
{money(receipt.saldo_restante)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{receipt.wallet_points > 0 && (
|
||||
<tr>
|
||||
<td className="py-1 pr-2 text-[#555]">Puntos monedero</td>
|
||||
@@ -124,11 +136,18 @@ export const ReciboPrint: FC<{ receipt: ReceiptData | null; onClose: () => void
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="mt-4 text-[11px] text-[#555]">
|
||||
{receipt.saldo_restante > 0
|
||||
? 'El paciente mantiene saldo pendiente. Este recibo ampara el pago parcial recibido.'
|
||||
: 'El adeudo queda liquidado. Gracias por su pago.'}
|
||||
</p>
|
||||
{esDeposito ? (
|
||||
<p className="mt-4 text-[11px] text-[#555]">
|
||||
El monto queda a favor del paciente y puede usarse como forma de pago en consultas o productos.
|
||||
{receipt.nota ? ` Nota: ${receipt.nota}.` : ''}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-4 text-[11px] text-[#555]">
|
||||
{receipt.saldo_restante > 0
|
||||
? 'El paciente mantiene saldo pendiente. Este recibo ampara el pago parcial recibido.'
|
||||
: 'El adeudo queda liquidado. Gracias por su pago.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-between text-[11px] text-[#555]">
|
||||
<span>_____________________<br />Recibió (caja)</span>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
MessageCircle,
|
||||
CircleDollarSign,
|
||||
PanelsTopLeft,
|
||||
Ticket,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||
@@ -76,6 +77,7 @@ const menuGroups = [
|
||||
items: [
|
||||
{ label: 'Punto de Venta', icon: Store, href: '/pos' },
|
||||
{ label: 'Ventas', icon: ShoppingCart, href: '/ventas' },
|
||||
{ label: 'Tickets', icon: Ticket, href: '/tickets' },
|
||||
{ label: 'Pagos', icon: CreditCard, href: '/pagos' },
|
||||
{ label: 'Adeudos', icon: Banknote, href: '/reportes?tab=adeudos' },
|
||||
{ label: 'Monedero', icon: Wallet, href: '/monedero' },
|
||||
|
||||
Reference in New Issue
Block a user