Pacientes: estado de cuenta imprimible

- Botón en la ficha del paciente con vista de impresión: resumen
  (gastado, saldo pendiente marcado, monedero), detalle de ventas
  con pendientes resaltados, movimientos de monedero y cronología
This commit is contained in:
2026-08-14 06:53:12 +00:00
parent 953cdf1108
commit c16017e3cf
2 changed files with 302 additions and 4 deletions

View File

@@ -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<string, string> = {
draft: 'Borrador', confirmed: 'Confirmada', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada',
};
const citaStateLabels: Record<string, string> = {
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
};
const visitaStateLabels: Record<string, string> = {
en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada',
};
const txTypeLabels: Record<string, string> = {
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<EstadoCuentaPrintProps> = ({ isOpen, onClose, paciente }) => {
const [loading, setLoading] = useState(true);
const [citas, setCitas] = useState<Appointment[]>([]);
const [ventas, setVentas] = useState<Sale[]>([]);
const [visitas, setVisitas] = useState<Visita[]>([]);
const [cuenta, setCuenta] = useState<EstadoCuenta | null>(null);
const [wallet, setWallet] = useState<WalletData | null>(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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Estado de cuenta — ${paciente.name}`}
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={onClose}>Cerrar</Button>
<Button onClick={() => window.print()} disabled={loading}>
<Printer size={16} className="mr-2" />
Imprimir
</Button>
</>
}
>
{loading ? (
<Skeleton count={6} className="h-16 w-full" />
) : (
<div className="print-receta bg-white text-[#222] p-6 rounded-xl border border-theme-border max-h-[75vh] overflow-y-auto">
{/* Encabezado */}
<div className="flex items-start justify-between border-b-2 border-[#1abc9c] pb-3 mb-4">
<div>
<p className="text-xl font-bold">{clinic.name}</p>
{clinic.address && <p className="text-xs text-[#555]">{clinic.address}</p>}
{clinic.phone && <p className="text-xs text-[#555]">Tel: {clinic.phone}</p>}
</div>
<div className="text-right">
<p className="text-lg font-bold">Estado de Cuenta</p>
<p className="text-xs text-[#555]">Emitido: {fechaEmision}</p>
</div>
</div>
{/* Paciente */}
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm mb-4">
<p><span className="text-[#555]">Paciente: </span><span className="font-semibold">{paciente.name}</span></p>
<p><span className="text-[#555]">Folio: </span>{paciente.patient_id}</p>
<p><span className="text-[#555]">Teléfono: </span>{paciente.phone}</p>
{paciente.primary_doctor && (
<p><span className="text-[#555]">Médico asignado: </span>{paciente.primary_doctor}</p>
)}
</div>
{/* Resumen destacado */}
<div className="grid grid-cols-3 gap-3 mb-5">
<div className="p-3 bg-[#f5f5f5] rounded-lg text-center">
<p className="text-xs text-[#555]">Total gastado</p>
<p className="text-lg font-bold">{fmtMoney(cuenta?.total_gastado ?? totalVentas)}</p>
</div>
<div className={`p-3 rounded-lg text-center ${totalPorCobrar > 0 ? 'bg-[#fdecea] border border-[#e74c3c]' : 'bg-[#f5f5f5]'}`}>
<p className="text-xs text-[#555]">Saldo pendiente</p>
<p className={`text-lg font-bold ${totalPorCobrar > 0 ? 'text-[#e74c3c]' : ''}`}>{fmtMoney(totalPorCobrar)}</p>
{totalPorCobrar > 0 && (
<p className="text-[10px] font-bold text-[#e74c3c] mt-0.5 flex items-center justify-center gap-1">
<AlertTriangle size={10} /> SALDO PENDIENTE
</p>
)}
</div>
<div className={`p-3 rounded-lg text-center ${wallet && wallet.points > 0 ? 'bg-[#e8f7ef] border border-[#2ecc71]' : 'bg-[#f5f5f5]'}`}>
<p className="text-xs text-[#555]">Monedero</p>
<p className="text-lg font-bold">{wallet ? `${wallet.points} pts` : '0 pts'}</p>
{wallet && wallet.points > 0 && (
<p className="text-[10px] font-bold text-[#1e8449] mt-0.5 flex items-center justify-center gap-1">
<Wallet size={10} /> PUNTOS DISPONIBLES ({fmtMoney(wallet.equivalent_mxn)})
</p>
)}
</div>
</div>
{/* Detalle de ventas */}
<h3 className="text-sm font-bold border-b border-[#ddd] pb-1 mb-2">Detalle de ventas</h3>
{ventas.length === 0 ? (
<p className="text-xs text-[#777] mb-4">Sin ventas registradas.</p>
) : (
<table className="w-full text-xs mb-4">
<thead>
<tr className="text-left text-[#555] border-b border-[#ddd]">
<th className="py-1.5 pr-2">Folio</th>
<th className="py-1.5 pr-2">Fecha</th>
<th className="py-1.5 pr-2">Conceptos</th>
<th className="py-1.5 text-right">Total</th>
<th className="py-1.5 text-right">Pagado</th>
<th className="py-1.5 text-right">Por cobrar</th>
<th className="py-1.5 text-right">Estado</th>
</tr>
</thead>
<tbody>
{ventas.map((v) => (
<tr key={v.id} className={`border-b border-[#eee] ${v.amount_due > 0 ? 'bg-[#fdecea]' : ''} ${v.refunded ? 'opacity-60' : ''}`}>
<td className="py-1.5 pr-2 font-medium">{v.name}</td>
<td className="py-1.5 pr-2">{v.date}</td>
<td className="py-1.5 pr-2 max-w-[220px] truncate">
{v.lines.map((l) => `${l.quantity}× ${l.service || l.item || l.description}`).join(', ') || '-'}
</td>
<td className="py-1.5 text-right">{fmtMoney(v.total)}</td>
<td className="py-1.5 text-right">{fmtMoney(v.amount_paid)}</td>
<td className={`py-1.5 text-right font-medium ${v.amount_due > 0 ? 'text-[#e74c3c]' : ''}`}>
{v.amount_due > 0 ? `${fmtMoney(v.amount_due)} · pendiente` : fmtMoney(0)}
</td>
<td className="py-1.5 text-right">
{ventaStateLabels[v.state] || v.state}
{v.refunded && <span className="block text-[#e74c3c]">reembolsada</span>}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Monedero */}
{wallet && (
<>
<h3 className="text-sm font-bold border-b border-[#ddd] pb-1 mb-2">Movimientos de monedero</h3>
{wallet.history.length === 0 ? (
<p className="text-xs text-[#777] mb-4">Sin movimientos.</p>
) : (
<table className="w-full text-xs mb-4">
<tbody>
{wallet.history.map((tx, i) => (
<tr key={i} className="border-b border-[#eee]">
<td className="py-1.5 pr-2 w-24">{tx.date}</td>
<td className="py-1.5 pr-2 w-20">{txTypeLabels[tx.type] || tx.type}</td>
<td className={`py-1.5 pr-2 w-20 text-right font-medium ${tx.points < 0 ? 'text-[#e74c3c]' : 'text-[#1e8449]'}`}>
{tx.points > 0 ? '+' : ''}{tx.points} pts
</td>
<td className="py-1.5">{tx.description || '-'}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
{/* Historial de citas y visitas */}
<h3 className="text-sm font-bold border-b border-[#ddd] pb-1 mb-2">Historial de citas y visitas</h3>
{cronologia.length === 0 ? (
<p className="text-xs text-[#777] mb-4">Sin historial.</p>
) : (
<table className="w-full text-xs mb-4">
<tbody>
{cronologia.slice(0, 20).map((c, i) => (
<tr key={i} className="border-b border-[#eee]">
<td className="py-1.5 pr-2 w-24">{c.fecha}</td>
<td className="py-1.5 pr-2">{c.texto}</td>
<td className="py-1.5 text-right w-28">{c.estado}</td>
</tr>
))}
</tbody>
</table>
)}
{cronologia.length > 20 && (
<p className="text-[10px] text-[#777] mb-4">Mostrando los 20 registros más recientes de {cronologia.length}.</p>
)}
{/* Pie */}
<div className="border-t-2 border-[#333] pt-2 text-sm">
<div className="flex justify-between font-bold">
<span>{ventas.length} ventas · Total {fmtMoney(totalVentas)} · Pagado {fmtMoney(totalPagado)}</span>
<span className={totalPorCobrar > 0 ? 'text-[#e74c3c]' : ''}>Por cobrar: {fmtMoney(totalPorCobrar)}</span>
</div>
{wallet && (
<p className="text-xs text-[#555] mt-1">Monedero: {wallet.points} puntos disponibles ({fmtMoney(wallet.equivalent_mxn)}).</p>
)}
<p className="text-[10px] text-[#777] mt-3 text-center">Documento informativo generado por {clinic.name}.</p>
</div>
</div>
)}
</Modal>
);
};
export default EstadoCuentaPrint;

View File

@@ -7,6 +7,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PatientAdjuntos from '../components/PatientAdjuntos'; import PatientAdjuntos from '../components/PatientAdjuntos';
import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
import { import {
Card, Card,
Button, Button,
@@ -124,6 +125,7 @@ const Pacientes: FC = () => {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
const [selected, setSelected] = useState<Patient | null>(null); 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); const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta?: EstadoCuenta } | null>(null);
const [historyLoading, setHistoryLoading] = useState(false); const [historyLoading, setHistoryLoading] = useState(false);
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info'); const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info');
@@ -764,10 +766,16 @@ const Pacientes: FC = () => {
title={selected?.name || 'Detalle del paciente'} title={selected?.name || 'Detalle del paciente'}
maxWidth="2xl" maxWidth="2xl"
footer={ footer={
<Button variant="outline" onClick={() => selected && openEdit(selected)}> <div className="flex items-center gap-2">
<Edit2 size={16} className="mr-2" /> <Button variant="outline" onClick={() => setEstadoCuentaOpen(true)}>
Editar paciente <FileText size={16} className="mr-2" />
</Button> Estado de cuenta
</Button>
<Button variant="outline" onClick={() => selected && openEdit(selected)}>
<Edit2 size={16} className="mr-2" />
Editar paciente
</Button>
</div>
} }
> >
<div className="space-y-4"> <div className="space-y-4">
@@ -1177,6 +1185,13 @@ const Pacientes: FC = () => {
</div> </div>
)} )}
</Modal> </Modal>
{/* Vista de impresión del estado de cuenta */}
<EstadoCuentaPrint
isOpen={estadoCuentaOpen}
onClose={() => setEstadoCuentaOpen(false)}
paciente={selected}
/>
</Layout> </Layout>
); );
}; };