Files
SKEEN-Proyecto/frontend/src/pages/UltimasVisitas.tsx
Consultoría Alcaraz Salazar 01f6007e30 Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy
- 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
2026-08-13 23:30:08 +00:00

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;