Oleada UX 2: Agenda (mensajes de conflicto humanos, imprimir agenda del dia, confirmacion en lote, prefs persistentes) y Pacientes (acciones en ficha, timeline unificada, aviso de telefono duplicado) + fixes de precarga e impresion
This commit is contained in:
194
frontend/src/components/AgendaPrint.tsx
Normal file
194
frontend/src/components/AgendaPrint.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { FC } from 'react';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { Button, Modal } from './ui';
|
||||
import type { Appointment, Bloqueo } from '../services/odoo';
|
||||
|
||||
// Vista de impresión de la agenda del día (patrón print-*): un bloque por
|
||||
// doctor con sus citas ordenadas por hora y los huecos libres del horario
|
||||
// de clínica (8:00-19:00). Se usa para pegar la lista en cada consultorio.
|
||||
|
||||
const CLINIC_START = 8; // 8:00
|
||||
const CLINIC_END = 19; // 19:00
|
||||
const SLOT_MIN = 15;
|
||||
|
||||
const floatToHHMM = (h: number) => {
|
||||
const hh = Math.floor(h);
|
||||
const mm = Math.round((h - hh) * 60);
|
||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const timeToFloat = (t: string): number | null => {
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec((t || '').trim());
|
||||
return m ? parseInt(m[1], 10) + parseInt(m[2], 10) / 60 : null;
|
||||
};
|
||||
|
||||
const confirmText = (apt: Appointment): string => {
|
||||
if (apt.state === 'done') return 'Completada';
|
||||
if (['arrived', 'in_progress'].includes(apt.state)) return 'En clínica';
|
||||
if (apt.phone_confirmed) return `Confirmada por teléfono${apt.phone_confirmed_by ? ` (${apt.phone_confirmed_by})` : ''}`;
|
||||
return 'SIN confirmar';
|
||||
};
|
||||
|
||||
interface AgendaPrintProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
appointments: Appointment[];
|
||||
bloqueos: Bloqueo[];
|
||||
doctors: { id: number; name: string; job_title?: string }[];
|
||||
filterDoctorName: string | null;
|
||||
}
|
||||
|
||||
const AgendaPrint: FC<AgendaPrintProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
date,
|
||||
dateLabel,
|
||||
appointments,
|
||||
bloqueos,
|
||||
doctors,
|
||||
filterDoctorName,
|
||||
}) => {
|
||||
const dayApts = appointments.filter((a) => a.date === date);
|
||||
// Bloques: un doctor por bloque (o solo el filtrado). Incluye "Sin asignar"
|
||||
// si hay citas sin doctor.
|
||||
const groups: { key: string; name: string; subtitle: string; apts: Appointment[] }[] = [];
|
||||
const doctorsToPrint = filterDoctorName ? doctors.filter((d) => d.name === filterDoctorName) : doctors;
|
||||
for (const d of doctorsToPrint) {
|
||||
groups.push({
|
||||
key: `d-${d.id}`,
|
||||
name: d.name,
|
||||
subtitle: d.job_title || '',
|
||||
apts: dayApts
|
||||
.filter((a) => a.doctor_id === d.id)
|
||||
.sort((a, b) => (timeToFloat(a.time) ?? 0) - (timeToFloat(b.time) ?? 0)),
|
||||
});
|
||||
}
|
||||
const sinAsignar = dayApts.filter((a) => !a.doctor_id);
|
||||
if (sinAsignar.length > 0) {
|
||||
groups.push({ key: 'none', name: 'Sin asignar', subtitle: '', apts: sinAsignar });
|
||||
}
|
||||
|
||||
const dayBloqueos = bloqueos.filter((b) => b.date === date);
|
||||
|
||||
// Huecos libres del doctor: rangos contiguos fuera de citas (activas) y bloqueos
|
||||
const huecosDe = (doctorId: number | null, apts: Appointment[]): string => {
|
||||
const busy: [number, number][] = apts
|
||||
.filter((a) => !['cancelled', 'no_show'].includes(a.state))
|
||||
.map((a) => {
|
||||
const start = timeToFloat(a.time) ?? 0;
|
||||
return [start, start + (a.duration ?? 30) / 60] as [number, number];
|
||||
});
|
||||
for (const b of dayBloqueos.filter((bl) => bl.doctor_id === doctorId)) {
|
||||
busy.push(b.all_day ? [CLINIC_START, CLINIC_END] : [b.time_from, b.time_to]);
|
||||
}
|
||||
const free: string[] = [];
|
||||
let cursor = CLINIC_START;
|
||||
const ordered = busy.sort((a, b) => a[0] - b[0]);
|
||||
for (const [ini, fin] of ordered) {
|
||||
const s = Math.max(ini, cursor);
|
||||
if (s - cursor >= SLOT_MIN / 60 - 1e-9) {
|
||||
free.push(`${floatToHHMM(cursor)}–${floatToHHMM(s)}`);
|
||||
}
|
||||
cursor = Math.max(cursor, Math.min(fin, CLINIC_END));
|
||||
if (cursor >= CLINIC_END - 1e-9) break;
|
||||
}
|
||||
if (cursor < CLINIC_END - 1e-9) {
|
||||
free.push(`${floatToHHMM(cursor)}–${floatToHHMM(CLINIC_END)}`);
|
||||
}
|
||||
return free.join(', ');
|
||||
};
|
||||
|
||||
const totalCitas = groups.reduce((n, g) => n + g.apts.length, 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Vista de impresión — Agenda del día"
|
||||
maxWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>Cerrar</Button>
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer size={16} className="mr-2" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="print-agenda bg-white text-[#222] p-6 rounded-xl border border-theme-border text-sm">
|
||||
{/* Encabezado */}
|
||||
<div className="flex items-start justify-between border-b-2 border-[#1A1A1A] pb-3 mb-4">
|
||||
<div>
|
||||
<p className="text-lg font-bold">SKEEN Derma Experts</p>
|
||||
<p className="text-sm text-[#555]">Agenda del día</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">{dateLabel}</p>
|
||||
<p className="text-xs text-[#555]">{date}</p>
|
||||
<p className="text-xs text-[#555]">
|
||||
{totalCitas} {totalCitas === 1 ? 'cita' : 'citas'}
|
||||
{filterDoctorName ? ` · ${filterDoctorName}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Un bloque por doctor */}
|
||||
<div className="space-y-5">
|
||||
{groups.map((g) => {
|
||||
const doctorId = g.key === 'none' ? null : parseInt(g.key.slice(2), 10);
|
||||
const huecos = huecosDe(doctorId, g.apts);
|
||||
return (
|
||||
<section key={g.key} className="break-inside-avoid">
|
||||
<p className="font-bold text-[13px] uppercase tracking-wide border-b border-[#ccc] pb-1 mb-1.5">
|
||||
{g.name}
|
||||
{g.subtitle ? <span className="font-normal text-[#666] normal-case"> · {g.subtitle}</span> : ''}
|
||||
<span className="float-right font-normal normal-case text-[#555]">
|
||||
{g.apts.length} {g.apts.length === 1 ? 'cita' : 'citas'}
|
||||
</span>
|
||||
</p>
|
||||
{g.apts.length === 0 ? (
|
||||
<p className="text-[#777] italic py-1">Sin citas para este día.</p>
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-[10px] uppercase text-[#666]">
|
||||
<th className="text-left py-1 pr-2 w-14">Hora</th>
|
||||
<th className="text-left py-1 pr-2">Paciente</th>
|
||||
<th className="text-left py-1 pr-2">Servicio</th>
|
||||
<th className="text-left py-1 pr-2 w-32">Teléfono</th>
|
||||
<th className="text-left py-1 w-44">Confirmación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{g.apts.map((a) => (
|
||||
<tr key={a.id} className="border-t border-[#eee]">
|
||||
<td className="py-1 pr-2 font-semibold whitespace-nowrap">{a.time}</td>
|
||||
<td className="py-1 pr-2">{a.patient}</td>
|
||||
<td className="py-1 pr-2 text-[#444]">{a.service}</td>
|
||||
<td className="py-1 pr-2 text-[#444] whitespace-nowrap">{a.phone || '—'}</td>
|
||||
<td className="py-1 text-[#444]">{confirmText(a)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="text-[11px] text-[#666] mt-1">
|
||||
<span className="font-semibold">Huecos libres:</span> {huecos || 'ninguno'}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-[#888] mt-5 border-t border-[#eee] pt-2">
|
||||
Impreso el {new Date().toLocaleString('es-MX')} · Las citas canceladas y los no-show no ocupan horario.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgendaPrint;
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { X, Star, AlertTriangle, Phone, MessageCircle, Mail, Wallet, Stethoscope, ArrowRight } from 'lucide-react';
|
||||
import { odooApi, type Patient, type Visita, type EstadoCuenta } from '../services/odoo';
|
||||
import {
|
||||
X, Star, AlertTriangle, Phone, MessageCircle, Mail, Wallet, Stethoscope, ArrowRight,
|
||||
Calendar, ClipboardList, ShoppingBag, CreditCard, History,
|
||||
} from 'lucide-react';
|
||||
import { odooApi, type Patient, type Visita, type EstadoCuenta, type Appointment, type Sale, type Payment } from '../services/odoo';
|
||||
import { Badge, Button, Skeleton } from './ui';
|
||||
import { waLink } from '../lib/whatsapp';
|
||||
import { badgeForAppointmentState, badgeForSaleState } from './ui';
|
||||
|
||||
// Drawer lateral global con el resumen del expediente del paciente.
|
||||
// Se abre por patient_id desde cualquier pantalla (Agenda, POS, Ventas, Pagos)
|
||||
@@ -24,14 +29,53 @@ const fmtMoney = (n: number) =>
|
||||
const initials = (name: string) =>
|
||||
name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase()).join('');
|
||||
|
||||
const waDigits = (phone: string) => (phone || '').replace(/\D/g, '');
|
||||
const fmtFecha = (s: string | null | undefined) => {
|
||||
if (!s) return '—';
|
||||
const d = new Date(`${s.slice(0, 10)}T12:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return s.slice(0, 10);
|
||||
return d.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
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 paymentMethodLabels: Record<string, string> = {
|
||||
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia', stripe: 'Stripe', mercadopago: 'MercadoPago',
|
||||
};
|
||||
|
||||
// Entrada unificada del historial: cita, visita, venta o pago
|
||||
type TimelineEntry =
|
||||
| { kind: 'cita'; sort: string; date: string | null; apt: Appointment }
|
||||
| { kind: 'visita'; sort: string; date: string | null; visita: Visita }
|
||||
| { kind: 'venta'; sort: string; date: string | null; sale: Sale }
|
||||
| { kind: 'pago'; sort: string; date: string | null; pago: Payment };
|
||||
|
||||
const TIMELINE_PAGE = 30;
|
||||
|
||||
const entryId = (e: TimelineEntry): number =>
|
||||
e.kind === 'cita' ? e.apt.id : e.kind === 'visita' ? e.visita.id : e.kind === 'venta' ? e.sale.id : e.pago.id;
|
||||
|
||||
const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ patientId, onClose }) => {
|
||||
const navigate = useNavigate();
|
||||
const [patient, setPatient] = useState<Patient | null>(null);
|
||||
const [visitas, setVisitas] = useState<Visita[]>([]);
|
||||
const [cuenta, setCuenta] = useState<EstadoCuenta | null>(null);
|
||||
const [history, setHistory] = useState<{
|
||||
appointments: Appointment[];
|
||||
sales: Sale[];
|
||||
visitas: Visita[];
|
||||
pagos: Payment[];
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<'resumen' | 'historial'>('resumen');
|
||||
const [visibleCount, setVisibleCount] = useState(TIMELINE_PAGE);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -39,6 +83,9 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa
|
||||
setPatient(null);
|
||||
setCuenta(null);
|
||||
setVisitas([]);
|
||||
setHistory(null);
|
||||
setTab('resumen');
|
||||
setVisibleCount(TIMELINE_PAGE);
|
||||
Promise.all([odooApi.getPatient(patientId), odooApi.getPatientHistory(patientId)])
|
||||
.then(([pRes, hRes]) => {
|
||||
if (!active) return;
|
||||
@@ -46,6 +93,12 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa
|
||||
if (hRes.status === 'success') {
|
||||
setVisitas((hRes.visitas || []).slice(0, 5));
|
||||
setCuenta(hRes.estado_cuenta);
|
||||
setHistory({
|
||||
appointments: hRes.appointments || [],
|
||||
sales: hRes.sales || [],
|
||||
visitas: hRes.visitas || [],
|
||||
pagos: (hRes as { pagos?: Payment[] }).pagos || [],
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -66,6 +119,52 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa
|
||||
|
||||
const adeudo = cuenta?.adeudo ?? 0;
|
||||
const whatsapp = patient?.whatsapp || patient?.phone || '';
|
||||
const waHref = waLink(whatsapp);
|
||||
|
||||
// Timeline cronológica descendente: citas + visitas + ventas + pagos
|
||||
const timeline = useMemo<TimelineEntry[]>(() => {
|
||||
if (!history) return [];
|
||||
const items: TimelineEntry[] = [
|
||||
...history.appointments.map((apt) => ({
|
||||
kind: 'cita' as const,
|
||||
sort: `${apt.date || ''} ${apt.time || ''}`,
|
||||
date: apt.date,
|
||||
apt,
|
||||
})),
|
||||
...history.visitas.map((v) => ({
|
||||
kind: 'visita' as const,
|
||||
sort: (v.date_start || '').replace('T', ' ').slice(0, 16),
|
||||
date: v.date_start,
|
||||
visita: v,
|
||||
})),
|
||||
...history.sales.map((s) => ({
|
||||
kind: 'venta' as const,
|
||||
sort: (s.date || '').slice(0, 10),
|
||||
date: s.date,
|
||||
sale: s,
|
||||
})),
|
||||
...history.pagos.map((p) => ({
|
||||
kind: 'pago' as const,
|
||||
sort: (p.create_date || '').replace('T', ' ').slice(0, 16),
|
||||
date: p.create_date ?? null,
|
||||
pago: p,
|
||||
})),
|
||||
];
|
||||
return items.sort((a, b) => b.sort.localeCompare(a.sort));
|
||||
}, [history]);
|
||||
|
||||
// Navegación desde la timeline: cierra el drawer y va al módulo
|
||||
const gotoModule = (path: string) => {
|
||||
onClose();
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const entryIcon = (kind: TimelineEntry['kind']) => {
|
||||
if (kind === 'cita') return <Calendar size={14} className="shrink-0 text-theme-accent" />;
|
||||
if (kind === 'visita') return <Stethoscope size={14} className="shrink-0 text-violet-600" />;
|
||||
if (kind === 'venta') return <ShoppingBag size={14} className="shrink-0 text-emerald-700" />;
|
||||
return <CreditCard size={14} className="shrink-0 text-sky-700" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -141,81 +240,256 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contacto */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Phone size={11} /> Teléfono</p>
|
||||
<p className="text-sm font-medium text-theme-heading break-all">{patient.phone || '—'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><MessageCircle size={11} /> WhatsApp</p>
|
||||
{whatsapp ? (
|
||||
<a
|
||||
href={`https://wa.me/${waDigits(whatsapp)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-medium text-theme-heading underline underline-offset-2 break-all"
|
||||
>
|
||||
{whatsapp}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm text-theme-muted">—</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl col-span-2">
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Mail size={11} /> Correo</p>
|
||||
<p className="text-sm font-medium text-theme-heading break-all">{patient.email || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estado de cuenta */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Estado de cuenta</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Wallet size={11} /> Monedero</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{cuenta?.puntos_monedero ?? 0} pts</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Saldo pendiente</p>
|
||||
<p className={`text-sm font-semibold ${adeudo > 0 ? 'text-amber-700' : 'text-theme-heading'}`}>
|
||||
{fmtMoney(adeudo)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Total gastado</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{fmtMoney(cuenta?.total_gastado ?? 0)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Visitas</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{cuenta?.visitas_total ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visitas recientes */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2 flex items-center gap-1">
|
||||
<Stethoscope size={12} /> Visitas recientes
|
||||
</p>
|
||||
{visitas.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin visitas registradas.</p>
|
||||
{/* Acciones rápidas: agendar / cobrar / WhatsApp */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
title="Agendar cita para este paciente"
|
||||
onClick={() => gotoModule(`/agenda?patient_id=${patient.id}`)}
|
||||
>
|
||||
<Calendar size={14} className="mr-1.5 shrink-0" />
|
||||
<span className="truncate">Agendar cita</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
title="Cobrar en punto de venta"
|
||||
onClick={() => gotoModule(`/pos?patient_id=${patient.id}`)}
|
||||
>
|
||||
<CreditCard size={14} className="mr-1.5 shrink-0" />
|
||||
<span className="truncate">Cobrar en POS</span>
|
||||
</Button>
|
||||
{waHref ? (
|
||||
<a
|
||||
href={waHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-xl border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm font-medium text-theme-heading transition-colors hover:bg-theme-bg"
|
||||
title={`Abrir WhatsApp: ${whatsapp}`}
|
||||
>
|
||||
<MessageCircle size={14} className="shrink-0" />
|
||||
<span className="truncate">WhatsApp</span>
|
||||
</a>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{visitas.map((v) => (
|
||||
<li key={v.id} className="p-2.5 bg-theme-bg rounded-lg text-sm">
|
||||
<p className="font-medium text-theme-heading">
|
||||
{(v.date_start || '').slice(0, 10)} · {v.servicio || v.motivo || v.name}
|
||||
</p>
|
||||
<p className="text-xs text-theme-muted">
|
||||
{v.doctor || 'Sin médico'}
|
||||
{v.diagnostico ? ` · ${v.diagnostico.slice(0, 80)}` : ''}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-xl border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm font-medium text-theme-muted opacity-50 cursor-not-allowed"
|
||||
title="Paciente sin teléfono registrado"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<MessageCircle size={14} className="shrink-0" />
|
||||
<span className="truncate">WhatsApp</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs: Resumen / Historial */}
|
||||
<div className="flex gap-1 border-b border-theme-border pb-2">
|
||||
{([
|
||||
{ key: 'resumen', label: 'Resumen' },
|
||||
{ key: 'historial', label: 'Historial' },
|
||||
] as const).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
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>
|
||||
|
||||
{tab === 'resumen' ? (
|
||||
<>
|
||||
{/* Contacto */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Phone size={11} /> Teléfono</p>
|
||||
<p className="text-sm font-medium text-theme-heading break-all">{patient.phone || '—'}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><MessageCircle size={11} /> WhatsApp</p>
|
||||
{waHref ? (
|
||||
<a
|
||||
href={waHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm font-medium text-theme-heading underline underline-offset-2 break-all"
|
||||
>
|
||||
{whatsapp}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-sm text-theme-muted">—</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl col-span-2">
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Mail size={11} /> Correo</p>
|
||||
<p className="text-sm font-medium text-theme-heading break-all">{patient.email || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estado de cuenta */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Estado de cuenta</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Wallet size={11} /> Monedero</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{cuenta?.puntos_monedero ?? 0} pts</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Saldo pendiente</p>
|
||||
<p className={`text-sm font-semibold ${adeudo > 0 ? 'text-amber-700' : 'text-theme-heading'}`}>
|
||||
{fmtMoney(adeudo)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Total gastado</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{fmtMoney(cuenta?.total_gastado ?? 0)}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
<p className="text-xs text-theme-muted mb-1">Visitas</p>
|
||||
<p className="text-sm font-semibold text-theme-heading">{cuenta?.visitas_total ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visitas recientes */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2 flex items-center gap-1">
|
||||
<Stethoscope size={12} /> Visitas recientes
|
||||
</p>
|
||||
{visitas.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted">Sin visitas registradas.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{visitas.map((v) => (
|
||||
<li key={v.id} className="p-2.5 bg-theme-bg rounded-lg text-sm">
|
||||
<p className="font-medium text-theme-heading">
|
||||
{(v.date_start || '').slice(0, 10)} · {v.servicio || v.motivo || v.name}
|
||||
</p>
|
||||
<p className="text-xs text-theme-muted">
|
||||
{v.doctor || 'Sin médico'}
|
||||
{v.diagnostico ? ` · ${v.diagnostico.slice(0, 80)}` : ''}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Historial unificado: citas + visitas + ventas + pagos */
|
||||
<div>
|
||||
<p className="text-xs font-medium text-theme-muted uppercase mb-2 flex items-center gap-1">
|
||||
<History size={12} /> Historial del paciente
|
||||
</p>
|
||||
{timeline.length === 0 ? (
|
||||
<p className="text-sm text-theme-muted py-4 text-center">
|
||||
Sin citas, visitas, ventas ni pagos registrados.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ol className="relative border-l-2 border-theme-border-strong ml-2 space-y-3">
|
||||
{timeline.slice(0, visibleCount).map((entry) => {
|
||||
const target =
|
||||
entry.kind === 'cita'
|
||||
? `/agenda?date=${entry.apt.date || ''}`
|
||||
: entry.kind === 'visita'
|
||||
? '/visitas'
|
||||
: entry.kind === 'venta'
|
||||
? '/ventas'
|
||||
: '/pagos';
|
||||
return (
|
||||
<li key={`${entry.kind}-${entryId(entry)}`} className="pl-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => gotoModule(target)}
|
||||
className="w-full text-left p-2.5 bg-theme-bg rounded-xl transition-colors hover:bg-theme-accent-bg cursor-pointer"
|
||||
title="Abrir en su módulo"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-theme-heading min-w-0">
|
||||
{entryIcon(entry.kind)}
|
||||
<span className="truncate">
|
||||
{fmtFecha(entry.date)}
|
||||
{entry.kind === 'cita' ? ` · ${entry.apt.time}` : ''}
|
||||
</span>
|
||||
</p>
|
||||
{entry.kind === 'cita' && (
|
||||
<Badge variant={badgeForAppointmentState(entry.apt.state)}>
|
||||
{appointmentStateLabels[entry.apt.state] || entry.apt.state}
|
||||
</Badge>
|
||||
)}
|
||||
{entry.kind === 'visita' && (
|
||||
<Badge variant={
|
||||
entry.visita.state === 'en_curso' ? 'info'
|
||||
: entry.visita.state === 'completada' ? 'success' : 'danger'
|
||||
}>
|
||||
{visitaStateLabels[entry.visita.state] || entry.visita.state}
|
||||
</Badge>
|
||||
)}
|
||||
{entry.kind === 'venta' && (
|
||||
<Badge variant={badgeForSaleState(entry.sale.state)}>
|
||||
{saleStateLabels[entry.sale.state] || entry.sale.state}
|
||||
</Badge>
|
||||
)}
|
||||
{entry.kind === 'pago' && (
|
||||
<Badge variant={entry.pago.state === 'completed' ? 'success' : 'default'}>
|
||||
{entry.pago.state === 'completed' ? 'Completado' : entry.pago.state}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-theme-muted mt-1 truncate">
|
||||
{entry.kind === 'cita' && (
|
||||
<>
|
||||
<Calendar size={10} className="inline mr-1" />
|
||||
Cita: {entry.apt.service}
|
||||
{entry.apt.doctor ? ` · ${entry.apt.doctor}` : ''}
|
||||
</>
|
||||
)}
|
||||
{entry.kind === 'visita' && (
|
||||
<>
|
||||
<ClipboardList size={10} className="inline mr-1" />
|
||||
Visita: {entry.visita.servicio || entry.visita.motivo || entry.visita.name}
|
||||
</>
|
||||
)}
|
||||
{entry.kind === 'venta' && (
|
||||
<>
|
||||
<ShoppingBag size={10} className="inline mr-1" />
|
||||
Venta {entry.sale.name}: {fmtMoney(entry.sale.total)}
|
||||
</>
|
||||
)}
|
||||
{entry.kind === 'pago' && (
|
||||
<>
|
||||
<CreditCard size={10} className="inline mr-1" />
|
||||
Pago: {fmtMoney(entry.pago.amount)} · {paymentMethodLabels[entry.pago.payment_method] || entry.pago.payment_method}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{timeline.length > visibleCount && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full mt-3"
|
||||
onClick={() => setVisibleCount((c) => c + TIMELINE_PAGE)}
|
||||
>
|
||||
Cargar más ({timeline.length - visibleCount} restantes)
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ export const Modal: FC<ModalProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
className="print-modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
>
|
||||
@@ -69,19 +69,19 @@ export const Modal: FC<ModalProps> = ({
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
'relative bg-theme-surface rounded-2xl shadow-xl w-full flex flex-col max-h-[calc(100vh-2rem)]',
|
||||
'print-modal-content relative bg-theme-surface rounded-2xl shadow-xl w-full flex flex-col max-h-[calc(100vh-2rem)]',
|
||||
maxWidthClasses[maxWidth]
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-theme-border">
|
||||
<div className="print-modal-header flex items-center justify-between px-5 py-4 border-b border-theme-border">
|
||||
<h3 className="font-heading text-lg sm:text-xl text-theme-heading pr-4">{title}</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Cerrar">
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5 sm:p-6">{children}</div>
|
||||
<div className="print-modal-body flex-1 overflow-y-auto p-5 sm:p-6">{children}</div>
|
||||
{footer && (
|
||||
<div className="px-5 py-4 border-t border-theme-border flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||
<div className="print-modal-footer px-5 py-4 border-t border-theme-border flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user