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';
|
||||
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<Patient | null>(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={
|
||||
<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)}>
|
||||
<Edit2 size={16} className="mr-2" />
|
||||
Editar paciente
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -1177,6 +1185,13 @@ const Pacientes: FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Vista de impresión del estado de cuenta */}
|
||||
<EstadoCuentaPrint
|
||||
isOpen={estadoCuentaOpen}
|
||||
onClose={() => setEstadoCuentaOpen(false)}
|
||||
paciente={selected}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user