- Módulo Visitas completo (auto desde agenda, insumos con descargo de inventario, fotos antes/después y documentos, receta imprimible) - Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con puntos monedero, ticket imprimible) - WACRM: leads automáticos desde WhatsApp, asignación de conversaciones y leads a agentes, conversión lead→paciente, ficha del paciente en chat - Pacientes: completitud de expediente, alertas clínicas, historial unificado con detalle, foto, WhatsApp, estado de cuenta, filtros rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería) - Agenda: vistas por médico y por hora, filtros rápidos (libres, primera vez, check-in, no-show), modal de acciones, bloqueos por médico, drag&drop para mover citas - Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos, comisiones, pagos, devoluciones, top clientes, horas, paquetes, vendedores, concentrado, recomendaciones, KPIs) con exportación Excel - Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo treeview, selector de tema; accesos rápidos personalizables con 3 presentaciones; búsqueda global; notificaciones reales - Configuración: secciones (clínica, usuarios con permisos por sección, recetas, catálogos de diagnósticos y procedimientos) - Inventario: alertas de caducidad y sugerencia de compra, cron diario que descuenta artículos caducados, compras/bajas - Consultas Médicas, página Expedientes, importadores delta (citas/visitas legacy idempotentes), depuración de duplicados - Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias Odoo corregidas (noupdate, company_id), rollback en validaciones
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import type { FC } from 'react';
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { Search, FolderClock, FileText, Images, StickyNote } from 'lucide-react';
|
|
import Layout from '../components/Layout';
|
|
import PatientAdjuntos from '../components/PatientAdjuntos';
|
|
import {
|
|
Card,
|
|
Button,
|
|
Input,
|
|
Select,
|
|
TextArea,
|
|
Modal,
|
|
EmptyState,
|
|
MobileCard,
|
|
PageHeader,
|
|
Skeleton,
|
|
toast,
|
|
} from '../components/ui';
|
|
import { odooApi, type ExpedienteReciente, type Doctor } from '../services/odoo';
|
|
|
|
const UltimasVisitas: FC = () => {
|
|
const [expedientes, setExpedientes] = useState<ExpedienteReciente[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [recencia, setRecencia] = useState<'' | '6m' | '6m_plus'>('');
|
|
const [onlyVip, setOnlyVip] = useState(false);
|
|
const [doctorFilter, setDoctorFilter] = useState('');
|
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
|
const [search, setSearch] = useState('');
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [totalPages, setTotalPages] = useState(0);
|
|
const [modalAdjuntos, setModalAdjuntos] = useState<{ patient: ExpedienteReciente; kind: 'expediente' | 'imagen' } | null>(null);
|
|
const [modalNotas, setModalNotas] = useState<ExpedienteReciente | null>(null);
|
|
const [notas, setNotas] = useState('');
|
|
const [savingNotas, setSavingNotas] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const params: Record<string, string | number> = { page, page_size: 50 };
|
|
if (recencia) {
|
|
params.recencia = recencia;
|
|
} else {
|
|
// Sin chip de recencia: todos los pacientes con visita, más reciente primero
|
|
params.date_from = 'todas';
|
|
}
|
|
if (onlyVip) params.vip = '1';
|
|
if (doctorFilter) params.doctor_id = doctorFilter;
|
|
if (search) params.search = search;
|
|
const res = await odooApi.getExpedientesRecientes(params);
|
|
if (res.status === 'success') {
|
|
setExpedientes(res.expedientes);
|
|
setTotal(res.total ?? 0);
|
|
setTotalPages(res.total_pages ?? 0);
|
|
}
|
|
} catch (err) {
|
|
toast.error('Error al cargar expedientes');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [recencia, onlyVip, doctorFilter, search, page]);
|
|
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [recencia, onlyVip, doctorFilter, search]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
odooApi.getDoctors()
|
|
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const openNotas = (p: ExpedienteReciente) => {
|
|
setModalNotas(p);
|
|
setNotas(p.internal_notes || '');
|
|
};
|
|
|
|
const saveNotas = async () => {
|
|
if (!modalNotas) return;
|
|
try {
|
|
setSavingNotas(true);
|
|
await odooApi.updatePatient(modalNotas.id, { internal_notes: notas });
|
|
toast.success('Notas actualizadas');
|
|
setModalNotas(null);
|
|
await load();
|
|
} catch (err) {
|
|
toast.error('Error al guardar notas');
|
|
console.error(err);
|
|
} finally {
|
|
setSavingNotas(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Layout title="Expedientes" subtitle="Expedientes y actividad de pacientes">
|
|
<PageHeader title="Expedientes" subtitle="Expedientes, galería y notas de los pacientes" />
|
|
|
|
<Card>
|
|
<Card.Body>
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
|
|
<div className="relative w-full sm:max-w-sm">
|
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
|
<Input
|
|
placeholder="Buscar por nombre o teléfono..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filtros rápidos */}
|
|
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
|
|
{([
|
|
{ key: '6m', label: 'Visita últimos 6 meses' },
|
|
{ key: '6m_plus', label: '+6 meses sin visita' },
|
|
] as const).map((chip) => (
|
|
<button
|
|
key={chip.key}
|
|
type="button"
|
|
onClick={() => setRecencia(recencia === chip.key ? '' : chip.key)}
|
|
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
|
recencia === chip.key
|
|
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
|
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
{chip.label}
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
onClick={() => setOnlyVip(!onlyVip)}
|
|
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
|
onlyVip
|
|
? 'bg-theme-accent text-theme-inverse border-theme-accent'
|
|
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
|
|
}`}
|
|
>
|
|
VIP
|
|
</button>
|
|
<Select
|
|
options={[
|
|
{ value: '', label: 'Todos los médicos' },
|
|
...doctors.map((d) => ({
|
|
value: String(d.id),
|
|
label: d.patient_count !== undefined ? `${d.name} (${d.patient_count})` : d.name,
|
|
})),
|
|
]}
|
|
value={doctorFilter}
|
|
onChange={(e) => setDoctorFilter(e.target.value)}
|
|
className="sm:max-w-[220px]"
|
|
/>
|
|
{(recencia || onlyVip || doctorFilter) && (
|
|
<button
|
|
type="button"
|
|
onClick={() => { setRecencia(''); setOnlyVip(false); setDoctorFilter(''); }}
|
|
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
|
|
>
|
|
Quitar filtros
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<Skeleton count={6} className="h-12 w-full" />
|
|
) : expedientes.length === 0 ? (
|
|
<EmptyState
|
|
title="Sin visitas recientes"
|
|
subtitle="No hay expedientes que coincidan con los filtros seleccionados."
|
|
icon={<FolderClock size={28} />}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className="hidden lg:block overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-theme-bg">
|
|
<tr>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Fecha de visita</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Cliente</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Edad</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Teléfono</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Médico asignado</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Exp. escaneado</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Galería</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Notas</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{expedientes.map((p) => (
|
|
<tr key={p.id} className="hover:bg-theme-bg">
|
|
<td className="p-3 text-sm text-theme-muted whitespace-nowrap">{p.last_visit || '-'}</td>
|
|
<td className="p-3 text-sm">
|
|
<p className="font-medium text-theme-heading">{p.name}</p>
|
|
<p className="text-xs text-theme-muted">{p.patient_id}</p>
|
|
</td>
|
|
<td className="p-3 text-sm text-theme-muted">{p.age ?? '-'}</td>
|
|
<td className="p-3 text-sm text-theme-muted">{p.phone || '-'}</td>
|
|
<td className="p-3 text-sm text-theme-muted">{p.primary_doctor || '-'}</td>
|
|
<td className="p-3 text-sm">
|
|
<Button variant="outline" size="sm" onClick={() => setModalAdjuntos({ patient: p, kind: 'expediente' })}>
|
|
<FileText size={14} className="mr-1.5" />
|
|
{p.expediente_count > 0 ? `Ver (${p.expediente_count})` : 'Subir'}
|
|
</Button>
|
|
</td>
|
|
<td className="p-3 text-sm">
|
|
<Button variant="outline" size="sm" onClick={() => setModalAdjuntos({ patient: p, kind: 'imagen' })}>
|
|
<Images size={14} className="mr-1.5" />
|
|
{p.imagenes_count} fotos
|
|
</Button>
|
|
</td>
|
|
<td className="p-3 text-sm">
|
|
<button
|
|
type="button"
|
|
onClick={() => openNotas(p)}
|
|
className="text-left text-theme-muted hover:text-theme-heading max-w-[180px] truncate block"
|
|
title={p.internal_notes || 'Agregar notas'}
|
|
>
|
|
{p.internal_notes ? p.internal_notes : <StickyNote size={14} />}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="lg:hidden space-y-3">
|
|
{expedientes.map((p) => (
|
|
<MobileCard
|
|
key={p.id}
|
|
title={p.name}
|
|
subtitle={`${p.last_visit || '-'} · ${p.patient_id}`}
|
|
rows={[
|
|
{ label: 'Edad', value: p.age ?? '-' },
|
|
{ label: 'Teléfono', value: p.phone || '-' },
|
|
{ label: 'Médico', value: p.primary_doctor || '-' },
|
|
{ label: 'Notas', value: p.internal_notes ? p.internal_notes.slice(0, 40) : '-' },
|
|
]}
|
|
actions={
|
|
<>
|
|
<Button variant="ghost" size="sm" title="Expediente escaneado" onClick={() => setModalAdjuntos({ patient: p, kind: 'expediente' })}>
|
|
<FileText size={16} className="text-theme-muted" />
|
|
{p.expediente_count > 0 && <span className="text-xs text-theme-muted ml-0.5">{p.expediente_count}</span>}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" title="Galería" onClick={() => setModalAdjuntos({ patient: p, kind: 'imagen' })}>
|
|
<Images size={16} className="text-theme-muted" />
|
|
{p.imagenes_count > 0 && <span className="text-xs text-theme-muted ml-0.5">{p.imagenes_count}</span>}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" title="Notas" onClick={() => openNotas(p)}>
|
|
<StickyNote size={16} className="text-theme-muted" />
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Paginación */}
|
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4 pt-4 border-t border-theme-border">
|
|
<p className="text-sm text-theme-muted">
|
|
{total} pacientes · página {page} de {totalPages || 1}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<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>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Card.Body>
|
|
</Card>
|
|
|
|
{/* Modal de adjuntos (expediente escaneado o galería) */}
|
|
<Modal
|
|
isOpen={!!modalAdjuntos}
|
|
onClose={() => setModalAdjuntos(null)}
|
|
title={modalAdjuntos
|
|
? `${modalAdjuntos.kind === 'expediente' ? 'Expediente escaneado' : 'Galería'} — ${modalAdjuntos.patient.name}`
|
|
: 'Archivos'}
|
|
maxWidth="2xl"
|
|
>
|
|
{modalAdjuntos && (
|
|
<PatientAdjuntos
|
|
patientId={modalAdjuntos.patient.id}
|
|
kind={modalAdjuntos.kind}
|
|
onChange={load}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Modal de notas */}
|
|
<Modal
|
|
isOpen={!!modalNotas}
|
|
onClose={() => setModalNotas(null)}
|
|
title={modalNotas ? `Notas — ${modalNotas.name}` : 'Notas'}
|
|
maxWidth="sm"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" onClick={() => setModalNotas(null)}>Cancelar</Button>
|
|
<Button onClick={saveNotas} loading={savingNotas}>Guardar</Button>
|
|
</>
|
|
}
|
|
>
|
|
<TextArea
|
|
label="Notas internas (solo equipo)"
|
|
value={notas}
|
|
onChange={(e) => setNotas(e.target.value)}
|
|
/>
|
|
</Modal>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default UltimasVisitas;
|