# 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 AgendaPro 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 AgendaPro) 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).