From b4f91527f1b96147daee5c8115ba94c77ff219d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Thu, 17 Sep 2026 18:47:35 +0000 Subject: [PATCH] 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 --- frontend/src/components/AgendaPrint.tsx | 194 ++++++++++ frontend/src/components/PatientDrawer.tsx | 426 ++++++++++++++++++---- frontend/src/components/ui/Modal.tsx | 10 +- frontend/src/index.css | 45 ++- frontend/src/pages/Agenda.tsx | 404 ++++++++++++++++++-- frontend/src/pages/Pacientes.tsx | 49 +++ frontend/src/pages/Pos.tsx | 19 +- frontend/src/services/odoo.ts | 17 +- 8 files changed, 1048 insertions(+), 116 deletions(-) create mode 100644 frontend/src/components/AgendaPrint.tsx diff --git a/frontend/src/components/AgendaPrint.tsx b/frontend/src/components/AgendaPrint.tsx new file mode 100644 index 0000000..d35def0 --- /dev/null +++ b/frontend/src/components/AgendaPrint.tsx @@ -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 = ({ + 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 ( + + + + + } + > +
+ {/* Encabezado */} +
+
+

SKEEN Derma Experts

+

Agenda del día

+
+
+

{dateLabel}

+

{date}

+

+ {totalCitas} {totalCitas === 1 ? 'cita' : 'citas'} + {filterDoctorName ? ` · ${filterDoctorName}` : ''} +

+
+
+ + {/* Un bloque por doctor */} +
+ {groups.map((g) => { + const doctorId = g.key === 'none' ? null : parseInt(g.key.slice(2), 10); + const huecos = huecosDe(doctorId, g.apts); + return ( +
+

+ {g.name} + {g.subtitle ? · {g.subtitle} : ''} + + {g.apts.length} {g.apts.length === 1 ? 'cita' : 'citas'} + +

+ {g.apts.length === 0 ? ( +

Sin citas para este día.

+ ) : ( + + + + + + + + + + + + {g.apts.map((a) => ( + + + + + + + + ))} + +
HoraPacienteServicioTeléfonoConfirmación
{a.time}{a.patient}{a.service}{a.phone || '—'}{confirmText(a)}
+ )} +

+ Huecos libres: {huecos || 'ninguno'} +

+
+ ); + })} +
+ +

+ Impreso el {new Date().toLocaleString('es-MX')} · Las citas canceladas y los no-show no ocupan horario. +

+
+
+ ); +}; + +export default AgendaPrint; diff --git a/frontend/src/components/PatientDrawer.tsx b/frontend/src/components/PatientDrawer.tsx index e231b46..95c2e1a 100644 --- a/frontend/src/components/PatientDrawer.tsx +++ b/frontend/src/components/PatientDrawer.tsx @@ -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 = { + pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó', + in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show', +}; +const saleStateLabels: Record = { + draft: 'Borrador', confirmed: 'Confirmada', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada', +}; +const visitaStateLabels: Record = { + en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada', +}; +const paymentMethodLabels: Record = { + 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(null); const [visitas, setVisitas] = useState([]); const [cuenta, setCuenta] = useState(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(() => { + 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 ; + if (kind === 'visita') return ; + if (kind === 'venta') return ; + return ; + }; return ( <> @@ -141,81 +240,256 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa )} - {/* Contacto */} -
-
-

Teléfono

-

{patient.phone || '—'}

-
-
-

WhatsApp

- {whatsapp ? ( - - {whatsapp} - - ) : ( -

—

- )} -
-
-

Correo

-

{patient.email || '—'}

-
-
- - {/* Estado de cuenta */} -
-

Estado de cuenta

-
-
-

Monedero

-

{cuenta?.puntos_monedero ?? 0} pts

-
-
-

Saldo pendiente

-

0 ? 'text-amber-700' : 'text-theme-heading'}`}> - {fmtMoney(adeudo)} -

-
-
-

Total gastado

-

{fmtMoney(cuenta?.total_gastado ?? 0)}

-
-
-

Visitas

-

{cuenta?.visitas_total ?? 0}

-
-
-
- - {/* Visitas recientes */} -
-

- Visitas recientes -

- {visitas.length === 0 ? ( -

Sin visitas registradas.

+ {/* Acciones rápidas: agendar / cobrar / WhatsApp */} +
+ + + {waHref ? ( + + + WhatsApp + ) : ( -
    - {visitas.map((v) => ( -
  • -

    - {(v.date_start || '').slice(0, 10)} · {v.servicio || v.motivo || v.name} -

    -

    - {v.doctor || 'Sin médico'} - {v.diagnostico ? ` · ${v.diagnostico.slice(0, 80)}` : ''} -

    -
  • - ))} -
+ + + WhatsApp + )}
+ + {/* Tabs: Resumen / Historial */} +
+ {([ + { key: 'resumen', label: 'Resumen' }, + { key: 'historial', label: 'Historial' }, + ] as const).map(({ key, label }) => ( + + ))} +
+ + {tab === 'resumen' ? ( + <> + {/* Contacto */} +
+
+

Teléfono

+

{patient.phone || '—'}

+
+
+

WhatsApp

+ {waHref ? ( + + {whatsapp} + + ) : ( +

—

+ )} +
+
+

Correo

+

{patient.email || '—'}

+
+
+ + {/* Estado de cuenta */} +
+

Estado de cuenta

+
+
+

Monedero

+

{cuenta?.puntos_monedero ?? 0} pts

+
+
+

Saldo pendiente

+

0 ? 'text-amber-700' : 'text-theme-heading'}`}> + {fmtMoney(adeudo)} +

