POS: totales y pago en USD con tipo de cambio
- Equivalente USD en totales del ticket (TC configurable) - Selector MXN/USD al cobrar: recibido en dólares convertido a MXN, cambio en ambas monedas, referencia 'USD X @ TC Y' en el pago - Validación de TC antes de crear la venta (sin ventas huérfanas)
This commit is contained in:
@@ -20,6 +20,9 @@ import { odooApi, type Service, type Patient, type InventoryItem, type PosChecko
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
||||
|
||||
const fmtUsd = (n: number) =>
|
||||
`$${(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`;
|
||||
|
||||
const METODO_OPTIONS = [
|
||||
{ value: 'cash', label: 'Efectivo' },
|
||||
{ value: 'card', label: 'Tarjeta' },
|
||||
@@ -62,9 +65,11 @@ const Pos: FC = () => {
|
||||
// Cobro
|
||||
const [cobroOpen, setCobroOpen] = useState(false);
|
||||
const [metodo, setMetodo] = useState('cash');
|
||||
const [moneda, setMoneda] = useState<'MXN' | 'USD'>('MXN');
|
||||
const [recibido, setRecibido] = useState('');
|
||||
const [conPuntos, setConPuntos] = useState(false);
|
||||
const [cobrando, setCobrando] = useState(false);
|
||||
const [tcRate, setTcRate] = useState<number | null>(null);
|
||||
|
||||
// Resultado
|
||||
const [resultado, setResultado] = useState<PosCheckoutResult | null>(null);
|
||||
@@ -118,6 +123,18 @@ const Pos: FC = () => {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Tipo de cambio USD/MXN: al montar y refresco cada 5 min
|
||||
useEffect(() => {
|
||||
const fetchTc = () => {
|
||||
odooApi.getExchangeRate()
|
||||
.then((res) => { if (res.current) setTcRate(res.current.rate); })
|
||||
.catch(() => {});
|
||||
};
|
||||
fetchTc();
|
||||
const interval = setInterval(fetchTc, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Búsqueda server-side de paciente
|
||||
useEffect(() => {
|
||||
if (paciente || altaRapida) return;
|
||||
@@ -183,7 +200,15 @@ const Pos: FC = () => {
|
||||
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;
|
||||
// Equivalentes USD (si hay tipo de cambio configurado)
|
||||
const toUsd = (mxn: number): number | null => (tcRate ? mxn / tcRate : null);
|
||||
const recibidoMxn = recibido
|
||||
? moneda === 'USD' && tcRate
|
||||
? (parseFloat(recibido) || 0) * tcRate
|
||||
: parseFloat(recibido) || 0
|
||||
: 0;
|
||||
const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - total) : 0;
|
||||
const cambioUsd = moneda === 'USD' && tcRate ? cambio / tcRate : 0;
|
||||
|
||||
const crearPacienteRapido = async () => {
|
||||
if (!altaForm.name.trim() || !altaForm.phone.trim()) {
|
||||
@@ -214,6 +239,7 @@ const Pos: FC = () => {
|
||||
return;
|
||||
}
|
||||
setMetodo('cash');
|
||||
setMoneda('MXN');
|
||||
setRecibido('');
|
||||
setConPuntos(false);
|
||||
setCobroOpen(true);
|
||||
@@ -235,6 +261,7 @@ const Pos: FC = () => {
|
||||
payment_method: metodo,
|
||||
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
|
||||
pay_with_points: conPuntos,
|
||||
currency: moneda,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setResultado(res);
|
||||
@@ -372,23 +399,41 @@ const Pos: FC = () => {
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-theme-muted">Subtotal</span>
|
||||
<span className="text-theme-heading">{fmtMoney(subtotal)}</span>
|
||||
<span className="text-theme-heading">
|
||||
{fmtMoney(subtotal)}
|
||||
<span className="text-xs text-theme-muted ml-1.5" title={toUsd(subtotal) === null ? 'Configura el tipo de cambio en Configuración' : undefined}>
|
||||
{toUsd(subtotal) !== null ? `≈ ${fmtUsd(toUsd(subtotal)!)}` : '—'}
|
||||
</span>
|
||||
</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"
|
||||
/>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{discountNum > 0 && toUsd(discountNum) !== null && (
|
||||
<span className="text-xs text-theme-muted">≈ {fmtUsd(toUsd(discountNum)!)}</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"
|
||||
/>
|
||||
</span>
|
||||
</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>
|
||||
<span className="text-right">
|
||||
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
|
||||
<span
|
||||
className="block text-xs text-theme-muted"
|
||||
title={toUsd(total) === null ? 'Configura el tipo de cambio en Configuración' : undefined}
|
||||
>
|
||||
{toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full mt-1 !py-3 text-base"
|
||||
@@ -591,24 +636,60 @@ const Pos: FC = () => {
|
||||
<Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
|
||||
{metodo === 'cash' && !conPuntos && (
|
||||
<>
|
||||
<div>
|
||||
<p className="block text-xs font-medium mb-1.5 text-theme-muted">Moneda</p>
|
||||
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoneda('MXN')}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition ${
|
||||
moneda === 'MXN' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||
}`}
|
||||
>
|
||||
MXN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => tcRate && setMoneda('USD')}
|
||||
disabled={!tcRate}
|
||||
title={!tcRate ? 'Configura el tipo de cambio en Configuración' : undefined}
|
||||
className={`px-4 py-1.5 text-sm font-medium transition ${
|
||||
moneda === 'USD' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||
} ${!tcRate ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
USD
|
||||
</button>
|
||||
</div>
|
||||
{moneda === 'USD' && tcRate && (
|
||||
<p className="text-xs text-theme-muted mt-1.5">Tipo de cambio: {tcRate.toFixed(2)} MXN por 1 USD</p>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
label="Recibido"
|
||||
label={moneda === 'USD' ? 'Recibido (USD)' : 'Recibido'}
|
||||
type="number"
|
||||
min={0}
|
||||
step={10}
|
||||
value={recibido}
|
||||
onChange={(e) => setRecibido(e.target.value)}
|
||||
placeholder={String(total)}
|
||||
placeholder={moneda === 'USD' && tcRate ? (total / tcRate).toFixed(2) : String(total)}
|
||||
/>
|
||||
{moneda === 'USD' && recibido && tcRate && (
|
||||
<p className="text-sm text-theme-muted">≈ {fmtMoney(recibidoMxn)} MXN</p>
|
||||
)}
|
||||
{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>
|
||||
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>
|
||||
{fmtMoney(cambio)}
|
||||
{moneda === 'USD' && cambio > 0 && (
|
||||
<span className="text-theme-muted font-normal"> (≈ {fmtUsd(cambioUsd)})</span>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{puedePuntos && (
|
||||
{puedePuntos && moneda === 'MXN' && (
|
||||
<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"
|
||||
@@ -676,8 +757,19 @@ const Pos: FC = () => {
|
||||
)}
|
||||
<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.recibido_usd && resultado.recibido_usd > 0 && resultado.rate_used && (
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Recibido USD</span><span>{fmtUsd(resultado.recibido_usd)} (TC {resultado.rate_used.toFixed(2)})</span></div>
|
||||
)}
|
||||
{resultado.cambio > 0 && (
|
||||
<div className="flex justify-between"><span className="text-theme-muted">Cambio</span><span>{fmtMoney(resultado.cambio)}</span></div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-theme-muted">Cambio</span>
|
||||
<span>
|
||||
{fmtMoney(resultado.cambio)}
|
||||
{resultado.cambio_usd && resultado.cambio_usd > 0 && (
|
||||
<span className="text-theme-muted"> · {fmtUsd(resultado.cambio_usd)}</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -690,6 +690,7 @@ export interface PosCheckoutPayload {
|
||||
payment_method?: string;
|
||||
amount_received?: number;
|
||||
pay_with_points?: boolean;
|
||||
currency?: 'MXN' | 'USD';
|
||||
}
|
||||
|
||||
export interface PosCheckoutResult {
|
||||
@@ -697,6 +698,9 @@ export interface PosCheckoutResult {
|
||||
sale: Sale;
|
||||
payment: Payment | null;
|
||||
cambio: number;
|
||||
cambio_usd?: number;
|
||||
rate_used?: number | null;
|
||||
recibido_usd?: number;
|
||||
puntos_usados: number;
|
||||
puntos_ganados: number;
|
||||
wallet_points: number;
|
||||
|
||||
Reference in New Issue
Block a user