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'; import { todayLocal } from '../lib/utils'; const visitaStateLabels: Record = { 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 = { antes: 'Antes', despues: 'Después', documento: 'Documento', }; const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024; const hoy = () => todayLocal(); const Visitas: FC = () => { const [visitas, setVisitas] = useState([]); const [doctors, setDoctors] = useState([]); 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(null); const [form, setForm] = useState({ motivo: '', diagnostico: '', tratamiento: '', notas: '', receta_text: '', doctor_id: '', cosmetologa_id: '', }); const [printOpen, setPrintOpen] = useState(false); const [items, setItems] = useState([]); const [itemSearch, setItemSearch] = useState(''); const [nuevoItem, setNuevoItem] = useState(''); const [nuevaQty, setNuevaQty] = useState('1'); const [visor, setVisor] = useState(null); const fileAntesRef = useRef(null); const fileDespuesRef = useRef(null); const fileDocRef = useRef(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 = {}; 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) => { 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) => { 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 (
setFecha(e.target.value)} /> setDoctorFiltro(e.target.value)} />
{loading ? ( ) : visitas.length === 0 ? ( } /> ) : ( <>
{visitas.map((v) => ( openDetail(v)}> ))}
Hora Paciente Servicio Médico Motivo Estado
{hora(v)} {v.patient} {v.servicio || '-'} {v.doctor || '-'} {v.motivo || '-'} {visitaStateLabels[v.state] || v.state}
{visitas.map((v) => ( {visitaStateLabels[v.state] || v.state} ), }, ]} onClick={() => openDetail(v)} /> ))}
)}
{/* Modal detalle/edición */} setSelected(null)} title={selected ? `Visita ${selected.name} — ${selected.patient}` : 'Visita'} maxWidth="2xl" footer={ <> {selected?.state === 'en_curso' && ( )} {selected?.state === 'en_curso' && ( )} } > {selected && (
{visitaStateLabels[selected.state] || selected.state} {selected.date_start || '-'}{selected.date_end ? ` → ${selected.date_end}` : ''} {selected.servicio && ( · {selected.servicio} )}
updateForm({ cosmetologa_id: e.target.value })} />
updateForm({ motivo: e.target.value })} />