Files
SKEEN-Proyecto/frontend/src/pages/Pacientes.tsx
Consultoría Alcaraz Salazar 2f7cbe237e white-label: brand de la clinica por configuracion (fase 1 de instancia sin branding SKEEN)
- backend: GET/PUT /skeen/frontend/v1/brand leyendo ir.config_parameter
  skeen.clinic.brand (JSON: nombre, logo_claro, logo_oscuro, pie) con
  defaults SKEEN; GET publico (el login lo necesita sin sesion), PUT admin
  con auditoria. frontend_users.xml: 'Administrador SKEEN' -> 'Administrador'
- frontend: ClinicBrand + BRAND_DEFAULT en odoo.ts; brand y setBrand en el
  contexto de auth (fetch al boot + cache localStorage + document.title)
- Login/layouts/mensajes/exports leen el brand en vez de strings SKEEN:
  Login.tsx, SkeenLayout, HomeNestLayout, Cumpleanos (wa + export),
  Pacientes/Ventas/Cortes/Reportes/Kpis/Alertas/AgendaPrint (titulos),
  useClinicaPrint (fallback nombre), Login hace GET publico al montar
- Configuracion > Clinica: tarjeta 'Identidad de la clinica' (solo admin)
  para editar nombre/pie/logos con preview
- no renombrado (invisible al usuario): rutas API /skeen/*, claves
  localStorage, nombres de modulos, parametros skeen.*

Verificacion API: 11/11 tests OK (defaults, PUT merge, campo invalido
ignorado, restore, admin neutral). Deploy: build + rsync + Odoo reiniciado
2026-10-08 00:09:00 +00:00

1448 lines
68 KiB
TypeScript

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,
} 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 { 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';
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<string, string> = {
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<string, string> = {
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
};
const saleStateLabels: Record<string, string> = {
draft: 'Borrador', confirmed: 'Confirmada', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada',
};
const visitaStateLabels: Record<string, string> = {
en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada',
};
const paymentStateLabels: Record<string, string> = {
not_paid: 'No pagado', partial: 'Pago parcial', paid: 'Pagado',
};
const branchLabels: Record<string, string> = { rosarito: 'Rosarito', tijuana: 'Tijuana' };
const mediumLabels: Record<string, string> = { 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 } = useAuth();
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<Patient | null>(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<PatientWallet | null>(null);
const [recargaOpen, setRecargaOpen] = useState(false);
const [reciboDeposito, setReciboDeposito] = useState<ReceiptData | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [tab, setTab] = useState<'info' | 'clinical' | 'timeline' | 'docs'>('info');
const [entryDetail, setEntryDetail] = useState<
{ kind: 'cita'; item: Appointment } | { kind: 'venta'; item: Sale } | { kind: 'visita'; item: Visita } | 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<Doctor[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Patient | null>(null);
const [photo, setPhoto] = useState<string | null>(null);
const photoRef = useRef<HTMLInputElement>(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<PatientForm>(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<PatientForm>) => {
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=<id>
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<HTMLInputElement>) => {
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<Patient> = {
...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 = () => (
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Nombre</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Expediente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Teléfono</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Edad</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Última visita</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Visitas</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Puntos</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr>
</thead>
);
const InfoRow = ({ label, value, icon: Icon }: { label: string; value: React.ReactNode; icon?: typeof User }) => (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">{label}</p>
<p className="text-sm font-medium text-theme-heading flex items-start">
{Icon && <Icon size={14} className="mr-1.5 mt-0.5 text-theme-muted shrink-0" />}
<span className="break-words">{value || '-'}</span>
</p>
</div>
);
const ClinicalItem = ({ label, value }: { label: string; value?: boolean }) => (
<div className="flex items-center justify-between p-2.5 bg-theme-surface border border-theme-border rounded-lg">
<span className="text-sm text-theme-heading">{label}</span>
{value ? (
<CheckCircle2 size={16} className="text-theme-heading" />
) : (
<XCircle size={16} className="text-[#D6D3D1]" />
)}
</div>
);
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 (
<Layout title="Pacientes" subtitle="Directorio de pacientes">
<PageHeader title="Pacientes" subtitle="Busca, crea y gestiona pacientes">
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0}>
<Download size={16} className="mr-2" />
Exportar CSV
</Button>
<Button onClick={openCreate}>
<Plus size={16} className="mr-2" />
Nuevo paciente
</Button>
</PageHeader>
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
<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 pointer-events-none" />
<Input
placeholder="Buscar por nombre, apellidos o teléfono..."
value={searchInput}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => { setSearchInput(''); setSearch(''); }}
title="Limpiar búsqueda"
aria-label="Limpiar búsqueda"
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 rounded-lg text-theme-muted hover:text-theme-heading hover:bg-theme-bg"
>
<X size={14} />
</button>
)}
</div>
<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]"
/>
</div>
{/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{([
{ key: 'vip', label: 'VIP', active: onlyVip, set: setOnlyVip },
{ key: 'recent', label: 'Visitas últimos 6 meses', active: onlyRecent, set: (v: boolean) => { setOnlyRecent(v); if (v) setOnlyStale(false); } },
{ key: 'stale', label: 'Sin visita +6 meses', active: onlyStale, set: (v: boolean) => { setOnlyStale(v); if (v) setOnlyRecent(false); } },
{ key: 'incomplete', label: 'Expediente incompleto', active: onlyIncomplete, set: setOnlyIncomplete },
] as const).map((chip) => (
<button
key={chip.key}
type="button"
onClick={() => chip.set(!chip.active)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
chip.active
? '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>
))}
{(onlyVip || onlyRecent || onlyStale || onlyIncomplete || doctorFilter) && (
<button
type="button"
onClick={() => { setOnlyVip(false); setOnlyRecent(false); setOnlyStale(false); setOnlyIncomplete(false); setDoctorFilter(''); }}
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
>
Quitar filtros
</button>
)}
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : patients.length === 0 ? (
<EmptyState
title="Sin pacientes"
subtitle="No se encontraron pacientes."
actionLabel="Nuevo paciente"
onAction={openCreate}
/>
) : (
<>
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
<TableHeader />
<tbody className="divide-y">
{patients.map((p) => (
<tr key={p.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm font-medium text-theme-heading">
<button onClick={() => openHistory(p)} className="text-left hover:underline">
{p.name}
</button>
</td>
<td className="p-3 text-sm">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${completionBadgeClasses(p.expediente_completion)}`}
title={p.expediente_missing && p.expediente_missing.length > 0 ? `Falta: ${p.expediente_missing.join(', ')}` : 'Expediente completo'}
>
{p.expediente_completion ?? 0}%
</span>
</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{p.phone}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{p.age ?? '-'}</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{p.last_visit || '-'}</td>
<td className="p-3 text-sm text-theme-muted">{p.total_visits}</td>
<td className="p-3 text-sm font-medium text-theme-heading">{p.wallet_points} pts</td>
<td className="p-3">
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openHistory(p)} title="Ver detalle">
<User size={16} className="text-theme-muted" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(p)} title="Editar">
<Edit2 size={16} className="text-theme-muted" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="sm:hidden space-y-3">
{patients.map((p) => (
<MobileCard
key={p.id}
title={p.name}
subtitle={p.phone}
rows={[
{ label: 'Expediente', value: `${p.expediente_completion ?? 0}%` },
{ label: 'Edad', value: p.age ?? '-' },
{ label: 'Última visita', value: p.last_visit || '-' },
{ label: 'Visitas', value: p.total_visits },
{ label: 'Puntos', value: `${p.wallet_points} pts` },
]}
actions={
<>
<Button variant="ghost" size="sm" onClick={() => openHistory(p)}>
<User size={16} />
</Button>
<Button variant="ghost" size="sm" onClick={() => openEdit(p)}>
<Edit2 size={16} />
</Button>
</>
}
onClick={() => openHistory(p)}
/>
))}
</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">
<select
value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(1); }}
className="border border-theme-border-strong rounded-lg px-2 py-1.5 text-sm"
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
<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 crear/editar */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editing ? 'Editar paciente' : 'Nuevo paciente'}
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button onClick={save} loading={submitting}>{editing ? 'Actualizar' : 'Crear'}</Button>
</>
}
>
<div className="space-y-6 max-h-[70vh] overflow-y-auto pr-1">
{/* Foto del paciente */}
<div className="flex items-center gap-4">
{photo ? (
<img src={photo} alt="Foto" className="w-16 h-16 rounded-full object-cover border border-theme-border shrink-0" />
) : editing?.has_photo && editing.photo_url ? (
<img src={odooApi.patientPhotoUrl(editing.photo_url)} alt={editing.name} className="w-16 h-16 rounded-full object-cover border border-theme-border shrink-0" />
) : (
<div className="w-16 h-16 rounded-full bg-theme-accent-bg flex items-center justify-center text-theme-muted shrink-0">
<Camera size={22} />
</div>
)}
<div>
<input ref={photoRef} type="file" accept="image/*" className="hidden" onChange={onPhotoChange} />
<Button variant="outline" size="sm" onClick={() => photoRef.current?.click()}>
<Camera size={14} className="mr-1.5" /> Subir foto
</Button>
<p className="text-xs text-theme-muted mt-1">JPG o PNG, máx 5 MB.</p>
</div>
</div>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<User size={16} className="mr-2 text-theme-muted" /> Datos generales
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Nombre *" value={form.name} onChange={(e) => updateForm({ name: e.target.value })} />
<Input label="Teléfono *" value={form.phone} onChange={(e) => updateForm({ phone: e.target.value })} />
{phoneDup && (
<div className="sm:col-span-2 rounded-xl border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm text-amber-800">
<p className="font-medium">
Este teléfono ya está registrado: {phoneDup.name}. ¿Es la misma persona?
</p>
<div className="flex flex-wrap gap-2 mt-2">
<Button size="sm" variant="outline" onClick={() => openPatient(phoneDup.id)}>
Abrir su ficha
</Button>
<Button size="sm" variant="ghost" onClick={() => setPhoneDup(null)}>
Continuar de todos modos
</Button>
</div>
</div>
)}
<Input label="Email" type="email" value={form.email} onChange={(e) => updateForm({ email: e.target.value })} />
<Input label="Celular" value={form.mobile} onChange={(e) => updateForm({ mobile: e.target.value })} />
<Input label="Teléfono casa" value={form.home_phone} onChange={(e) => updateForm({ home_phone: e.target.value })} />
<Input label="WhatsApp" value={form.whatsapp} onChange={(e) => updateForm({ whatsapp: e.target.value })} />
<Input label="Fecha de nacimiento" type="date" value={form.birth_date} onChange={(e) => updateForm({ birth_date: e.target.value })} />
<Select label="Género" options={genderOptions} value={form.gender} onChange={(e) => updateForm({ gender: e.target.value })} />
<Select label="Tipo sanguíneo" options={bloodOptions} value={form.blood_type} onChange={(e) => updateForm({ blood_type: e.target.value })} />
<Select
label="Médico asignado"
options={[{ value: '', label: 'Sin asignar' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={form.primary_doctor_id}
onChange={(e) => updateForm({ primary_doctor_id: e.target.value })}
/>
</div>
</section>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<MapPin size={16} className="mr-2 text-theme-muted" /> Datos personales
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Lugar de nacimiento" value={form.birthplace} onChange={(e) => updateForm({ birthplace: e.target.value })} />
<Input label="Empleo / Ocupación" value={form.occupation} onChange={(e) => updateForm({ occupation: e.target.value })} />
<Select label="Estado civil" options={maritalOptions} value={form.marital_status} onChange={(e) => updateForm({ marital_status: e.target.value })} />
<Input label="Recomendado por" value={form.referred_by} onChange={(e) => updateForm({ referred_by: e.target.value })} />
<Input label="Contacto de emergencia" value={form.emergency_contact} onChange={(e) => updateForm({ emergency_contact: e.target.value })} />
<Input label="Teléfono de emergencia" value={form.emergency_phone} onChange={(e) => updateForm({ emergency_phone: e.target.value })} />
</div>
<div className="mt-4">
<TextArea label="Dirección completa" value={form.address_notes} onChange={(e) => updateForm({ address_notes: e.target.value })} />
</div>
</section>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<Baby size={16} className="mr-2 text-theme-muted" /> Antecedentes gineco-obstétricos
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={form.is_pregnant}
onChange={(e) => updateForm({ is_pregnant: e.target.checked })}
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
/>
<span className="text-sm text-theme-heading">¿Está embarazada?</span>
</label>
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={form.is_breastfeeding}
onChange={(e) => updateForm({ is_breastfeeding: e.target.checked })}
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
/>
<span className="text-sm text-theme-heading">¿Está lactando?</span>
</label>
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={form.uses_contraceptives}
onChange={(e) => updateForm({ uses_contraceptives: e.target.checked })}
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
/>
<span className="text-sm text-theme-heading">¿Usa anticonceptivos?</span>
</label>
<Input
label="Número de hijos"
type="number"
min={0}
value={String(form.children_count ?? 0)}
onChange={(e) => updateForm({ children_count: parseInt(e.target.value || '0', 10) })}
/>
</div>
</section>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<Stethoscope size={16} className="mr-2 text-theme-muted" /> Historia clínica
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{[
['kidney_problems', 'Problemas de riñón'],
['back_pain', 'Dolor de espalda'],
['heart_disease', 'Enfermedades cardíacas'],
['respiratory_problems', 'Problemas respiratorios'],
['blood_pressure', 'Presión arterial'],
['diabetes', 'Diabetes'],
['thyroid', 'Problemas de tiroides'],
['colitis', 'Colitis'],
['constipation', 'Estreñimiento'],
['liver_problems', 'Problemas de hígado'],
['surgeries', 'Cirugías'],
['varicose_veins', 'Varices'],
['migraine', 'Migraña'],
['faints_with_needles', 'Se desmaya con agujas'],
].map(([key, label]) => {
const checked = !!form[key as keyof PatientForm];
return (
<label key={key} className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input
type="checkbox"
checked={typeof checked === 'boolean' ? checked : false}
onChange={(e) => updateForm({ [key]: e.target.checked } as Partial<PatientForm>)}
className="w-4 h-4 rounded border-theme-border-strong text-theme-heading focus:ring-[#EDA588]"
/>
<span className="text-sm text-theme-heading">{label}</span>
</label>
);
})}
</div>
<div className="grid grid-cols-1 gap-4 mt-4">
<TextArea label="Alergias" value={form.allergies} onChange={(e) => updateForm({ allergies: e.target.value })} />
<TextArea label="Historial médico" value={form.medical_history} onChange={(e) => updateForm({ medical_history: e.target.value })} />
<TextArea label="Medicación actual" value={form.current_medication} onChange={(e) => updateForm({ current_medication: e.target.value })} />
<TextArea label="Detalle de cirugías" value={form.surgeries_notes} onChange={(e) => updateForm({ surgeries_notes: e.target.value })} />
<TextArea label="Notas médicas adicionales" value={form.medical_notes} onChange={(e) => updateForm({ medical_notes: e.target.value })} />
<TextArea label="Comentarios del paciente" value={form.patient_comments} onChange={(e) => updateForm({ patient_comments: e.target.value })} />
<TextArea label="Notas internas (solo equipo, no visible para el paciente)" value={form.internal_notes} onChange={(e) => updateForm({ internal_notes: e.target.value })} />
</div>
</section>
</div>
</Modal>
{/* Modal detalle */}
<Modal
isOpen={!!selected}
onClose={() => setSelected(null)}
title={selected?.name || 'Detalle del paciente'}
maxWidth="2xl"
footer={
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setEstadoCuentaOpen(true)}>
<FileText size={16} className="mr-2" />
Estado de cuenta
</Button>
<Button
variant="outline"
onClick={() => {
// El modal de detalle se cierra antes de abrir el de edición:
// ambos comparten z-index y el de detalle, al estar después en
// el DOM, tapaba el formulario (parecía que "Editar" no hacía nada).
if (!selected) return;
const p = selected;
setSelected(null);
openEdit(p);
}}
>
<Edit2 size={16} className="mr-2" />
Editar paciente
</Button>
</div>
}
>
<div className="space-y-4">
{/* Encabezado con foto */}
{selected && (
<div className="flex items-center gap-3">
{selected.has_photo && selected.photo_url ? (
<img
src={odooApi.patientPhotoUrl(selected.photo_url)}
alt={selected.name}
className="w-14 h-14 rounded-full object-cover border border-theme-border shrink-0"
/>
) : (
<div className="w-14 h-14 rounded-full bg-theme-accent-bg flex items-center justify-center text-lg font-bold text-theme-heading shrink-0">
{selected.name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase()).join('')}
</div>
)}
<div className="min-w-0">
<p className="font-semibold text-theme-heading truncate">{selected.name}</p>
<p className="text-xs text-theme-muted truncate">
{selected.patient_id}{selected.primary_doctor ? ` · Médico: ${selected.primary_doctor}` : ''}
</p>
</div>
</div>
)}
{/* Alertas clínicas */}
{selected?.allergies && (
<div className="flex items-start gap-2 p-3 bg-rose-100 text-rose-700 rounded-xl text-sm">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<p><span className="font-semibold">Alergias:</span> {selected.allergies}</p>
</div>
)}
{selected && CRITICAL_ALERTS.some(({ key }) => selected[key]) && (
<div className="flex flex-wrap gap-2">
{CRITICAL_ALERTS.filter(({ key }) => selected[key]).map(({ key, label }) => (
<span key={key} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-theme-warning text-theme-warning-text">
<AlertTriangle size={12} />
{label}
</span>
))}
</div>
)}
{/* Completitud del expediente */}
{selected && (
<div className="p-3 bg-theme-bg rounded-xl space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-theme-muted">Completitud del expediente</p>
<p className="text-sm font-semibold text-theme-heading">{selected.expediente_completion ?? 0}%</p>
</div>
<div className="h-2 bg-theme-surface border border-theme-border rounded-full overflow-hidden">
<div
className={`h-full ${(selected.expediente_completion ?? 0) >= 100 ? 'bg-brand-mint' : 'bg-brand-coral'}`}
style={{ width: `${selected.expediente_completion ?? 0}%` }}
/>
</div>
{selected.expediente_missing && selected.expediente_missing.length > 0 && (
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-xs text-theme-muted">Falta: {selected.expediente_missing.join(', ')}</p>
<Button
variant="outline"
size="sm"
onClick={() => { const p = selected; setSelected(null); openEdit(p); }}
>
Completar expediente
</Button>
</div>
)}
</div>
)}
<div className="flex gap-2 border-b border-theme-border pb-2 overflow-x-auto">
{([
{ key: 'info', label: 'Información general' },
{ key: 'clinical', label: 'Historia clínica' },
{ key: 'timeline', label: 'Historial' },
{ key: 'docs', label: 'Documentos' },
] as const).map(({ key, label }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`px-3 py-1.5 text-sm font-medium rounded-lg whitespace-nowrap ${
tab === key ? 'bg-[#1A1A1A] text-white' : 'text-theme-muted hover:bg-theme-bg'
}`}
>
{label}
</button>
))}
</div>
{historyLoading ? (
<Skeleton count={4} className="h-16 w-full" />
) : tab === 'info' ? (
<div className="space-y-4">
{/* Estado de cuenta */}
{history?.estado_cuenta && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Estado de cuenta</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<p className="text-xs text-theme-muted">Puntos monedero</p>
<p className="text-sm font-semibold text-theme-heading">{history.estado_cuenta.puntos_monedero} pts</p>
</div>
<div>
<p className="text-xs text-theme-muted">Total gastado</p>
<p className="text-sm font-semibold text-theme-heading">${history.estado_cuenta.total_gastado}</p>
</div>
<div>
<p className="text-xs text-theme-muted">Adeudo pendiente</p>
<p className={`text-sm font-semibold ${history.estado_cuenta.adeudo > 0 ? 'text-rose-600' : 'text-theme-heading'}`}>
${history.estado_cuenta.adeudo}
</p>
</div>
<div>
<p className="text-xs text-theme-muted">Total visitas</p>
<p className="text-sm font-semibold text-theme-heading">{history.estado_cuenta.visitas_total}</p>
</div>
</div>
</div>
)}
{/* Saldo a favor: dinero a cuenta del paciente */}
<div className="p-3 rounded-xl border border-emerald-200 bg-emerald-50/60">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-1 flex items-center gap-1">
<Wallet size={12} /> Saldo a favor
</p>
<p className="text-2xl font-heading font-semibold text-theme-heading">
{fmtMoney(wallet?.equivalent_mxn ?? 0)}
</p>
<p className="text-xs text-theme-muted">
{wallet?.equivalent_usd != null
? `≈ ${wallet.equivalent_usd.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} USD`
: 'Sin tipo de cambio'}
{(wallet?.depositado ?? 0) > 0 ? ` · recargado ${fmtMoney(wallet?.depositado ?? 0)}` : ''}
</p>
</div>
<Button size="sm" variant="outline" onClick={() => setRecargaOpen(true)}>
<Plus size={14} className="mr-1.5" /> Recargar saldo
</Button>
</div>
{(wallet?.movimientos || []).length > 0 && (
<div className="mt-3 pt-3 border-t border-emerald-200 max-h-52 overflow-y-auto space-y-1.5">
{wallet!.movimientos.slice(0, 25).map((m) => (
<div key={m.id} className="flex items-start justify-between gap-2 text-[11px]">
<span className="text-theme-muted min-w-0">
<span className="block text-theme-heading">{m.narration}</span>
<span className="block">{m.date}{m.operator ? ` · ${m.operator}` : ''}</span>
</span>
<span className={`shrink-0 font-semibold ${m.points > 0 ? 'text-emerald-700' : 'text-rose-600'}`}>
{m.points > 0 ? '+' : ''}{m.points}
</span>
</div>
))}
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<InfoRow label="Folio" value={selected?.patient_id} icon={FileText} />
<InfoRow label="Teléfono" value={selected?.phone} icon={Phone} />
<InfoRow label="Celular" value={selected?.mobile} icon={Phone} />
<InfoRow label="WhatsApp" value={selected?.whatsapp} icon={Phone} />
<InfoRow label="Email" value={selected?.email} icon={Mail} />
<InfoRow label="Teléfono casa" value={selected?.home_phone} icon={Phone} />
<InfoRow label="Fecha de nacimiento" value={selected?.birth_date} icon={Calendar} />
<InfoRow label="Edad" value={selected?.age} icon={Calendar} />
<InfoRow label="Género" value={formatGender(selected?.gender)} icon={User} />
<InfoRow label="Tipo sanguíneo" value={formatBlood(selected?.blood_type)} icon={Droplet} />
<InfoRow label="Lugar de nacimiento" value={selected?.birthplace} icon={MapPin} />
<InfoRow label="Empleo / Ocupación" value={selected?.occupation} icon={Briefcase} />
<InfoRow label="Estado civil" value={formatMarital(selected?.marital_status)} icon={Users} />
<InfoRow label="Contacto de emergencia" value={selected?.emergency_contact} icon={AlertCircle} />
<InfoRow label="Tel. emergencia" value={selected?.emergency_phone} icon={Phone} />
<InfoRow label="Recomendado por" value={selected?.referred_by} icon={Users} />
<InfoRow label="Médico asignado" value={selected?.primary_doctor || 'Sin asignar'} icon={Stethoscope} />
<InfoRow label="Puntos" value={`${selected?.wallet_points ?? 0} pts`} icon={Heart} />
</div>
{selected?.address_notes && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Dirección</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.address_notes}</p>
</div>
)}
{selected?.patient_comments && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Comentarios</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.patient_comments}</p>
</div>
)}
{selected?.internal_notes && (
<div className="p-3 bg-amber-50 border border-theme-bg rounded-xl">
<p className="text-xs text-theme-warning-text mb-1">🔒 Notas internas (solo equipo)</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{selected.internal_notes}</p>
</div>
)}
</div>
) : tab === 'clinical' ? (
<div className="space-y-4">
<div>
<h4 className="text-sm font-semibold text-theme-heading mb-2 flex items-center">
<Baby size={16} className="mr-2 text-theme-muted" /> Gineco-obstétricos
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<ClinicalItem label="Embarazo" value={selected?.is_pregnant} />
<ClinicalItem label="Lactancia" value={selected?.is_breastfeeding} />
<ClinicalItem label="Anticonceptivos" value={selected?.uses_contraceptives} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3">
<InfoRow label="Número de hijos" value={selected?.children_count} />
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-theme-heading mb-2 flex items-center">
<Stethoscope size={16} className="mr-2 text-theme-muted" /> Antecedentes
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<ClinicalItem label="Problemas de riñón" value={selected?.kidney_problems} />
<ClinicalItem label="Dolor de espalda" value={selected?.back_pain} />
<ClinicalItem label="Enfermedades cardíacas" value={selected?.heart_disease} />
<ClinicalItem label="Problemas respiratorios" value={selected?.respiratory_problems} />
<ClinicalItem label="Presión arterial" value={selected?.blood_pressure} />
<ClinicalItem label="Diabetes" value={selected?.diabetes} />
<ClinicalItem label="Problemas de tiroides" value={selected?.thyroid} />
<ClinicalItem label="Colitis" value={selected?.colitis} />
<ClinicalItem label="Estreñimiento" value={selected?.constipation} />
<ClinicalItem label="Problemas de hígado" value={selected?.liver_problems} />
<ClinicalItem label="Cirugías" value={selected?.surgeries} />
<ClinicalItem label="Varices" value={selected?.varicose_veins} />
<ClinicalItem label="Migraña" value={selected?.migraine} />
<ClinicalItem label="Se desmaya con agujas" value={selected?.faints_with_needles} />
</div>
</div>
{[
{ label: 'Alergias', value: selected?.allergies },
{ label: 'Historial médico', value: selected?.medical_history },
{ label: 'Medicación actual', value: selected?.current_medication },
{ label: 'Detalle de cirugías', value: selected?.surgeries_notes },
{ label: 'Notas médicas adicionales', value: selected?.medical_notes },
].map(({ label, value }) =>
value ? (
<div key={label} className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">{label}</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{value}</p>
</div>
) : null
)}
</div>
) : tab === 'docs' ? (
<div className="space-y-6">
{selected && (
<>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<FileText size={16} className="mr-2 text-theme-muted" /> Expediente escaneado
</h4>
<PatientAdjuntos patientId={selected.id} kind="expediente" />
</section>
<section>
<h4 className="text-sm font-semibold text-theme-heading mb-3 flex items-center">
<ClipboardList size={16} className="mr-2 text-theme-muted" /> Galería de imágenes
</h4>
<PatientAdjuntos patientId={selected.id} kind="imagen" />
</section>
</>
)}
</div>
) : (
timelineItems.length > 0 ? (
<div className="space-y-2">
{timelineItems.map((entry) => (
<button
key={`${entry.kind}-${entry.item.id}`}
type="button"
onClick={() => setEntryDetail(entry)}
className="w-full text-left p-3 border border-theme-border rounded-xl text-sm transition-all cursor-pointer hover:shadow-card hover:border-theme-border-strong"
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-theme-heading flex items-center">
{entry.kind === 'cita' ? (
<Calendar size={14} className="mr-1.5 text-theme-muted shrink-0" />
) : entry.kind === 'visita' ? (
<ClipboardList size={14} className="mr-1.5 text-theme-muted shrink-0" />
) : (
<ShoppingBag size={14} className="mr-1.5 text-theme-muted shrink-0" />
)}
{entry.kind === 'cita' ? `${entry.item.date} ${entry.item.time}` : entry.date}
</p>
<span className="flex items-center gap-1.5">
<Badge variant={
entry.kind === 'cita'
? badgeForAppointmentState(entry.item.state)
: entry.kind === 'visita'
? badgeForVisitaState(entry.item.state)
: badgeForSaleState(entry.item.state)
}>
{entry.kind === 'cita'
? (appointmentStateLabels[entry.item.state] || entry.item.state)
: entry.kind === 'visita'
? (visitaStateLabels[entry.item.state] || entry.item.state)
: (saleStateLabels[entry.item.state] || entry.item.state)}
</Badge>
<ChevronRight size={14} className="text-theme-muted" />
</span>
</div>
<p className="text-theme-muted mt-1">
{entry.kind === 'cita'
? entry.item.service
: entry.kind === 'visita'
? (entry.item.motivo || entry.item.servicio || 'Visita clínica')
: `${entry.item.name} — $${entry.item.total}`}
</p>
</button>
))}
</div>
) : (
<EmptyState title="Sin historial" subtitle="Este paciente no tiene citas, ventas ni visitas registradas." icon={<Activity size={28} />} />
)
)}
</div>
</Modal>
{/* Modal detalle de cita/venta/visita del historial */}
<Modal
isOpen={!!entryDetail}
onClose={() => setEntryDetail(null)}
title={entryDetail?.kind === 'cita' ? 'Detalle de la cita' : entryDetail?.kind === 'visita' ? 'Detalle de la visita' : 'Detalle de la venta'}
maxWidth="lg"
>
{entryDetail?.kind === 'cita' && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForAppointmentState(entryDetail.item.state)}>
{appointmentStateLabels[entryDetail.item.state] || entryDetail.item.state}
</Badge>
<Badge variant={entryDetail.item.payment_state === 'paid' ? 'paid' : entryDetail.item.payment_state === 'partial' ? 'warning' : 'unpaid'}>
{paymentStateLabels[entryDetail.item.payment_state] || entryDetail.item.payment_state}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoRow label="Referencia" value={entryDetail.item.reference} icon={FileText} />
<InfoRow label="Fecha y hora" value={`${entryDetail.item.date} · ${entryDetail.item.time}`} icon={Calendar} />
<InfoRow label="Servicio" value={entryDetail.item.service} icon={Stethoscope} />
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
<InfoRow label="Duración" value={entryDetail.item.duration ? `${entryDetail.item.duration} min` : '-'} />
<InfoRow label="Precio" value={`$${entryDetail.item.price}`} />
<InfoRow label="Monto pagado" value={`$${entryDetail.item.amount_paid ?? 0}`} />
<InfoRow label="Sucursal" value={branchLabels[entryDetail.item.branch || ''] || entryDetail.item.branch || '-'} icon={MapPin} />
<InfoRow label="Medio" value={mediumLabels[entryDetail.item.medium || ''] || entryDetail.item.medium || '-'} />
{entryDetail.item.package_finished && (
<InfoRow label="Paquete terminado" value={entryDetail.item.package_finished_date || 'Sí'} />
)}
</div>
{entryDetail.item.notes && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Notas</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{entryDetail.item.notes}</p>
</div>
)}
</div>
)}
{entryDetail?.kind === 'venta' && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForSaleState(entryDetail.item.state)}>
{saleStateLabels[entryDetail.item.state] || entryDetail.item.state}
</Badge>
{entryDetail.item.refunded && (
<Badge variant="danger">Reembolsada ${entryDetail.item.refund_amount}</Badge>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<InfoRow label="Folio" value={entryDetail.item.name} icon={FileText} />
<InfoRow label="Fecha" value={entryDetail.item.date} icon={Calendar} />
<InfoRow label="Subtotal" value={`$${entryDetail.item.subtotal}`} />
<InfoRow label="Descuento" value={`$${entryDetail.item.discount}`} />
<InfoRow label="Impuesto" value={`$${entryDetail.item.tax}`} />
<InfoRow label="Total" value={`$${entryDetail.item.total}`} />
<InfoRow label="Pagado" value={`$${entryDetail.item.amount_paid}`} />
<InfoRow label="Por cobrar" value={`$${entryDetail.item.amount_due}`} />
</div>
{entryDetail.item.refunded && entryDetail.item.refund_reason && (
<div className="p-3 bg-rose-100 rounded-xl">
<p className="text-xs text-rose-700 mb-1">Motivo de reembolso{entryDetail.item.refunded_at ? ` · ${entryDetail.item.refunded_at}` : ''}</p>
<p className="text-sm text-rose-700">{entryDetail.item.refund_reason}</p>
</div>
)}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Conceptos</p>
<div className="border border-theme-border rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2.5 text-xs font-medium text-theme-muted">Servicio</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Cant.</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Precio</th>
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">Subtotal</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{entryDetail.item.lines.map((line) => (
<tr key={line.id}>
<td className="p-2.5 text-theme-heading">
{line.service || line.description}
{line.prescribed_by && (
<span className="block text-xs text-theme-muted">Recetó: {line.prescribed_by}</span>
)}
</td>
<td className="p-2.5 text-right text-theme-muted">{line.quantity}</td>
<td className="p-2.5 text-right text-theme-muted">${line.price_unit}</td>
<td className="p-2.5 text-right font-medium text-theme-heading">${line.subtotal}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{entryDetail?.kind === 'visita' && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForVisitaState(entryDetail.item.state)}>
{visitaStateLabels[entryDetail.item.state] || entryDetail.item.state}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoRow label="Folio" value={entryDetail.item.name} icon={FileText} />
<InfoRow label="Inicio" value={entryDetail.item.date_start || '-'} icon={Calendar} />
<InfoRow label="Fin" value={entryDetail.item.date_end || '-'} icon={Calendar} />
<InfoRow label="Servicio" value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
<InfoRow label="Médico" value={entryDetail.item.doctor || 'Sin asignar'} icon={User} />
<InfoRow label="Cosmetóloga" value={entryDetail.item.cosmetologa || 'Sin asignar'} icon={User} />
</div>
{[
{ label: 'Motivo de consulta', value: entryDetail.item.motivo },
{ label: 'Diagnóstico', value: entryDetail.item.diagnostico },
{ label: 'Tratamiento', value: entryDetail.item.tratamiento },
{ label: 'Notas', value: entryDetail.item.notas },
].map(({ label, value }) =>
value ? (
<div key={label} className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">{label}</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{value}</p>
</div>
) : null
)}
</div>
)}
</Modal>
{/* Vista de impresión del estado de cuenta */}
<EstadoCuentaPrint
isOpen={estadoCuentaOpen}
onClose={() => setEstadoCuentaOpen(false)}
paciente={selected}
/>
<RecargarSaldoModal
isOpen={recargaOpen}
onClose={() => setRecargaOpen(false)}
patientId={selected?.id ?? null}
patientName={selected?.name}
saldoActual={wallet?.equivalent_mxn ?? 0}
onDone={(receipt) => {
setReciboDeposito(receipt);
if (selected?.id) {
odooApi.getPatientWallet(selected.id)
.then((w) => { if (w.status === 'success') setWallet(w); })
.catch(() => {});
}
}}
/>
<ReciboPrint receipt={reciboDeposito} onClose={() => setReciboDeposito(null)} />
</Layout>
);
};
interface PatientForm {
name: string;
phone: string;
email: string;
birth_date: string;
gender: string;
blood_type: string;
birthplace: string;
occupation: string;
marital_status: string;
emergency_contact: string;
emergency_phone: string;
home_phone: string;
mobile: string;
whatsapp: string;
primary_doctor_id: string;
address_notes: string;
referred_by: string;
patient_comments: string;
internal_notes: string;
allergies: string;
medical_history: string;
current_medication: string;
medical_notes: string;
surgeries_notes: string;
children_count: number;
is_pregnant: boolean;
is_breastfeeding: boolean;
uses_contraceptives: boolean;
kidney_problems: boolean;
back_pain: boolean;
heart_disease: boolean;
respiratory_problems: boolean;
blood_pressure: boolean;
diabetes: boolean;
thyroid: boolean;
colitis: boolean;
constipation: boolean;
liver_problems: boolean;
surgeries: boolean;
varicose_veins: boolean;
migraine: boolean;
faints_with_needles: boolean;
}
export default Pacientes;