import type { FC } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react'; import { Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart, MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle, Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet, X, FileSignature, } from 'lucide-react'; import Layout from '../components/Layout'; import PatientAdjuntos from '../components/PatientAdjuntos'; import EstadoCuentaPrint from '../components/EstadoCuentaPrint'; import RecargarSaldoModal from '../components/RecargarSaldoModal'; import ReciboPrint from '../components/ReciboPrint'; import FormulariosPaciente from '../components/FormulariosPaciente'; import MembresiasPaciente from '../components/MembresiasPaciente'; import EnviarFormularioModal from '../components/formulario/EnviarFormularioModal'; import { usePatientDrawer } from '../components/PatientDrawer'; import { Card, Button, Input, Select, Modal, Badge, EmptyState, MobileCard, PageHeader, Skeleton, toast, TextArea, badgeForAppointmentState, badgeForSaleState, } from '../components/ui'; import type { BadgeVariant } from '../components/ui/Badge'; import { useSearchParams } from 'react-router-dom'; import { odooApi, type Patient, type Appointment, type Sale, type Visita, type Doctor, type EstadoCuenta, type PatientWallet, type ReceiptData } from '../services/odoo'; import { exportToExcel } from '../lib/exporter'; import { useAuth } from '../lib/auth'; import { useI18n, useBusinessTerm } from '../lib/i18n'; import { useSucursales } from '../lib/sucursales'; const fmtMoney = (n: number) => (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); const genderOptions = [ { value: '', label: 'No especificado' }, { value: 'male', label: 'Masculino' }, { value: 'female', label: 'Femenino' }, { value: 'other', label: 'Otro' }, ]; const bloodOptions = [ { value: '', label: 'No especificado' }, { value: 'a+', label: 'A+' }, { value: 'a-', label: 'A-' }, { value: 'b+', label: 'B+' }, { value: 'b-', label: 'B-' }, { value: 'ab+', label: 'AB+' }, { value: 'ab-', label: 'AB-' }, { value: 'o+', label: 'O+' }, { value: 'o-', label: 'O-' }, ]; const maritalOptions = [ { value: '', label: 'No especificado' }, { value: 'single', label: 'Soltero/a' }, { value: 'married', label: 'Casado/a' }, { value: 'divorced', label: 'Divorciado/a' }, { value: 'widowed', label: 'Viudo/a' }, { value: 'union', label: 'Unión libre' }, { value: 'other', label: 'Otro' }, ]; const formatGender = (gender?: string | false) => { if (!gender) return 'No especificado'; const map: Record = { male: 'Masculino', female: 'Femenino', other: 'Otro', }; return map[gender] || gender; }; const formatMarital = (status?: string) => { if (!status) return 'No especificado'; const opt = maritalOptions.find((o) => o.value === status); return opt?.label || status; }; const formatBlood = (bt?: string | false) => { if (!bt || typeof bt !== 'string') return 'No especificado'; const opt = bloodOptions.find((o) => o.value === bt.toLowerCase()); return opt?.label || bt.toUpperCase(); }; const appointmentStateLabels: Record = { pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó', in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show', }; const saleStateLabels: Record = { draft: 'Borrador', confirmed: 'Confirmada', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada', }; const visitaStateLabels: Record = { en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada', }; const paymentStateLabels: Record = { not_paid: 'No pagado', partial: 'Pago parcial', paid: 'Pagado', }; const mediumLabels: Record = { onsite: 'Presencial', videocall: 'Videollamada' }; const completionBadgeClasses = (pct?: number) => { const v = pct ?? 0; if (v >= 100) return 'bg-brand-mint text-theme-heading'; if (v < 40) return 'bg-rose-100 text-rose-700'; return 'bg-theme-warning text-theme-warning-text'; }; const badgeForVisitaState = (state: string): BadgeVariant => state === 'en_curso' ? 'info' : state === 'completada' ? 'success' : state === 'cancelada' ? 'danger' : 'default'; const CRITICAL_ALERTS: { key: keyof Patient; label: string }[] = [ { key: 'is_pregnant', label: 'Embarazo' }, { key: 'heart_disease', label: 'Enfermedad cardíaca' }, { key: 'diabetes', label: 'Diabetes' }, { key: 'faints_with_needles', label: 'Se desmaya con agujas' }, ]; const Pacientes: FC = () => { const { brand, can } = useAuth(); const term = useBusinessTerm(); const { t } = useI18n(); const { labelFor: branchLabel } = useSucursales(); const [patients, setPatients] = useState([]); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(() => searchParams.get('q') || ''); const [searchInput, setSearchInput] = useState(() => searchParams.get('q') || ''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(50); const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(0); const [selected, setSelected] = useState(null); const [estadoCuentaOpen, setEstadoCuentaOpen] = useState(false); const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta?: EstadoCuenta } | null>(null); // Saldo a favor (monedero, 1 pt = $1 MXN) del paciente abierto const [wallet, setWallet] = useState(null); const [recargaOpen, setRecargaOpen] = useState(false); const [reciboDeposito, setReciboDeposito] = useState(null); const [historyLoading, setHistoryLoading] = useState(false); const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs' | 'formularios'>('info'); const [entryDetail, setEntryDetail] = useState< { kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | null >(null); // Envío de formulario desde el detalle de la cita del historial const [enviarFormularioCita, setEnviarFormularioCita] = useState<{ patientId: number; patientName: string; citaId: number } | null>(null); const [exporting, setExporting] = useState(false); const [onlyIncomplete, setOnlyIncomplete] = useState(false); const [onlyVip, setOnlyVip] = useState(false); const [onlyRecent, setOnlyRecent] = useState(false); const [onlyStale, setOnlyStale] = useState(false); const [doctorFilter, setDoctorFilter] = useState(''); const [doctors, setDoctors] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [photo, setPhoto] = useState(null); const photoRef = useRef(null); const { openPatient } = usePatientDrawer(); // Aviso de duplicado por teléfono/móvil en el formulario de alta/edición const [phoneDup, setPhoneDup] = useState<{ id: number; name: string } | null>(null); const emptyForm: PatientForm = { name: '', phone: '', email: '', birth_date: '', gender: '', blood_type: '', birthplace: '', occupation: '', marital_status: '', emergency_contact: '', emergency_phone: '', home_phone: '', mobile: '', whatsapp: '', primary_doctor_id: '', address_notes: '', referred_by: '', patient_comments: '', internal_notes: '', allergies: '', medical_history: '', current_medication: '', medical_notes: '', surgeries_notes: '', children_count: 0, is_pregnant: false, is_breastfeeding: false, uses_contraceptives: false, kidney_problems: false, back_pain: false, heart_disease: false, respiratory_problems: false, blood_pressure: false, diabetes: false, thyroid: false, colitis: false, constipation: false, liver_problems: false, surgeries: false, varicose_veins: false, migraine: false, faints_with_needles: false, }; const [form, setForm] = useState(emptyForm); const load = useCallback(async () => { try { setLoading(true); setError(null); const res = await odooApi.getPatients({ search, page, page_size: pageSize, incomplete: onlyIncomplete ? 1 : undefined, vip: onlyVip ? 1 : undefined, recent: onlyRecent ? 1 : (onlyStale ? 0 : undefined), doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined, }); if (res.status === 'success') { setPatients(res.patients); setTotal(res.total ?? 0); setTotalPages(res.total_pages ?? 0); } } catch (err) { setError('Error al cargar pacientes'); toast.error('Error al cargar pacientes'); console.error(err); } finally { setLoading(false); } }, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]); useEffect(() => { setPage(1); }, [search, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]); // Búsqueda con debounce: el listado se refresca solo, sin botón "Buscar". useEffect(() => { const t = setTimeout(() => setSearch(searchInput.trim()), 250); return () => clearTimeout(t); }, [searchInput]); useEffect(() => { odooApi.getDoctors() .then((res) => { if (res.status === 'success') setDoctors(res.doctors); }) .catch(() => {}); }, []); useEffect(() => { load(); }, [load]); const openHistory = async (patient: Patient) => { setSelected(patient); setTab('info'); setHistoryLoading(true); setWallet(null); try { const res = await odooApi.getPatientHistory(patient.id); if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales, visitas: res.visitas || [], estado_cuenta: res.estado_cuenta }); odooApi.getPatientWallet(patient.id) .then((w) => { if (w.status === 'success') setWallet(w); }) .catch(() => setWallet(null)); } catch (err) { toast.error('Error al cargar historial'); console.error(err); } finally { setHistoryLoading(false); } }; const openCreate = () => { setEditing(null); setForm(emptyForm); setPhoto(null); setModalOpen(true); }; const openEdit = (patient: Patient) => { setEditing(patient); setPhoto(null); setForm({ ...emptyForm, ...patient, birth_date: patient.birth_date || '', gender: patient.gender || '', blood_type: patient.blood_type || '', marital_status: patient.marital_status || '', whatsapp: patient.whatsapp || '', primary_doctor_id: patient.primary_doctor_id ? String(patient.primary_doctor_id) : '', children_count: patient.children_count ?? 0, is_pregnant: patient.is_pregnant ?? false, is_breastfeeding: patient.is_breastfeeding ?? false, uses_contraceptives: patient.uses_contraceptives ?? false, kidney_problems: patient.kidney_problems ?? false, back_pain: patient.back_pain ?? false, heart_disease: patient.heart_disease ?? false, respiratory_problems: patient.respiratory_problems ?? false, blood_pressure: patient.blood_pressure ?? false, diabetes: patient.diabetes ?? false, thyroid: patient.thyroid ?? false, colitis: patient.colitis ?? false, constipation: patient.constipation ?? false, liver_problems: patient.liver_problems ?? false, surgeries: patient.surgeries ?? false, varicose_veins: patient.varicose_veins ?? false, migraine: patient.migraine ?? false, faints_with_needles: patient.faints_with_needles ?? false, }); setModalOpen(true); }; const updateForm = (patch: Partial) => { setForm((prev) => ({ ...prev, ...patch })); }; // Abrir el formulario de edición desde otra pantalla (p. ej. el botón // "Editar" del drawer del expediente): /pacientes?edit= const editPatientId = searchParams.get('edit'); useEffect(() => { if (!editPatientId) return; let active = true; (async () => { try { const res = await odooApi.getPatient(parseInt(editPatientId, 10)); if (active && res.status === 'success') openEdit(res.patient); } catch (err) { toast.error('No se pudo abrir el paciente'); console.error(err); } finally { if (active) { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('edit'); return next; }, { replace: true }); } } })(); return () => { active = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [editPatientId, setSearchParams]); // Duplicado de teléfono: se consulta con debounce al escribir (phone o mobile). // Al editar se excluye el propio paciente para no auto-alertarse. useEffect(() => { if (!modalOpen) { setPhoneDup(null); return; } const digitsOf = (s: string) => (s || '').replace(/\D/g, ''); const phone = (form.phone || '').trim(); const mobile = (form.mobile || '').trim(); const checkVal = digitsOf(phone).length >= 7 ? phone : mobile; if (digitsOf(checkVal).length < 7) { setPhoneDup(null); return; } let active = true; const t = setTimeout(async () => { try { const res = await odooApi.checkPatientPhone(checkVal, editing?.id); if (active) setPhoneDup(res.status === 'success' && res.exists && res.patient ? { id: res.patient.id, name: res.patient.name } : null); } catch { if (active) setPhoneDup(null); } }, 400); return () => { active = false; clearTimeout(t); }; }, [form.phone, form.mobile, editing?.id, modalOpen]); const onPhotoChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; if (!file) return; if (file.size > 5 * 1024 * 1024) { toast.error('La foto excede 5 MB'); return; } const reader = new FileReader(); reader.onload = () => setPhoto(String(reader.result || '')); reader.readAsDataURL(file); }; const save = async () => { if (!form.name || !form.phone) { toast.error('Nombre y teléfono son obligatorios'); return; } try { setSubmitting(true); const payload: Partial = { ...form, birth_date: form.birth_date || null, gender: form.gender || undefined, blood_type: form.blood_type || undefined, marital_status: form.marital_status || undefined, primary_doctor_id: form.primary_doctor_id ? parseInt(form.primary_doctor_id, 10) : null, }; if (photo) payload.photo = photo.split(',')[1] || ''; if (editing) { const res = await odooApi.updatePatient(editing.id, payload); toast.success('Paciente actualizado'); if (selected?.id === editing.id) setSelected(res.patient); } else { await odooApi.createPatient(payload); toast.success('Paciente creado'); } setModalOpen(false); await load(); } catch (err) { toast.error(editing ? 'Error al actualizar paciente' : 'Error al crear paciente'); console.error(err); } finally { setSubmitting(false); } }; const TableHeader = () => ( {t('patients.colName')} {t('patients.colRecord')} {t('patients.colPhone')} {t('patients.colAge')} {t('patients.colLastVisit')} {term('visit_plural')} {t('patients.colPoints')} {t('patients.colActions')} ); const InfoRow = ({ label, value, icon: Icon }: { label: string; value: React.ReactNode; icon?: typeof User }) => (

{label}

{Icon && } {value || '-'}

); const ClinicalItem = ({ label, value }: { label: string; value?: boolean }) => (
{label} {value ? ( ) : ( )}
); const timelineItems = history ? [ ...history.appointments.map((a) => ({ kind: 'cita' as const, date: a.date || '', item: a })), ...history.sales.map((s) => ({ kind: 'venta' as const, date: (s.date || '').slice(0, 10), item: s })), ...history.visitas.map((v) => ({ kind: 'visita' as const, date: (v.date_start || '').slice(0, 10), item: v })), ].sort((a, b) => b.date.localeCompare(a.date)) : []; // Exportación COMPLETA: pide al servidor todos los pacientes del filtro // (no sólo la página visible). Hoy son ~7.2k filas. const exportPatients = async () => { try { setExporting(true); const res = await odooApi.exportPatients({ search: search || undefined, incomplete: onlyIncomplete ? 1 : undefined, vip: onlyVip ? 1 : undefined, recent: onlyRecent ? 1 : (onlyStale ? 0 : undefined), doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined, }); if (res.status !== 'success') { toast.error('Error al exportar'); return; } const todos = res.patients; await exportToExcel({ filename: `pacientes-${new Date().toISOString().split('T')[0]}`, sheetName: 'Pacientes', title: `Pacientes ${brand.nombre}`, subtitle: res.truncado ? `${res.total} de ${res.total_disponible} pacientes (límite ${res.limite})` : `${res.total} pacientes (exportación completa)`, columns: [ { header: 'Expediente', key: 'patient_id' }, { header: 'Nombre', key: 'name' }, { header: 'Teléfono', key: 'phone' }, { header: 'Email', key: 'email' }, { header: 'Fecha nacimiento', key: 'birth_date', format: 'date' }, { header: 'Género', key: 'gender' }, { header: 'Última visita', key: 'last_visit', format: 'date' }, { header: 'Visitas', key: 'total_visits', format: 'number' }, { header: 'Puntos monedero', key: 'wallet_points', format: 'number' }, { header: 'Total gastado', key: 'total_spent', format: 'currency' }, { header: 'Fuente', key: 'source' }, { header: 'VIP', key: 'vip' }, ], rows: todos.map((p) => ({ patient_id: p.patient_id, name: p.name, phone: p.phone, email: p.email, birth_date: p.birth_date, gender: p.gender || '', last_visit: p.last_visit, total_visits: p.total_visits, wallet_points: p.wallet_points, total_spent: p.total_spent, source: p.source, vip: p.is_vip ? 'Sí' : 'No', })), }); toast.success(`Excel descargado (${res.total} pacientes)`); } catch (err) { toast.error('Error al exportar'); console.error(err); } finally { setExporting(false); } }; return (
setSearchInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput.trim()); if (e.key === 'Escape') { setSearchInput(''); setSearch(''); } }} className="pl-10" inputClassName={searchInput ? 'pr-9' : undefined} /> {searchInput && ( )}
{ setPageSize(parseInt(e.target.value, 10)); setPage(1); }} className="border border-theme-border-strong rounded-lg px-2 py-1.5 text-sm" >
)}
{/* Modal crear/editar */} setModalOpen(false)} title={editing ? `Editar ${term('patient_singular').toLowerCase()}` : `Nuevo ${term('patient_singular').toLowerCase()}`} maxWidth="2xl" footer={ <> } >
{/* Foto del paciente */}
{photo ? ( Foto ) : editing?.has_photo && editing.photo_url ? ( {editing.name} ) : (
)}

JPG o PNG, máx 5 MB.

Datos generales

updateForm({ name: e.target.value })} /> updateForm({ phone: e.target.value })} /> {phoneDup && (

Este teléfono ya está registrado: {phoneDup.name}. ¿Es la misma persona?

)} updateForm({ email: e.target.value })} /> updateForm({ mobile: e.target.value })} /> updateForm({ home_phone: e.target.value })} /> updateForm({ whatsapp: e.target.value })} /> updateForm({ birth_date: e.target.value })} /> updateForm({ blood_type: e.target.value })} /> updateForm({ birthplace: e.target.value })} /> updateForm({ occupation: e.target.value })} /> updateForm({ referred_by: e.target.value })} /> updateForm({ emergency_contact: e.target.value })} /> updateForm({ emergency_phone: e.target.value })} />