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
This commit is contained in:
324
frontend/src/pages/UltimasVisitas.tsx
Normal file
324
frontend/src/pages/UltimasVisitas.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user