# Auto-asignación de especialista + rediseño de la página de reservas **Fecha:** 2026-07-26 **Estado:** Borrador para revisión del usuario **Alcance:** Página pública de reservas (`/b/:slug`) + sección de Configuración + modelo de datos ## 1. Objetivo 1. Añadir en Configuración un check **"Asignar especialista automáticamente"** que, al activarse, **omite el paso de elección de especialista** para el cliente en la URL pública de reservas. 2. La asignación se resuelve con un **algoritmo matemático** que considera especialidad, eficiencia y —sobre todo— **disponibilidad**, garantizando que **un especialista nunca atienda a dos clientes a la vez** (sin traslape con citas ya agendadas). 3. En "Elige fecha y hora", reemplazar el `` nativo por un **calendario de mes siempre visible**. 4. En **escritorio**, dividir "Elige fecha y hora" en **dos columnas** (calendario + horarios) para aprovechar el ancho de pantalla; en móvil se mantiene el stack actual. 5. **Colores indicativos llamativos** (alto contraste, mayor tamaño) para ayudar a usuarios mayores de 50, **manteniendo** el estilo moderno/fresco de AgendaMax y **sin alterar** los bloques de horario actuales. 6. **Fix:** iconos de "Tus datos" (nombre/teléfono/correo) que se solapan con el placeholder gris. ## 2. No-objetivos (YAGNI) - Pasarela de pago / depósitos (`require_deposit` queda sin aplicar). - Política de cancelación automatizada (fuera de alcance aquí). - Recurrencia de citas, descansos/almuerzos puntuales. - Vista de disponibilidad tipo FullCalendar en el flujo público (se usa una grilla de mes propia, más simple y enfocada). ## 3. Arquitectura Nuevo módulo **`server/lib/scheduling.ts`** con funciones puras y testeables que centralizan toda la "data science": - `getWorkingHours(business, employee|null, date) → { start, end } | null` (resuelve horario: empleado → si no, negocio; si el día es `null`, fuera de servicio). - `isAvailable(db, employeeId, startIso, endIso) → boolean` (verifica traslape contra citas `(scheduled, completed)`). - `computeOccupiedMinutes(db, employeeId, date) → number` (para load balance). - `specialtyMatch(employee, service) → 0..1`. - `scoreCandidate(ctx) → { score, reasons }`. - `autoAssign(db, { business, service, startIso, endIso }) → { employeeId, score, reasons } | null` (filtra por hard constraints, luego scorea). Se descarta una versión SQL-pura (window functions): el scoring ponderado es más legible y mantenible en TypeScript y el número de especialistas por negocio es pequeño (cabe en memoria). El módulo es importado por `server/routes/booking.ts` y `server/routes/appointments.ts`. ## 4. Modelo de datos (migración V3 → V4) Función `migrateV3ToV4` en `server/db.ts` (sigue el patrón de `migrateV2ToV3`). ### Tabla `businesses` — 2 columnas | Columna | Tipo | Default | Notas | |---|---|---|---| | `auto_assign_specialist` | `INTEGER NOT NULL` | `0` | El check que omite el paso de especialista. | | `working_hours` | `TEXT` (JSON) | ver backfill | Horario default del negocio por día. | Formato JSON: `{"1":{"start":"09:00","end":"20:00"}, "2":{...}, ..., "6":null, "7":null}` (1=Lun … 7=Dom; `null` = cerrado). ### Tabla `employees` — 3 columnas | Columna | Tipo | Default | Notas | |---|---|---|---| | `specialties` | `TEXT` (JSON array) | `'[]'` | Etiquetas: `["Corte","Barba"]`. Se comparan con `service.category` y tokens de `service.name`. | | `working_hours` | `TEXT` (JSON) | `NULL` | Horario individual; `NULL` = hereda el del negocio. | | `efficiency_score` | `REAL NOT NULL` | `50` | 0–100. Lo captura el dueño. | ### Backfill (en la migración) - `auto_assign_specialist = 0` para todos. - `businesses.working_hours` = `{"1":{"start":"09:00","end":"20:00"},...,"5":{...},"6":null,"7":null}` (replica el `09:00–20:00` Lun–Vie actualmente hardcodeado en `booking.ts:57-58`). - `employees.specialties = '[]'`, `employees.working_hours = NULL`, `employees.efficiency_score = 50`. ### Tipos (`shared/types.ts`) - `Business` añade `auto_assign_specialist: boolean`, `working_hours: WorkingHours | null`. - `Employee` añade `specialties: string[]`, `working_hours: WorkingHours | null`, `efficiency_score: number`. - `WorkingHours` = `Record<1..7, { start: string; end: string } | null>`. ### Settings API (`server/routes/settings.ts`) - La lista blanca (`settings.ts:8-20`) añade: `auto_assign_specialist`, `working_hours`. ## 5. Algoritmo de auto-asignación (la "data science") Restricción dura + score ponderado, para una cita candidata (servicio `S`, intervalo `[t0, t1)`): ### 5.1 Hard constraints — candidato válido si: 1. El empleado está `active = 1`. 2. Ofrece el servicio: existe fila en `employee_services(employee_id, service_id)`. 3. `[t0, t1)` cae **dentro** de su horario laboral ese día (`getWorkingHours`). Si el día es cerrado → descartado. 4. **No traslapa** ninguna cita existente con `status IN ('scheduled','completed')`: para toda cita `c`, es falso que `t0 < c.end_at && t1 > c.start_at` (`isAvailable`). → Garantiza que **un especialista jamás atiende a dos personas a la vez**. ### 5.2 Score (0–100) de cada candidato válido ``` score = 50 * specialtyMatch + 30 * efficiency + 20 * (1 - loadBalance) ``` - **specialtyMatch (0..1):** `1.0` si una etiqueta de `employee.specialties` coincide (normalizado: lowercase, sin acentos, match por token/subcadena) con `service.category` o un token de `service.name`; `0.5` si ofrece el servicio (paso 2) pero sin coincidencia de etiqueta. - **efficiency (0..1):** `employee.efficiency_score / 100`. - **loadBalance (0..1):** `min(1, ocupadoHoy / laborableHoy)`, donde `ocupadoHoy = computeOccupiedMinutes` (suma de duraciones de citas del día) y `laborableHoy = minutos del turno ese día`. A menor carga → mayor `(1 - loadBalance)` → premia reparto justo. ### 5.3 Desempate y resultado - Mayor `score` gana. Empate → menor `id` (determinista). - `autoAssign` devuelve `{ employeeId, score, reasons: string[] }` para auditoría/UI. ### 5.4 Pesos `specialty 50 / efficiency 30 / load 20`. Disponibilidad es **restricción dura** (peso infinito): nunca se viola. ## 6. Guard anti double-booking (transaccional) Hoy `POST /api/public/:slug/book` (`booking.ts:100-153`) y `POST /api/appointments` (`appointments.ts:75-167`) insertan **sin re-verificar** conflictos (race condition). Fix: - Envolver la creación en una **transacción** de SQLite (`db.exec('BEGIN IMMEDIATE')` o el equivalente de `node:sqlite`). - Antes del `INSERT`, llamar a `isAvailable(employeeId, t0, t1)`. - Si ya no está libre → **409 Conflict** con `{ error: "ESE_HORARIO_OCUPADO" }`. El cliente público ve "Esa hora acaba de ocuparse, elige otra" y vuelve al paso de horario. - En auto-asignación, `autoAssign` corre **dentro** de la misma transacción, sobre el estado ya bloqueado. ## 7. Flujo del wizard público (`src/pages/public/BookingPage.tsx`) ### 7.1 Pasos - `auto_assign_specialist = ON` → **3 pasos**: Servicio → **Horario** → Datos (sin paso Especialista). - `OFF` → 4 pasos actuales, pero la card "Cualquiera" usa `autoAssign` (no más `LIMIT 1`). ### 7.2 Slots (`GET /api/public/:slug/slots`) - Se mantiene la firma y la grilla de **30 min**, pero el rango ahora se lee de `getWorkingHours` (negocio/empleado) en vez del hardcodeado `09:00–20:00`. - Cuando se omite el especialista, los candidatos son **todos** los empleados que ofrecen el servicio; un slot se muestra si **al menos uno** está libre (unión de disponibilidades). La asignación concreta se resuelve al confirmar. - El payload del slot puede incluir un flag opcional `hasMultiple` para mostrar "varios especialistas disponibles" (informativo). ### 7.3 Confirmación - Muestra el especialista asignado: avatar/color, nombre, rol, y 1–2 `reasons` legibles (p. ej. "Especialista en Corte · alta eficiencia · disponible"). El cliente nunca queda sin saber a quién le tocó. ## 8. UI — `DateTimePicker` (Elige fecha y hora) ### 8.1 Calendario de mes siempre visible (`MonthCalendar`) - Componente nuevo (sin librería externa). Grilla **Lun–Dom**, header con mes/año + flechas ‹ › (limite ±3 meses). - Días pasados o fuera de horario: atenuados (`slate-100`, texto `slate-300`), no seleccionables. - Fin de semana: color tenue (no bloqueado salvo que `working_hours` sea `null`). - Día con disponibilidad (≥1 slot): **punto `accent`** (naranja AgendaMax) bajo el número. - Día seleccionado: relleno `brand-500`, texto blanco, `ring-2 ring-brand-500/30`, `shadow-soft`. - Tipografía base >=16px; celda táctil >=44px (accesibilidad 50+). ### 8.2 Dos columnas en escritorio - En el paso Horario, `main` pasa de `max-w-2xl` a **`max-w-5xl`** (solo en este paso). - `DateTimePicker`: `md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:gap-8` → **calendario izquierda**, **tarjeta de horarios derecha**. - Tarjeta de horarios: encabezado con la fecha elegida en grande + agrupación visual de slots por **Mañana / Tarde** (etiquetas con color `accent`/`brand` suave). **La grilla de bloques actual no se modifica** (`grid-cols-3 sm:grid-cols-4`, estilos seleccionado/disponible idénticos). - En `=16px; estado enfocado con `ring` más visible. ## 9. UI — Fix de iconos en "Tus datos" (y app-wide) ### 9.1 Causa raíz `src/index.css:272-284`: `.input { padding: 0.55rem 0.75rem }` (shorthand) tiene la misma especificidad (0,1,0) que `.pl-9` de Tailwind. Como `@tailwind utilities;` se emite **antes** en el archivo, la regla `.input` (posterior) **gana** y fija `padding-left: 0.75rem` (12px), justo donde inicia el icono (`left-3` = 12px, ancho 16px → ocupa 12–28px). El texto arranca en 12px → **solape**. Afecta a **9 inputs** de la app. ### 9.2 Fix 1. Envolver `.input`, `.select`, `.textarea` (y demás reglas de componente custom) en **`@layer components { … }`** en `src/index.css`. Así las utilidades del layer `utilities` (incluido `pl-*`) **siempre** ganan, independientemente del orden de fuente. Resuelve los 9 sitios a la vez. 2. En los 3 fields de "Tus datos" subir `pl-9` → **`pl-10`** (icono `left-3` + 16px → texto a 40px, ~12px de holgura, mejor para 50+). Opcionalmente replicar `pl-10` en los otros 6 inputs con icono para consistencia. ## 10. UI — Configuración (`src/pages/SettingsPage.tsx`) - Card **"Reservas online"** (`:64-101`): añade - Check **"Asignar especialista automáticamente"** con helper "El cliente no elige especialista; lo asigna el sistema según especialidad, eficiencia y disponibilidad". - Editor de **horario del negocio**: grilla 7 días, cada uno con switch activo + `start`/`end` (type=time). Persiste en `businesses.working_hours`. - El listado de campos del mutation PATCH (`SettingsPage.tsx:28-49`) incluye los nuevos. ## 11. UI — Ficha de Especialista (edición de empleado) - Vista de edición de empleado añade: - **Especialidades** (input de chips/tags → array). - **Horario laboral** (default: "Heredar del negocio"; si se edita, grilla 7 días como en el negocio). - **Eficiencia** (slider 0–100 con etiqueta descriptiva). - Endpoints existentes `PATCH /api/employees/:id` se extienden con los 3 campos en la lista blanca. ## 12. Plan de pruebas - **Unit (scheduling):** `isAvailable` (casos de traslape en bordes), `specialtyMatch` (acentos/case), `scoreCandidate` (monotocidad de cada factor), `autoAssign` (elige óptimo, desempate determinista, respeta horario, ninguno disponible → null). - **Integración (rutas):** reservar la misma hora dos veces → la 2ª da 409; reservar con auto-assign activo asigna y no traspasa. - **Build/estático:** `npm run typecheck` y `npm run lint` limpios. - **E2E si aplica:** flujo público completo con check activo (3 pasos) y sin check (4 pasos). ## 13. Riesgos y mitigaciones | Riesgo | Mitigación | |---|---| | Datos vacíos al migrar (sin especialidades/eficiencia) | Defaults neutros: `specialties=[]`, `efficiency=50`. El algoritmo degrada a disponibilidad + load balance. | | Race condition de doble reserva | Transacción `BEGIN IMMEDIATE` + `isAvailable` dentro de la misma. | | Performance con muchos especialistas | N por negocio es chico; peor caso O(N·slots·citas). Cache de `computeOccupiedMinutes` por (employee, date). | | Cambio de `working_hours` rompe slots pasados | Solo afecta a fechas futuras; citas pasadas no se revalidan. | | `@layer components` cambia cascada | Re-auditar visualmente inputs/selects existentes; los utilities ya pensados para ganar. | ## 14. Entregables (resumen de archivos) - **Nuevos:** `server/lib/scheduling.ts`. - **Migración:** `server/db.ts` (`migrateV3ToV4`). - **Backend rutas:** `server/routes/booking.ts`, `server/routes/appointments.ts`, `server/routes/settings.ts`, `server/routes/employees.ts`. - **Tipos:** `shared/types.ts`. - **Frontend:** `src/pages/public/BookingPage.tsx` (MonthCalendar, layout 2 col, flujo 3/4 pasos, confirmación), `src/pages/SettingsPage.tsx`, ficha de empleado, `src/lib/publicApi.ts`, `src/lib/api.ts`. - **Estilos:** `src/index.css` (`@layer components` + fix de padding).