1493 lines
71 KiB
TypeScript
1493 lines
71 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, 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<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 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, can } = useAuth();
|
|
const term = useBusinessTerm();
|
|
const { t } = useI18n();
|
|
const { labelFor: branchLabel } = useSucursales();
|
|
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' | '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<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">{t('patients.colName')}</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colRecord')}</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">{t('patients.colPhone')}</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{t('patients.colAge')}</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">{t('patients.colLastVisit')}</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{term('visit_plural')}</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colPoints')}</th>
|
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">{t('patients.colActions')}</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={term('patient_plural')} subtitle={t('patients.subtitle')}>
|
|
<PageHeader title={term('patient_plural')} subtitle={t('patients.pageSubtitle')}>
|
|
<Button variant="outline" onClick={exportPatients} loading={exporting} disabled={patients.length === 0 || !can('pacientes.exportar')}
|
|
title={can('pacientes.exportar') ? undefined : t('patients.noExportPerm')}>
|
|
<Download size={16} className="mr-2" />
|
|
{t('patients.exportCsv')}
|
|
</Button>
|
|
<Button onClick={openCreate}>
|
|
<Plus size={16} className="mr-2" />
|
|
{t('patients.newWord')} {term('patient_singular').toLowerCase()}
|
|
</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={t('patients.searchPlaceholder')}
|
|
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={t('patients.clearSearch')}
|
|
aria-label={t('patients.clearSearch')}
|
|
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: t('patients.allDoctors') },
|
|
...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: t('patients.filterRecent'), active: onlyRecent, set: (v: boolean) => { setOnlyRecent(v); if (v) setOnlyStale(false); } },
|
|
{ key: 'stale', label: t('patients.filterStale'), active: onlyStale, set: (v: boolean) => { setOnlyStale(v); if (v) setOnlyRecent(false); } },
|
|
{ key: 'incomplete', label: t('patients.filterIncomplete'), 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"
|
|
>
|
|
{t('patients.clearFilters')}
|
|
</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={t('patients.emptyTitle')}
|
|
subtitle={t('patients.emptySub')}
|
|
actionLabel={`${t('patients.newWord')} ${term('patient_singular').toLowerCase()}`}
|
|
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 ? t('patients.missingRecord', { list: p.expediente_missing.join(', ') }) : t('patients.complete')}
|
|
>
|
|
{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 ${term('patient_singular').toLowerCase()}` : `Nuevo ${term('patient_singular').toLowerCase()}`}
|
|
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: t('patients.tabInfo') },
|
|
{ key: 'clinical', label: t('patients.tabClinical') },
|
|
{ key: 'timeline', label: t('patients.tabTimeline') },
|
|
{ key: 'docs', label: t('patients.tabDocs') },
|
|
{ key: 'formularios', label: t('patients.tabForms') },
|
|
] 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 || t('patients.unassigned')} 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>
|
|
)}
|
|
{/* Membresías del paciente (solo si la clínica las tiene activas) */}
|
|
{selected?.id ? (
|
|
<div className="p-3 rounded-xl border border-theme-border">
|
|
<MembresiasPaciente partnerId={selected.id} />
|
|
</div>
|
|
) : null}
|
|
</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" /> {t('patients.scannedRecord')}
|
|
</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>
|
|
) : tab === 'formularios' ? (
|
|
selected ? (
|
|
<FormulariosPaciente patientId={selected.id} patientName={selected.name} />
|
|
) : null
|
|
) : (
|
|
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"
|
|
footer={
|
|
entryDetail?.kind === 'cita' && can('formularios.enviar') ? (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setEnviarFormularioCita({
|
|
patientId: entryDetail.item.patient_id,
|
|
patientName: entryDetail.item.patient,
|
|
citaId: entryDetail.item.id,
|
|
})}
|
|
>
|
|
<FileSignature size={16} className="mr-2" /> Enviar formulario
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
>
|
|
{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={term('service_singular')} value={entryDetail.item.service} icon={Stethoscope} />
|
|
<InfoRow label={term('doctor_singular')} 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={branchLabel(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">{t('patients.colService')}</th>
|
|
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('patients.colQty')}</th>
|
|
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('patients.colPrice')}</th>
|
|
<th className="text-right p-2.5 text-xs font-medium text-theme-muted">{t('common.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={term('service_singular')} value={entryDetail.item.servicio || '-'} icon={Stethoscope} />
|
|
<InfoRow label={term('doctor_singular')} 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)} />
|
|
<EnviarFormularioModal
|
|
isOpen={!!enviarFormularioCita}
|
|
onClose={() => setEnviarFormularioCita(null)}
|
|
patientId={enviarFormularioCita?.patientId ?? null}
|
|
patientName={enviarFormularioCita?.patientName}
|
|
citaId={enviarFormularioCita?.citaId ?? null}
|
|
onSent={() => {
|
|
if (selected?.id) setTab('formularios');
|
|
}}
|
|
/>
|
|
</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;
|