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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 })} />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user