diff --git a/frontend/src/components/EstadoCuentaPrint.tsx b/frontend/src/components/EstadoCuentaPrint.tsx new file mode 100644 index 0000000..843a3f9 --- /dev/null +++ b/frontend/src/components/EstadoCuentaPrint.tsx @@ -0,0 +1,283 @@ +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import { Printer, Wallet, AlertTriangle } from 'lucide-react'; +import { Button, Modal, Skeleton, toast } from './ui'; +import { odooApi, type Patient, type Appointment, type Sale, type Visita, type EstadoCuenta } from '../services/odoo'; + +interface ClinicSettings { + name: string; + phone: string; + address: string; +} + +const clinicSettings = (): ClinicSettings => { + try { + const saved = localStorage.getItem('skeen_clinic_settings'); + if (saved) return JSON.parse(saved); + } catch { + // ignore + } + return { name: 'SKEEN Derma Experts', phone: '', address: '' }; +}; + +const fmtMoney = (n: number) => + (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 }); + +const ventaStateLabels: Record = { + draft: 'Borrador', confirmed: 'Confirmada', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada', +}; +const citaStateLabels: Record = { + pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó', + in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show', +}; +const visitaStateLabels: Record = { + en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada', +}; +const txTypeLabels: Record = { + accrual: 'Carga', redemption: 'Canje', adjustment: 'Ajuste', expiration: 'Vencimiento', +}; + +interface WalletData { + points: number; + equivalent_mxn: number; + history: { date: string; type: string; points: number; description: string }[]; +} + +interface EstadoCuentaPrintProps { + isOpen: boolean; + onClose: () => void; + paciente: Patient | null; +} + +export const EstadoCuentaPrint: FC = ({ isOpen, onClose, paciente }) => { + const [loading, setLoading] = useState(true); + const [citas, setCitas] = useState([]); + const [ventas, setVentas] = useState([]); + const [visitas, setVisitas] = useState([]); + const [cuenta, setCuenta] = useState(null); + const [wallet, setWallet] = useState(null); + + useEffect(() => { + if (!isOpen || !paciente) return; + let alive = true; + setLoading(true); + const load = async () => { + try { + const [histRes, walletRes] = await Promise.all([ + odooApi.getPatientHistory(paciente.id), + odooApi.getWalletByPhone(paciente.phone).catch(() => null), + ]); + if (!alive) return; + if (histRes.status === 'success') { + setCitas(histRes.appointments); + setVentas(histRes.sales); + setVisitas(histRes.visitas); + setCuenta(histRes.estado_cuenta); + } + if (walletRes && walletRes.wallet && (walletRes.wallet.points > 0 || walletRes.wallet.history.length > 0)) { + setWallet(walletRes.wallet as WalletData); + } else { + setWallet(null); + } + } catch (err) { + toast.error('Error al cargar el estado de cuenta'); + console.error(err); + } finally { + if (alive) setLoading(false); + } + }; + load(); + return () => { alive = false; }; + }, [isOpen, paciente]); + + if (!paciente) return null; + + const clinic = clinicSettings(); + const fechaEmision = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' }); + const totalVentas = ventas.reduce((a, v) => a + v.total, 0); + const totalPagado = ventas.reduce((a, v) => a + v.amount_paid, 0); + const totalPorCobrar = ventas.reduce((a, v) => a + v.amount_due, 0); + + // Cronología unificada de citas y visitas + const cronologia = [ + ...citas.map((c) => ({ fecha: c.date || '', texto: c.service, estado: citaStateLabels[c.state] || c.state })), + ...visitas.map((v) => ({ + fecha: (v.date_start || '').slice(0, 10), + texto: `Visita — ${v.motivo || v.servicio || 'Consulta'}`, + estado: visitaStateLabels[v.state] || v.state, + })), + ].sort((a, b) => b.fecha.localeCompare(a.fecha)); + + return ( + + + + + } + > + {loading ? ( + + ) : ( +
+ {/* Encabezado */} +
+
+

{clinic.name}

+ {clinic.address &&

{clinic.address}

} + {clinic.phone &&

Tel: {clinic.phone}

} +
+
+

Estado de Cuenta

+

Emitido: {fechaEmision}

+
+
+ + {/* Paciente */} +
+

Paciente: {paciente.name}

+

Folio: {paciente.patient_id}

+

Teléfono: {paciente.phone}

+ {paciente.primary_doctor && ( +

Médico asignado: {paciente.primary_doctor}

+ )} +
+ + {/* Resumen destacado */} +
+
+

Total gastado

+

{fmtMoney(cuenta?.total_gastado ?? totalVentas)}

+
+
0 ? 'bg-[#fdecea] border border-[#e74c3c]' : 'bg-[#f5f5f5]'}`}> +

Saldo pendiente

+

0 ? 'text-[#e74c3c]' : ''}`}>{fmtMoney(totalPorCobrar)}

