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
This commit is contained in:
217
docs/diagnostico-whatsapp-entrante.md
Normal file
217
docs/diagnostico-whatsapp-entrante.md
Normal file
@@ -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=<hmac_sha256_hex>` 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.
|
||||
@@ -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<GlobalSearchProps> = ({ placeholder = 'Buscar paciente, cita, servicio...', className = '' }) => {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Results>(EMPTY);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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<GlobalSearchProps> = ({ 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<Option[]>(() => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div ref={boxRef} className={`relative ${className}`}>
|
||||
<div className="flex items-center rounded-full px-3 py-1.5 border" style={{ backgroundColor: 'var(--bg)', borderColor: 'var(--border)' }}>
|
||||
<Search size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
{searching ? (
|
||||
<Loader2 size={14} className="animate-spin shrink-0" style={{ color: 'var(--text-muted)' }} />
|
||||
) : (
|
||||
<Search size={14} className="shrink-0" style={{ color: 'var(--text-muted)' }} />
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={showDropdown}
|
||||
aria-autocomplete="list"
|
||||
aria-label="Búsqueda global"
|
||||
autoComplete="off"
|
||||
value={query}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setQuery(''); setResults(EMPTY); setOpen(false); inputRef.current?.focus(); }}
|
||||
title="Limpiar búsqueda"
|
||||
aria-label="Limpiar búsqueda"
|
||||
className="ml-1 shrink-0 rounded-full p-0.5 text-theme-muted hover:text-theme-heading"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 max-h-[70vh] overflow-y-auto rounded-2xl border border-theme-border bg-theme-surface shadow-card z-50">
|
||||
{searching && (
|
||||
<p className="px-4 py-3 text-xs text-theme-muted">Buscando...</p>
|
||||
)}
|
||||
{!searching && !hasResults && (
|
||||
<p className="px-4 py-3 text-xs text-theme-muted">Sin resultados para "{query.trim()}"</p>
|
||||
<div
|
||||
role="listbox"
|
||||
className="absolute right-0 top-full mt-2 w-80 sm:w-96 max-h-[70vh] overflow-y-auto rounded-2xl border border-theme-border bg-theme-surface shadow-card z-50"
|
||||
>
|
||||
{!searching && options.length === 0 && (
|
||||
<p className="px-4 py-3 text-xs text-theme-muted">Sin resultados para "{query.trim()}". Prueba con otro nombre o teléfono.</p>
|
||||
)}
|
||||
|
||||
{results.patients.length > 0 && (
|
||||
<div className="p-1.5">
|
||||
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">Pacientes</p>
|
||||
{results.patients.map((p) => (
|
||||
{options.map((opt, idx) => {
|
||||
const Icon = opt.icon;
|
||||
const firstOfGroup = idx === 0 || options[idx - 1].group !== opt.group;
|
||||
return (
|
||||
<div key={opt.key} className={firstOfGroup ? (idx === 0 ? 'p-1.5' : 'p-1.5 border-t border-theme-border') : ''}>
|
||||
{firstOfGroup && (
|
||||
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">{opt.group}</p>
|
||||
)}
|
||||
<button
|
||||
key={`p-${p.id}`}
|
||||
type="button"
|
||||
onClick={() => go(`/pacientes?q=${encodeURIComponent(p.phone || p.name)}`)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading hover:bg-theme-accent-bg transition-colors text-left"
|
||||
role="option"
|
||||
aria-selected={idx === highlight}
|
||||
onMouseEnter={() => setHighlight(idx)}
|
||||
onClick={() => go(opt.path)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading transition-colors text-left',
|
||||
idx === highlight ? 'bg-theme-accent-bg' : 'hover:bg-theme-accent-bg'
|
||||
)}
|
||||
>
|
||||
<Users size={15} className="text-theme-muted shrink-0" />
|
||||
<span className="truncate">{p.name}</span>
|
||||
<span className="ml-auto text-xs text-theme-muted shrink-0">{p.phone}</span>
|
||||
<Icon size={15} className="text-theme-muted shrink-0" />
|
||||
<span className="truncate">{opt.label}</span>
|
||||
<span className="ml-auto text-xs text-theme-muted shrink-0">{opt.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{results.appointments.length > 0 && (
|
||||
<div className="p-1.5 border-t border-theme-border">
|
||||
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">Citas próximas</p>
|
||||
{results.appointments.map((a) => (
|
||||
<button
|
||||
key={`a-${a.id}`}
|
||||
type="button"
|
||||
onClick={() => go(`/agenda?date=${a.date}`)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading hover:bg-theme-accent-bg transition-colors text-left"
|
||||
>
|
||||
<Calendar size={15} className="text-theme-muted shrink-0" />
|
||||
<span className="truncate">{a.patient} · {a.service}</span>
|
||||
<span className="ml-auto text-xs text-theme-muted shrink-0">{a.date.slice(5)} {a.time}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.services.length > 0 && (
|
||||
<div className="p-1.5 border-t border-theme-border">
|
||||
<p className="px-2.5 py-1 text-[10px] uppercase tracking-wider text-theme-muted font-medium">Servicios</p>
|
||||
{results.services.map((s) => (
|
||||
<button
|
||||
key={`s-${s.id}`}
|
||||
type="button"
|
||||
onClick={() => go(`/servicios?q=${encodeURIComponent(s.name)}`)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-sm text-theme-heading hover:bg-theme-accent-bg transition-colors text-left"
|
||||
>
|
||||
<Briefcase size={15} className="text-theme-muted shrink-0" />
|
||||
<span className="truncate">{s.name}</span>
|
||||
<span className="ml-auto text-xs text-theme-muted shrink-0">${s.price}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{options.length > 0 && (
|
||||
<p className="px-4 py-2 border-t border-theme-border text-[10px] text-theme-muted">
|
||||
Usa ↑ ↓ para moverte y Enter para abrir
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
306
frontend/src/components/PatientCombobox.tsx
Normal file
306
frontend/src/components/PatientCombobox.tsx
Normal file
@@ -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<PatientComboboxProps> = ({
|
||||
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<Patient[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [picked, setPicked] = useState<Patient | null>(null);
|
||||
const inputId = useId();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div ref={boxRef} className={cn('relative w-full', className)}>
|
||||
{label && (
|
||||
<label className="block text-xs font-medium mb-1.5 text-theme-muted" htmlFor={inputId}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
autoComplete="off"
|
||||
disabled={disabled}
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
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 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
title="Quitar paciente"
|
||||
aria-label="Quitar paciente"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 rounded-lg text-theme-muted hover:text-theme-heading hover:bg-theme-bg"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{open && !disabled && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-30 max-h-72 overflow-y-auto rounded-xl border border-theme-border bg-theme-surface shadow-card">
|
||||
{!canSearch ? (
|
||||
<p className="px-3 py-2.5 text-xs text-theme-muted">Escribe al menos 2 letras para buscar.</p>
|
||||
) : loading && optionsCount === 0 ? (
|
||||
<p className="px-3 py-2.5 text-xs text-theme-muted">Buscando...</p>
|
||||
) : optionsCount === 0 ? (
|
||||
<p className="px-3 py-2.5 text-xs text-theme-muted">{emptyHint}</p>
|
||||
) : (
|
||||
<ul role="listbox" className="py-1">
|
||||
{results.map((p, idx) => (
|
||||
<li key={p.id} role="option" aria-selected={idx === highlight}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setHighlight(idx)}
|
||||
onClick={() => selectPatient(p)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
||||
idx === highlight ? 'bg-theme-accent-bg' : 'hover:bg-theme-bg'
|
||||
)}
|
||||
>
|
||||
<User size={15} className="shrink-0 text-theme-muted" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium text-theme-heading">{p.name}</span>
|
||||
<span className="block truncate text-xs text-theme-muted">
|
||||
{p.phone || 'sin teléfono'}
|
||||
{p.primary_doctor ? ` · ${p.primary_doctor}` : ''}
|
||||
{p.last_visit ? ` · última visita ${p.last_visit}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{String(p.id) === value && <span className="text-[10px] font-semibold text-theme-accent shrink-0">seleccionado</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{canCreate && (
|
||||
<li role="option" aria-selected={showCreateHighlight}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={() => setHighlight(results.length)}
|
||||
onClick={() => {
|
||||
onCreateNew?.(query);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left text-sm border-t border-theme-border transition-colors text-theme-heading',
|
||||
showCreateHighlight ? 'bg-theme-accent-bg' : 'hover:bg-theme-bg'
|
||||
)}
|
||||
>
|
||||
<UserPlus size={15} className="shrink-0 text-theme-muted" />
|
||||
<span className="truncate">Crear paciente nuevo{query ? `: «${query}»` : ''}</span>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPatient && (
|
||||
<p className="mt-1 text-xs text-theme-muted">
|
||||
{selectedPatient.phone || 'sin teléfono'}
|
||||
{selectedPatient.primary_doctor ? ` · ${selectedPatient.primary_doctor}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PatientCombobox;
|
||||
@@ -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
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-theme-border">
|
||||
<div className="p-4 border-t border-theme-border flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
className="flex-1"
|
||||
disabled={!patient}
|
||||
onClick={() => {
|
||||
if (!patient) return;
|
||||
onClose();
|
||||
navigate(`/pacientes?edit=${patient.id}`);
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} className="mr-1.5" /> Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(`/pacientes?q=${encodeURIComponent(patient?.name || '')}`);
|
||||
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
ChevronDown,
|
||||
MessageCircle,
|
||||
CircleDollarSign,
|
||||
PanelsTopLeft,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Ticket,
|
||||
} from 'lucide-react';
|
||||
import { Button, Modal, Input } from './ui';
|
||||
@@ -255,17 +256,17 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
navRef?: MutableRefObject<HTMLElement | null> | null;
|
||||
}) => (
|
||||
<>
|
||||
<div className={`h-20 flex items-center border-b border-white/10 shrink-0 ${collapsed ? 'justify-center px-2' : 'px-5 lg:px-6'}`}>
|
||||
<img src={LOGO_SKEEN} alt="SKEEN" className={collapsed ? 'h-6 w-auto object-contain' : 'h-7 w-auto object-contain'} />
|
||||
{!collapsed && showToggle && (
|
||||
<div className={`h-20 flex items-center border-b border-white/10 shrink-0 ${collapsed ? 'flex-col justify-center gap-1 px-1' : 'px-5 lg:px-6'}`}>
|
||||
<img src={LOGO_SKEEN} alt="SKEEN" className={collapsed ? 'h-5 w-auto object-contain' : 'h-7 w-auto object-contain'} />
|
||||
{showToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
title="Colapsar menú"
|
||||
aria-label="Colapsar menú"
|
||||
className="ml-auto p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-theme-surface/10 transition-colors"
|
||||
title={collapsed ? 'Expandir menú' : 'Colapsar menú'}
|
||||
aria-label={collapsed ? 'Expandir menú' : 'Colapsar menú'}
|
||||
className={`p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-theme-surface/10 transition-colors ${collapsed ? '' : 'ml-auto'}`}
|
||||
>
|
||||
<PanelsTopLeft size={16} />
|
||||
{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -432,17 +433,6 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
title="Expandir menú"
|
||||
aria-label="Expandir menú"
|
||||
className="p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-theme-surface/10 transition-colors"
|
||||
>
|
||||
<PanelsTopLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -472,10 +462,10 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||
<X size={22} />
|
||||
</Button>
|
||||
</div>
|
||||
{renderSidebar({ navRef: navMobileRef })}
|
||||
{renderSidebar({ showToggle: false, navRef: navMobileRef })}
|
||||
</aside>
|
||||
|
||||
<div className={`flex-1 min-h-screen flex flex-col w-full transition-all duration-200 ${collapsed ? 'lg:ml-16' : 'lg:ml-64'}`}>
|
||||
<div className={`flex-1 min-w-0 min-h-screen flex flex-col transition-all duration-200 ${collapsed ? 'lg:ml-16' : 'lg:ml-64'}`}>
|
||||
<header className="backdrop-blur border-b sticky top-0 z-10" style={{ backgroundColor: 'var(--header-bg)', borderColor: 'var(--border)' }}>
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
|
||||
@@ -27,9 +27,9 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||
'/' : ['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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Appointment[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [updating, setUpdating] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
|
||||
const [date, setDate] = useState<string>(() => 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<Patient | null>(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<Appointment | null>(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<string | null>(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<HTMLDivElement | null>(null);
|
||||
const gridScrollRef = useRef<HTMLDivElement | null>(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 = () => {
|
||||
<span className="text-xs text-theme-muted">
|
||||
· Las citas canceladas no bloquean horario: su hueco aparece como libre.
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-x-3 gap-y-1 text-xs text-theme-muted">
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-theme-muted">
|
||||
· Barra por categoría:
|
||||
{Object.entries(categoryLabels).map(([key, label]) => (
|
||||
<span key={key} className="inline-flex items-center gap-1">
|
||||
@@ -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 = () => {
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
{/* Navegación rápida de fecha: día anterior/siguiente (semana en vista Semana) y Hoy */}
|
||||
<div className="flex items-end gap-1.5">
|
||||
<div className="flex flex-wrap items-end gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1349,7 +1330,7 @@ const Agenda: FC = () => {
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="sm:max-w-[180px]"
|
||||
className="w-full sm:max-w-[180px]"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1623,15 +1604,15 @@ const Agenda: FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
|
||||
<div ref={gridScrollRef} className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
|
||||
<table className="w-full border-collapse bg-theme-surface">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 w-20 bg-theme-surface p-3 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
<th className="sticky left-0 top-0 z-30 w-20 bg-theme-surface p-3 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
Hora
|
||||
</th>
|
||||
{gridColumns.map((col) => (
|
||||
<th key={col.key} className="min-w-[180px] p-3 border-b border-l border-theme-border">
|
||||
<th key={col.key} className="sticky top-0 z-20 min-w-[180px] bg-theme-surface p-3 border-b border-l border-theme-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
|
||||
{initials(col.label)}
|
||||
@@ -1739,17 +1720,17 @@ const Agenda: FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div ref={horasScrollRef} className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
|
||||
<div ref={gridScrollRef} className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
|
||||
<table className="w-full border-collapse bg-theme-surface">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
<th className="sticky left-0 top-0 z-30 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
Médico
|
||||
</th>
|
||||
{gridSlots.map((slot) => {
|
||||
const isNowSlot = nowSlot !== null && slot === nowSlot;
|
||||
return (
|
||||
<th key={slot} data-grid-slot={slot.toFixed(2)} className={`min-w-[52px] p-1 text-center text-[10px] font-medium border-b border-l border-theme-border ${isNowSlot ? 'text-theme-accent border-b-2 border-b-theme-accent' : 'text-theme-muted'}`}>
|
||||
<th key={slot} data-grid-slot={slot.toFixed(2)} className={`sticky top-0 z-20 min-w-[60px] bg-theme-surface p-1 text-center text-[10px] font-medium border-b border-l border-theme-border ${isNowSlot ? 'text-theme-accent border-b-2 border-b-theme-accent' : 'text-theme-muted'}`}>
|
||||
{horaFloatStr(slot)}
|
||||
{isNowSlot && <span className="block text-[8px] font-semibold uppercase">ahora</span>}
|
||||
</th>
|
||||
@@ -1852,17 +1833,17 @@ const Agenda: FC = () => {
|
||||
<p className="text-sm text-theme-muted mb-2">
|
||||
Semana del {weekDates[0]} al {weekDates[6]} · usa ‹ › para moverte por semana
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
|
||||
<div className="overflow-auto max-h-[70vh] rounded-2xl border border-theme-border shadow-card">
|
||||
<table className="w-full border-collapse bg-theme-surface">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 min-w-[150px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
<th className="sticky left-0 top-0 z-30 min-w-[150px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
|
||||
Médico
|
||||
</th>
|
||||
{weekDates.map((d, i) => (
|
||||
<th
|
||||
key={d}
|
||||
className={`min-w-[132px] p-2 text-center text-[11px] font-medium border-b border-l border-theme-border ${
|
||||
className={`sticky top-0 z-20 min-w-[132px] bg-theme-surface p-2 text-center text-[11px] font-medium border-b border-l border-theme-border ${
|
||||
d === todayStr ? 'bg-theme-accent-bg text-theme-accent' : 'text-theme-muted'
|
||||
}`}
|
||||
>
|
||||
@@ -2255,11 +2236,21 @@ const Agenda: FC = () => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
label="Buscar paciente"
|
||||
placeholder="Escribe nombre o teléfono..."
|
||||
value={patientSearch}
|
||||
onChange={(e) => setPatientSearch(e.target.value)}
|
||||
<PatientCombobox
|
||||
label="Paciente *"
|
||||
value={form.patient_id}
|
||||
initialName={selectedPatientName}
|
||||
placeholder="Escribe nombre, apellidos o teléfono..."
|
||||
emptyHint="Sin resultados. Usa «Crear paciente nuevo» para darlo de alta."
|
||||
onCreateNew={(q) => {
|
||||
setNuevoPac((p) => ({ ...p, name: p.name || q }));
|
||||
setCrearNuevo(true);
|
||||
}}
|
||||
onChange={(id, patient) => {
|
||||
setCrearNuevo(false);
|
||||
setPickedPatient(patient ? { id: String(patient.id), name: patient.name } : null);
|
||||
setForm((f) => ({ ...f, patient_id: id }));
|
||||
}}
|
||||
/>
|
||||
{crearNuevo && (
|
||||
<div className="rounded-xl border border-theme-border p-3 space-y-3 bg-theme-bg">
|
||||
@@ -2297,25 +2288,12 @@ const Agenda: FC = () => {
|
||||
<Button size="sm" onClick={crearYSeleccionarPaciente} loading={creandoPac}>
|
||||
Crear y seleccionar
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setCrearNuevo(false); setForm((f) => ({ ...f, patient_id: '' })); }}>
|
||||
<Button size="sm" variant="ghost" onClick={() => setCrearNuevo(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Select
|
||||
label="Paciente *"
|
||||
options={patientOptions}
|
||||
value={form.patient_id}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === '__crear__') {
|
||||
setCrearNuevo(true);
|
||||
return;
|
||||
}
|
||||
setCrearNuevo(false);
|
||||
setForm({ ...form, patient_id: e.target.value });
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Servicio *"
|
||||
options={serviceOptions}
|
||||
|
||||
@@ -124,19 +124,29 @@ const Chat: FC = () => {
|
||||
fetchUsers();
|
||||
}, [fetchThread, fetchUsers]);
|
||||
|
||||
// Buscar en el historial (servidor)
|
||||
const buscarHistorial = useCallback(async () => {
|
||||
if (!activeId || histQuery.trim().length < 2) return;
|
||||
// Buscar en el historial (servidor). La búsqueda es en vivo (debounce 250 ms)
|
||||
// sobre el mismo campo; el botón "Buscar" quedó como atajo opcional.
|
||||
const buscarHistorial = useCallback(async (q: string) => {
|
||||
if (!activeId || q.trim().length < 2) {
|
||||
setHistResults([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setHistLoading(true);
|
||||
const res = await odooApi.searchChatMessages(activeId, histQuery.trim());
|
||||
const res = await odooApi.searchChatMessages(activeId, q.trim());
|
||||
if (res.status === 'success') setHistResults(res.results);
|
||||
} catch {
|
||||
toast.error('No se pudo buscar en el historial');
|
||||
} finally {
|
||||
setHistLoading(false);
|
||||
}
|
||||
}, [activeId, histQuery]);
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!histOpen) return;
|
||||
const t = setTimeout(() => { void buscarHistorial(histQuery); }, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [histQuery, histOpen, buscarHistorial]);
|
||||
|
||||
// Saltar a un resultado: si ya está cargado se centra; si es antiguo se
|
||||
// pide la ventana de historial que termina en ese mensaje.
|
||||
@@ -361,11 +371,25 @@ const Chat: FC = () => {
|
||||
placeholder="Buscar en el historial..."
|
||||
value={histQuery}
|
||||
onChange={(e) => setHistQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') buscarHistorial(); }}
|
||||
className="w-full rounded-xl border border-theme-border-strong bg-theme-surface pl-9 pr-3 py-2 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void buscarHistorial(histQuery);
|
||||
if (e.key === 'Escape') { setHistOpen(false); setHistResults([]); setHistQuery(''); }
|
||||
}}
|
||||
className="w-full rounded-xl border border-theme-border-strong bg-theme-surface pl-9 pr-9 py-2 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent"
|
||||
/>
|
||||
{histQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setHistQuery(''); setHistResults([]); }}
|
||||
title="Limpiar búsqueda"
|
||||
aria-label="Limpiar búsqueda"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 rounded-lg text-theme-muted hover:text-theme-heading hover:bg-theme-bg"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={buscarHistorial} loading={histLoading}>Buscar</Button>
|
||||
{histLoading && <span className="text-xs text-theme-muted">Buscando...</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setHistOpen(false); setHistResults([]); setHistQuery(''); }}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Search, Plus, Activity, ShoppingBag, User, Edit2, Phone, Mail, Calendar, Droplet, Heart,
|
||||
MapPin, Briefcase, Users, Baby, Stethoscope, FileText, AlertCircle, CheckCircle2, XCircle,
|
||||
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet,
|
||||
Download, ChevronRight, AlertTriangle, ClipboardList, Camera, Wallet, X,
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import PatientAdjuntos from '../components/PatientAdjuntos';
|
||||
@@ -124,8 +124,9 @@ const Pacientes: FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [search, setSearch] = useState(() => searchParams.get('q') || '');
|
||||
const [searchInput, setSearchInput] = useState(() => searchParams.get('q') || '');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -234,6 +235,12 @@ const Pacientes: FC = () => {
|
||||
setPage(1);
|
||||
}, [search, onlyIncomplete, onlyVip, onlyRecent, onlyStale, doctorFilter]);
|
||||
|
||||
// Búsqueda con debounce: el listado se refresca solo, sin botón "Buscar".
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(searchInput.trim()), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
odooApi.getDoctors()
|
||||
.then((res) => { if (res.status === 'success') setDoctors(res.doctors); })
|
||||
@@ -308,6 +315,33 @@ const Pacientes: FC = () => {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
// Abrir el formulario de edición desde otra pantalla (p. ej. el botón
|
||||
// "Editar" del drawer del expediente): /pacientes?edit=<id>
|
||||
const editPatientId = searchParams.get('edit');
|
||||
useEffect(() => {
|
||||
if (!editPatientId) return;
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await odooApi.getPatient(parseInt(editPatientId, 10));
|
||||
if (active && res.status === 'success') openEdit(res.patient);
|
||||
} catch (err) {
|
||||
toast.error('No se pudo abrir el paciente');
|
||||
console.error(err);
|
||||
} finally {
|
||||
if (active) {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('edit');
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editPatientId, setSearchParams]);
|
||||
|
||||
// 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(() => {
|
||||
@@ -500,13 +534,26 @@ const Pacientes: FC = () => {
|
||||
<Card.Body>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4 sm:mb-6">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Buscar por nombre o teléfono..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Buscar por nombre, apellidos o teléfono..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setSearch(searchInput.trim()); if (e.key === 'Escape') { setSearchInput(''); setSearch(''); } }}
|
||||
className="pl-10"
|
||||
inputClassName={searchInput ? 'pr-9' : undefined}
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchInput(''); setSearch(''); }}
|
||||
title="Limpiar búsqueda"
|
||||
aria-label="Limpiar búsqueda"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 rounded-lg text-theme-muted hover:text-theme-heading hover:bg-theme-bg"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
options={[
|
||||
@@ -855,7 +902,18 @@ const Pacientes: FC = () => {
|
||||
<FileText size={16} className="mr-2" />
|
||||
Estado de cuenta
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => selected && openEdit(selected)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// El modal de detalle se cierra antes de abrir el de edición:
|
||||
// ambos comparten z-index y el de detalle, al estar después en
|
||||
// el DOM, tapaba el formulario (parecía que "Editar" no hacía nada).
|
||||
if (!selected) return;
|
||||
const p = selected;
|
||||
setSelected(null);
|
||||
openEdit(p);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={16} className="mr-2" />
|
||||
Editar paciente
|
||||
</Button>
|
||||
@@ -920,7 +978,11 @@ const Pacientes: FC = () => {
|
||||
{selected.expediente_missing && selected.expediente_missing.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-xs text-theme-muted">Falta: {selected.expediente_missing.join(', ')}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => openEdit(selected)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { const p = selected; setSelected(null); openEdit(p); }}
|
||||
>
|
||||
Completar expediente
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Fragment, useEffect, useState, useCallback } from 'react';
|
||||
import { Plus, Trash2, CreditCard, ShoppingCart, Search, Undo2, Download, Printer, ChevronDown, ChevronRight, Ban } from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { usePatientDrawer } from '../components/PatientDrawer';
|
||||
import PatientCombobox from '../components/PatientCombobox';
|
||||
import TicketPrint from '../components/TicketPrint';
|
||||
import {
|
||||
Card,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
toast,
|
||||
badgeForSaleState,
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Sale, type SaleLine, type Service, type Patient, type Doctor } from '../services/odoo';
|
||||
import { odooApi, type Sale, type SaleLine, type Service, type Doctor } from '../services/odoo';
|
||||
import { exportToExcel } from '../lib/exporter';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs, type DatePreset } from '../lib/dateRange';
|
||||
@@ -50,7 +51,6 @@ const Ventas: FC = () => {
|
||||
const puedeCancelar = ROLES_CANCELAN.includes(user?.role || '');
|
||||
const [sales, setSales] = useState<Sale[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -81,7 +81,6 @@ const Ventas: FC = () => {
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [patientSearch, setPatientSearch] = useState('');
|
||||
|
||||
// ---- Rango de fechas recordado (presets) ----
|
||||
const saved = readDateFilterPrefs(FILTERS_KEY);
|
||||
@@ -154,20 +153,6 @@ const Ventas: FC = () => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
// 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: 50 });
|
||||
if (res.status === 'success') setPatients(res.patients);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [createOpen, patientSearch]);
|
||||
|
||||
const addLine = () =>
|
||||
setLines([...lines, emptyLine()]);
|
||||
|
||||
@@ -362,11 +347,6 @@ const Ventas: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const patientOptions = [
|
||||
{ value: '', label: 'Seleccionar paciente...' },
|
||||
...patients.map((p) => ({ value: String(p.id), label: `${p.name} (${p.phone})` })),
|
||||
];
|
||||
|
||||
const serviceOptions = [
|
||||
{ value: '', label: 'Servicio...' },
|
||||
...services.map((s) => ({ value: String(s.id), label: `${s.name} — $${s.price}` })),
|
||||
@@ -694,17 +674,12 @@ const Ventas: FC = () => {
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Buscar paciente"
|
||||
placeholder="Escribe nombre o teléfono..."
|
||||
value={patientSearch}
|
||||
onChange={(e) => setPatientSearch(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
<PatientCombobox
|
||||
label="Paciente *"
|
||||
options={patientOptions}
|
||||
value={patientId}
|
||||
onChange={(e) => setPatientId(e.target.value)}
|
||||
placeholder="Escribe nombre, apellidos o teléfono..."
|
||||
emptyHint="Sin resultados. Da de alta al paciente en Pacientes y vuelve a intentar."
|
||||
onChange={(id) => setPatientId(id)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../components/ui';
|
||||
import { odooApi, type Visita, type VisitaAdjunto, type Doctor, type InventoryItem } from '../services/odoo';
|
||||
import type { BadgeVariant } from '../components/ui/Badge';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
|
||||
const visitaStateLabels: Record<string, string> = {
|
||||
en_curso: 'En curso',
|
||||
@@ -44,7 +45,7 @@ const adjuntoKindLabels: Record<string, string> = {
|
||||
|
||||
const MAX_ADJUNTO_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
const hoy = () => new Date().toISOString().split('T')[0];
|
||||
const hoy = () => todayLocal();
|
||||
|
||||
const Visitas: FC = () => {
|
||||
const [visitas, setVisitas] = useState<Visita[]>([]);
|
||||
|
||||
@@ -34,6 +34,22 @@ def _parse_json_body():
|
||||
return {}
|
||||
|
||||
|
||||
def _patient_search_domain(search):
|
||||
"""Dominio de búsqueda de pacientes por texto libre.
|
||||
|
||||
Se parte la consulta en tokens y cada token debe aparecer en nombre,
|
||||
teléfono o email (AND implícito entre grupos OR). Así "juan arevalo"
|
||||
encuentra a "AREVALO LEAL JUAN ENRIQUE" sin importar el orden ni que el
|
||||
nombre esté capturado con apellidos primero. ilike de Odoo aplica
|
||||
unaccent, por lo que acentos y mayúsculas son indistintos.
|
||||
"""
|
||||
tokens = [t for t in re.split(r'[\s,]+', search or '') if t]
|
||||
domain = []
|
||||
for t in tokens:
|
||||
domain += ['|', '|', ('name', 'ilike', t), ('phone', 'ilike', t), ('email', 'ilike', t)]
|
||||
return domain
|
||||
|
||||
|
||||
class SkeenFrontendController(http.Controller):
|
||||
"""Controladores API públicos para el Frontend React de SKEEN"""
|
||||
|
||||
@@ -1345,8 +1361,7 @@ class SkeenFrontendController(http.Controller):
|
||||
try:
|
||||
domain = [('is_patient', '=', True)]
|
||||
if kw.get('search'):
|
||||
search = kw.get('search')
|
||||
domain += ['|', '|', ('name', 'ilike', search), ('phone', 'ilike', search), ('email', 'ilike', search)]
|
||||
domain += _patient_search_domain(kw.get('search'))
|
||||
if kw.get('incomplete') == '1':
|
||||
domain += [('expediente_completion', '<', 100)]
|
||||
if kw.get('vip') == '1':
|
||||
@@ -1390,8 +1405,7 @@ class SkeenFrontendController(http.Controller):
|
||||
try:
|
||||
domain = [('is_patient', '=', True)]
|
||||
if kw.get('search'):
|
||||
search = kw['search']
|
||||
domain += ['|', '|', ('name', 'ilike', search), ('phone', 'ilike', search), ('email', 'ilike', search)]
|
||||
domain += _patient_search_domain(kw['search'])
|
||||
if kw.get('incomplete') == '1':
|
||||
domain += [('expediente_completion', '<', 100)]
|
||||
if kw.get('vip') == '1':
|
||||
@@ -2435,8 +2449,7 @@ class SkeenFrontendController(http.Controller):
|
||||
return json_response({'status': 'success', 'patients': [], 'appointments': [], 'services': []})
|
||||
Partner = request.env['res.partner'].sudo()
|
||||
patients = Partner.search(
|
||||
[('is_patient', '=', True), '|', '|',
|
||||
('name', 'ilike', q), ('phone', 'ilike', q), ('email', 'ilike', q)],
|
||||
[('is_patient', '=', True)] + _patient_search_domain(q),
|
||||
limit=5, order='name asc')
|
||||
Citas = request.env['skeen.cita'].sudo()
|
||||
citas = Citas.search(
|
||||
|
||||
@@ -52,9 +52,9 @@ DEFAULT_MENU_ROLES = {
|
||||
'/' : ['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'],
|
||||
|
||||
Reference in New Issue
Block a user