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:
283
frontend/src/components/EstadoCuentaPrint.tsx
Normal file
283
frontend/src/components/EstadoCuentaPrint.tsx
Normal 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;
|
||||||
@@ -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={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setEstadoCuentaOpen(true)}>
|
||||||
|
<FileText size={16} className="mr-2" />
|
||||||
|
Estado de cuenta
|
||||||
|
</Button>
|
||||||
<Button variant="outline" onClick={() => selected && openEdit(selected)}>
|
<Button variant="outline" onClick={() => selected && openEdit(selected)}>
|
||||||
<Edit2 size={16} className="mr-2" />
|
<Edit2 size={16} className="mr-2" />
|
||||||
Editar paciente
|
Editar paciente
|
||||||
</Button>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user