Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy

- Módulo Visitas completo (auto desde agenda, insumos con descargo de
  inventario, fotos antes/después y documentos, receta imprimible)
- Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con
  puntos monedero, ticket imprimible)
- WACRM: leads automáticos desde WhatsApp, asignación de conversaciones
  y leads a agentes, conversión lead→paciente, ficha del paciente en chat
- Pacientes: completitud de expediente, alertas clínicas, historial
  unificado con detalle, foto, WhatsApp, estado de cuenta, filtros
  rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería)
- Agenda: vistas por médico y por hora, filtros rápidos (libres, primera
  vez, check-in, no-show), modal de acciones, bloqueos por médico,
  drag&drop para mover citas
- Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos,
  comisiones, pagos, devoluciones, top clientes, horas, paquetes,
  vendedores, concentrado, recomendaciones, KPIs) con exportación Excel
- Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo
  treeview, selector de tema; accesos rápidos personalizables con 3
  presentaciones; búsqueda global; notificaciones reales
- Configuración: secciones (clínica, usuarios con permisos por sección,
  recetas, catálogos de diagnósticos y procedimientos)
- Inventario: alertas de caducidad y sugerencia de compra, cron diario
  que descuenta artículos caducados, compras/bajas
- Consultas Médicas, página Expedientes, importadores delta
  (citas/visitas legacy idempotentes), depuración de duplicados
- Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias
  Odoo corregidas (noupdate, company_id), rollback en validaciones
This commit is contained in:
2026-08-13 23:30:08 +00:00
parent a718592291
commit 01f6007e30
109 changed files with 19067 additions and 931 deletions

View File

