Files
SKEEN-Proyecto/frontend/src/pages/Visitas.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

615 lines
24 KiB
TypeScript

import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { ClipboardList, CheckCircle2, XCircle, X, Plus, Camera, FileText } from 'lucide-react';
import Layout from '../components/Layout';
import { RecetaSection, RecetaPrint } from '../components/RecetaPrint';
import {
Card,
Button,
Input,
Select,
TextArea,
Modal,
Badge,
EmptyState,
MobileCard,
PageHeader,
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
import type { BadgeVariant } from '../components/ui/Badge';
const visitaStateLabels: Record<string, string> = {
en_curso: 'En curso',
completada: 'Completada',
cancelada: 'Cancelada',
};
const badgeForVisitaState = (state: string): BadgeVariant =>
state === 'en_curso' ? 'info' : state === 'completada' ? 'success' : state === 'cancelada' ? 'danger' : 'default';
const estadoOptions = [
{ value: '', label: 'Todos' },
{ value: 'en_curso', label: 'En curso' },
{ value: 'completada', label: 'Completada' },
{ value: 'cancelada', label: 'Cancelada' },
];
const adjuntoKindLabels: Record<string, string> = {
antes: 'Antes',
despues: 'Después',
documento: 'Documento',
};
const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
const hoy = () => new Date().toISOString().split('T')[0];
const Visitas: FC = () => {
const [visitas, setVisitas] = useState<Visita[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [fecha, setFecha] = useState(hoy());
const [estado, setEstado] = useState('');
const [doctorFiltro, setDoctorFiltro] = useState('');
const [selected, setSelected] = useState<Visita | null>(null);
const [form, setForm] = useState({
motivo: '',
diagnostico: '',
tratamiento: '',
notas: '',
receta_text: '',
doctor_id: '',
cosmetologa_id: '',
});
const [printOpen, setPrintOpen] = useState(false);
const [items, setItems] = useState<InventoryItem[]>([]);
const [itemSearch, setItemSearch] = useState('');
const [nuevoItem, setNuevoItem] = useState('');
const [nuevaQty, setNuevaQty] = useState('1');
const [visor, setVisor] = useState<VisitaAdjunto | null>(null);
const fileAntesRef = useRef<HTMLInputElement>(null);
const fileDespuesRef = useRef<HTMLInputElement>(null);
const fileDocRef = useRef<HTMLInputElement>(null);
const doctorOptions = [
{ value: '', label: 'Sin asignar' },
...doctors.map((d) => ({ value: String(d.id), label: d.name })),
];
const load = useCallback(async () => {
try {
setLoading(true);
const params: Record<string, string | number> = {};
if (fecha) params.date = fecha;
if (estado) params.state = estado;
if (doctorFiltro) params.doctor_id = doctorFiltro;
const res = await odooApi.getVisitas(params);
if (res.status === 'success') setVisitas(res.visitas);
} catch (err) {
toast.error('Error al cargar visitas');
console.error(err);
} finally {
setLoading(false);
}
}, [fecha, estado, doctorFiltro]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
odooApi.getDoctors()
.then((res) => {
if (res.status === 'success') setDoctors(res.doctors);
})
.catch(() => {});
}, []);
// Catálogo de artículos del inventario para el picker de insumos
useEffect(() => {
if (!selected || selected.state !== 'en_curso') return;
odooApi.getInventarioItems(itemSearch || undefined)
.then((res) => {
if (res.status === 'success') setItems(res.items);
})
.catch(() => {});
}, [selected, itemSearch]);
const itemOptions = [
{ value: '', label: 'Selecciona artículo...' },
...items.map((it) => ({ value: String(it.id), label: `${it.name} (${it.qty} ${it.unit})` })),
];
const agregarInsumo = async () => {
if (!selected || !nuevoItem) return;
const qty = parseFloat(nuevaQty);
if (Number.isNaN(qty) || qty <= 0) {
toast.error('La cantidad debe ser mayor a 0');
return;
}
try {
setSubmitting(true);
const res = await odooApi.addVisitaInsumo(selected.id, { item_id: parseInt(nuevoItem, 10), qty });
if (res.status === 'success') {
applyUpdate(res.visita);
setNuevoItem('');
setNuevaQty('1');
toast.success('Insumo agregado');
}
} catch (err) {
toast.error('Error al agregar insumo');
console.error(err);
} finally {
setSubmitting(false);
}
};
const quitarInsumo = async (lineId: number) => {
if (!selected) return;
try {
setSubmitting(true);
const res = await odooApi.deleteVisitaInsumo(selected.id, lineId);
if (res.status === 'success') applyUpdate(res.visita);
} catch (err) {
toast.error('Error al quitar insumo');
console.error(err);
} finally {
setSubmitting(false);
}
};
const subirAdjunto = (kind: 'antes' | 'despues' | 'documento') => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !selected) return;
if (file.size > MAX_ADJUNTO_BYTES) {
toast.error('El archivo excede 10 MB');
return;
}
const visitaId = selected.id;
const reader = new FileReader();
reader.onload = async () => {
const file_b64 = String(reader.result || '').split(',')[1] || '';
try {
setSubmitting(true);
const res = await odooApi.uploadVisitaAdjunto(visitaId, {
kind,
name: file.name,
file_b64,
mimetype: file.type || 'application/octet-stream',
});
if (res.status === 'success') {
applyUpdate(res.visita);
toast.success('Archivo subido');
}
} catch (err) {
toast.error('Error al subir el archivo');
console.error(err);
} finally {
setSubmitting(false);
}
};
reader.readAsDataURL(file);
};
const borrarAdjunto = async (adjId: number) => {
if (!window.confirm('¿Eliminar este archivo?')) return;
try {
setSubmitting(true);
const res = await odooApi.deleteVisitaAdjunto(adjId);
if (res.status === 'success') applyUpdate(res.visita);
} catch (err) {
toast.error('Error al eliminar el archivo');
console.error(err);
} finally {
setSubmitting(false);
}
};
const openDetail = (v: Visita) => {
setSelected(v);
setForm({
motivo: v.motivo || '',
diagnostico: v.diagnostico || '',
tratamiento: v.tratamiento || '',
notas: v.notas || '',
receta_text: v.receta_text || '',
doctor_id: v.doctor_id ? String(v.doctor_id) : '',
cosmetologa_id: v.cosmetologa_id ? String(v.cosmetologa_id) : '',
});
};
const updateForm = (patch: Partial<typeof form>) => {
setForm((prev) => ({ ...prev, ...patch }));
};
const applyUpdate = (v: Visita) => {
setSelected(v);
setVisitas((prev) => prev.map((it) => (it.id === v.id ? v : it)));
};
const save = async () => {
if (!selected) return;
try {
setSubmitting(true);
const res = await odooApi.updateVisita(selected.id, {
motivo: form.motivo,
diagnostico: form.diagnostico,
tratamiento: form.tratamiento,
notas: form.notas,
receta_text: form.receta_text,
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : null,
cosmetologa_id: form.cosmetologa_id ? parseInt(form.cosmetologa_id, 10) : null,
});
if (res.status === 'success') {
applyUpdate(res.visita);
toast.success('Visita actualizada');
}
} catch (err) {
toast.error('Error al guardar la visita');
console.error(err);
} finally {
setSubmitting(false);
}
};
const completar = async () => {
if (!selected) return;
try {
setSubmitting(true);
const res = await odooApi.completeVisita(selected.id);
if (res.status === 'success') {
applyUpdate(res.visita);
toast.success('Visita completada');
}
} catch (err) {
toast.error('Error al completar la visita');
console.error(err);
} finally {
setSubmitting(false);
}
};
const cancelar = async () => {
if (!selected) return;
try {
setSubmitting(true);
const res = await odooApi.cancelVisita(selected.id);
if (res.status === 'success') {
applyUpdate(res.visita);
toast.success('Visita cancelada');
}
} catch (err) {
toast.error('Error al cancelar la visita');
console.error(err);
} finally {
setSubmitting(false);
}
};
const hora = (v: Visita) => (v.date_start ? v.date_start.split(' ')[1] || '' : '-');
return (
<Layout title="Visitas" subtitle="Registro de visitas clínicas">
<PageHeader title="Visitas" subtitle="Consulta y completa las visitas clínicas del día" />
<Card>
<Card.Body>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4 sm:mb-6">
<Input label="Fecha" type="date" value={fecha} onChange={(e) => setFecha(e.target.value)} />
<Select label="Estado" options={estadoOptions} value={estado} onChange={(e) => setEstado(e.target.value)} />
<Select
label="Médico"
options={[{ value: '', label: 'Todos' }, ...doctorOptions.slice(1)]}
value={doctorFiltro}
onChange={(e) => setDoctorFiltro(e.target.value)}
/>
</div>
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : visitas.length === 0 ? (
<EmptyState
title={fecha === hoy() ? 'Sin visitas hoy' : 'Sin visitas'}
subtitle="No se encontraron visitas con los filtros seleccionados."
icon={<ClipboardList size={28} />}
/>
) : (
<>
<div className="hidden sm: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">Hora</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Servicio</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Médico</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Motivo</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
</tr>
</thead>
<tbody className="divide-y">
{visitas.map((v) => (
<tr key={v.id} className="hover:bg-theme-bg cursor-pointer" onClick={() => openDetail(v)}>
<td className="p-3 text-sm text-theme-muted">{hora(v)}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{v.patient}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{v.servicio || '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{v.doctor || '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden lg:table-cell truncate max-w-[200px]">{v.motivo || '-'}</td>
<td className="p-3 text-sm">
<Badge variant={badgeForVisitaState(v.state)}>
{visitaStateLabels[v.state] || v.state}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{visitas.map((v) => (
<MobileCard
key={v.id}
title={v.patient}
subtitle={hora(v)}
rows={[
{ label: 'Servicio', value: v.servicio || '-' },
{ label: 'Médico', value: v.doctor || '-' },
{
label: 'Estado',
value: (
<Badge variant={badgeForVisitaState(v.state)}>
{visitaStateLabels[v.state] || v.state}
</Badge>
),
},
]}
onClick={() => openDetail(v)}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
{/* Modal detalle/edición */}
<Modal
isOpen={!!selected}
onClose={() => setSelected(null)}
title={selected ? `Visita ${selected.name} — ${selected.patient}` : 'Visita'}
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>Cerrar</Button>
{selected?.state === 'en_curso' && (
<Button variant="outline" onClick={cancelar} loading={submitting}>
<XCircle size={16} className="mr-2" />
Cancelar visita
</Button>
)}
<Button onClick={save} loading={submitting}>Guardar</Button>
{selected?.state === 'en_curso' && (
<Button onClick={completar} loading={submitting}>
<CheckCircle2 size={16} className="mr-2" />
Completar visita
</Button>
)}
</>
}
>
{selected && (
<div className="space-y-4 max-h-[70vh] overflow-y-auto pr-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForVisitaState(selected.state)}>
{visitaStateLabels[selected.state] || selected.state}
</Badge>
<span className="text-sm text-theme-muted">
{selected.date_start || '-'}{selected.date_end ? ` → ${selected.date_end}` : ''}
</span>
{selected.servicio && (
<span className="text-sm text-theme-muted">· {selected.servicio}</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Select
label="Médico"
options={doctorOptions}
value={form.doctor_id}
onChange={(e) => updateForm({ doctor_id: e.target.value })}
/>
<Select
label="Cosmetóloga"
options={doctorOptions}
value={form.cosmetologa_id}
onChange={(e) => updateForm({ cosmetologa_id: e.target.value })}
/>
</div>
<Input label="Motivo de consulta" value={form.motivo} onChange={(e) => updateForm({ motivo: e.target.value })} />
<TextArea label="Diagnóstico" value={form.diagnostico} onChange={(e) => updateForm({ diagnostico: e.target.value })} />
<TextArea label="Tratamiento" value={form.tratamiento} onChange={(e) => updateForm({ tratamiento: e.target.value })} />
<TextArea label="Notas" value={form.notas} onChange={(e) => updateForm({ notas: e.target.value })} />
<RecetaSection
value={form.receta_text}
onChange={(v) => updateForm({ receta_text: v })}
onPrint={() => setPrintOpen(true)}
/>
{/* Insumos usados */}
<section className="border-t border-theme-border pt-4">
<h4 className="text-sm font-semibold text-theme-heading mb-3">Insumos usados</h4>
{selected.insumos_descargados && (
<p className="text-xs text-theme-muted mb-2">Insumos descargados del inventario.</p>
)}
{selected.insumos.length === 0 ? (
<p className="text-sm text-theme-muted mb-3">Sin insumos registrados.</p>
) : (
<ul className="space-y-1 mb-3">
{selected.insumos.map((l) => (
<li key={l.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">
{l.item}
{l.notes && <span className="block text-xs text-theme-muted">{l.notes}</span>}
</span>
<span className="flex items-center gap-2 shrink-0">
<span className="text-theme-muted">{l.qty} {l.unit}</span>
{selected.state === 'en_curso' && (
<Button variant="ghost" size="sm" onClick={() => quitarInsumo(l.id)} title="Quitar" disabled={submitting}>
<X size={14} className="text-theme-muted" />
</Button>
)}
</span>
</li>
))}
</ul>
)}
{selected.state === 'en_curso' && (
<div className="space-y-2">
<Input
placeholder="Buscar artículo..."
value={itemSearch}
onChange={(e) => setItemSearch(e.target.value)}
/>
<div className="flex items-end gap-2">
<div className="flex-1">
<Select
options={itemOptions}
value={nuevoItem}
onChange={(e) => setNuevoItem(e.target.value)}
/>
</div>
<div className="w-24">
<Input
type="number"
min="0.5"
step="0.5"
value={nuevaQty}
onChange={(e) => setNuevaQty(e.target.value)}
/>
</div>
<Button onClick={agregarInsumo} disabled={!nuevoItem} loading={submitting}>
<Plus size={16} className="mr-1" />
Agregar
</Button>
</div>
</div>
)}
</section>
{/* Fotos y documentos */}
<section className="border-t border-theme-border pt-4">
<h4 className="text-sm font-semibold text-theme-heading mb-3">Fotos y documentos</h4>
{selected.state === 'en_curso' && (
<div className="flex flex-wrap gap-2 mb-3">
<input ref={fileAntesRef} type="file" accept="image/*" className="hidden" onChange={subirAdjunto('antes')} />
<input ref={fileDespuesRef} type="file" accept="image/*" className="hidden" onChange={subirAdjunto('despues')} />
<input ref={fileDocRef} type="file" className="hidden" onChange={subirAdjunto('documento')} />
<Button variant="outline" size="sm" onClick={() => fileAntesRef.current?.click()} disabled={submitting}>
<Camera size={14} className="mr-1.5" /> Foto antes
</Button>
<Button variant="outline" size="sm" onClick={() => fileDespuesRef.current?.click()} disabled={submitting}>
<Camera size={14} className="mr-1.5" /> Foto después
</Button>
<Button variant="outline" size="sm" onClick={() => fileDocRef.current?.click()} disabled={submitting}>
<FileText size={14} className="mr-1.5" /> Documento
</Button>
</div>
)}
{selected.adjuntos.length === 0 ? (
<p className="text-sm text-theme-muted">Sin archivos adjuntos.</p>
) : (
<>
{selected.adjuntos.filter((a) => a.kind !== 'documento').length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
{selected.adjuntos.filter((a) => a.kind !== 'documento').map((a) => (
<div key={a.id} className="relative">
<button type="button" onClick={() => setVisor(a)} className="w-full">
<img
src={odooApi.visitaAdjuntoUrl(a.url)}
alt={a.name}
className="w-full h-24 object-cover rounded-xl border border-theme-border"
/>
</button>
<span className="absolute bottom-1 left-1 px-1.5 py-0.5 rounded-md text-[10px] font-medium bg-theme-surface/90 text-theme-heading">
{adjuntoKindLabels[a.kind] || a.kind}
</span>
{selected.state === 'en_curso' && (
<button
type="button"
onClick={() => borrarAdjunto(a.id)}
title="Eliminar"
className="absolute top-1 right-1 p-1 rounded-full bg-theme-surface/90 text-theme-muted hover:text-rose-600"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
)}
{selected.adjuntos.filter((a) => a.kind === 'documento').length > 0 && (
<ul className="space-y-1">
{selected.adjuntos.filter((a) => a.kind === 'documento').map((a) => (
<li key={a.id} className="flex items-center justify-between p-2 bg-theme-bg rounded-lg text-sm">
<a
href={odooApi.visitaAdjuntoUrl(a.url)}
target="_blank"
rel="noreferrer"
className="flex items-center text-theme-heading hover:underline min-w-0"
>
<FileText size={14} className="mr-1.5 text-theme-muted shrink-0" />
<span className="truncate">{a.name}</span>
</a>
{selected.state === 'en_curso' && (
<Button variant="ghost" size="sm" onClick={() => borrarAdjunto(a.id)} title="Eliminar" disabled={submitting}>
<X size={14} className="text-theme-muted" />
</Button>
)}
</li>
))}
</ul>
)}
</>
)}
</section>
</div>
)}
</Modal>
{/* Visor de foto */}
<Modal
isOpen={!!visor}
onClose={() => setVisor(null)}
title={visor ? `${adjuntoKindLabels[visor.kind] || visor.kind} — ${visor.name}` : 'Foto'}
maxWidth="2xl"
>
{visor && (
<img
src={odooApi.visitaAdjuntoUrl(visor.url)}
alt={visor.name}
className="w-full rounded-xl"
/>
)}
</Modal>
{/* Vista de impresión de la receta */}
{selected && (
<RecetaPrint
isOpen={printOpen}
onClose={() => setPrintOpen(false)}
paciente={selected.patient}
doctor={selected.doctor}
contenido={form.receta_text}
/>
)}
</Layout>
);
};
export default Visitas;