From e32a79084a6f736e59f7f39c3937bcc5c0131f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Thu, 24 Sep 2026 10:01:55 +0000 Subject: [PATCH] Fix IA (max_tokens del modelo de razonamiento + fallback al gateway), sidebar con toggle arriba, busqueda de un solo campo (paciente/global), editar expediente, marketing en visitas, responsive de agenda; doc de diagnostico WhatsApp --- docs/diagnostico-whatsapp-entrante.md | 217 +++++++++++++ frontend/src/components/GlobalSearch.tsx | 196 +++++++---- frontend/src/components/PatientCombobox.tsx | 306 ++++++++++++++++++ frontend/src/components/PatientDrawer.tsx | 18 +- frontend/src/components/SkeenLayout.tsx | 32 +- frontend/src/lib/auth.tsx | 4 +- frontend/src/lib/utils.ts | 13 + frontend/src/pages/Agenda.tsx | 162 ++++------ frontend/src/pages/Chat.tsx | 40 ++- frontend/src/pages/Pacientes.tsx | 78 ++++- frontend/src/pages/Ventas.tsx | 37 +-- frontend/src/pages/Visitas.tsx | 3 +- .../skeen_whatsapp/controllers/frontend.py | 25 +- odoo-addons/skeen_whatsapp/roles.py | 4 +- 14 files changed, 897 insertions(+), 238 deletions(-) create mode 100644 docs/diagnostico-whatsapp-entrante.md create mode 100644 frontend/src/components/PatientCombobox.tsx diff --git a/docs/diagnostico-whatsapp-entrante.md b/docs/diagnostico-whatsapp-entrante.md new file mode 100644 index 0000000..762567d --- /dev/null +++ b/docs/diagnostico-whatsapp-entrante.md @@ -0,0 +1,217 @@ +# Diagnóstico — WhatsApp entrante "Sofía no contesta" + +Fecha: 2026-09-24 (UTC) · VM SKEEN · Autor: diagnóstico técnico automatizado +Alcance: por qué no entran mensajes de pacientes al WACRM y por qué la IA responde (o no) cuando entran. + +--- + +## 1. Resumen ejecutivo + +1. **El camino de entrada (Meta → Cloudflare → cloudflared → nginx → Next.js/WACRM) está sano y verificado hoy**: la suscripción del webhook está `active=true` apuntando a `https://skeen.consultoria-as.com/api/whatsapp/webhook`, con el campo `messages` suscrito, y Meta completó un handshake real contra esa URL el 24/Sep 08:51:54 UTC respondiendo **200 + challenge**. +2. **No hay ningún evento de mensaje entrante de Meta desde el 3/Sep.** En los logs de nginx retenidos (11→24 sep) los únicos POST de webhook son **6 el 20-22 sep, desde user-agent `facebookexternalua`, con 200 OK**, que corresponden a *actualizaciones de estado* de nuestros propios mensajes de monitoreo (llegan 1-3 s después de insertar la alerta), no a mensajes de pacientes. No hay ni un POST más desde el 22/Sep 12:30. +3. **La aplicación procesa correctamente un mensaje entrante cuando Meta lo entrega.** Se inyectó un webhook firmado con el App Secret real y el pipeline completo funcionó: contacto → conversación → mensaje `customer` en `messages` → notificación al bridge → respuesta de Sofía enviada. +4. **Se encontró y corrigió un defecto real que hacía que Sofía NO contestara bien**: el bridge usaba `max_tokens: 500` contra `qwen3.6` (modelo de razonamiento en `api.nan.builders`). El presupuesto se agotaba en `reasoning_content`, `content` volvía `null` con `finish_reason="length"` y **el bridge respondía el texto de disculpa "Lo siento, no pude procesar tu mensaje." a todos los pacientes**. Reproducido y corregido (ver §4); la respuesta verificada ya es la de Sofía. +5. **Hipótesis principal de la falta de entrantes:** los pacientes no están escribiendo *a este número*, o el número no está recibiendo. Evidencia de contexto: el número del CRM es un número **piloto** ("Consultoria Alcaraz Salazar", +52 1 663 700 6400) y el propio código del bridge dice que falta *"el cambio al número de WhatsApp de SKEEN"*. En toda la vida del WACRM sólo existen **2 conversaciones** (la de pruebas de Ivan y la del healthcheck), es decir, el CRM nunca ha operado tráfico real de pacientes. + +--- + +## 2. Evidencia recolectada + +### 2.1 Lado Meta (Graph API, token del WACRM) + +Token: `debug_token` → `type: SYSTEM_USER`, `app_id: 1607064673936924` ("Consultoria AS Social"), `is_valid: true`, `expires_at: 0` (no expira), scopes `whatsapp_business_messaging`, `whatsapp_business_management`, `business_management`. + +Número `1157212604148350`: +``` +display_phone_number : +52 1 663 700 6400 status : CONNECTED +platform_type : CLOUD_API quality_rating : GREEN +code_verification_status : EXPIRED throughput : STANDARD +webhook_configuration.application : https://skeen.consultoria-as.com/api/whatsapp/webhook +``` + +WABA `773071042535033` ("Consultoria Alcaraz Salazar"): `account_review_status: APPROVED`, `business_verification_status: verified`. Es el **único** número de la WABA. + +Suscripción de la app: +``` +GET /1607064673936924/subscriptions +object=whatsapp_business_account | active=True +callback_url=https://skeen.consultoria-as.com/api/whatsapp/webhook +fields=account_alerts,account_review_update,account_update,calls,message_template_quality_update, + message_template_status_update,messages,phone_number_name_update,phone_number_quality_update,security +``` +`GET /{waba-id}/subscribed_apps` → la app 1607064673936924 está suscrita a la WABA. + +### 2.2 Verificación real del webhook (handshake) + +Handshake con el `verify_token` real contra la URL pública: **200 + challenge** en `skeen.consultoria-as.com` y en `skeencrm.consultoria-as.com`. Token inválido → 403 (comportamiento esperado). + +Re-registro forzado el 24/Sep: +``` +POST /1607064673936924/subscriptions → {"success":true} +``` +Ese POST hace que Meta ejecute un handshake inmediato. En `/var/log/nginx/access.log`: +``` +127.0.0.1 - - [24/Sep/2026:08:51:54 +0000] "GET /api/whatsapp/webhook?hub.mode=subscribe&hub.challenge=790688494 + &hub.verify_token=skeen_webhook_verify_2026&hub_mode=subscribe&... HTTP/1.1" 200 40 "-" + "facebookplatform/1.0 (+http://developers.facebook.com)" +``` +→ **Meta sí alcanza nuestro endpoint hoy**, atravesando Cloudflare → cloudflared → nginx → Next.js. + +Nota: los webhooks entran por el túnel cloudflared, por eso `$remote_addr` es `127.0.0.1`; no es tráfico local. + +### 2.3 Logs nginx (todo el histórico retenido: 11→24 sep) + +``` +6 POST /api/whatsapp/webhook 200 UA "facebookexternalua" 20/Sep 15:45:21, 16:00:03; + 21/Sep 05:48:04, 08:35:58; + 22/Sep 12:15:37, 12:30:13 +2 GET /api/whatsapp/webhook 403 UA "curl/8.5.0" pruebas manuales 24/Sep 08:48 +``` +Los 6 POST coinciden en el tiempo con las alertas del healthcheck insertadas en `messages` +(p. ej. fila creada 22/Sep 12:30:12 → POST 12:30:13) y esas filas terminaron en `status='failed'`, +consistente con *status updates* de Meta, **no** con eventos de mensaje entrante (si lo fueran, +existirían filas de contacto/conversación/mensaje en la base). + +### 2.4 Base de datos (Supabase `supabase-db/postgres`) + +- Último mensaje real de paciente: **2026-09-03 00:14:43** ("Hola", conversación `f9a45f24`). Sólo hay **2 conversaciones** en total: la de Ivan (`f9a45f24`) y la del healthcheck (`4557417e`). +- `ai_autoreply_disabled = false` en ambas conversaciones. No hay filas en `ai_configs` → la auto-respuesta propia del WACRM no está configurada; **el único respondedor de IA es el bridge de Hermes**. +- `whatsapp_config`: 1 fila, `status=connected`, `phone_number_id=1157212604148350`, `verify_token` coincide con el del `.env.local` y con el de `wacrm.service`. + +### 2.5 Logs de la aplicación + +`journalctl -u wacrm.service` (desde 1/Sep): +``` +Sep 21 03:15:50 [send-message] Meta send failed for all variants: fetch failed +... (cada 15 min) ... +Sep 23 07:15:15 [send-message] Meta send failed for all variants: fetch failed +``` +→ El proceso de Next.js **no pudo salir a `graph.facebook.com`** entre el 21/Sep 03:15 y el 23/Sep 07:15. Coincide con las alertas del healthcheck del 20-21/Sep ("Acceso web NO responde") y con el reinicio de la VM del 20/Sep 07:38. Hoy la salida a Meta funciona (Graph API 200, y un envío de prueba devolvió `wamid`). + +`journalctl -u hermes-bridge.service`: tras reiniciar el servicio sólo aparece el arranque normal; no hay kill-switch activo (`REMINDERS_ENABLED` no está definido → el heartbeat de recordatorios está desactivado por diseño, **no afecta a las respuestas**). + +### 2.6 Defecto del motor de respuesta (reproducido) + +Petición idéntica a la del bridge (`node fetch`, mismo body) contra `https://api.nan.builders/v1/chat/completions`: +``` +model=qwen3.6, max_tokens=500 → status 200, finish_reason="length", content=null, reasoning_content=1956 chars +model=qwen3.6, max_tokens=1500 → status 200, finish_reason="stop", content="\n\n¡Hola! 👋 Soy Sofía, tu asistente virtual en SKEEN Derma Experts..." +``` +Con `max_tokens: 500` el modelo gastaba todo el presupuesto razonando y nunca escribía la respuesta; `callNanBuilders` devolvía entonces `'Lo siento, no pude procesar tu mensaje.'` (`webhook-bridge.js:187`). Verificado en vivo con un webhook firmado: **antes** del arreglo, el mensaje del paciente produjo como respuesta "Lo siento, no pude procesar tu mensaje."; **después**, "¡Hola, buenas tardes! Soy Sofía, tu asistente virtual en SKEEN Derma Experts…". + +Además, el modelo local (`http://localhost:8080/v1/chat/completions`, gateway Hermes) responde correctamente el mismo prompt (`finish_reason="stop"`, sin `reasoning_content`), por lo que sirve como respaldo real. + +--- + +## 3. Causa raíz / hipótesis priorizadas + +| # | Hipótesis | Confianza | Estado | +|---|-----------|-----------|--------| +| H1 | Los mensajes de los pacientes **no llegan a este número**: el CRM usa un número piloto ("Consultoria Alcaraz Salazar") mientras el código del bridge documenta que el cambio al número de WhatsApp de SKEEN sigue pendiente; en toda la vida del CRM sólo hay 2 conversaciones (una de pruebas y la del healthcheck). | Alta | **A confirmar por el cliente** con una prueba manual (§5.A) | +| H2 | El número no puede recibir: `code_verification_status: EXPIRED` y estado de registro a revisar. `status=CONNECTED` no lo descarta por sí solo. | Media | Requiere panel de Meta (§5.D) | +| H3 | Suscripción del webhook caída / URL mal apuntada (la causa "clásica"). | **Descartada** | `active=true`, URL correcta, handshake 200 hoy | +| H4 | Ventana de fallos de red del VPS (21→23 sep) que rompió la salida a Meta y pudo haber tirado webhooks de esos días. | Media (histórica) | **Resuelta**; hoy sale a Meta. No explica la ausencia de entrantes desde el 3/Sep | +| H5 | El motor de respuesta estaba roto: todos los pacientes recibían la disculpa de error. | **Confirmada** | **Corregida hoy** (§4) | + +Lo único que no se puede ver desde la VM es el historial de entregas de Meta (pestaña de webhooks de la app); ver §5.C. + +--- + +## 4. Qué se arregló hoy + +### 4.1 Webhook de Meta re-verificado +`POST /{app-id}/subscriptions` con la misma URL, token y lista de campos → `{"success":true}`, y Meta ejecutó el handshake contra nuestro endpoint (200 OK registrado en nginx). Suscripción de la WABA y del número apuntan al mismo callback. + +### 4.2 Respuesta de la IA reparada — `/root/skeen-roadmap-hermes/hermes/webhook-bridge.js` +`callNanBuilders()`: +- `max_tokens: 500 → 1500` (el modelo alcanza `finish_reason="stop"` y escribe `content`). +- Si Nan devuelve `content` vacío (o falla por HTTP/red, p. ej. Cloudflare bloqueando la IP del VPS) → **fallback real al gateway local** `http://localhost:8080/v1/chat/completions` (verificado funcionando) y se registra `[Nan] ... Fallback a Hermes`. +- Sólo si ambos fallan se conserva la disculpa como último recurso. + +Servicio reiniciado (`systemctl restart hermes-bridge`, `active`, `/health` OK). `node --check` OK. **Sin commit, según instrucción.** + +### 4.3 Datos de prueba: insertados y eliminados +Ver §6 (auditoría). + +--- + +## 5. Qué falta hacer del lado de Meta / del cliente + +**A. Prueba manual de entrada (lo primero, 2 minutos).** +Desde un WhatsApp personal que **no** sea el del CRM, enviar `Prueba` a **+52 1 663 700 6400**. +- Si el mensaje aparece en el CRM (o en los logs, §7) → la entrada funciona; el problema era sólo H5 (ya corregido) y/o el cliente estaba mirando otro número. +- Si **no** aparece en ningún lado, entonces el mensaje no llega a Meta/nuestro webhook → seguir con C, D, E. + +**B. WhatsApp Manager → Configuración (Configuration) → Webhook.** +Confirmar: URL de devolución de llamada `https://skeen.consultoria-as.com/api/whatsapp/webhook`, token de verificación (valor real en `/root/skeen-wacrm/.env.local`, variable `WHATSAPP_VERIFY_TOKEN`) y que el campo **`messages`** esté suscrito. Debe pulsarse "Verificar y guardar" para que Meta revalide. (Ya validado por API, pero conviene que el cliente lo confirme visualmente.) + +**C. Panel de la app (developers.facebook.com/apps/1607064673936924 → Webhooks → entregas recientes).** +Ahí está el historial de entregas que **no** se puede consultar por API. Filtrar por el campo `messages` y revisar: +- si llegan eventos de tipo mensaje en las últimas 24-72 h y con qué código HTTP respondió el servidor; +- si sólo hay eventos de estado (`statuses`), confirma que **no están entrando mensajes al número**, no que el webhook falle. + +**D. WhatsApp Manager → Números de teléfono → +52 1 663 700 6400.** +Completar lo que esté pendiente: la API reporta `code_verification_status = EXPIRED`. Rehacer la verificación del número y configurar/renovar el **PIN de verificación en dos pasos** (pantalla "Configuración de dos pasos"). Es el único estado del número que no está "limpio". + +**E. Confirmar a qué número escriben los pacientes.** +Preguntar al cliente qué número está publicado (Google Business, Instagram, tarjetas, sitio). Si es distinto de +52 1 663 700 6400, **ese** es el problema: hay que registrar/migrar ese número a la Cloud API y apuntarlo al WACRM (el bridge sigue esperando "el cambio al número de WhatsApp de SKEEN"). + +--- + +## 6. Auditoría de datos de prueba (producción) + +Se inyectó **un webhook firmado real** (HMAC-SHA256 con `META_APP_SECRET`, `phone_number_id` reales) a la URL pública, dos veces, con el remitente ficticio `5200000000000` (número no entregable → Meta lo marca `failed`, no se envió nada a ninguna persona real) y texto "Hola" / "Hola, buenas tardes". + +Creado: 1 conversación (`b0e0c328-a94a-4dd2-b8f6-38aed040758a`) y 4 mensajes (2 `customer`, 2 `agent`), más `memory/_5210000000000.json` del bridge. + +**Eliminado/restaurado** (verificado después): +``` +DELETE 4 messages (de esa conversación) → mensajes creados en la última hora: 0 +DELETE 1 conversations → conversaciones totales: 2 (estado original) +DELETE 0 message_reactions +UPDATE 1 contacts → nombre restaurado a '5210000000000' (el contacto ya existía; mi prueba lo reutilizó + y le había cambiado el nombre) +rm /root/skeen-roadmap-hermes/hermes/memory/_5210000000000.json +Sin efectos en Odoo (0 partners creados por la prueba; el intent clasificado fue 'otro') +``` + +--- + +## 7. Cómo verificar que quedó funcionando + +1. Ver el webhook en vivo (deja `$remote_addr` 127.0.0.1 porque entra por el túnel; UA de Meta = `facebookexternalua`): +```bash +tail -f /var/log/nginx/access.log | grep --line-buffered api/whatsapp/webhook +``` +2. Ver el pipeline y las respuestas de Sofía: +```bash +journalctl -u hermes-bridge -f # "[WACRM] Webhook recibido: message.received", "[Intent] ...", "[WACRM] sent message to ..." +journalctl -u wacrm -f # errores de envío / webhook +``` +3. Ver el mensaje en la base: +```bash +docker exec supabase-db psql -U postgres -d postgres -c \ +"select m.created_at, c.phone, m.sender_type, left(m.content_text,60) txt, m.status + from messages m join conversations cv on cv.id=m.conversation_id + join contacts c on c.id=cv.contact_id order by m.created_at desc limit 10;" +``` +4. Re-verificar la suscripción de Meta (si se duda de ella): +```bash +cd /root/skeen-wacrm && set -a && . ./.env.local && set +a +curl -s "https://graph.facebook.com/v20.0/1607064673936924/subscriptions?access_token=1607064673936924|$META_APP_SECRET" +``` +5. Prueba funcional sin depender de Meta (inyecta un entrante firmado; **borrar después**): + generar un payload con `phone_number_id=1157212604148350`, firmarlo con `sha256=` usando `META_APP_SECRET` y `POST` a `https://skeen.consultoria-as.com/api/whatsapp/webhook`. + +Criterio de "funcionando": un mensaje real de un paciente produce en <15 s (a) fila `customer` en `messages`, (b) log `message.received` en el bridge, (c) fila `agent` con la respuesta de Sofía (no "Lo siento, no pude procesar tu mensaje."). + +--- + +## 8. Pendientes / recomendaciones (fuera del arreglo de hoy) + +1. `webhook-bridge.js` usa `const CLINIC_PHONE = '+52 (664) 123-4567'`, un **placeholder**: el prompt le dice a Sofía un teléfono falso. Confirmar con el cliente el número correcto y corregirlo. +2. Las alertas del healthcheck se envían como texto libre a `526644990030`, fuera de la ventana de 24 h → Meta las marca `failed`. Para avisos proactivos hace falta una plantilla aprobada y usarla. +3. El bridge deja de responder si la conversación está asignada a un agente humano (`isConversationAssigned` → `/skeen/frontend/v1/wacrm/conversations/{id}/assigned`). Revisar que no haya conversaciones asignadas cuando se espera que conteste Sofía. +4. La salida a Meta cayó del 21/Sep 03:15 al 23/Sep 07:15 (IPv4/DNS del VPS). Vale la pena añadir un check de conectividad a `graph.facebook.com` (IPv4) al healthcheck, no sólo al frontend. +5. La app está suscrita también al objeto `user` con la misma callback URL; es inocuo, pero puede limpiarse para reducir ruido. +6. Sin commit: los cambios de `webhook-bridge.js` están sólo en disco, pendientes de revisión del responsable. diff --git a/frontend/src/components/GlobalSearch.tsx b/frontend/src/components/GlobalSearch.tsx index 471fcaf..c4b0cba 100644 --- a/frontend/src/components/GlobalSearch.tsx +++ b/frontend/src/components/GlobalSearch.tsx @@ -1,8 +1,9 @@ import type { FC } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Search, Users, Calendar, Briefcase } from 'lucide-react'; +import { Search, Users, Calendar, Briefcase, Loader2, X } from 'lucide-react'; import { odooApi } from '../services/odoo'; +import { cn } from '../lib/utils'; interface Results { patients: { id: number; name: string; phone: string }[]; @@ -12,19 +13,28 @@ interface Results { const EMPTY: Results = { patients: [], appointments: [], services: [] }; +type Option = { key: string; group: 'Pacientes' | 'Citas próximas' | 'Servicios'; label: string; hint: string; icon: typeof Users; path: string }; + interface GlobalSearchProps { placeholder?: string; className?: string; } -/** Búsqueda global del header: dropdown con autocompletado (pacientes, citas, servicios). */ +/** + * Búsqueda global del header: UN solo campo con resultados en vivo + * (debounce 250 ms) navegables con flechas + Enter. + */ export const GlobalSearch: FC = ({ placeholder = 'Buscar paciente, cita, servicio...', className = '' }) => { const navigate = useNavigate(); const [query, setQuery] = useState(''); const [results, setResults] = useState(EMPTY); const [open, setOpen] = useState(false); const [searching, setSearching] = useState(false); + const [highlight, setHighlight] = useState(0); const boxRef = useRef(null); + const inputRef = useRef(null); + // true cuando el usuario ya movió el resaltado con las flechas + const movedRef = useRef(false); // Cerrar al hacer click fuera useEffect(() => { @@ -44,112 +54,170 @@ export const GlobalSearch: FC = ({ placeholder = 'Buscar paci return; } setSearching(true); + let active = true; const t = setTimeout(async () => { try { const res = await odooApi.globalSearch(q); + if (!active) return; if (res.status === 'success') { setResults({ patients: res.patients || [], appointments: res.appointments || [], services: res.services || [], }); + setHighlight(0); + movedRef.current = false; setOpen(true); } } catch { - setResults(EMPTY); + if (active) setResults(EMPTY); } finally { - setSearching(false); + if (active) setSearching(false); } }, 250); - return () => clearTimeout(t); + return () => { + active = false; + clearTimeout(t); + }; }, [query]); + const options = useMemo(() => { + const out: Option[] = []; + for (const p of results.patients) { + out.push({ + key: `p-${p.id}`, group: 'Pacientes', label: p.name, hint: p.phone || '', + icon: Users, path: `/pacientes?q=${encodeURIComponent(p.phone || p.name)}`, + }); + } + for (const a of results.appointments) { + out.push({ + key: `a-${a.id}`, group: 'Citas próximas', label: `${a.patient} · ${a.service}`, + hint: `${a.date?.slice(5) ?? ''} ${a.time}`, icon: Calendar, path: `/agenda?date=${a.date}`, + }); + } + for (const s of results.services) { + out.push({ + key: `s-${s.id}`, group: 'Servicios', label: s.name, hint: `$${s.price}`, + icon: Briefcase, path: `/servicios?q=${encodeURIComponent(s.name)}`, + }); + } + return out; + }, [results]); + const go = (path: string) => { setOpen(false); setQuery(''); + setResults(EMPTY); navigate(path); }; - const hasResults = results.patients.length + results.appointments.length + results.services.length > 0; const showDropdown = open && query.trim().length >= 2; + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + if (!showDropdown || options.length === 0) return; + e.preventDefault(); + const dir = e.key === 'ArrowDown' ? 1 : -1; + if (!movedRef.current) { + // El primer desplazamiento no salta el primer resultado (ya resaltado). + movedRef.current = true; + setHighlight(dir === 1 ? 0 : options.length - 1); + return; + } + setHighlight((h) => (h + dir + options.length) % options.length); + return; + } + if (e.key === 'Enter') { + const opt = options[highlight]; + if (showDropdown && opt) { + e.preventDefault(); + go(opt.path); + } + return; + } + }; + return (
- + {searching ? ( + + ) : ( + + )} setQuery(e.target.value)} - onFocus={() => { if (hasResults) setOpen(true); }} - onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }} - className="bg-transparent border-none text-sm ml-2 focus:outline-none w-48 placeholder:text-theme-muted/60" + onFocus={() => { if (options.length > 0) setOpen(true); }} + onKeyDown={onKeyDown} + className="bg-transparent border-none text-sm ml-2 focus:outline-none w-40 sm:w-48 lg:w-56 placeholder:text-theme-muted/60" style={{ color: 'var(--text-heading)' }} /> + {query && ( + + )}
{showDropdown && ( -
- {searching && ( -

Buscando...

- )} - {!searching && !hasResults && ( -

Sin resultados para "{query.trim()}"

+
+ {!searching && options.length === 0 && ( +

Sin resultados para "{query.trim()}". Prueba con otro nombre o teléfono.

)} - {results.patients.length > 0 && ( -
-

Pacientes

- {results.patients.map((p) => ( + {options.map((opt, idx) => { + const Icon = opt.icon; + const firstOfGroup = idx === 0 || options[idx - 1].group !== opt.group; + return ( +
+ {firstOfGroup && ( +

{opt.group}

+ )} - ))} -
- )} +
+ ); + })} - {results.appointments.length > 0 && ( -
-

Citas próximas

- {results.appointments.map((a) => ( - - ))} -
- )} - - {results.services.length > 0 && ( -
-

Servicios

- {results.services.map((s) => ( - - ))} -
+ {options.length > 0 && ( +

+ Usa ↑ ↓ para moverte y Enter para abrir +

)}
)} diff --git a/frontend/src/components/PatientCombobox.tsx b/frontend/src/components/PatientCombobox.tsx new file mode 100644 index 0000000..3524bfb --- /dev/null +++ b/frontend/src/components/PatientCombobox.tsx @@ -0,0 +1,306 @@ +import type { FC, KeyboardEvent } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { Search, User, UserPlus, X } from 'lucide-react'; +import { odooApi, type Patient } from '../services/odoo'; +import { cn } from '../lib/utils'; + +interface PatientComboboxProps { + /** id del paciente seleccionado ('' = ninguno) */ + value: string; + onChange: (patientId: string, patient?: Patient | null) => void; + /** Nombre a mostrar cuando el valor viene precargado desde fuera */ + initialName?: string; + label?: string; + placeholder?: string; + disabled?: boolean; + className?: string; + /** Máximo de resultados en el dropdown */ + pageSize?: number; + /** Si se define, agrega la opción "Crear paciente nuevo" al final del dropdown */ + onCreateNew?: (query: string) => void; + /** Mensaje cuando la búsqueda no devuelve nada */ + emptyHint?: string; +} + +/** + * Selector de paciente de UN SOLO campo: se escribe y los resultados aparecen + * en el mismo cuadro (dropdown en vivo, debounce 250 ms). Reemplaza el patrón + * viejo de "escribir en un buscador y luego ir a elegir en otro select". + */ +export const PatientCombobox: FC = ({ + value, + onChange, + initialName = '', + label = 'Paciente', + placeholder = 'Escribe nombre, apellidos o teléfono...', + disabled = false, + className = '', + pageSize = 8, + onCreateNew, + emptyHint = 'Sin resultados.', +}) => { + const [text, setText] = useState(initialName); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [highlight, setHighlight] = useState(0); + const [picked, setPicked] = useState(null); + const inputId = useId(); + const boxRef = useRef(null); + const inputRef = useRef(null); + const selectedNameRef = useRef(initialName); + // true cuando el usuario ya movió el resaltado con las flechas + const movedRef = useRef(false); + // Marca los cambios de `value` que nacen dentro del propio control, para no + // pisarle el texto al usuario cuando el padre re-renderiza. + const internalChangeRef = useRef(false); + + const emit = (patientId: string, patient?: Patient | null) => { + internalChangeRef.current = true; + onChange(patientId, patient); + }; + + // El valor puede llegar precargado (precarga de cita, edición) o limpiarse. + useEffect(() => { + if (internalChangeRef.current) { + internalChangeRef.current = false; + return; + } + if (!value) { + setText(''); + selectedNameRef.current = ''; + return; + } + if (initialName && initialName !== selectedNameRef.current) { + selectedNameRef.current = initialName; + setText(initialName); + } + }, [value, initialName]); + + // Cerrar al hacer click fuera + useEffect(() => { + const close = (e: MouseEvent) => { + if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, []); + + // Búsqueda server-side con debounce + useEffect(() => { + const q = text.trim(); + if (!open || q.length < 2) { + setResults([]); + setLoading(false); + return; + } + setLoading(true); + let active = true; + const t = setTimeout(async () => { + try { + const res = await odooApi.getPatients({ search: q, page_size: pageSize }); + if (active && res.status === 'success') { + setResults((res.patients || []).slice(0, pageSize)); + setHighlight(0); + movedRef.current = false; + } + } catch { + if (active) setResults([]); + } finally { + if (active) setLoading(false); + } + }, 250); + return () => { + active = false; + clearTimeout(t); + }; + }, [text, open, pageSize]); + + const query = text.trim(); + const canSearch = query.length >= 2; + const canCreate = !!onCreateNew && canSearch; + const optionsCount = results.length + (canCreate ? 1 : 0); + const showCreateHighlight = canCreate && highlight === results.length; + + const selectPatient = (p: Patient) => { + selectedNameRef.current = p.name; + setText(p.name); + setOpen(false); + setResults([]); + setPicked(p); + emit(String(p.id), p); + }; + + const clear = () => { + selectedNameRef.current = ''; + setText(''); + setResults([]); + setOpen(false); + setPicked(null); + emit('', null); + inputRef.current?.focus(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + if (!open) { + setOpen(true); + return; + } + if (optionsCount === 0) return; + const dir = e.key === 'ArrowDown' ? 1 : -1; + if (!movedRef.current) { + // Primer desplazamiento: no salta el primer resultado (ya está resaltado). + movedRef.current = true; + setHighlight(dir === 1 ? 0 : optionsCount - 1); + return; + } + setHighlight((h) => (h + dir + optionsCount) % optionsCount); + return; + } + if (e.key === 'Enter') { + if (!open) return; + e.preventDefault(); + if (showCreateHighlight) { + onCreateNew?.(query); + setOpen(false); + return; + } + const p = results[highlight]; + if (p) selectPatient(p); + } + }; + + const selectedPatient = useMemo(() => { + if (picked && String(picked.id) === value) return picked; + return results.find((p) => String(p.id) === value) || null; + }, [picked, results, value]); + + return ( +
+ {label && ( + + )} +
+ + { + const next = e.target.value; + setText(next); + setOpen(true); + // Si estaba seleccionado otro paciente y se escribe, se suelta la selección. + if (value && next !== selectedNameRef.current) { + selectedNameRef.current = ''; + emit('', null); + } + }} + onFocus={() => { + setOpen(true); + inputRef.current?.select(); + }} + onKeyDown={handleKeyDown} + className={cn( + 'w-full rounded-xl border bg-theme-surface pl-9 pr-9 py-2.5 text-sm text-theme-heading placeholder:text-theme-muted/60 focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent transition', + 'border-theme-border-strong', + disabled && 'opacity-60 cursor-not-allowed' + )} + /> + {value ? ( + + ) : null} +
+ + {open && !disabled && ( +
+ {!canSearch ? ( +

Escribe al menos 2 letras para buscar.

+ ) : loading && optionsCount === 0 ? ( +

Buscando...

+ ) : optionsCount === 0 ? ( +

{emptyHint}

+ ) : ( +
    + {results.map((p, idx) => ( +
  • + +
  • + ))} + {canCreate && ( +
  • + +
  • + )} +
+ )} +
+ )} + + {selectedPatient && ( +

+ {selectedPatient.phone || 'sin teléfono'} + {selectedPatient.primary_doctor ? ` · ${selectedPatient.primary_doctor}` : ''} +

+ )} +
+ ); +}; + +export default PatientCombobox; diff --git a/frontend/src/components/PatientDrawer.tsx b/frontend/src/components/PatientDrawer.tsx index 769e884..d754bad 100644 --- a/frontend/src/components/PatientDrawer.tsx +++ b/frontend/src/components/PatientDrawer.tsx @@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } import { useNavigate } from 'react-router-dom'; import { X, Star, AlertTriangle, Phone, MessageCircle, Mail, Wallet, Stethoscope, ArrowRight, - Calendar, ClipboardList, ShoppingBag, CreditCard, History, Plus, + Calendar, ClipboardList, ShoppingBag, CreditCard, History, Plus, Pencil, } from 'lucide-react'; import { odooApi, type Patient, type Visita, type EstadoCuenta, type PatientWallet, type ReceiptData, type Appointment, type Sale, type Payment } from '../services/odoo'; import { Badge, Button, Skeleton } from './ui'; @@ -545,10 +545,22 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa )}
-
+
+ )}
@@ -432,17 +433,6 @@ export const SkeenLayout: FC = ({ children, title, subtitle }) => {
- {collapsed && ( - - )}
); @@ -472,10 +462,10 @@ export const SkeenLayout: FC = ({ children, title, subtitle }) => { - {renderSidebar({ navRef: navMobileRef })} + {renderSidebar({ showToggle: false, navRef: navMobileRef })} -
+
diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx index e70af86..b3f59ab 100644 --- a/frontend/src/lib/auth.tsx +++ b/frontend/src/lib/auth.tsx @@ -27,9 +27,9 @@ const MENU_ROLES: Record = { '/' : ['admin', 'medico', 'recepcion', 'enfermeria', 'cosmetologia', 'psicologa', 'farmacia', 'gerencia', 'marketing', 'lectura'], '/agenda': ['admin', 'recepcion', 'medico', 'gerencia', 'cosmetologia', 'marketing'], '/mis-ventas': ['medico'], - '/pacientes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'], + '/pacientes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'marketing'], '/pos': ['admin', 'recepcion', 'gerencia'], - '/visitas': ['admin', 'recepcion', 'medico', 'gerencia'], + '/visitas': ['admin', 'recepcion', 'medico', 'gerencia', 'marketing'], '/consultas': ['admin', 'recepcion', 'medico', 'gerencia'], '/medicos': ['admin', 'recepcion', 'gerencia'], '/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'], diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 7c70710..24692d1 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -5,6 +5,19 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +/** + * Fecha de HOY en horario local (YYYY-MM-DD). + * No usar `new Date().toISOString().split('T')[0]`: convierte a UTC y, en la + * zona de la clínica (Tijuana, UTC-7/-8), a partir de las 16/17 h devuelve el + * día siguiente, con lo que los módulos mostraban "sin datos del día". + */ +export function todayLocal(d: Date = new Date()): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + type CsvCell = string | number | boolean | null | undefined; /** diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index e739924..2f7c91a 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -4,6 +4,7 @@ import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, Packag import Layout from '../components/Layout'; import { usePatientDrawer } from '../components/PatientDrawer'; import AgendaPrint from '../components/AgendaPrint'; +import PatientCombobox from '../components/PatientCombobox'; import RecargarSaldoModal from '../components/RecargarSaldoModal'; import ReciboPrint from '../components/ReciboPrint'; import { @@ -24,6 +25,7 @@ import { import { useSearchParams, useNavigate } from 'react-router-dom'; import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo, type PatientWallet, type ReceiptData } from '../services/odoo'; import { useAuth } from '../lib/auth'; +import { todayLocal } from '../lib/utils'; const stateOptions = [ { value: '', label: 'Todos los estados' }, @@ -116,14 +118,13 @@ const Agenda: FC = () => { const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null; const propiaColumnaId = isCosmetologa && user?.employee_id ? user.employee_id : null; const [appointments, setAppointments] = useState([]); - const [patients, setPatients] = useState([]); const [services, setServices] = useState([]); const [doctors, setDoctors] = useState([]); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [updating, setUpdating] = useState(null); const [error, setError] = useState(null); - const [date, setDate] = useState(() => searchParams.get('date') || new Date().toISOString().split('T')[0]); + const [date, setDate] = useState(() => searchParams.get('date') || todayLocal()); const [stateFilter, setStateFilter] = useState(''); const [doctorFilter, setDoctorFilter] = useState(() => { if (medicoEmployeeId) return String(medicoEmployeeId); @@ -161,6 +162,9 @@ const Agenda: FC = () => { const [printOpen, setPrintOpen] = useState(false); // Paciente precargado vía query param (?patient_id=X) desde la ficha const [preloadedPatient, setPreloadedPatient] = useState(null); + // Paciente elegido/creado en el combobox del modal (para mostrar su nombre + // sin depender de la búsqueda ni de la lista de citas cargadas). + const [pickedPatient, setPickedPatient] = useState<{ id: string; name: string } | null>(null); const [detailApt, setDetailApt] = useState(null); const [editMode, setEditMode] = useState(false); const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' }); @@ -177,19 +181,18 @@ const Agenda: FC = () => { const [dropTarget, setDropTarget] = useState(null); // Tooltip flotante de tarjeta de cita (hover en vistas de grid) const [tooltip, setTooltip] = useState<{ apt: Appointment; x: number; y: number; above: boolean } | null>(null); - const horasScrollRef = useRef(null); + const gridScrollRef = useRef(null); const autoScrollKey = useRef(''); const [form, setForm] = useState({ patient_id: '', service_id: '', doctor_id: '', - date: new Date().toISOString().split('T')[0], + date: todayLocal(), time: '', notes: '', sessions: 1, }); - const [patientSearch, setPatientSearch] = useState(''); const quickToggleClass = editingId ? 'hidden' : 'inline-flex items-center gap-2 text-sm text-theme-heading cursor-pointer'; @@ -204,7 +207,7 @@ const Agenda: FC = () => { name: '', phone: '', doctor_id: '', - date: new Date().toISOString().split('T')[0], + date: todayLocal(), time: '', notes: '', }); @@ -218,7 +221,7 @@ const Agenda: FC = () => { withAppointment: false, service_id: '', doctor_id: '', - date: new Date().toISOString().split('T')[0], + date: todayLocal(), time: '', }); @@ -565,7 +568,7 @@ const Agenda: FC = () => { · Las citas canceladas no bloquean horario: su hueco aparece como libre. - + · Barra por categoría: {Object.entries(categoryLabels).map(([key, label]) => ( @@ -593,14 +596,19 @@ const Agenda: FC = () => { Math.floor((nowFloat - 1) * SLOTS_PER_HOUR + 1e-6) / SLOTS_PER_HOUR ); setTimeout(() => { - const el = document.querySelector(`[data-grid-slot="${target.toFixed(2)}"]`); + const el = document.querySelector(`[data-grid-slot="${target.toFixed(2)}"]`) as HTMLElement | null; if (!el) return; - if (viewMode === 'horas' && horasScrollRef.current) { + const wrap = gridScrollRef.current; + if (viewMode === 'horas' && wrap) { // En "Por hora" el desplazamiento es horizontal dentro del contenedor - horasScrollRef.current.scrollLeft = Math.max(0, (el as HTMLElement).offsetLeft - 200); + wrap.scrollLeft = Math.max(0, el.offsetLeft - 200); + } else if (wrap) { + // El grid tiene su propio scroll vertical con la cabecera sticky: hay + // que descontar su alto para que la fila no quede tapada. + const headerH = 72; + wrap.scrollTop = Math.max(0, el.getBoundingClientRect().top - wrap.getBoundingClientRect().top + wrap.scrollTop - headerH); } else { el.scrollIntoView({ block: 'start' }); - window.scrollBy(0, -90); // compensa el encabezado de la página } }, 80); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -666,6 +674,7 @@ const Agenda: FC = () => { setQuickMode(false); setEditingId(null); setCrearNuevo(false); + setPickedPatient({ id: String(res.patient.id), name: res.patient.name }); setForm((f) => ({ ...f, patient_id: String(res.patient.id) })); setCreateOpen(true); } @@ -686,20 +695,6 @@ const Agenda: FC = () => { }; }, [preloadPatientId, setSearchParams]); - // Cargar pacientes para el selector (búsqueda server-side) - useEffect(() => { - if (!createOpen) return; - const t = setTimeout(async () => { - try { - const res = await odooApi.getPatients({ search: patientSearch, page_size: 8 }); - if (res.status === 'success') setPatients(res.patients); - } catch (err) { - console.error(err); - } - }, 250); - return () => clearTimeout(t); - }, [createOpen, patientSearch]); - // Aviso de duplicado: teléfono del mini-form "Crear paciente nuevo" useEffect(() => { if (!crearNuevo) { @@ -740,13 +735,9 @@ const Agenda: FC = () => { }); if (res.status === 'success') { toast.success('Paciente creado'); + setPickedPatient({ id: String(res.patient.id), name: res.patient.name }); setForm((f) => ({ ...f, patient_id: String(res.patient.id) })); - setPatients((prev) => [ - res.patient, - ...prev.filter((p) => String(p.id) !== String(res.patient.id)), - ]); setCrearNuevo(false); - setPatientSearch(''); } } catch (err) { toast.error('Error al crear el paciente'); @@ -943,7 +934,7 @@ const Agenda: FC = () => { const resetExpressForm = () => setExpressForm({ name: '', phone: '', notes: '', withAppointment: false, - service_id: '', doctor_id: '', date: new Date().toISOString().split('T')[0], time: '', + service_id: '', doctor_id: '', date: todayLocal(), time: '', }); const submitExpress = async () => { @@ -1008,11 +999,12 @@ const Agenda: FC = () => { }); toast.success(sessions > 1 ? `Paquete creado: ${sessions} citas` : 'Cita creada'); setCreateOpen(false); + setPickedPatient(null); setForm({ patient_id: '', service_id: '', doctor_id: '', - date: new Date().toISOString().split('T')[0], + date: todayLocal(), time: '', notes: '', sessions: 1, @@ -1031,6 +1023,7 @@ const Agenda: FC = () => { setDetailApt(null); setEditMode(false); setEditingId(apt.id); + setPickedPatient(null); setForm({ patient_id: String(apt.patient_id), service_id: String(apt.service_id), @@ -1040,7 +1033,6 @@ const Agenda: FC = () => { notes: apt.notes || '', sessions: 1, }); - setPatientSearch(''); setCreateOpen(true); }; @@ -1076,9 +1068,8 @@ const Agenda: FC = () => { const openCreate = () => { setQuickMode(false); setEditingId(null); - if (medicoEmployeeId) { - setForm((f) => ({ ...f, doctor_id: String(medicoEmployeeId) })); - } + setPickedPatient(null); + setForm((f) => ({ ...f, patient_id: '', ...(medicoEmployeeId ? { doctor_id: String(medicoEmployeeId) } : {}) })); setQuickForm({ name: '', phone: '', @@ -1097,12 +1088,13 @@ const Agenda: FC = () => { const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => { setQuickMode(false); setEditingId(null); + setPickedPatient(null); const d = opts?.date ?? date; const docId = medicoEmployeeId ? String(medicoEmployeeId) : (opts?.doctorId != null ? String(opts.doctorId) : doctorFilter); const t = opts?.time ?? ''; - setForm((f) => ({ ...f, date: d, time: t, doctor_id: docId })); + setForm((f) => ({ ...f, patient_id: '', date: d, time: t, doctor_id: docId })); setQuickForm({ name: '', phone: '', @@ -1156,31 +1148,20 @@ const Agenda: FC = () => { } }; - const patientOptions = useMemo(() => { - const opts: { value: string; label: string }[] = [{ value: '', label: 'Seleccionar paciente...' }]; - const list = patients.map((p) => ({ - 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, 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: pre - ? `${pre.name} — ${pre.phone || 'sin teléfono'}` - : sel - ? `${sel.patient} — (cita seleccionada)` - : `Paciente #${form.patient_id}`, - }); + // Nombre del paciente precargado (celda libre, query param o edición) para + // que el combobox muestre el texto correcto sin pedir otra búsqueda. + const selectedPatientName = useMemo(() => { + if (!form.patient_id) return ''; + if (pickedPatient && pickedPatient.id === form.patient_id) return pickedPatient.name; + if (preloadedPatient && String(preloadedPatient.id) === form.patient_id) return preloadedPatient.name; + const sel = appointments.find((a) => String(a.patient_id) === form.patient_id); + if (sel) return sel.patient; + if (editingId) { + const edit = appointments.find((a) => a.id === editingId); + if (edit && String(edit.patient_id) === form.patient_id) return edit.patient; } - opts.push(...list); - if (patientSearch.trim()) { - opts.push({ value: '__crear__', label: `Crear paciente nuevo: "${patientSearch.trim()}"` }); - } - return opts; - }, [patients, patientSearch, form.patient_id, appointments, preloadedPatient]); + return ''; + }, [form.patient_id, pickedPatient, preloadedPatient, appointments, editingId]); const serviceOptions = [ { value: '', label: 'Seleccionar servicio...' }, @@ -1334,7 +1315,7 @@ const Agenda: FC = () => {
{/* Navegación rápida de fecha: día anterior/siguiente (semana en vista Semana) y Hoy */} -
+