+ {totalPorCobrar > 0 && ( +

+ SALDO PENDIENTE +

+ )} +
+
0 ? 'bg-[#e8f7ef] border border-[#2ecc71]' : 'bg-[#f5f5f5]'}`}> +

Monedero

+

{wallet ? `${wallet.points} pts` : '0 pts'}

+ {wallet && wallet.points > 0 && ( +

+ PUNTOS DISPONIBLES ({fmtMoney(wallet.equivalent_mxn)}) +

+ )} +
+
+ + {/* Detalle de ventas */} +

Detalle de ventas

+ {ventas.length === 0 ? ( +

Sin ventas registradas.

+ ) : ( + + + + + + + + + + + + + + {ventas.map((v) => ( + 0 ? 'bg-[#fdecea]' : ''} ${v.refunded ? 'opacity-60' : ''}`}> + + + + + + + + + ))} + +
FolioFechaConceptosTotalPagadoPor cobrarEstado
{v.name}{v.date} + {v.lines.map((l) => `${l.quantity}× ${l.service || l.item || l.description}`).join(', ') || '-'} + {fmtMoney(v.total)}{fmtMoney(v.amount_paid)} 0 ? 'text-[#e74c3c]' : ''}`}> + {v.amount_due > 0 ? `${fmtMoney(v.amount_due)} · pendiente` : fmtMoney(0)} + + {ventaStateLabels[v.state] || v.state} + {v.refunded && reembolsada} +
+ )} + + {/* Monedero */} + {wallet && ( + <> +

Movimientos de monedero

+ {wallet.history.length === 0 ? ( +

Sin movimientos.

+ ) : ( + + + {wallet.history.map((tx, i) => ( + + + + + + + ))} + +
{tx.date}{txTypeLabels[tx.type] || tx.type} + {tx.points > 0 ? '+' : ''}{tx.points} pts + {tx.description || '-'}
+ )} + + )} + + {/* Historial de citas y visitas */} +

Historial de citas y visitas

+ {cronologia.length === 0 ? ( +

Sin historial.

+ ) : ( + + + {cronologia.slice(0, 20).map((c, i) => ( + + + + + + ))} + +
{c.fecha}{c.texto}{c.estado}
+ )} + {cronologia.length > 20 && ( +

Mostrando los 20 registros más recientes de {cronologia.length}.

+ )} + + {/* Pie */} +
+
+ {ventas.length} ventas · Total {fmtMoney(totalVentas)} · Pagado {fmtMoney(totalPagado)} + 0 ? 'text-[#e74c3c]' : ''}>Por cobrar: {fmtMoney(totalPorCobrar)} +
+ {wallet && ( +

Monedero: {wallet.points} puntos disponibles ({fmtMoney(wallet.equivalent_mxn)}).

+ )} +

Documento informativo generado por {clinic.name}.

+
+
+ )} +
+ ); +}; + +export default EstadoCuentaPrint; diff --git a/frontend/src/pages/Pacientes.tsx b/frontend/src/pages/Pacientes.tsx index 44e5d37..255cbd2 100644 --- a/frontend/src/pages/Pacientes.tsx +++ b/frontend/src/pages/Pacientes.tsx @@ -7,6 +7,7 @@ import { } from 'lucide-react'; import Layout from '../components/Layout'; import PatientAdjuntos from '../components/PatientAdjuntos'; +import EstadoCuentaPrint from '../components/EstadoCuentaPrint'; import { Card, Button, @@ -124,6 +125,7 @@ const Pacientes: FC = () => { const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(0); const [selected, setSelected] = useState(null); + const [estadoCuentaOpen, setEstadoCuentaOpen] = useState(false); const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta?: EstadoCuenta } | null>(null); const [historyLoading, setHistoryLoading] = useState(false); const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info'); @@ -764,10 +766,16 @@ const Pacientes: FC = () => { title={selected?.name || 'Detalle del paciente'} maxWidth="2xl" footer={ - +
+ + +
} >
@@ -1177,6 +1185,13 @@ const Pacientes: FC = () => {
)} + + {/* Vista de impresión del estado de cuenta */} + setEstadoCuentaOpen(false)} + paciente={selected} + /> ); };