+
+
+

Total gastado

+

{fmtMoney(cuenta?.total_gastado ?? 0)}

+
+
+

Visitas

+

{cuenta?.visitas_total ?? 0}

+
+
+
+ + {/* Visitas recientes */} +
+

+ Visitas recientes +

+ {visitas.length === 0 ? ( +

Sin visitas registradas.

+ ) : ( +
    + {visitas.map((v) => ( +
  • +

    + {(v.date_start || '').slice(0, 10)} · {v.servicio || v.motivo || v.name} +

    +

    + {v.doctor || 'Sin médico'} + {v.diagnostico ? ` · ${v.diagnostico.slice(0, 80)}` : ''} +

    +
  • + ))} +
+ )} +
+ + ) : ( + /* Historial unificado: citas + visitas + ventas + pagos */ +
+

+ Historial del paciente +

+ {timeline.length === 0 ? ( +

+ Sin citas, visitas, ventas ni pagos registrados. +

+ ) : ( + <> +
    + {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 ( +
  1. + +
  2. + ); + })} +
+ {timeline.length > visibleCount && ( + + )} + + )} +
+ )} )}
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index dc750ee..d3c19b3 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -57,7 +57,7 @@ export const Modal: FC = ({ return (
@@ -69,19 +69,19 @@ export const Modal: FC = ({
-
+

{title}

-
{children}
+
{children}
{footer && ( -
+
{footer}
)} diff --git a/frontend/src/index.css b/frontend/src/index.css index ccfd93b..b7d4829 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; + } } diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index fd3b8d6..c4e2b14 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -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(null); const [date, setDate] = useState(() => 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(() => { + 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([]); + const [bulkConfirming, setBulkConfirming] = useState(false); + // Conflicto de agenda (solape / bloqueo) con acción "Ver esa cita" + const [conflict, setConflict] = useState(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(null); const [detailApt, setDetailApt] = useState(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 = 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 = () => ( + + 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" + /> + Hora Paciente Servicio @@ -1107,6 +1335,16 @@ const Agenda: FC = () => { > Hoy +
toggleSelected(apt.id)} + aria-label={`Seleccionar cita de ${apt.patient} ${apt.time}`} + className="w-4 h-4 rounded border-theme-border-strong" + /> + ) : null} + {apt.time} @@ -1734,7 +2006,22 @@ const Agenda: FC = () => { { label: 'Confirmación', value: {confirmIndicator(apt, 14)} }, { label: 'Pago', value: apt.payment_state }, ]} - actions={} + actions={ + <> + {bulkEligible(apt) && ( + + )} + + + } /> ))}
@@ -1948,6 +2235,21 @@ const Agenda: FC = () => { onChange={(e) => setNuevoPac({ ...nuevoPac, phone: e.target.value })} />
+ {nuevoPacDup && ( +
+

+ Este teléfono ya está registrado: {nuevoPacDup.name}. ¿Es la misma persona? +

+
+ + +
+
+ )}
)} + + {/* Modal de conflicto de agenda: mensaje humano + ver la cita que choca */} + setConflict(null)} + title="Conflicto de horario" + maxWidth="sm" + footer={ + <> + + {conflict?.conflicting && ( + + )} + + } + > +
+ +
+

{conflict?.message}

+ {conflict?.conflicting && ( +

+ Cita existente: {conflict.conflicting.time} · {conflict.conflicting.patient} + {conflict.conflicting.doctor ? ` · ${conflict.conflicting.doctor}` : ''} +

+ )} + {conflict?.kind === 'overlap' && !conflict.conflicting && ( +

Ajusta la hora o elige otro médico.

+ )} +
+
+
+ + {/* Vista de impresión de la agenda del día (bloque por doctor) */} + 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} + /> ); }; diff --git a/frontend/src/pages/Pacientes.tsx b/frontend/src/pages/Pacientes.tsx index 11bd346..a8e634c 100644 --- a/frontend/src/pages/Pacientes.tsx +++ b/frontend/src/pages/Pacientes.tsx @@ -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(null); const [photo, setPhoto] = useState(null); const photoRef = useRef(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) => { const file = e.target.files?.[0]; e.target.value = ''; @@ -638,6 +672,21 @@ const Pacientes: FC = () => {
updateForm({ name: e.target.value })} /> updateForm({ phone: e.target.value })} /> + {phoneDup && ( +
+

+ Este teléfono ya está registrado: {phoneDup.name}. ¿Es la misma persona? +

+
+ + +
+
+ )} updateForm({ email: e.target.value })} /> updateForm({ mobile: e.target.value })} /> updateForm({ home_phone: e.target.value })} /> diff --git a/frontend/src/pages/Pos.tsx b/frontend/src/pages/Pos.tsx index 5c150e8..39c7d6d 100644 --- a/frontend/src/pages/Pos.tsx +++ b/frontend/src/pages/Pos.tsx @@ -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([]); @@ -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(() => { diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 67605a6..7a20cf0 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -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; },