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:
@@ -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 = () => {
|
||||
<Route path="/monedero" element={<RequireAuth href="/monedero"><Monedero /></RequireAuth>} />
|
||||
<Route path="/pagos" element={<RequireAuth href="/pagos"><Pagos /></RequireAuth>} />
|
||||
<Route path="/ventas" element={<RequireAuth href="/ventas"><Ventas /></RequireAuth>} />
|
||||
<Route path="/tickets" element={<RequireAuth href="/tickets"><Tickets /></RequireAuth>} />
|
||||
<Route path="/cortes" element={<RequireAuth href="/cortes"><Cortes /></RequireAuth>} />
|
||||
<Route path="/reportes" element={<RequireAuth href="/reportes"><Reportes /></RequireAuth>} />
|
||||
<Route path="/configuracion" element={<RequireAuth href="/configuracion"><Configuracion /></RequireAuth>} />
|
||||
|
||||
@@ -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>
|
||||
{!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>
|
||||
{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>
|
||||
|
||||
{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' },
|
||||
|
||||
@@ -35,6 +35,7 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
|
||||
'/ventas': ['admin', 'recepcion', 'gerencia'],
|
||||
'/tickets': ['admin', 'gerencia'],
|
||||
'/pagos': ['admin', 'recepcion', 'gerencia'],
|
||||
'/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall } from 'lucide-react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import AgendaPrint from '../components/AgendaPrint';
|
||||
import RecargarSaldoModal from '../components/RecargarSaldoModal';
|
||||
import ReciboPrint from '../components/ReciboPrint';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
badgeForAppointmentState,
|
||||
} from '../components/ui';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo';
|
||||
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo, type PatientWallet, type ReceiptData } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
const stateOptions = [
|
||||
@@ -790,8 +792,20 @@ const Agenda: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Saldo a favor del paciente de la cita abierta (para verlo al llegar)
|
||||
const [saldoPaciente, setSaldoPaciente] = useState<PatientWallet | null>(null);
|
||||
const [recargaAptOpen, setRecargaAptOpen] = useState(false);
|
||||
const [reciboApt, setReciboApt] = useState<ReceiptData | null>(null);
|
||||
|
||||
const openAptDetail = (apt: Appointment) => {
|
||||
setDetailApt(apt);
|
||||
setSaldoPaciente(null);
|
||||
setRecargaAptOpen(false);
|
||||
if (apt.patient_id) {
|
||||
odooApi.getPatientWallet(apt.patient_id)
|
||||
.then((w) => { if (w.status === 'success') setSaldoPaciente(w); })
|
||||
.catch(() => setSaldoPaciente(null));
|
||||
}
|
||||
setEditMode(false);
|
||||
setEditForm({
|
||||
date: apt.date,
|
||||
@@ -2503,6 +2517,24 @@ const Agenda: FC = () => {
|
||||
<p className="text-xs text-theme-muted mb-1">Precio</p>
|
||||
<p className="text-sm font-medium text-theme-heading">${detailApt.price}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl border border-emerald-200 bg-emerald-50/60 col-span-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1">
|
||||
<Wallet size={11} /> Saldo a favor del paciente
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">
|
||||
${(saldoPaciente?.equivalent_mxn ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
{saldoPaciente?.equivalent_usd != null && (
|
||||
<span className="ml-1.5 text-xs font-normal text-theme-muted">
|
||||
≈ {saldoPaciente.equivalent_usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} USD
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setRecargaAptOpen(true)}>
|
||||
<Wallet size={14} className="mr-1.5" /> Recargar saldo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{detailApt.notes && (
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
@@ -2630,6 +2662,26 @@ const Agenda: FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Recarga de saldo a favor del paciente de la cita: se monta después del
|
||||
modal de detalle para quedar por encima (mismo z-index). */}
|
||||
<RecargarSaldoModal
|
||||
isOpen={recargaAptOpen}
|
||||
onClose={() => setRecargaAptOpen(false)}
|
||||
patientId={detailApt?.patient_id ?? null}
|
||||
patientName={detailApt?.patient}
|
||||
citaId={detailApt?.id ?? null}
|
||||
saldoActual={saldoPaciente?.equivalent_mxn ?? 0}
|
||||
onDone={(receipt) => {
|
||||
setReciboApt(receipt);
|
||||
if (detailApt?.patient_id) {
|
||||
odooApi.getPatientWallet(detailApt.patient_id)
|
||||
.then((w) => { if (w.status === 'success') setSaldoPaciente(w); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReciboPrint receipt={reciboApt} onClose={() => setReciboApt(null)} />
|
||||
|
||||
{/* Modal de conflicto de agenda: mensaje humano + ver la cita que choca */}
|
||||
<Modal
|
||||
isOpen={!!conflict}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
|
||||
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
|
||||
Download, ChevronRight, AlertTriangle, ClipboardList, Camera,
|
||||
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import PatientAdjuntos from '../components/PatientAdjuntos';
|
||||
import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
|
||||
import RecargarSaldoModal from '../components/RecargarSaldoModal';
|
||||
import ReciboPrint from '../components/ReciboPrint';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import {
|
||||
Card,
|
||||
@@ -27,9 +29,12 @@ import {
|
||||
} from '../components/ui';
|
||||
import type { BadgeVariant } from '../components/ui/Badge';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta } from '../services/odoo';
|
||||
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta, type PatientWallet, type ReceiptData } from '../services/odoo';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
|
||||
const fmtMoney = (n: number) =>
|
||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
|
||||
|
||||
const genderOptions = [
|
||||
{ value: '', label: 'No especificado' },
|
||||
{ value: 'male', label: 'Masculino' },
|
||||
@@ -128,6 +133,10 @@ const Pacientes: FC = () => {
|
||||
const [selected, setSelected] = useState<Patient | null>(null);
|
||||
const [estadoCuentaOpen, setEstadoCuentaOpen] = useState(false);
|
||||
const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta?: EstadoCuenta } | null>(null);
|
||||
// Saldo a favor (monedero, 1 pt = $1 MXN) del paciente abierto
|
||||
const [wallet, setWallet] = useState<PatientWallet | null>(null);
|
||||
const [recargaOpen, setRecargaOpen] = useState(false);
|
||||
const [reciboDeposito, setReciboDeposito] = useState<ReceiptData | null>(null);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info');
|
||||
const [entryDetail, setEntryDetail] = useState<
|
||||
@@ -239,9 +248,13 @@ const Pacientes: FC = () => {
|
||||
setSelected(patient);
|
||||
setTab('info');
|
||||
setHistoryLoading(true);
|
||||
setWallet(null);
|
||||
try {
|
||||
const res = await odooApi.getPatientHistory(patient.id);
|
||||
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales, visitas: res.visitas || [], estado_cuenta: res.estado_cuenta });
|
||||
odooApi.getPatientWallet(patient.id)
|
||||
.then((w) => { if (w.status === 'success') setWallet(w); })
|
||||
.catch(() => setWallet(null));
|
||||
} catch (err) {
|
||||
toast.error('Error al cargar historial');
|
||||
console.error(err);
|
||||
@@ -964,6 +977,44 @@ const Pacientes: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Saldo a favor: dinero a cuenta del paciente */}
|
||||
<div className="p-3 rounded-xl border border-emerald-200 bg-emerald-50/60">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-1 flex items-center gap-1">
|
||||
<Wallet size={12} /> Saldo a favor
|
||||
</p>
|
||||
<p className="text-2xl font-heading font-semibold text-theme-heading">
|
||||
{fmtMoney(wallet?.equivalent_mxn ?? 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-52 overflow-y-auto space-y-1.5">
|
||||
{wallet!.movimientos.slice(0, 25).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 className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<InfoRow label="Folio" value={selected?.patient_id} icon={FileText} />
|
||||
<InfoRow label="Teléfono" value={selected?.phone} icon={Phone} />
|
||||
@@ -1263,6 +1314,23 @@ const Pacientes: FC = () => {
|
||||
onClose={() => setEstadoCuentaOpen(false)}
|
||||
paciente={selected}
|
||||
/>
|
||||
|
||||
<RecargarSaldoModal
|
||||
isOpen={recargaOpen}
|
||||
onClose={() => setRecargaOpen(false)}
|
||||
patientId={selected?.id ?? null}
|
||||
patientName={selected?.name}
|
||||
saldoActual={wallet?.equivalent_mxn ?? 0}
|
||||
onDone={(receipt) => {
|
||||
setReciboDeposito(receipt);
|
||||
if (selected?.id) {
|
||||
odooApi.getPatientWallet(selected.id)
|
||||
.then((w) => { if (w.status === 'success') setWallet(w); })
|
||||
.catch(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReciboPrint receipt={reciboDeposito} onClose={() => setReciboDeposito(null)} />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
|
||||
RotateCcw, Keyboard, Lock, TrendingUp,
|
||||
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
@@ -58,6 +58,28 @@ const Pos: FC = () => {
|
||||
const [categoria, setCategoria] = useState('');
|
||||
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
||||
const [ordenServicios, setOrdenServicios] = useState<'manual' | 'best_sellers'>('manual');
|
||||
// Vista del catálogo: cuadrícula (tarjetas) o lista compacta, para catálogos
|
||||
// largos. Se recuerda entre sesiones como el resto de preferencias.
|
||||
const [vista, setVista] = useState<'grid' | 'lista'>(() => {
|
||||
try {
|
||||
return localStorage.getItem('skeen_pos_vista') === 'lista' ? 'lista' : 'grid';
|
||||
} catch {
|
||||
return 'grid';
|
||||
}
|
||||
});
|
||||
const cambiarVista = (v: 'grid' | 'lista') => {
|
||||
setVista(v);
|
||||
try {
|
||||
localStorage.setItem('skeen_pos_vista', v);
|
||||
} catch {
|
||||
// localStorage no disponible: queda solo en memoria
|
||||
}
|
||||
};
|
||||
// Orden de la lista: por nombre o por precio (click en el encabezado)
|
||||
const [ordenLista, setOrdenLista] = useState<{ campo: 'nombre' | 'precio'; dir: 1 | -1 }>({ campo: 'nombre', dir: 1 });
|
||||
const alternarOrdenLista = (campo: 'nombre' | 'precio') =>
|
||||
setOrdenLista((prev) => (prev.campo === campo ? { campo, dir: prev.dir === 1 ? -1 : 1 } : { campo, dir: 1 }));
|
||||
|
||||
const [invItems, setInvItems] = useState<InventoryItem[]>([]);
|
||||
|
||||
// Ticket
|
||||
@@ -247,6 +269,29 @@ const Pos: FC = () => {
|
||||
// Los artículos ya vienen filtrados del server-side (search)
|
||||
const visiblesItems = invItems;
|
||||
|
||||
// Lista ordenable por nombre o precio (click en el encabezado)
|
||||
const serviciosLista = useMemo(() => {
|
||||
const arr = [...visibles];
|
||||
arr.sort((a, b) => {
|
||||
const v = ordenLista.campo === 'nombre'
|
||||
? a.name.localeCompare(b.name, 'es')
|
||||
: (a.price || 0) - (b.price || 0);
|
||||
return v * ordenLista.dir;
|
||||
});
|
||||
return arr;
|
||||
}, [visibles, ordenLista]);
|
||||
|
||||
const itemsLista = useMemo(() => {
|
||||
const arr = [...visiblesItems];
|
||||
arr.sort((a, b) => {
|
||||
const v = ordenLista.campo === 'nombre'
|
||||
? a.name.localeCompare(b.name, 'es')
|
||||
: (a.price || 0) - (b.price || 0);
|
||||
return v * ordenLista.dir;
|
||||
});
|
||||
return arr;
|
||||
}, [visiblesItems, ordenLista]);
|
||||
|
||||
// ---- Ticket ----
|
||||
const addLine = (service: Service) => {
|
||||
setLines((prev) => {
|
||||
@@ -871,8 +916,103 @@ const Pos: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cambiarVista('grid')}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
|
||||
vista === 'grid' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||
}`}
|
||||
title="Vista de cuadrícula"
|
||||
>
|
||||
<LayoutGrid size={12} /> Cuadrícula
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cambiarVista('lista')}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition flex items-center gap-1 ${
|
||||
vista === 'lista' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||
}`}
|
||||
title="Vista de lista (catálogos largos)"
|
||||
>
|
||||
<List size={12} /> Lista
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{catalogLoading ? (
|
||||
<Skeleton count={8} className="h-16 w-full" />
|
||||
) : vista === 'lista' ? (
|
||||
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto border border-theme-border rounded-xl">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg sticky top-0 z-10 text-theme-muted text-[11px] uppercase">
|
||||
<tr>
|
||||
<th
|
||||
className="text-left px-3 py-2 cursor-pointer select-none"
|
||||
onClick={() => alternarOrdenLista('nombre')}
|
||||
title="Ordenar por nombre"
|
||||
>
|
||||
{catMode === 'servicios' ? 'Servicio' : 'Artículo'}
|
||||
{ordenLista.campo === 'nombre' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 hidden sm:table-cell">Categoría</th>
|
||||
{catMode === 'servicios' && <th className="text-left px-3 py-2 hidden md:table-cell">Duración</th>}
|
||||
<th
|
||||
className="text-right px-3 py-2 cursor-pointer select-none"
|
||||
onClick={() => alternarOrdenLista('precio')}
|
||||
title="Ordenar por precio"
|
||||
>
|
||||
Precio
|
||||
{ordenLista.campo === 'precio' && (ordenLista.dir === 1 ? ' ▲' : ' ▼')}
|
||||
</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{catMode === 'servicios' ? (
|
||||
serviciosLista.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-3 py-6 text-center text-theme-muted">Sin servicios con estos filtros.</td></tr>
|
||||
) : serviciosLista.map((sv) => (
|
||||
<tr key={sv.id} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2">
|
||||
<p className="font-medium text-theme-heading">{sv.name}</p>
|
||||
{sv.code && <p className="text-[11px] text-theme-muted">{sv.code}</p>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-theme-muted capitalize hidden sm:table-cell">{sv.category || '—'}</td>
|
||||
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">{sv.duration_min} min</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-theme-heading">{fmtMoney(sv.price)}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button size="sm" onClick={() => addLine(sv)} title="Agregar al ticket">
|
||||
<Plus size={13} className="mr-1" /> Agregar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
itemsLista.length === 0 ? (
|
||||
<tr><td colSpan={4} className="px-3 py-6 text-center text-theme-muted">Sin artículos con esta búsqueda.</td></tr>
|
||||
) : itemsLista.map((it) => (
|
||||
<tr key={it.id} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2">
|
||||
<p className="font-medium text-theme-heading">{it.name}</p>
|
||||
<p className={`text-[11px] ${it.qty <= 0 ? 'text-rose-600' : 'text-theme-muted'}`}>
|
||||
{it.qty} {it.unit || 'pzas'} en existencia
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-theme-muted hidden sm:table-cell">{it.category || '—'}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-theme-heading">
|
||||
{(it.price ?? 0) > 0 ? fmtMoney(it.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button size="sm" onClick={() => addItemLine(it)} title="Agregar al ticket">
|
||||
<Plus size={13} className="mr-1" /> Agregar
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : catMode === 'articulos' ? (
|
||||
visiblesItems.length === 0 ? (
|
||||
<EmptyState title="Sin artículos" subtitle="No hay artículos de inventario con esta búsqueda." icon={<Package size={28} />} />
|
||||
@@ -1115,7 +1255,7 @@ const Pos: FC = () => {
|
||||
/>
|
||||
<span className="text-sm text-theme-heading flex items-center gap-1.5">
|
||||
<Wallet size={15} className="text-theme-muted" />
|
||||
Pagar con puntos ({puntosPaciente} pts disponibles)
|
||||
Pagar con saldo a favor ({puntosPaciente} pts = ${puntosPaciente})
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
467
frontend/src/pages/Tickets.tsx
Normal file
467
frontend/src/pages/Tickets.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, Ticket, XCircle, RefreshCw, AlertTriangle, Printer, Receipt, User } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { Card, Button, Input, Select, Modal, TextArea, Badge, EmptyState, Skeleton, PageHeader, toast } from '../components/ui';
|
||||
import TicketPrint from '../components/TicketPrint';
|
||||
import { odooApi, type Sale } from '../services/odoo';
|
||||
import { readDateFilterPrefs, writeDateFilterPrefs, DATE_PRESETS, rangeForPreset, type DatePreset } from '../lib/dateRange';
|
||||
|
||||
const PREF_KEY = 'skeen_tickets_filtros';
|
||||
|
||||
const METODO_LABELS: Record<string, string> = {
|
||||
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
|
||||
stripe: 'Stripe', mercadopago: 'MercadoPago', points: 'Saldo a favor',
|
||||
mixed: 'Mixto', '': '—',
|
||||
};
|
||||
|
||||
const money = (v: number) =>
|
||||
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const ESTADOS = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'paid', label: 'Pagados' },
|
||||
{ value: 'partial', label: 'Parciales' },
|
||||
{ value: 'confirmed', label: 'Confirmados' },
|
||||
{ value: 'cancelled', label: 'Cancelados' },
|
||||
];
|
||||
|
||||
const METODOS_FILTRO = [
|
||||
{ value: '', label: 'Todos los métodos' },
|
||||
{ value: 'cash', label: 'Efectivo' },
|
||||
{ value: 'card', label: 'Tarjeta' },
|
||||
{ value: 'transfer', label: 'Transferencia' },
|
||||
{ value: 'points', label: 'Saldo a favor' },
|
||||
{ value: 'mixed', label: 'Mixto' },
|
||||
{ value: 'stripe', label: 'Stripe' },
|
||||
{ value: 'mercadopago', label: 'MercadoPago' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Tickets: encontrar un cobro del POS y cancelarlo sin navegar el módulo de
|
||||
* Ventas. Permiso de cancelación: admin y gerencia (el API lo valida).
|
||||
*/
|
||||
const Tickets: FC = () => {
|
||||
const prefs = readDateFilterPrefs(PREF_KEY);
|
||||
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
|
||||
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
|
||||
const [hasta, setHasta] = useState(prefs?.end || rangeForPreset('hoy').end);
|
||||
const [estado, setEstado] = useState('');
|
||||
const [metodo, setMetodo] = useState('');
|
||||
const [usuario, setUsuario] = useState('');
|
||||
const [busqueda, setBusqueda] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [sales, setSales] = useState<Sale[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detalle, setDetalle] = useState<Sale | null>(null);
|
||||
const [cancelando, setCancelando] = useState<Sale | null>(null);
|
||||
const [motivo, setMotivo] = useState('');
|
||||
const [enviando, setEnviando] = useState(false);
|
||||
const [reimprimir, setReimprimir] = useState<Sale | null>(null);
|
||||
|
||||
const aplicarPreset = (p: DatePreset) => {
|
||||
setPreset(p);
|
||||
if (p !== 'custom') {
|
||||
const r = rangeForPreset(p);
|
||||
setDesde(r.start);
|
||||
setHasta(r.end);
|
||||
writeDateFilterPrefs(PREF_KEY, { preset: p, start: r.start, end: r.end });
|
||||
}
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getSales({
|
||||
date_from: desde,
|
||||
date_to: hasta,
|
||||
...(estado ? { state: estado } : {}),
|
||||
...(metodo ? { payment_method: metodo } : {}),
|
||||
...(usuario ? { user: usuario } : {}),
|
||||
...(busqueda ? { search: busqueda } : {}),
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
setSales(res.sales || []);
|
||||
setTotal(res.total ?? 0);
|
||||
setTotalPages(res.total_pages ?? 1);
|
||||
}
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar los tickets');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [desde, hasta, estado, metodo, usuario, busqueda, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(load, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [desde, hasta, estado, metodo, usuario, busqueda]);
|
||||
|
||||
const totalPeriodo = useMemo(
|
||||
() => sales.filter((s) => s.state !== 'cancelled').reduce((a, s) => a + s.total, 0),
|
||||
[sales],
|
||||
);
|
||||
const canceladosPeriodo = useMemo(
|
||||
() => sales.filter((s) => s.state === 'cancelled').length,
|
||||
[sales],
|
||||
);
|
||||
|
||||
const abrirCancelar = (s: Sale) => {
|
||||
setCancelando(s);
|
||||
setMotivo('');
|
||||
};
|
||||
|
||||
const submitCancelar = async () => {
|
||||
if (!cancelando) return;
|
||||
if (!motivo.trim()) {
|
||||
toast.error('El motivo de cancelación es obligatorio');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setEnviando(true);
|
||||
const res = await odooApi.cancelSale(cancelando.id, { reason: motivo.trim() });
|
||||
const r = res.result;
|
||||
const partes = [
|
||||
r?.points_reversed ? `${r.points_reversed} pts revertidos` : '',
|
||||
r?.points_restored ? `${r.points_restored} pts devueltos al saldo` : '',
|
||||
r?.stock_moves ? `${r.stock_moves} artículos devueltos a inventario` : '',
|
||||
].filter(Boolean);
|
||||
toast.success(`Ticket ${cancelando.name} cancelado${partes.length ? ` · ${partes.join(' · ')}` : ''}`);
|
||||
setCancelando(null);
|
||||
setDetalle(res.sale);
|
||||
await load();
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo cancelar el ticket');
|
||||
} finally {
|
||||
setEnviando(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout title="Tickets" subtitle="Cobros del día y cancelaciones">
|
||||
<PageHeader title="Tickets" subtitle="Busca un cobro, revísalo y cancélalo con nota de crédito">
|
||||
<Button variant="outline" onClick={load} loading={loading}>
|
||||
<RefreshCw size={15} className="mr-2" /> Actualizar
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<Card className="mb-4">
|
||||
<Card.Body className="space-y-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{DATE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => aplicarPreset(p.value)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition ${
|
||||
preset === p.value ? 'bg-theme-accent text-theme-inverse border-theme-accent' : 'bg-theme-bg text-theme-muted border-theme-border'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<Input label="Desde" type="date" value={desde} onChange={(e) => { setDesde(e.target.value); setPreset('custom'); }} />
|
||||
<Input label="Hasta" type="date" value={hasta} onChange={(e) => { setHasta(e.target.value); setPreset('custom'); }} />
|
||||
<Select label="Estado" options={ESTADOS} value={estado} onChange={(e) => setEstado(e.target.value)} />
|
||||
<Select label="Método de pago" options={METODOS_FILTRO} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
|
||||
<Input label="Cobró (usuario)" placeholder="ej. recepcion" value={usuario} onChange={(e) => setUsuario(e.target.value)} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Input
|
||||
placeholder="Buscar por folio (VENTA-…) o paciente..."
|
||||
value={busqueda}
|
||||
onChange={(e) => setBusqueda(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Tickets en el filtro</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{total}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Importe (página)</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{money(totalPeriodo)}</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Cancelados (página)</p>
|
||||
<p className={`text-xl font-heading font-semibold ${canceladosPeriodo > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
|
||||
{canceladosPeriodo}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4 bg-theme-bg border-0">
|
||||
<p className="text-xs text-theme-muted">Página</p>
|
||||
<p className="text-xl font-heading font-semibold text-theme-heading">{page} / {totalPages || 1}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card.Body>
|
||||
{loading ? (
|
||||
<Skeleton count={8} className="h-12 w-full" />
|
||||
) : sales.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Sin tickets"
|
||||
subtitle="No hay cobros con estos filtros. Prueba otro rango de fechas."
|
||||
icon={<Ticket size={28} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-3 py-2">Folio</th>
|
||||
<th className="text-left px-3 py-2">Fecha</th>
|
||||
<th className="text-left px-3 py-2">Paciente</th>
|
||||
<th className="text-right px-3 py-2">Total</th>
|
||||
<th className="text-left px-3 py-2 hidden md:table-cell">Método</th>
|
||||
<th className="text-left px-3 py-2 hidden lg:table-cell">Cobró</th>
|
||||
<th className="text-left px-3 py-2">Estado</th>
|
||||
<th className="text-right px-3 py-2">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{sales.map((s) => (
|
||||
<tr key={s.id} className="hover:bg-theme-bg">
|
||||
<td className="px-3 py-2 font-mono text-[12px] text-theme-heading">{s.name}</td>
|
||||
<td className="px-3 py-2 text-theme-muted whitespace-nowrap">{(s.date || '').slice(0, 10)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Link to={`/pacientes?q=${encodeURIComponent(s.patient || '')}`} className="text-theme-heading hover:underline">
|
||||
{s.patient}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-theme-heading">{money(s.total)}</td>
|
||||
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">
|
||||
{METODO_LABELS[s.payment_method || ''] || s.payment_method || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-theme-muted hidden lg:table-cell">{s.user_login || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant={s.state === 'cancelled' ? 'danger' : s.state === 'paid' ? 'success' : 'pending'}>
|
||||
{s.state === 'cancelled' ? 'Cancelado' : s.state === 'paid' ? 'Pagado' : s.state === 'partial' ? 'Parcial' : 'Confirmado'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDetalle(s)} title="Ver detalle del ticket">
|
||||
<Receipt size={14} />
|
||||
</Button>
|
||||
{s.state !== 'cancelled' && (
|
||||
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
|
||||
<XCircle size={14} className="text-rose-600" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 mt-4 pt-3 border-t border-theme-border">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
{/* Detalle del ticket */}
|
||||
<Modal
|
||||
isOpen={!!detalle}
|
||||
onClose={() => setDetalle(null)}
|
||||
title={detalle ? `Ticket ${detalle.name}` : 'Ticket'}
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
{detalle && detalle.state === 'cancelled' && (
|
||||
<Button variant="outline" onClick={() => setReimprimir(detalle)}>
|
||||
<Printer size={14} className="mr-1.5" /> Imprimir
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setDetalle(null)}>Cerrar</Button>
|
||||
{detalle && detalle.state !== 'cancelled' && (
|
||||
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
|
||||
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{detalle && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">Paciente</p>
|
||||
<p className="text-sm font-medium text-theme-heading">{detalle.patient}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">Total</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{money(detalle.total)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">Método</p>
|
||||
<p className="text-sm text-theme-heading">{METODO_LABELS[detalle.payment_method || ''] || '—'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted">Cobró</p>
|
||||
<p className="text-sm text-theme-heading inline-flex items-center gap-1">
|
||||
<User size={12} /> {detalle.user_login || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Conceptos</p>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{detalle.lines.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="py-1.5 text-theme-heading">{l.description || l.service || l.item || '—'}</td>
|
||||
<td className="py-1.5 text-right text-theme-muted">{l.quantity} × {money(l.price_unit)}</td>
|
||||
<td className="py-1.5 text-right font-medium text-theme-heading">{money(l.subtotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Pagos registrados</p>
|
||||
{(detalle.pagos || []).length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">
|
||||
Sin pagos ligados a este ticket (los cobros anteriores al vínculo de pagos no se pueden asociar).
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-theme-border">
|
||||
{(detalle.pagos || []).map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td className="py-1.5 font-mono text-[12px] text-theme-heading">{p.name}</td>
|
||||
<td className="py-1.5 text-theme-muted">{METODO_LABELS[p.payment_method] || p.payment_method}</td>
|
||||
<td className="py-1.5 text-theme-muted">{p.create_date || '—'}</td>
|
||||
<td className="py-1.5 text-right font-medium text-theme-heading">{money(p.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detalle.state === 'cancelled' && (
|
||||
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-xs text-rose-800 space-y-1">
|
||||
<p className="font-medium">Ticket cancelado</p>
|
||||
<p>Motivo: {detalle.cancel_reason || '—'}</p>
|
||||
<p>Canceló: {detalle.cancelled_by || '—'} · {detalle.cancelled_at || '—'}</p>
|
||||
<p>
|
||||
Puntos revertidos: {detalle.cancel_points_reversed || 0} ·
|
||||
Saldo devuelto al paciente: {detalle.cancel_points_restored || 0}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Diálogo de cancelación (nota de crédito) */}
|
||||
<Modal
|
||||
isOpen={!!cancelando}
|
||||
onClose={() => setCancelando(null)}
|
||||
title={`Cancelar ticket ${cancelando?.name || ''}`}
|
||||
maxWidth="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setCancelando(null)}>Volver</Button>
|
||||
<Button variant="danger" onClick={submitCancelar} loading={enviando}>Cancelar ticket</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{cancelando && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle size={16} className="text-amber-600 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-theme-muted">
|
||||
Paciente: <span className="font-medium text-theme-heading">{cancelando.patient}</span>
|
||||
{' · '}Total: <span className="font-medium text-theme-heading">{money(cancelando.total)}</span>
|
||||
{cancelando.user_login ? ` · Cobró: ${cancelando.user_login}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-xs text-rose-800 space-y-1.5">
|
||||
<p className="font-medium">Se emitirá la nota de crédito:</p>
|
||||
<p>La venta queda cancelada y deja de contar en ingresos, comisiones y reportes.</p>
|
||||
<p>Se revierten los puntos otorgados y se devuelve al saldo a favor lo que el paciente canjeó.</p>
|
||||
<p>Los artículos vendidos vuelven al inventario.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-xs text-amber-900 space-y-1.5">
|
||||
<p className="font-medium">Importante: el pago NO se revierte automáticamente</p>
|
||||
<p>
|
||||
El cobro queda registrado en caja y en el historial de pagos del paciente
|
||||
({(cancelando.pagos || []).length > 0
|
||||
? `${(cancelando.pagos || []).length} pago(s) por ${money((cancelando.pagos || []).reduce((a, p) => a + p.amount, 0))}`
|
||||
: 'sin pagos ligados a este ticket'}).
|
||||
</p>
|
||||
<p>Si corresponde devolver el dinero, hay que ajustar la caja por separado.</p>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
label="Motivo de cancelación *"
|
||||
placeholder="Ej. recepción cobró el servicio equivocado, cobro duplicado..."
|
||||
value={motivo}
|
||||
onChange={(e) => setMotivo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<TicketPrint
|
||||
isOpen={!!reimprimir}
|
||||
onClose={() => setReimprimir(null)}
|
||||
ticket={reimprimir && {
|
||||
folio: reimprimir.name,
|
||||
fecha: reimprimir.date,
|
||||
paciente: reimprimir.patient,
|
||||
lines: reimprimir.lines.map((l) => ({
|
||||
name: l.service || l.item || l.description,
|
||||
qty: l.quantity,
|
||||
price: l.price_unit,
|
||||
subtotal: l.subtotal,
|
||||
})),
|
||||
subtotal: reimprimir.subtotal,
|
||||
discount: reimprimir.discount,
|
||||
total: reimprimir.total,
|
||||
amount_paid: reimprimir.amount_paid,
|
||||
amount_due: reimprimir.amount_due,
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tickets;
|
||||
@@ -454,6 +454,11 @@ export interface Sale {
|
||||
cancelled_at?: string | null;
|
||||
cancel_points_reversed?: number;
|
||||
cancel_points_restored?: number;
|
||||
/** Cobro: quién cobró y con qué método (módulo Tickets) */
|
||||
payment_method?: string;
|
||||
user_login?: string;
|
||||
/** Pagos ligados al ticket (los nuevos; los antiguos no tienen vínculo) */
|
||||
pagos?: Payment[];
|
||||
}
|
||||
|
||||
// "Repetir última venta" (POS): línea de la última venta con el precio vigente
|
||||
@@ -823,9 +828,28 @@ export interface ReceiptData {
|
||||
aplicado: ReceiptApplied[];
|
||||
operator: string;
|
||||
wallet_points: number;
|
||||
/** Recibo de recarga de saldo a favor (tipo 'deposito') */
|
||||
tipo?: 'pago' | 'deposito';
|
||||
saldo_favor?: number;
|
||||
nota?: string;
|
||||
}
|
||||
|
||||
export type WalletTxType = 'accrual' | 'redemption' | 'adjustment' | 'expiration';
|
||||
export type WalletTxType = 'deposito' | 'accrual' | 'redemption' | 'adjustment' | 'reversion' | 'expiration';
|
||||
|
||||
/** Saldo a favor del paciente (monedero: 1 pt = $1 MXN) */
|
||||
export interface PatientWallet {
|
||||
status: string;
|
||||
patient_id: number;
|
||||
patient: string;
|
||||
wallet_id: number | null;
|
||||
points: number;
|
||||
equivalent_mxn: number;
|
||||
equivalent_usd: number | null;
|
||||
rate: number | null;
|
||||
depositado: number;
|
||||
movimientos: WalletTransaction[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface WalletTransaction {
|
||||
id: number;
|
||||
@@ -1654,6 +1678,18 @@ export const odooApi = {
|
||||
},
|
||||
|
||||
// Cancelación con nota de crédito (permiso admin/gerencia)
|
||||
/** Saldo a favor del paciente + movimientos narrados. */
|
||||
async getPatientWallet(patientId: number): Promise<PatientWallet> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/wallet`);
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Recarga de saldo a favor (anticipo). Permiso: recepción o más. */
|
||||
async depositPatientWallet(patientId: number, payload: { amount: number; payment_method: string; notes?: string; cita_id?: number }): Promise<{ status: string; movimiento: WalletTransaction; wallet: { id: number; points: number; equivalent_mxn: number; equivalent_usd: number | null }; receipt: ReceiptData; message?: string }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/deposit`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
async cancelSale(id: number, payload: { reason: string; restore_stock?: boolean }): Promise<{ status: string; sale: Sale; result: { points_reversed: number; points_restored: number; stock_moves: number } }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}/cancel`, payload);
|
||||
return data;
|
||||
|
||||
@@ -54,6 +54,28 @@ class SkeenMonedero(models.Model):
|
||||
'history': history,
|
||||
}
|
||||
|
||||
def add_deposit(self, amount, description='', reference='', operator=''):
|
||||
"""Recarga de saldo a favor (dinero a cuenta, p. ej. un anticipo).
|
||||
|
||||
A diferencia de add_points (puntos ganados por compra), el depósito es
|
||||
dinero que el paciente paga por adelantado: suma al mismo saldo y se
|
||||
puede canjear en el POS. El saldo se lleva en pesos enteros (1 pt = $1).
|
||||
"""
|
||||
self.ensure_one()
|
||||
amount = int(round(float(amount or 0)))
|
||||
if amount <= 0:
|
||||
raise ValidationError(_('El monto de la recarga debe ser mayor a 0'))
|
||||
self.write({'points': self.points + amount})
|
||||
tx = self.env['skeen.monedero.transaction'].create({
|
||||
'monedero_id': self.id,
|
||||
'type': 'deposito',
|
||||
'points': amount,
|
||||
'description': description or 'Recarga de saldo a favor',
|
||||
'reference': reference,
|
||||
'operator': operator,
|
||||
})
|
||||
return tx
|
||||
|
||||
def add_points(self, points, description='', reference='', operator=''):
|
||||
"""Agregar puntos al monedero"""
|
||||
self.ensure_one()
|
||||
@@ -120,9 +142,11 @@ class SkeenMonederoTransaction(models.Model):
|
||||
|
||||
# Transacción
|
||||
type = fields.Selection([
|
||||
('deposito', 'Depósito (saldo a favor)'),
|
||||
('accrual', 'Acumulación'),
|
||||
('redemption', 'Redención'),
|
||||
('adjustment', 'Ajuste'),
|
||||
('reversion', 'Reversión'),
|
||||
('expiration', 'Vencimiento'),
|
||||
], string='Tipo', required=True)
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import venta
|
||||
from . import tipo_cambio
|
||||
from . import pago
|
||||
from . import venta
|
||||
|
||||
15
odoo-addons/skeen_ventas/models/pago.py
Normal file
15
odoo-addons/skeen_ventas/models/pago.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class SkeenPago(models.Model):
|
||||
"""Extiende el pago con su venta de origen.
|
||||
|
||||
El campo vive aquí (y no en skeen_pagos) porque skeen_ventas ya depende de
|
||||
skeen_pagos: declararlo al revés crearía un ciclo de dependencias.
|
||||
"""
|
||||
_inherit = 'skeen.pago'
|
||||
|
||||
venta_id = fields.Many2one('skeen.venta', string='Venta', index=True,
|
||||
ondelete='set null')
|
||||
@@ -64,6 +64,25 @@ class SkeenVenta(models.Model):
|
||||
cancel_points_reversed = fields.Integer(string='Puntos Revertidos', default=0, readonly=True, copy=False)
|
||||
cancel_points_restored = fields.Integer(string='Puntos Restaurados', default=0, readonly=True, copy=False)
|
||||
|
||||
# Cobro: quién cobró y con qué método (para el módulo de Tickets).
|
||||
# user_login se llena desde el usuario del frontend (los controladores
|
||||
# auth='none' no dejan uid de Odoo en create_uid).
|
||||
user_login = fields.Char(string='Cobró (usuario)', index=True)
|
||||
payment_method = fields.Selection([
|
||||
('cash', 'Efectivo'),
|
||||
('card', 'Tarjeta'),
|
||||
('transfer', 'Transferencia'),
|
||||
('stripe', 'Stripe'),
|
||||
('mercadopago', 'MercadoPago'),
|
||||
('points', 'Puntos / saldo a favor'),
|
||||
('mixed', 'Mixto'),
|
||||
('none', 'Sin pago'),
|
||||
], string='Método de pago', index=True)
|
||||
|
||||
# Pagos cobrados por esta venta (el campo está en skeen_ventas para no
|
||||
# crear un ciclo de dependencias con skeen_pagos)
|
||||
pago_ids = fields.One2many('skeen.pago', 'venta_id', string='Pagos')
|
||||
|
||||
# Fechas
|
||||
date = fields.Date(string='Fecha', required=True, default=fields.Date.today)
|
||||
create_date = fields.Datetime(string='Fecha Creación', readonly=True, default=fields.Datetime.now)
|
||||
|
||||
@@ -1718,6 +1718,152 @@ class SkeenFrontendController(http.Controller):
|
||||
('amount_due', '>', 0),
|
||||
], order='date asc, id asc')
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/wallet', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def patient_wallet(self, patient_id, **kw):
|
||||
"""Saldo a favor del paciente + sus movimientos narrados.
|
||||
|
||||
El saldo es el del monedero (1 pt = $1 MXN): incluye depósitos
|
||||
(anticipos), puntos ganados por compra, canjes y ajustes.
|
||||
"""
|
||||
try:
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
monedero = self._monedero_de(partner, crear=False)
|
||||
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
|
||||
rate = tc.rate if tc else None
|
||||
saldo = monedero.points if monedero else 0
|
||||
txs = monedero.transaction_ids.sorted('id', reverse=True)[:200] if monedero else []
|
||||
refs = {t.reference for t in txs if t.reference and t.reference.startswith('VENTA-')}
|
||||
totales = {}
|
||||
if refs:
|
||||
for v in request.env['skeen.venta'].sudo().search([('name', 'in', list(refs))]):
|
||||
totales[v.name] = v.total
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'patient_id': partner.id,
|
||||
'patient': partner.name,
|
||||
'wallet_id': monedero.id if monedero else None,
|
||||
'points': saldo,
|
||||
'equivalent_mxn': float(saldo),
|
||||
'equivalent_usd': round(saldo / rate, 2) if rate else None,
|
||||
'rate': rate,
|
||||
'depositado': float(sum(t.points for t in (monedero.transaction_ids if monedero else []) if t.type == 'deposito')),
|
||||
'movimientos': [{
|
||||
'id': t.id,
|
||||
'type': t.type,
|
||||
'type_label': dict(
|
||||
request.env['skeen.monedero.transaction']._fields['type'].selection).get(t.type, t.type),
|
||||
'points': t.points,
|
||||
'narration': self._tx_narration(t, totales),
|
||||
'description': t.description or '',
|
||||
'reference': t.reference or '',
|
||||
'operator': t.operator or '',
|
||||
'date': t.create_date.strftime('%Y-%m-%d %H:%M') if t.create_date else None,
|
||||
} for t in txs],
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def patient_deposit(self, patient_id, **kw):
|
||||
"""Recarga de saldo a favor (anticipo): dinero a cuenta del paciente.
|
||||
|
||||
Registra el cobro real (skeen.pago) y el movimiento `deposito` en el
|
||||
monedero, y devuelve el recibo imprimible. Permiso: recepción o más
|
||||
(recepción cobra anticipos); el ajuste manual sigue siendo admin/gerencia.
|
||||
"""
|
||||
try:
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
try:
|
||||
amount = float(data.get('amount') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Monto inválido'}, 400)
|
||||
if amount <= 0:
|
||||
return json_response({'status': 'error', 'message': 'El monto debe ser mayor a 0'}, 400)
|
||||
method = data.get('payment_method') or ''
|
||||
metodos = ('cash', 'card', 'transfer', 'stripe', 'mercadopago')
|
||||
if method not in metodos:
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': 'Método de pago inválido (efectivo, tarjeta, transferencia, stripe o mercadopago)',
|
||||
}, 400)
|
||||
usuario = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||
nota = (data.get('notes') or '').strip()
|
||||
cita_id = int(data.get('cita_id') or 0) or False
|
||||
|
||||
pago = request.env['skeen.pago'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'cita_id': cita_id,
|
||||
'amount': amount,
|
||||
'payment_method': method,
|
||||
'state': 'completed',
|
||||
'payment_date': fields.Datetime.now(),
|
||||
'provider_reference': nota or '',
|
||||
})
|
||||
monedero = self._monedero_de(partner, crear=True)
|
||||
tx = monedero.add_deposit(
|
||||
amount,
|
||||
description=nota or 'Recarga de saldo a favor',
|
||||
reference=pago.name,
|
||||
operator=usuario,
|
||||
)
|
||||
self._auditar('monedero', f'paciente:{partner.id}', 'deposito', 0, amount,
|
||||
entity_label=partner.name, notes=nota or 'Recarga de saldo a favor')
|
||||
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
|
||||
rate = tc.rate if tc else None
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'movimiento': {
|
||||
'id': tx.id,
|
||||
'type': tx.type,
|
||||
'points': tx.points,
|
||||
'narration': self._tx_narration(tx, {}),
|
||||
'operator': tx.operator or '',
|
||||
},
|
||||
'wallet': {
|
||||
'id': monedero.id,
|
||||
'points': monedero.points,
|
||||
'equivalent_mxn': monedero.equivalent_mxn,
|
||||
'equivalent_usd': round(monedero.points / rate, 2) if rate else None,
|
||||
},
|
||||
'receipt': {
|
||||
'tipo': 'deposito',
|
||||
'folio': pago.name,
|
||||
'date': fields.Datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||
'patient_id': partner.id,
|
||||
'patient': partner.name,
|
||||
'patient_code': partner.patient_id or '',
|
||||
'phone': partner.phone or '',
|
||||
'amount': amount,
|
||||
'payment_method': method,
|
||||
'adeudo_anterior': 0.0,
|
||||
'saldo_restante': 0.0,
|
||||
'saldo_favor': monedero.points,
|
||||
'aplicado': [],
|
||||
'nota': nota,
|
||||
'operator': usuario,
|
||||
'wallet_points': monedero.points,
|
||||
},
|
||||
}, 201)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _monedero_de(self, partner, crear=False):
|
||||
"""Monedero del paciente (opcionalmente lo crea si no existe)."""
|
||||
Monedero = request.env['skeen.monedero'].sudo()
|
||||
monedero = Monedero.search([('partner_id', '=', partner.id)], limit=1)
|
||||
if not monedero and crear:
|
||||
monedero = Monedero.create({'partner_id': partner.id})
|
||||
return monedero
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
@@ -1764,12 +1910,17 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
restante = round(amount, 2)
|
||||
aplicado = []
|
||||
primera_asignacion = True
|
||||
for venta in ventas:
|
||||
if restante <= 0:
|
||||
break
|
||||
por_aplicar = round(min(restante, venta.amount_due), 2)
|
||||
if por_aplicar <= 0:
|
||||
continue
|
||||
if primera_asignacion and por_aplicar == round(amount, 2):
|
||||
# El pago cubre una sola venta: se liga para poder verlo en Tickets
|
||||
payment.write({'venta_id': venta.id})
|
||||
primera_asignacion = False
|
||||
nuevo_pagado = round(venta.amount_paid + por_aplicar, 2)
|
||||
if nuevo_pagado >= venta.total - 0.005:
|
||||
venta.write({'amount_paid': nuevo_pagado})
|
||||
@@ -3159,6 +3310,12 @@ class SkeenFrontendController(http.Controller):
|
||||
if tx.type == 'expiration':
|
||||
return f'Vencieron {abs_pts} pts de la venta {ref}'
|
||||
|
||||
if tx.type == 'deposito':
|
||||
extra = f' · {desc}' if desc and desc_low not in ('recarga de saldo a favor', 'depósito') else ''
|
||||
return f'Recargó ${abs_pts:,.2f} de saldo a favor{extra}{operador}'
|
||||
if tx.type == 'reversion':
|
||||
base = desc or f'Reversión de {abs_pts} pts'
|
||||
return f'{base}{operador}'
|
||||
if tx.type == 'adjustment':
|
||||
if desc_low.startswith('ajuste manual'):
|
||||
return f'{desc}{operador}'
|
||||
@@ -3185,7 +3342,7 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
|
||||
domain = [('monedero_id', '=', monedero.id)]
|
||||
ttype = kw.get('type')
|
||||
if ttype in ('accrual', 'redemption', 'adjustment', 'expiration'):
|
||||
if ttype in ('deposito', 'accrual', 'redemption', 'adjustment', 'reversion', 'expiration'):
|
||||
domain.append(('type', '=', ttype))
|
||||
if kw.get('date_from'):
|
||||
domain.append(('create_date', '>=', f"{kw['date_from']} 00:00:00"))
|
||||
@@ -3362,6 +3519,10 @@ class SkeenFrontendController(http.Controller):
|
||||
domain.append(('date', '<=', kw.get('date_to')))
|
||||
if kw.get('state'):
|
||||
domain.append(('state', '=', kw.get('state')))
|
||||
if kw.get('payment_method'):
|
||||
domain.append(('payment_method', '=', kw['payment_method']))
|
||||
if kw.get('user'):
|
||||
domain.append(('user_login', 'ilike', kw['user']))
|
||||
if kw.get('search'):
|
||||
s = kw.get('search')
|
||||
domain += ['|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s)]
|
||||
@@ -3371,9 +3532,12 @@ class SkeenFrontendController(http.Controller):
|
||||
total = Ventas.search_count(domain)
|
||||
offset = (page - 1) * page_size
|
||||
sales = Ventas.search(domain, order='create_date desc', limit=page_size, offset=offset)
|
||||
pagos = {}
|
||||
for p in request.env['skeen.pago'].sudo().search([('venta_id', 'in', sales.ids)]):
|
||||
pagos.setdefault(p.venta_id.id, []).append(self._payment_to_dict(p))
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'sales': [self._sale_to_dict(v) for v in sales],
|
||||
'sales': [{**self._sale_to_dict(v), 'pagos': pagos.get(v.id, [])} for v in sales],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
@@ -3437,6 +3601,7 @@ class SkeenFrontendController(http.Controller):
|
||||
# Crear pago asociado
|
||||
payment = request.env['skeen.pago'].sudo().create({
|
||||
'partner_id': sale.partner_id.id,
|
||||
'venta_id': sale.id,
|
||||
'cita_id': sale.cita_id.id if sale.cita_id else False,
|
||||
'amount': amount,
|
||||
'payment_method': method,
|
||||
@@ -3445,6 +3610,10 @@ class SkeenFrontendController(http.Controller):
|
||||
})
|
||||
# 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí
|
||||
vals = {'amount_paid': sale.amount_paid + amount}
|
||||
if not sale.payment_method:
|
||||
vals['payment_method'] = method
|
||||
elif sale.payment_method != method:
|
||||
vals['payment_method'] = 'mixed'
|
||||
if method == 'card':
|
||||
vals['card_amount'] = sale.card_amount + amount
|
||||
sale.write(vals)
|
||||
@@ -3600,6 +3769,8 @@ class SkeenFrontendController(http.Controller):
|
||||
'cancelled_at': v.cancelled_at.strftime('%Y-%m-%d %H:%M') if v.cancelled_at else None,
|
||||
'cancel_points_reversed': v.cancel_points_reversed,
|
||||
'cancel_points_restored': v.cancel_points_restored,
|
||||
'payment_method': v.payment_method or '',
|
||||
'user_login': v.user_login or '',
|
||||
'lines': [{
|
||||
'id': line.id,
|
||||
'service_id': line.service_id.id if line.service_id else None,
|
||||
@@ -3707,10 +3878,12 @@ class SkeenFrontendController(http.Controller):
|
||||
if method not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago'):
|
||||
return json_response({'status': 'error', 'message': 'Método de pago inválido'}, 400)
|
||||
|
||||
cobrador = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||
venta = request.env['skeen.venta'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'discount': discount,
|
||||
'line_ids': line_vals,
|
||||
'user_login': cobrador,
|
||||
})
|
||||
venta.action_confirm()
|
||||
|
||||
@@ -3738,6 +3911,14 @@ class SkeenFrontendController(http.Controller):
|
||||
|
||||
# Pago monetario del restante
|
||||
restante = round(venta.amount_due, 2)
|
||||
# Método del ticket: puntos si el saldo cubrió todo, mixto si fue
|
||||
# parte en saldo y parte en dinero
|
||||
if puntos_usados and restante <= 0:
|
||||
venta.write({'payment_method': 'points'})
|
||||
elif puntos_usados and restante > 0:
|
||||
venta.write({'payment_method': 'mixed'})
|
||||
else:
|
||||
venta.write({'payment_method': method})
|
||||
pago = None
|
||||
cambio = 0.0
|
||||
cambio_usd = 0.0
|
||||
@@ -3756,6 +3937,7 @@ class SkeenFrontendController(http.Controller):
|
||||
cambio_usd = round(cambio / rate_used, 2)
|
||||
pago = request.env['skeen.pago'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'venta_id': venta.id,
|
||||
'amount': pay_amount,
|
||||
'payment_method': method,
|
||||
'state': 'completed',
|
||||
@@ -4804,6 +4986,10 @@ class SkeenFrontendController(http.Controller):
|
||||
['service_id'], orderby='subtotal desc', limit=limit,
|
||||
)
|
||||
for g in grouped:
|
||||
# Las ventas de solo artículos no tienen servicio: read_group
|
||||
# agrupa esas líneas bajo la clave False y no son "top servicios".
|
||||
if not g.get('service_id'):
|
||||
continue
|
||||
rows.append({
|
||||
'service_id': g['service_id'][0],
|
||||
'service': g['service_id'][1],
|
||||
|
||||
@@ -60,6 +60,7 @@ DEFAULT_MENU_ROLES = {
|
||||
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/ventas': ['admin', 'recepcion', 'gerencia'],
|
||||
'/tickets': ['admin', 'gerencia'],
|
||||
'/pagos': ['admin', 'recepcion', 'gerencia'],
|
||||
'/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
'/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'],
|
||||
|
||||
Reference in New Issue
Block a user