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:
2026-09-17 18:47:35 +00:00
parent afc208a7b7
commit b4f91527f1
8 changed files with 1048 additions and 116 deletions

View 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;

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -257,11 +257,14 @@ p {
.print-ticket,
.print-ticket *,
.print-receta,
.print-receta * {
.print-receta *,
.print-agenda,
.print-agenda * {
visibility: visible;
}
.print-ticket,
.print-receta {
.print-receta,
.print-agenda {
position: absolute;
top: 0;
left: 0;
@@ -277,4 +280,42 @@ p {
border-radius: 0;
padding: 0;
}
/* La agenda es un documento de varias páginas: mientras está abierta, el
modal deja de ser un overlay fijo (los elementos `fixed` se repiten en
cada página impresa y anclan el contenido a su caja centrada) y se libera
el ancho/alto del diálogo para que el documento fluya desde el inicio. */
body:has(.print-agenda) .print-modal-overlay {
position: absolute;
top: 0;
left: 0;
right: auto;
bottom: auto;
display: block;
width: 100%;
padding: 0;
}
body:has(.print-agenda) .print-modal-content {
max-width: none;
max-height: none;
border-radius: 0;
box-shadow: none;
}
body:has(.print-agenda) .print-modal-body {
overflow: visible;
padding: 0;
}
body:has(.print-agenda) .print-modal-header,
body:has(.print-agenda) .print-modal-footer {
display: none;
}
/* El resto de la app queda oculto (visibility) pero seguiría ocupando su
altura y generaría páginas en blanco al final: se colapsa su caja. El
overlay no se recorta porque su bloque contenedor es la página. */
body:has(.print-agenda) > #root {
height: 0 !important;
overflow: hidden !important;
}
.print-agenda {
position: static;
}
}

View File

@@ -1,8 +1,9 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil } from 'lucide-react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall } from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
import AgendaPrint from '../components/AgendaPrint';
import {
Card,
Button,
@@ -30,6 +31,63 @@ const stateOptions = [
{ value: 'cancelled', label: 'Cancelada' },
];
// ---- Preferencias persistidas de la agenda (vista y filtros, NO la fecha) ----
const AGENDA_PREFS_KEY = 'skeen_agenda_prefs';
type AgendaViewMode = 'lista' | 'doctores' | 'horas' | 'semana';
interface AgendaPrefs {
viewMode?: AgendaViewMode;
doctorFilter?: string;
staffTypeFilter?: '' | 'doctor' | 'cosmetologa';
}
const readAgendaPrefs = (): AgendaPrefs => {
try {
const raw = localStorage.getItem(AGENDA_PREFS_KEY);
return raw ? (JSON.parse(raw) as AgendaPrefs) : {};
} catch {
return {};
}
};
const VALID_VIEW_MODES: AgendaViewMode[] = ['lista', 'doctores', 'horas', 'semana'];
// ---- Conflictos de agenda (solape / bloqueo) en lenguaje humano ----
export interface AgendaConflict {
kind: 'overlap' | 'bloqueo';
message: string;
conflicting?: { id: number; time: string; patient: string; doctor: string } | null;
}
interface ApiErrorShape {
response?: {
data?: {
message?: string;
conflicting_appointment?: { id: number; time: string; patient: string; doctor: string };
};
};
}
// Traduce los mensajes técnicos del backend (validaciones de skeen.cita) a
// texto entendible; null si el error no es un conflicto de agenda.
const parseAgendaConflict = (err: unknown): AgendaConflict | null => {
const data = (err as ApiErrorShape)?.response?.data;
const msg = data?.message || '';
if (msg.includes('Ya existe una cita')) {
return {
kind: 'overlap',
message: 'El doctor ya tiene una cita en ese horario',
conflicting: data?.conflicting_appointment || null,
};
}
const m = /bloqueo \((.+?)\)/.exec(msg);
if (m) {
return { kind: 'bloqueo', message: `El doctor no puede atender en ese horario: ${m[1]}` };
}
if (msg.includes('bloqueo')) {
return { kind: 'bloqueo', message: 'El doctor no puede atender en ese horario: agenda bloqueada' };
}
return null;
};
// Opciones de hora en bloques de 15 min dentro del horario de agenda (8:00-19:00)
const QUARTER_TIME_OPTIONS = (() => {
const opts = [{ value: '', label: 'Seleccionar hora...' }];
@@ -43,7 +101,7 @@ const QUARTER_TIME_OPTIONS = (() => {
})();
const Agenda: FC = () => {
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
const { openPatient } = usePatientDrawer();
@@ -60,10 +118,41 @@ const Agenda: FC = () => {
const [error, setError] = useState<string | null>(null);
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
const [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : ''));
const [staffTypeFilter, setStaffTypeFilter] = useState<'' | 'doctor' | 'cosmetologa'>('');
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas' | 'semana'>('doctores');
const [doctorFilter, setDoctorFilter] = useState(() => {
if (medicoEmployeeId) return String(medicoEmployeeId);
const saved = readAgendaPrefs().doctorFilter;
return typeof saved === 'string' ? saved : '';
});
const [staffTypeFilter, setStaffTypeFilter] = useState<'' | 'doctor' | 'cosmetologa'>(() => {
const saved = readAgendaPrefs().staffTypeFilter;
return saved === 'doctor' || saved === 'cosmetologa' ? saved : '';
});
const [viewMode, setViewMode] = useState<AgendaViewMode>(() => {
const saved = readAgendaPrefs().viewMode;
return saved && VALID_VIEW_MODES.includes(saved) ? saved : 'doctores';
});
// Persistencia de vista y filtros (la fecha nunca se guarda: siempre arranca en hoy)
useEffect(() => {
try {
localStorage.setItem(AGENDA_PREFS_KEY, JSON.stringify({
viewMode,
staffTypeFilter,
doctorFilter: isMedico ? undefined : doctorFilter,
} as AgendaPrefs));
} catch {
// localStorage no disponible: ignorar
}
}, [viewMode, staffTypeFilter, doctorFilter, isMedico]);
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar'>('');
// Confirmación telefónica en lote (solo vista Lista)
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [bulkConfirming, setBulkConfirming] = useState(false);
// Conflicto de agenda (solape / bloqueo) con acción "Ver esa cita"
const [conflict, setConflict] = useState<AgendaConflict | null>(null);
// Impresión de la agenda del día
const [printOpen, setPrintOpen] = useState(false);
// Paciente precargado vía query param (?patient_id=X) desde la ficha
const [preloadedPatient, setPreloadedPatient] = useState<Patient | null>(null);
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
@@ -99,6 +188,8 @@ const Agenda: FC = () => {
const [crearNuevo, setCrearNuevo] = useState(false);
const [nuevoPac, setNuevoPac] = useState({ name: '', phone: '' });
const [creandoPac, setCreandoPac] = useState(false);
// Aviso de duplicado por teléfono en el mini-form "Crear paciente nuevo"
const [nuevoPacDup, setNuevoPacDup] = useState<{ id: number; name: string } | null>(null);
// Cita rápida: solo nombre y teléfono (+ fecha/hora/médico del contexto)
const [quickMode, setQuickMode] = useState(false);
const [quickForm, setQuickForm] = useState({
@@ -322,9 +413,7 @@ const Agenda: FC = () => {
toast.success('Cita movida');
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo mover la cita');
console.error(err);
applyAgendaError(err, 'No se pudo mover la cita');
}
};
@@ -498,6 +587,8 @@ const Agenda: FC = () => {
try {
setLoading(true);
setError(null);
// La selección en lote no sobrevive cambios de fecha/vista/filtros
setSelectedIds([]);
// Vista Semana: el API ya soporta date_from/date_to (una sola llamada por rango)
const params: Record<string, string> = viewMode === 'semana'
? { date_from: weekDatesOf(date)[0], date_to: weekDatesOf(date)[6], page_size: '200' }
@@ -535,6 +626,43 @@ const Agenda: FC = () => {
loadReferences();
}, [load, loadReferences]);
// Pre-carga "Agendar cita" desde la ficha del paciente: /agenda?patient_id=X
// Abre el modal de nueva cita con el paciente ya seleccionado.
// Se re-ejecuta cuando cambia patient_id (llegar a /agenda ya montada desde el
// drawer no remonta el componente) y el parámetro se consume tras abrir el
// modal para poder reabrir la misma ficha más de una vez.
const preloadPatientId = searchParams.get('patient_id');
useEffect(() => {
if (!preloadPatientId) return;
let active = true;
(async () => {
try {
const res = await odooApi.getPatient(parseInt(preloadPatientId, 10));
if (active && res.status === 'success') {
setPreloadedPatient(res.patient);
setQuickMode(false);
setEditingId(null);
setCrearNuevo(false);
setForm((f) => ({ ...f, patient_id: String(res.patient.id) }));
setCreateOpen(true);
}
} catch {
console.error('No se pudo precargar el paciente');
} finally {
if (active) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('patient_id');
return next;
}, { replace: true });
}
}
})();
return () => {
active = false;
};
}, [preloadPatientId, setSearchParams]);
// Cargar pacientes para el selector (búsqueda server-side)
useEffect(() => {
if (!createOpen) return;
@@ -549,6 +677,32 @@ const Agenda: FC = () => {
return () => clearTimeout(t);
}, [createOpen, patientSearch]);
// Aviso de duplicado: teléfono del mini-form "Crear paciente nuevo"
useEffect(() => {
if (!crearNuevo) {
setNuevoPacDup(null);
return;
}
const phone = nuevoPac.phone.trim();
if (phone.replace(/\D/g, '').length < 7) {
setNuevoPacDup(null);
return;
}
let active = true;
const t = setTimeout(async () => {
try {
const res = await odooApi.checkPatientPhone(phone);
if (active) setNuevoPacDup(res.status === 'success' && res.exists && res.patient ? { id: res.patient.id, name: res.patient.name } : null);
} catch {
if (active) setNuevoPacDup(null);
}
}, 400);
return () => {
active = false;
clearTimeout(t);
};
}, [nuevoPac.phone, crearNuevo]);
// Crear paciente nuevo desde el selector del modal y seleccionarlo
const crearYSeleccionarPaciente = async () => {
if (!nuevoPac.name.trim() || !nuevoPac.phone.trim()) {
@@ -644,10 +798,8 @@ const Agenda: FC = () => {
setDetailApt(null);
await load();
} catch (err) {
// Mostrar el rechazo del servidor (horario 8-19h, solapamiento por médico, bloqueos)
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al actualizar la reserva');
console.error(err);
// Mostrar el rechazo del servidor con mensaje humano si es conflicto
applyAgendaError(err, 'Error al actualizar la reserva');
} finally {
setSavingEdit(false);
}
@@ -673,6 +825,65 @@ const Agenda: FC = () => {
}
};
// ---- Confirmación telefónica en lote (solo vista Lista) ----
// Solo citas pendientes/confirmadas sin 1ra confirmación telefónica.
const bulkEligible = (apt: Appointment): boolean =>
['pending', 'confirmed'].includes(apt.state) && !apt.phone_confirmed;
const toggleSelected = (id: number) => {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const bulkConfirmSelected = async () => {
if (selectedIds.length === 0) return;
try {
setBulkConfirming(true);
const res = await odooApi.bulkPhoneConfirm(selectedIds, true);
const n = res.confirmed_count ?? 0;
const omitidas = (res.skipped_invalid_state?.length || 0) + (res.not_found?.length || 0);
toast.success(`${n} ${n === 1 ? 'cita confirmada' : 'citas confirmadas'}${omitidas ? ` · ${omitidas} omitidas` : ''}`);
setSelectedIds([]);
await load();
} catch (err) {
toast.error('Error en la confirmación en lote');
console.error(err);
} finally {
setBulkConfirming(false);
}
};
// ---- Conflicto de agenda: mensaje humano + "Ver esa cita" ----
const applyAgendaError = (err: unknown, fallback: string) => {
const c = parseAgendaConflict(err);
if (c) {
setConflict(c);
toast.error(c.message);
return;
}
const msg = (err as ApiErrorShape)?.response?.data?.message;
toast.error(msg || fallback);
console.error(err);
};
const verCitaEnConflicto = async () => {
if (!conflict?.conflicting) return;
try {
const res = await odooApi.getAppointment(conflict.conflicting.id);
setConflict(null);
setCreateOpen(false);
setEditingId(null);
setCrearNuevo(false);
if (res.status === 'success') {
openAptDetail(res.appointment);
} else {
toast.error('No se pudo abrir la cita en conflicto');
}
} catch (err) {
toast.error('No se pudo abrir la cita en conflicto');
console.error(err);
}
};
const handlePackageFinished = async (id: number, finished: boolean) => {
try {
setPkgUpdating(id);
@@ -756,7 +967,7 @@ const Agenda: FC = () => {
service_id: parseInt(form.service_id, 10),
doctor_id: form.doctor_id ? parseInt(form.doctor_id, 10) : undefined,
date: form.date,
time: form.time,
time: parseTimeToFloat(form.time) as unknown as string,
notes: form.notes,
sessions,
});
@@ -773,8 +984,7 @@ const Agenda: FC = () => {
});
await load();
} catch (err) {
toast.error('Error al crear cita');
console.error(err);
applyAgendaError(err, 'Error al crear cita');
} finally {
setSubmitting(false);
}
@@ -822,9 +1032,7 @@ const Agenda: FC = () => {
setEditMode(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo actualizar la cita');
console.error(err);
applyAgendaError(err, 'No se pudo actualizar la cita');
} finally {
setSubmitting(false);
}
@@ -907,9 +1115,7 @@ const Agenda: FC = () => {
setCreateOpen(false);
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al crear la cita rápida');
console.error(err);
applyAgendaError(err, 'Error al crear la cita rápida');
} finally {
setSubmitting(false);
}
@@ -921,17 +1127,25 @@ const Agenda: FC = () => {
value: String(p.id),
label: `${p.name} — ${p.phone || 'sin teléfono'}${p.primary_doctor ? ` · ${p.primary_doctor}` : ''}`,
}));
// Garantiza que el paciente precargado (desde celda o edición) aparezca
// Garantiza que el paciente precargado (desde celda, query param o edición) aparezca
if (form.patient_id && !list.some((o) => o.value === form.patient_id)) {
const pre = preloadedPatient && String(preloadedPatient.id) === form.patient_id ? preloadedPatient : null;
const sel = appointments.find((a) => String(a.patient_id) === form.patient_id) as Appointment | undefined;
opts.push({ value: form.patient_id, label: sel ? `${sel.patient} — (cita seleccionada)` : `Paciente #${form.patient_id}` });
opts.push({
value: form.patient_id,
label: pre
? `${pre.name} — ${pre.phone || 'sin teléfono'}`
: sel
? `${sel.patient} — (cita seleccionada)`
: `Paciente #${form.patient_id}`,
});
}
opts.push(...list);
if (patientSearch.trim()) {
opts.push({ value: '__crear__', label: `Crear paciente nuevo: "${patientSearch.trim()}"` });
}
return opts;
}, [patients, patientSearch, form.patient_id, appointments]);
}, [patients, patientSearch, form.patient_id, appointments, preloadedPatient]);
const serviceOptions = [
{ value: '', label: 'Seleccionar servicio...' },
@@ -955,6 +1169,20 @@ const Agenda: FC = () => {
const TableHeader = () => (
<thead className="bg-theme-bg">
<tr>
<th className="text-center p-3 text-xs font-medium text-theme-muted uppercase" title="Seleccionar citas por confirmar">
<input
type="checkbox"
aria-label="Seleccionar todas las citas por confirmar"
checked={
visibleAppointments.some(bulkEligible) &&
visibleAppointments.filter(bulkEligible).every((a) => selectedIds.includes(a.id))
}
onChange={(e) => {
setSelectedIds(e.target.checked ? visibleAppointments.filter(bulkEligible).map((a) => a.id) : []);
}}
className="w-4 h-4 rounded border-theme-border-strong"
/>
</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Hora</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Servicio</th>
@@ -1107,6 +1335,16 @@ const Agenda: FC = () => {
>
Hoy
</Button>
<Button
variant="outline"
size="sm"
className="mb-[1px]"
onClick={() => setPrintOpen(true)}
title="Imprimir agenda del día"
>
<Printer size={16} className="mr-1" />
Imprimir
</Button>
</div>
<Select
label="Estado"
@@ -1668,6 +1906,29 @@ const Agenda: FC = () => {
/>
) : (
<>
{/* Barra de confirmación telefónica en lote (solo vista Lista) */}
{selectedIds.length > 0 && (
<div className="flex flex-wrap items-center gap-3 mb-3 p-3 rounded-xl border border-blue-200 bg-blue-50">
<PhoneCall size={16} className="text-blue-700 shrink-0" />
<p className="text-sm font-medium text-theme-heading">
{selectedIds.length} {selectedIds.length === 1 ? 'cita seleccionada' : 'citas seleccionadas'}
</p>
<div className="ml-auto flex gap-2">
<Button size="sm" variant="outline" onClick={() => setSelectedIds([])}>
Quitar selección
</Button>
<Button
size="sm"
onClick={bulkConfirmSelected}
loading={bulkConfirming}
title="Marcar la 1ra confirmación (teléfono) de las citas seleccionadas"
>
<PhoneCall size={14} className="mr-1.5" />
Confirmar seleccionadas
</Button>
</div>
</div>
)}
{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto">
<table className="w-full">
@@ -1675,6 +1936,17 @@ const Agenda: FC = () => {
<tbody className="divide-y">
{visibleAppointments.map((apt) => (
<tr key={apt.id} className="hover:bg-theme-bg">
<td className="p-3 text-center">
{bulkEligible(apt) ? (
<input
type="checkbox"
checked={selectedIds.includes(apt.id)}
onChange={() => toggleSelected(apt.id)}
aria-label={`Seleccionar cita de ${apt.patient} ${apt.time}`}
className="w-4 h-4 rounded border-theme-border-strong"
/>
) : null}
</td>
<td className="p-3 text-sm text-theme-heading">
<Clock size={14} className="inline mr-1 text-theme-muted" />
{apt.time}
@@ -1734,7 +2006,22 @@ const Agenda: FC = () => {
{ label: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
{ label: 'Pago', value: apt.payment_state },
]}
actions={<ActionButtons apt={apt} />}
actions={
<>
{bulkEligible(apt) && (
<label className="inline-flex items-center gap-1.5 text-xs text-theme-muted cursor-pointer">
<input
type="checkbox"
checked={selectedIds.includes(apt.id)}
onChange={() => toggleSelected(apt.id)}
className="w-4 h-4 rounded border-theme-border-strong"
/>
Sel.
</label>
)}
<ActionButtons apt={apt} />
</>
}
/>
))}
</div>
@@ -1948,6 +2235,21 @@ const Agenda: FC = () => {
onChange={(e) => setNuevoPac({ ...nuevoPac, phone: e.target.value })}
/>
</div>
{nuevoPacDup && (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800">
<p className="font-medium">
Este teléfono ya está registrado: {nuevoPacDup.name}. ¿Es la misma persona?
</p>
<div className="flex gap-2 mt-1.5">
<Button size="sm" variant="outline" onClick={() => openPatient(nuevoPacDup.id)}>
Abrir su ficha
</Button>
<Button size="sm" variant="ghost" onClick={() => setNuevoPacDup(null)}>
Continuar de todos modos
</Button>
</div>
</div>
)}
<div className="flex gap-2">
<Button size="sm" onClick={crearYSeleccionarPaciente} loading={creandoPac}>
Crear y seleccionar
@@ -2298,6 +2600,58 @@ const Agenda: FC = () => {
</div>
)}
</Modal>
{/* Modal de conflicto de agenda: mensaje humano + ver la cita que choca */}
<Modal
isOpen={!!conflict}
onClose={() => setConflict(null)}
title="Conflicto de horario"
maxWidth="sm"
footer={
<>
<Button variant="outline" onClick={() => setConflict(null)}>Entendido</Button>
{conflict?.conflicting && (
<Button onClick={verCitaEnConflicto}>
<CalendarClock size={16} className="mr-1.5" />
Ver esa cita
</Button>
)}
</>
}
>
<div className="flex items-start gap-3">
<AlertTriangle size={22} className="text-amber-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-theme-heading">{conflict?.message}</p>
{conflict?.conflicting && (
<p className="mt-2 text-sm text-theme-muted">
Cita existente: <span className="font-medium text-theme-heading">{conflict.conflicting.time} · {conflict.conflicting.patient}</span>
{conflict.conflicting.doctor ? ` · ${conflict.conflicting.doctor}` : ''}
</p>
)}
{conflict?.kind === 'overlap' && !conflict.conflicting && (
<p className="mt-2 text-xs text-theme-muted">Ajusta la hora o elige otro médico.</p>
)}
</div>
</div>
</Modal>
{/* Vista de impresión de la agenda del día (bloque por doctor) */}
<AgendaPrint
isOpen={printOpen}
onClose={() => setPrintOpen(false)}
date={date}
dateLabel={new Date(`${date}T12:00:00`).toLocaleDateString('es-MX', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})}
appointments={appointments}
bloqueos={bloqueos}
doctors={typedDoctors}
filterDoctorName={doctorFilter ? (doctors.find((d) => String(d.id) === doctorFilter)?.name || null) : null}
/>
</Layout>
);
};

View File

@@ -8,6 +8,7 @@ import {
import Layout from '../components/Layout';
import PatientAdjuntos from '../components/PatientAdjuntos';
import EstadoCuentaPrint from '../components/EstadoCuentaPrint';
import { usePatientDrawer } from '../components/PatientDrawer';
import {
Card,
Button,
@@ -142,6 +143,9 @@ const Pacientes: FC = () => {
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: '',
@@ -290,6 +294,36 @@ const Pacientes: FC = () => {
setForm((prev) => ({ ...prev, ...patch }));
};
// 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 = '';
@@ -638,6 +672,21 @@ const Pacientes: FC = () => {
<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 })} />

View File

@@ -47,7 +47,7 @@ interface TicketLine {
const Pos: FC = () => {
const { openPatient } = usePatientDrawer();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
// Catálogo
const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios');
const [services, setServices] = useState<Service[]>([]);
@@ -142,14 +142,16 @@ const Pos: FC = () => {
// Pre-carga "Cobrar a la cita" desde Agenda:
// /pos?patient_id=X&service_id=Y&doctor_id=Z&appointment_id=N
// Se re-ejecuta cuando cambia patient_id (llegar a /pos ya montado desde el
// drawer no remonta el componente) y los params se consumen tras precargar.
const preloadPatientId = searchParams.get('patient_id');
useEffect(() => {
const pid = searchParams.get('patient_id');
if (!pid) return;
if (!preloadPatientId) return;
const sid = searchParams.get('service_id');
const did = searchParams.get('doctor_id');
(async () => {
try {
const res = await odooApi.getPatient(parseInt(pid, 10));
const res = await odooApi.getPatient(parseInt(preloadPatientId, 10));
if (res.status === 'success') selectPaciente(res.patient);
} catch {
toast.error('No se pudo cargar el paciente de la cita');
@@ -173,10 +175,13 @@ const Pos: FC = () => {
}
}
toast.success('Cita precargada en el ticket');
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
['patient_id', 'service_id', 'doctor_id', 'appointment_id'].forEach((k) => next.delete(k));
return next;
}, { replace: true });
})();
// Solo al montar: los query params son la fuente inicial
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [preloadPatientId, searchParams, setSearchParams]);
// Tipo de cambio USD/MXN: al montar y refresco cada 5 min
useEffect(() => {

View File

@@ -958,6 +958,21 @@ export const odooApi = {
return data;
},
async getAppointment(id: number): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}`);
return data;
},
async bulkPhoneConfirm(ids: number[], confirmed = true): Promise<{ status: string; confirmed_count: number; skipped_invalid_state: number[]; not_found: number[] }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/bulk_phone_confirm`, { ids, confirmed });
return data;
},
async checkPatientPhone(phone: string, excludeId?: number): Promise<{ status: string; exists: boolean; patient: { id: number; name: string; patient_id: string; phone: string; mobile: string } | null }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients/check_phone`, { phone, exclude_id: excludeId });
return data;
},
async updateAppointmentStatus(id: number, action: string, reason?: string): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/status`, { action, reason });
return data;
@@ -1094,7 +1109,7 @@ export const odooApi = {
return data;
},
async getPatientHistory(id: number): Promise<{ status: string; appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; estado_cuenta: EstadoCuenta }> {
async getPatientHistory(id: number): Promise<{ status: string; appointments: Appointment[]; sales: Sale[]; visitas: Visita[]; pagos: Payment[]; estado_cuenta: EstadoCuenta }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${id}/history`);
return data;
},