@@ -1,11 +1,12 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } 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,
Download, ChevronRight, AlertTriangle, ClipboardList, Camera,
} from 'lucide-react';
import Layout from '../components/Layout';
import PatientAdjuntos from '../components/PatientAdjuntos';
import {
Card,
Button,
@@ -20,9 +21,12 @@ import {
toast,
TextArea,
badgeForAppointmentState,
badgeForSaleState,
} from '../components/ui';
import { odooApi, type Patient, type Appointment, type Sale } from '../services/odoo';
import { downloadCsv } from '../lib/utils';
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 } from '../services/odoo';
import { exportToExcel } from '../lib/exporter';
const genderOptions = [
{ value: '', label: 'No especificado' },
@@ -75,22 +79,66 @@ const formatBlood = (bt?: string | false) => {
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 [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [searchParams] = useSearchParams();
const [search, setSearch] = 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 [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[] } | null>(null);
const [history, setHistory] = useState<{ appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta?: EstadoCuenta } | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [tab, setTab] = useState<'info' | 'clinical' | 'appointments' | 'sales'>('info');
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 [onlyIncomplete, setOnlyIncomplete] = useState(false);
const [onlyVip, setOnlyVip] = useState(false);
const [onlyRecent, setOnlyRecent] = 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 emptyForm: PatientForm = {
name: '',
@@ -106,6 +154,8 @@ const Pacientes: FC = () => {
emergency_phone: '',
home_phone: '',
mobile: '',
whatsapp: '',
primary_doctor_id: '',
address_notes: '',
referred_by: '',
patient_comments: '',
@@ -140,7 +190,15 @@ const Pacientes: FC = () => {
try {
setLoading(true);
setError(null);
const res = await odooApi.getPatients({ search, page, page_size: pageSize });
const res = await odooApi.getPatients({
search,
page,
page_size: pageSize,
incomplete: onlyIncomplete ? 1 : undefined,
vip: onlyVip ? 1 : undefined,
recent: onlyRecent ? 1 : undefined,
doctor_id: doctorFilter ? parseInt(doctorFilter, 10) : undefined,
});
if (res.status === 'success') {
setPatients(res.patients);
setTotal(res.total ?? 0);
@@ -153,11 +211,17 @@ const Pacientes: FC = () => {
} finally {
setLoading(false);
}
}, [search, page, pageSize]);
}, [search, page, pageSize, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
useEffect(() => {
setPage(1);
}, [search]);
}, [search, onlyIncomplete, onlyVip, onlyRecent, doctorFilter]);
useEffect(() => {
odooApi.getDoctors()
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
.catch(() => {});
}, []);
useEffect(() => {
load();
@@ -169,7 +233,7 @@ const Pacientes: FC = () => {
setHistoryLoading(true);
try {
const res = await odooApi.getPatientHistory(patient.id);
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales });
if (res.status === 'success') setHistory({ appointments: res.appointments, sales: res.sales, visitas: res.visitas || [], estado_cuenta: res.estado_cuenta });
} catch (err) {
toast.error('Error al cargar historial');
console.error(err);
@@ -181,11 +245,13 @@ const Pacientes: FC = () => {
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setPhoto(null);
setModalOpen(true);
};
const openEdit = (patient: Patient) => {
setEditing(patient);
setPhoto(null);
setForm({
...emptyForm,
...patient,
@@ -193,6 +259,8 @@ const Pacientes: FC = () => {
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,
@@ -219,6 +287,19 @@ const Pacientes: FC = () => {
setForm((prev) => ({ ...prev, ...patch }));
};
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');
@@ -232,10 +313,13 @@ const Pacientes: FC = () => {
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) {
await odooApi.updatePatient(editing.id, payload);
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');
@@ -254,6 +338,7 @@ const Pacientes: FC = () => {
<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>
@@ -285,18 +370,47 @@ const Pacientes: FC = () => {
</div>
);
const exportPatients = () => {
const rows: (string | number | boolean | null | undefined)[][] = [
['Expediente', 'Nombre', 'Teléfono', 'Email', 'Fecha nacimiento', 'Género', 'Última visita', 'Visitas', 'Puntos monedero', 'Total gastado', 'Fuente', 'VIP'],
];
patients.forEach((p) =>
rows.push([
p.patient_id, p.name, p.phone, p.email, p.birth_date, p.gender || '', p.last_visit,
p.total_visits, p.wallet_points, p.total_spent, p.source, p.is_vip ? 'Sí' : 'No',
])
);
downloadCsv(`pacientes-skeen-${new Date().toISOString().split('T')[0]}.csv`, rows);
toast.success(`CSV descargado (${patients.length} pacientes de la vista actual)`);
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))
: [];
const exportPatients = async () => {
try {
await exportToExcel({
filename: `pacientes-skeen-${new Date().toISOString().split('T')[0]}`,
sheetName: 'Pacientes',
title: 'Pacientes SKEEN',
subtitle: `${patients.length} pacientes de la vista actual`,
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: patients.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 (${patients.length} pacientes de la vista actual)`);
} catch (err) {
toast.error('Error al exportar');
console.error(err);
}
};
return (
@@ -324,6 +438,49 @@ const Pacientes: FC = () => {
className="pl-10"
/>
</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: setOnlyRecent },
{ 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 || onlyIncomplete || doctorFilter) && (
<button
type="button"
onClick={() => { setOnlyVip(false); setOnlyRecent(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>}
@@ -350,6 +507,14 @@ const Pacientes: FC = () => {
{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>
@@ -378,6 +543,7 @@ const Pacientes: FC = () => {
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 },
@@ -441,6 +607,26 @@ const Pacientes: FC = () => {
}
>
<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
@@ -451,9 +637,16 @@ const Pacientes: FC = () => {
<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>
@@ -570,14 +763,85 @@ const Pacientes: FC = () => {
onClose={() => setSelected(null)}
title={selected?.name || 'Detalle del paciente'}
maxWidth="2xl"
footer={
<Button variant="outline" onClick={() => selected && openEdit(selected)}>
<Edit2 size={16} className="mr-2" />
Editar paciente
</Button>
}
>
<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={() => openEdit(selected)}>
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: 'appointments', label: 'Citas' },
{ key: 'sales', label: 'Ventas' },
{ key: 'timeline', label: 'Historial' },
{ key: 'docs', label: 'Documentos' },
] as const).map(({ key, label }) => (
<button
key={key}
@@ -595,10 +859,37 @@ const Pacientes: FC = () => {
<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>
)}
<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} />
@@ -611,6 +902,7 @@ const Pacientes: FC = () => {
<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 && (
@@ -634,27 +926,39 @@ const Pacientes: FC = () => {
</div>
) : tab === 'clinical' ? (
<div className="space-y-4">
<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} />
<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>
<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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoRow label="Número de hijos" value={selected?.children_count} />
<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 },
@@ -671,43 +975,208 @@ const Pacientes: FC = () => {
) : null
)}
</div>
) : tab === 'appointments' ? (
history && history.appointments.length > 0 ? (
<div className="space-y-2">
{history.appointments.map((a) => (
<div key={a.id} className="p-3 border border-theme-border rounded-xl text-sm">
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">
{a.date} {a.time}
</p>
<Badge variant={badgeForAppointmentState(a.state)}>{a.state}</Badge>
</div>
<p className="text-theme-muted mt-1">{a.service}</p>
</div>
))}
</div>
) : (
<EmptyState title="Sin citas" subtitle="Este paciente no tiene citas registradas." icon={<Activity size={28} />} />
)
) : 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>
) : (
history && history.sales.length > 0 ? (
timelineItems.length > 0 ? (
<div className="space-y-2">
{history.sales.map((s) => (
<div key={s.id} className="p-3 border border-theme-border rounded-xl text-sm">
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">{s.name}</p>
<p className="font-semibold text-theme-heading">${s.total}</p>
{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">{s.date} — {s.state}</p>
</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 ventas" subtitle="Este paciente no tiene ventas registradas." icon={<ShoppingBag size={28} />} />
<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>
</Layout>
);
};
@@ -726,6 +1195,8 @@ interface PatientForm {
emergency_phone: string;
home_phone: string;
mobile: string;
whatsapp: string;
primary_doctor_id: string;
address_notes: string;
referred_by: string;
patient_comments: string;