Files
AgendaPro/docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md

2270 lines
91 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Auto-asignación de especialista + rediseño de reservas — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Añadir auto-asignación matemática de especialistas (sin doble agenda) a la página pública de reservas, con un check en Configuración que omite el paso del especialista, calendario de mes siempre visible, layout de dos columnas en escritorio, colores de alta legibilidad y fix de iconos solapados.
**Architecture:** Nuevo módulo `server/lib/scheduling.ts` (funciones puras + funciones con DB) que centraliza disponibilidad + scoring. Restricción dura de no-traslape + score `50·specialtyMatch + 30·efficiency + 20·(1loadBalance)`. Guard transaccional anti-race en la creación de citas. Frontend: nuevo `MonthCalendar`, `DateTimePicker` a 2 columnas en desktop, wizard de 3/4 pasos según el check, y captura de especialidades/horario/eficiencia en ajustes y ficha de empleado.
**Tech Stack:** React 18, TypeScript, Vite, Express, `node:sqlite` (`DatabaseSync`), react-query, Tailwind 3.4, lucide-react. Tests: `node:test` (built-in) vía `tsx` para unit; scripts `.mjs` HTTP para integración.
## Global Constraints
- **Idioma/copy:** todo el texto面向 usuario final en español (MX). Etiquetas exactas según el spec.
- **Sin nuevas dependencias npm:** usar `node:test` + `tsx` (ya instalado) para unit tests. No agregar jest/vitest.
- **Stack DB:** `node:sqlite` síncrono. Transacciones vía `db.exec("BEGIN IMMEDIATE")` / `COMMIT` / `ROLLBACK`.
- **Estilo:** usar la paleta existente: `brand` (azul `#3b66ff`) primario, `accent` (naranja `#f17616`) secundario. Tailwind config ya define ambas escalas. Border radius `.input` = 0.65rem.
- **Formato horario (JSON):** `WorkingHoursMap = Record<1..7, {start:"HH:mm", end:"HH:mm"} | null>` donde 1=Lun…7=Dom; `null` = cerrado.
- **Grid de slots:** 30 min (inalterado). Límite 40 slots/día (inalterado).
- **No romper mobile:** el stack vertical actual del paso Horario en `<md` se conserva.
- **No commitear sin pedirlo:** la política del repo es no hacer commit salvo petición explícita. Cada tarea termina con un paso "Commit" **opcional** — solo ejecutarlo si el usuario lo pide; si no, dejar cambios sin commitear.
---
## File Structure
**Nuevos:**
- `server/lib/scheduling.ts` — funciones puras (parseWorkingHours, getWorkingHoursForDate, specialtyMatch, overlaps, hasConflict, scoreCandidate, isoDayOfWeek, isoDateStr, normalizeText, tokens) + funciones con DB (getCandidates, getExistingBusy, isAvailable, pickBestSlotEmployee, autoAssign, runInTransaction).
- `server/lib/scheduling.test.ts` — unit tests de las funciones puras (`node:test`).
- `src/components/MonthCalendar.tsx` — grilla de mes siempre visible.
- `server/scripts/booking-e2e.mjs` — tests de integración HTTP de reservas (auto-asignación + doble-booking).
**Modificados:**
- `server/db.ts``migrateV3ToV4` (2 columnas en `businesses`, 3 en `employees`).
- `shared/types.ts``Business.auto_assign_specialist`, `Business.working_hours`, `Employee.specialties/working_hours/efficiency_score`, tipo `WorkingHoursMap`.
- `server/routes/booking.ts` — slots lee `working_hours`, usa `pickBestSlotEmployee`; `POST /book` usa `autoAssign` + guard transaccional; expone `auto_assign_specialist`.
- `server/routes/appointments.ts` — guard `isAvailable` en `POST`; `autoAssign` cuando no hay empleado.
- `server/routes/settings.ts` — whitelist + selects con los campos nuevos.
- `server/routes/employees.ts` — CRUD con `specialties/working_hours/efficiency_score`.
- `src/lib/publicApi.ts` — tipos (auto_assign_specialist, employee_id opcional, reasons en BookResponse).
- `src/lib/api.ts` — sin cambios de firma (usa `any`), solo documentado.
- `src/index.css` — envolver `.input/.select/.textarea` en `@layer components`.
- `src/pages/public/BookingPage.tsx` — wizard 3/4 pasos, `DateTimePicker` 2-col + `MonthCalendar`, agrupación Mañana/Tarde, `DetailsForm` `pl-10`, `Confirmation` con reasons.
- `src/pages/SettingsPage.tsx` — check auto-assign + editor de horario del negocio.
- `src/pages/EmployeesPage.tsx``EmployeeModal` con especialidades, horario, eficiencia.
- `package.json` — script `test:unit`.
---
## Task 1: Migración V3→V4 + tipos compartidos
**Files:**
- Modify: `server/db.ts` (añadir `migrateV3ToV4` y llamarla en `runMigrations`)
- Modify: `shared/types.ts` (Business, Employee, WorkingHoursMap)
- Test: arranque del servidor + `npm run typecheck`
**Interfaces:**
- Produces: columnas DB `businesses.auto_assign_specialist`, `businesses.working_hours`, `employees.specialties`, `employees.working_hours`, `employees.efficiency_score`; tipos TS `WorkingHoursMap`, y los campos en `Business`/`Employee`.
- [ ] **Step 1: Añadir `migrateV3ToV4` en `server/db.ts`**
Insertar antes de `export function runMigrations()` (línea ~225) la nueva función, y registrarla en `runMigrations`:
```ts
/** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */
function migrateV3ToV4() {
if (getMeta("schema_version") >= "4") return;
const bizCols: [string, string][] = [
["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"],
["working_hours", "TEXT"],
];
for (const [col, def] of bizCols) {
if (!columnExists("businesses", col)) db.exec(`ALTER TABLE businesses ADD COLUMN ${col} ${def};`);
}
const empCols: [string, string][] = [
["specialties", "TEXT"],
["working_hours", "TEXT"],
["efficiency_score", "REAL NOT NULL DEFAULT 50"],
];
for (const [col, def] of empCols) {
if (!columnExists("employees", col)) db.exec(`ALTER TABLE employees ADD COLUMN ${col} ${def};`);
}
// Backfill business working_hours: Lun-Vie 09:00-20:00 (replica del hardcodeado anterior)
const DEFAULT_WH = JSON.stringify({
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
});
const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL`).all() as { id: number }[];
const upd = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
for (const b of noWh) upd.run(DEFAULT_WH, b.id);
setMeta("schema_version", "4");
}
```
Y en `runMigrations()` añadir la llamada (después de `migrateV2ToV3();`):
```ts
export function runMigrations() {
db.exec(SCHEMA);
migrateV1ToV2();
migrateV2ToV3();
migrateV3ToV4();
}
```
- [ ] **Step 2: Actualizar `shared/types.ts`**
Añadir el tipo `WorkingHoursMap` y los campos. Reemplazar la interfaz `Business` (líneas 4-18) añadiendo antes de `created_at?`:
```ts
export interface Business {
id: number;
name: string;
industry: string;
currency: string;
currency_symbol: string;
phone: string | null;
address: string | null;
slug?: string | null;
plan?: string;
status?: string;
template?: string | null;
trial_ends_at?: string | null;
booking_enabled?: number | boolean;
cancel_window_hours?: number;
cancel_penalty_pct?: number;
require_deposit?: number | boolean;
deposit_pct?: number;
timezone?: string;
auto_assign_specialist?: number | boolean;
working_hours?: WorkingHoursMap | string | null;
created_at?: string;
}
```
Añadir tras `Business`:
```ts
export type WorkingDay = { start: string; end: string };
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
```
Y en `Employee` (líneas 30-43) añadir tras `service_ids?`:
```ts
specialties?: string[];
working_hours?: WorkingHoursMap | string | null;
efficiency_score?: number;
```
- [ ] **Step 3: Verificar que el servidor arranca y migra**
Run: `npm run dev:server` (iniciar y detener tras ver "migrations ok" o que el server escucha).
Esperado: sin errores de SQL. La BD adquiere las columnas nuevas.
Comprobación rápida (PowerShell):
```powershell
npm run typecheck
```
Esperado: 0 errores.
- [ ] **Step 4: Commit (opcional, solo si el usuario lo pide)**
```bash
git add server/db.ts shared/types.ts
git commit -m "feat(db): v4 migration — auto-assign, working hours, specialties, efficiency"
```
---
## Task 2: scheduling.ts — funciones puras + unit tests
**Files:**
- Create: `server/lib/scheduling.ts` (solo la parte pura por ahora; las funciones con DB se añaden en Task 3)
- Create: `server/lib/scheduling.test.ts`
- Modify: `package.json` (añadir script `test:unit`)
**Interfaces:**
- Produces: `WorkingDay`, `WorkingHoursMap`, `BusyWindow`, `ScoreInput`, `parseWorkingHours`, `isoDayOfWeek`, `getWorkingHoursForDate`, `normalizeText`, `tokens`, `specialtyMatch`, `overlaps`, `hasConflict`, `scoreCandidate`, `isoDateStr`, `clamp01`.
- [ ] **Step 1: Crear `server/lib/scheduling.ts` con las funciones puras**
```ts
// server/lib/scheduling.ts
// Disponibilidad + scoring de especialistas. Las funciones puras de aquí
// se unit-testean en scheduling.test.ts; las que tocan la DB se añaden más abajo.
export interface WorkingDay { start: string; end: string; } // "HH:mm"
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
export interface BusyWindow { startMs: number; endMs: number; }
export interface ScoreInput {
specialtyMatch: number; // 0..1
efficiency: number; // 0..1
loadBalance: number; // 0..1 (fracción de jornada ocupada)
}
export function clamp01(n: number): number {
return Math.max(0, Math.min(1, n));
}
export function parseWorkingHours(json: string | null | undefined): WorkingHoursMap | null {
if (!json) return null;
try {
const obj = JSON.parse(json);
if (typeof obj !== "object" || obj === null) return null;
const out: WorkingHoursMap = {};
for (let d = 1; d <= 7; d++) {
const v = obj[String(d)];
if (v && typeof v.start === "string" && typeof v.end === "string") out[d] = { start: v.start, end: v.end };
else out[d] = null;
}
return out;
} catch {
return null;
}
}
/** JS getDay(): 0=Dom..6=Sáb → ISO 1=Lun..7=Dom. */
export function isoDayOfWeek(date: Date): number {
const j = date.getDay();
return j === 0 ? 7 : j;
}
export function isoDateStr(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Resuelve el horario (empleado tiene prioridad; si no, negocio) para esa fecha. Devuelve ms o null si es día cerrado. */
export function getWorkingHoursForDate(
empWh: WorkingHoursMap | null,
bizWh: WorkingHoursMap | null,
date: Date
): { startMs: number; endMs: number } | null {
const dow = isoDayOfWeek(date);
const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
if (!day) return null;
const [sh, sm] = day.start.split(":").map(Number);
const [eh, em] = day.end.split(":").map(Number);
const startMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm).getTime();
const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime();
if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) return null;
return { startMs, endMs };
}
export function normalizeText(s: string): string {
return (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
}
export function tokens(s: string): string[] {
return normalizeText(s).split(/[^a-z0-9]+/).filter(Boolean);
}
/**
* 1.0 si una especialidad coincide con la categoría o un token del nombre del servicio.
* 0.5 si no hay etiquetas o no coincide (el caller ya garantiza que ofrece el servicio).
*/
export function specialtyMatch(specialties: string[], service: { name: string; category: string }): number {
if (!specialties || specialties.length === 0) return 0.5;
const catN = normalizeText(service.category);
const nameTokens = tokens(service.name);
const hay = new Set<string>([catN, ...nameTokens]);
for (const sp of specialties) {
const spn = normalizeText(sp);
if (!spn) continue;
if (hay.has(spn)) return 1;
if (catN && (catN.includes(spn) || spn.includes(catN))) return 1;
for (const t of nameTokens) {
if (t.length >= 4 && (t.includes(spn) || spn.includes(t))) return 1;
}
}
return 0.5;
}
export function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
return aStart < bEnd && aEnd > bStart;
}
export function hasConflict(existing: BusyWindow[], startMs: number, endMs: number): boolean {
return existing.some((b) => overlaps(startMs, endMs, b.startMs, b.endMs));
}
/** score = 50·specialtyMatch + 30·efficiency + 20·(1loadBalance), todo en [0,1]. Rango 0..100. */
export function scoreCandidate(s: ScoreInput): number {
const sm = clamp01(s.specialtyMatch);
const eff = clamp01(s.efficiency);
const inv = clamp01(1 - clamp01(s.loadBalance));
return 50 * sm + 30 * eff + 20 * inv;
}
```
- [ ] **Step 2: Crear `server/lib/scheduling.test.ts`**
```ts
// server/lib/scheduling.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import {
parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate,
normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate,
} from "./scheduling.ts";
test("parseWorkingHours: json válido → mapa 1..7", () => {
const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
assert.equal(m?.[1]?.start, "09:00");
assert.equal(m?.[6], null);
assert.equal(m?.[7], null);
assert.equal(m?.[2], null); // ausente → null
});
test("parseWorkingHours: null/invalid → null", () => {
assert.equal(parseWorkingHours(null), null);
assert.equal(parseWorkingHours("no-json"), null);
});
test("isoDayOfWeek: lunes=1, domingo=7", () => {
assert.equal(isoDayOfWeek(new Date("2026-07-27T12:00:00")), 1); // lunes
assert.equal(isoDayOfWeek(new Date("2026-07-26T12:00:00")), 7); // domingo
});
test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio", () => {
const emp = parseWorkingHours(JSON.stringify({ 1: { start: "10:00", end: "14:00" } }));
const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
const r = getWorkingHoursForDate(emp, biz, new Date("2026-07-27T12:00:00")); // lunes
assert.equal(r?.startMs, new Date(2026, 6, 27, 10, 0).getTime());
});
test("getWorkingHoursForDate: día cerrado → null", () => {
const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null })); // fin de semana cerrado
assert.equal(getWorkingHoursForDate(null, biz, new Date("2026-07-25T12:00:00")), null); // sábado
});
test("normalizeText/tokens: quita acentos y lowercase", () => {
assert.equal(normalizeText("Coloración"), "coloracion");
assert.deepEqual(tokens("Corte de cabello"), ["corte", "de", "cabello"]);
});
test("specialtyMatch: coincidencia exacta de etiqueta → 1", () => {
assert.equal(specialtyMatch(["corte"], { name: "Corte de cabello", category: "Cabello" }), 1);
});
test("specialtyMatch: etiqueta con acento/case → 1", () => {
assert.equal(specialtyMatch(["Coloración"], { name: "Tinte", category: "Coloración" }), 1);
});
test("specialtyMatch: sin etiquetas → 0.5", () => {
assert.equal(specialtyMatch([], { name: "Corte", category: "Cabello" }), 0.5);
});
test("specialtyMatch: etiqueta no relacionada → 0.5", () => {
assert.equal(specialtyMatch(["barba"], { name: "Manicura", category: "Uñas" }), 0.5);
});
test("overlaps: bordes inclusivos de no-traslape", () => {
// una cita termina exactamente cuando empieza otra → NO traslape
assert.equal(overlaps(100, 200, 200, 300), false);
// traslape real
assert.equal(overlaps(100, 250, 200, 300), true);
assert.equal(overlaps(200, 300, 100, 250), true);
});
test("hasConflict: lista vacía → false", () => {
assert.equal(hasConflict([], 100, 200), false);
});
test("scoreCandidate: pesos 50/30/20", () => {
// todo al máximo → 100
assert.equal(scoreCandidate({ specialtyMatch: 1, efficiency: 1, loadBalance: 0 }), 100);
// todo al mínimo → 0
assert.equal(scoreCandidate({ specialtyMatch: 0, efficiency: 0, loadBalance: 1 }), 0);
// sólo specialty (0.5) → 25
assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 25);
});
test("scoreCandidate: mayor efficiency → mayor score", () => {
const low = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.5 });
const high = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.5 });
assert.ok(high > low);
});
test("scoreCandidate: menor load (más disponible) → mayor score", () => {
const busy = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.9 });
const free = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.1 });
assert.ok(free > busy);
});
```
- [ ] **Step 3: Añadir script `test:unit` en `package.json`**
En la sección `"scripts"` añadir:
```json
"test:unit": "node --import tsx --test server/lib/scheduling.test.ts",
```
- [ ] **Step 4: Correr los tests (deben pasar)**
Run: `npm run test:unit`
Esperado: todos los tests PASS (≈14 tests).
- [ ] **Step 5: Commit (opcional)**
```bash
git add server/lib/scheduling.ts server/lib/scheduling.test.ts package.json
git commit -m "feat(scheduling): pure availability + scoring helpers with unit tests"
```
---
## Task 3: scheduling.ts — funciones con DB (isAvailable, autoAssign, pickBestSlotEmployee)
**Files:**
- Modify: `server/lib/scheduling.ts` (añadir al final las funciones con DB)
- Test: `server/lib/scheduling.test.ts` sigue verde; verificación de tipos.
**Interfaces:**
- Consumes: tipos de Task 1/2.
- Produces: `CandidateInfo`, `getCandidates(db, businessId, serviceId)`, `getExistingBusy(db, employeeIds, dateIso)`, `isAvailable(db, employeeId, startMs, endMs)`, `pickBestSlotEmployee(...)`, `autoAssign(db, ctx)`, `runInTransaction(db, fn)`.
- [ ] **Step 1: Añadir las funciones con DB al final de `server/lib/scheduling.ts`**
```ts
import type { DatabaseSync } from "node:sqlite";
export interface CandidateInfo {
id: number;
name: string;
color: string;
role: string;
specialties: string[];
efficiency_score: number;
empWh: WorkingHoursMap | null;
}
function safeArr(json: unknown): string[] {
if (typeof json !== "string" || !json) return [];
try {
const a = JSON.parse(json);
return Array.isArray(a) ? a.map(String) : [];
} catch {
return [];
}
}
export function getCandidates(db: DatabaseSync, businessId: number, serviceId: number): CandidateInfo[] {
const rows = db
.prepare(
`SELECT e.id, e.name, e.color, e.role, e.specialties, e.working_hours wh, e.efficiency_score
FROM employees e
JOIN employee_services es ON es.employee_id = e.id
WHERE e.business_id = ? AND es.service_id = ? AND e.active = 1`
)
.all(businessId, serviceId) as any[];
return rows.map((r) => ({
id: r.id,
name: r.name,
color: r.color,
role: r.role,
specialties: safeArr(r.specialties),
efficiency_score: typeof r.efficiency_score === "number" ? r.efficiency_score : 50,
empWh: parseWorkingHours(r.wh),
}));
}
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
const map = new Map<number, BusyWindow[]>();
if (employeeIds.length === 0) return map;
const startOfDay = `${dateIso}T00:00:00`;
const endOfDay = `${dateIso}T23:59:59`;
const rows = db
.prepare(
`SELECT employee_id, start_at, end_at FROM appointments
WHERE employee_id IN (${employeeIds.map(() => "?").join(",")})
AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
)
.all(...employeeIds, startOfDay, endOfDay) as any[];
for (const r of rows) {
const arr = map.get(r.employee_id) ?? [];
arr.push({ startMs: new Date(r.start_at).getTime(), endMs: new Date(r.end_at).getTime() });
map.set(r.employee_id, arr);
}
return map;
}
export function isAvailable(db: DatabaseSync, employeeId: number, startMs: number, endMs: number): boolean {
const dateIso = isoDateStr(new Date(startMs));
const busy = getExistingBusy(db, [employeeId], dateIso).get(employeeId) ?? [];
return !hasConflict(busy, startMs, endMs);
}
interface RankedCandidate { info: CandidateInfo; score: number; reasons: string[] }
function rankOne(
c: CandidateInfo,
busy: BusyWindow[],
wh: { startMs: number; endMs: number },
service: { name: string; category: string }
): RankedCandidate | null {
const workingMs = Math.max(1, wh.endMs - wh.startMs);
let occupiedMs = 0;
for (const b of busy) {
occupiedMs += Math.min(b.endMs, wh.endMs) - Math.max(b.startMs, wh.startMs);
if (occupiedMs < 0) occupiedMs = 0;
}
const loadBalance = clamp01(occupiedMs / workingMs);
const sm = specialtyMatch(c.specialties, service);
const score = scoreCandidate({ specialtyMatch: sm, efficiency: c.efficiency_score / 100, loadBalance });
const reasons: string[] = [];
if (sm >= 1) reasons.push("Especialidad coincidente");
if (c.efficiency_score >= 75) reasons.push("Alta eficiencia");
if (loadBalance <= 0.25) reasons.push("Buena disponibilidad");
return { info: c, score, reasons };
}
/** Usado por el endpoint de slots: devuelve el mejor candidato libre para un slot concreto. */
export function pickBestSlotEmployee(
candidates: CandidateInfo[],
busyMap: Map<number, BusyWindow[]>,
bizWh: WorkingHoursMap | null,
service: { name: string; category: string },
startMs: number,
endMs: number
): { id: number; name: string } | null {
const date = new Date(startMs);
let best: { id: number; name: string; score: number } | null = null;
for (const c of candidates) {
const wh = getWorkingHoursForDate(c.empWh, bizWh, date);
if (!wh) continue;
if (startMs < wh.startMs || endMs > wh.endMs) continue;
if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, service);
if (!ranked) continue;
if (!best || ranked.score > best.score || (ranked.score === best.score && c.id < best.id)) {
best = { id: c.id, name: c.name, score: ranked.score };
}
}
return best ? { id: best.id, name: best.name } : null;
}
export interface AutoAssignResult {
employeeId: number;
score: number;
reasons: string[];
}
export interface AutoAssignCtx {
businessId: number;
serviceId: number;
serviceName: string;
serviceCategory: string;
startMs: number;
endMs: number;
bizWh: WorkingHoursMap | null;
}
/** Asigna el mejor especialista disponible (hard constraints + score). Llamar dentro de transacción. */
export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResult | null {
const candidates = getCandidates(db, ctx.businessId, ctx.serviceId);
if (candidates.length === 0) return null;
const dateIso = isoDateStr(new Date(ctx.startMs));
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso);
let best: RankedCandidate | null = null;
for (const c of candidates) {
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs));
if (!wh) continue;
if (ctx.startMs < wh.startMs || ctx.endMs > wh.endMs) continue;
if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, { name: ctx.serviceName, category: ctx.serviceCategory });
if (!ranked) continue;
if (!best || ranked.score > best.score || (ranked.score === best.score && c.info.id < best.info.id)) {
best = ranked;
}
}
if (!best) return null;
return {
employeeId: best.info.id,
score: best.score,
reasons: best.reasons.length ? best.reasons : ["Disponible"],
};
}
/** Ejecuta fn dentro de una transacción BEGIN IMMEDIATE…COMMIT; ROLLBACK en error. */
export function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
db.exec("BEGIN IMMEDIATE");
try {
const r = fn();
db.exec("COMMIT");
return r;
} catch (err) {
try { db.exec("ROLLBACK"); } catch { /* ignore */ }
throw err;
}
}
```
- [ ] **Step 2: Verificar tipos y tests**
Run: `npm run typecheck` → 0 errores.
Run: `npm run test:unit` → sigue verde.
- [ ] **Step 3: Commit (opcional)**
```bash
git add server/lib/scheduling.ts
git commit -m "feat(scheduling): db-backed candidate ranking + autoAssign + tx helper"
```
---
## Task 4: backend `booking.ts` — slots con working_hours + `pickBestSlotEmployee`
**Files:**
- Modify: `server/routes/booking.ts` (endpoint `GET /:slug/slots` y `publicBusiness`)
- Test: arranque + integración (Task 7 cubre el script HTTP).
**Interfaces:**
- Consumes: `getWorkingHoursForDate`, `pickBestSlotEmployee`, `getCandidates`, `getExistingBusy`, `parseWorkingHours`, `isoDateStr` de `server/lib/scheduling.ts`.
- Produces: `publicBusiness()` devuelve `auto_assign_specialist` y `working_hours`; slots respetan horario real.
- [ ] **Step 1: Importar scheduling en `server/routes/booking.ts`**
Al inicio del archivo (tras `import { db }`):
```ts
import {
parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy,
pickBestSlotEmployee, isoDateStr, type WorkingHoursMap,
} from "../lib/scheduling.ts";
```
- [ ] **Step 2: Exponer campos nuevos en `publicBusiness`**
Reemplazar la función `publicBusiness` (líneas 6-14) por:
```ts
function publicBusiness(slug: string) {
return db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, timezone,
booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct,
auto_assign_specialist, working_hours
FROM businesses WHERE slug = ? AND status = 'active'`
)
.get(slug) as any;
}
```
- [ ] **Step 3: Reescribir el bloque de cálculo de slots (líneas 41-96)**
Reemplazar desde `// candidate employees` (línea 41) hasta el final del endpoint (línea 96) por:
```ts
// candidate employees
let candidates: number[];
if (employeeId) {
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(serviceId, employeeId);
if (!ok) return res.status(400).json({ error: "El especialista no ofrece este servicio" });
candidates = [employeeId];
} else {
candidates = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ?`).all(serviceId).map((r: any) => r.id);
if (candidates.length === 0) {
const any = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
candidates = any ? [any.id] : [];
}
}
if (candidates.length === 0) return res.json({ slots: [] });
const bizWh = parseWorkingHours(biz.working_hours) as WorkingHoursMap | null;
// Si el cliente no eligió especialista, usar el ranking para mostrar el mejor disponible por slot.
const useRanking = !employeeId;
const candInfos = useRanking ? getCandidates(db, biz.id, serviceId) : [];
const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date) : new Map();
// Para cuando sí se eligió especialista: ventana = horario de ese empleado (o negocio).
const dateObj = new Date(`${date}T12:00:00`);
const singleEmpRow = employeeId
? (db.prepare(`SELECT working_hours wh FROM employees WHERE id = ?`).get(employeeId) as any)
: null;
const singleEmpWh = singleEmpRow ? parseWorkingHours(singleEmpRow.wh) : null;
const window = employeeId
? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, dateObj) ?? getWorkingHoursForDate(null, bizWh, dateObj))
: getWorkingHoursForDate(null, bizWh, dateObj);
const now = new Date();
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
const dur = service.duration_min;
if (!window) {
return res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
}
const dayStart = window.startMs;
const dayEnd = window.endMs;
// citas existentes del día para el caso de especialista fijo
const existing = employeeId
? (getExistingBusy(db, [employeeId], date).get(employeeId) ?? [])
: [];
for (let t = dayStart; t + dur * 60000 <= dayEnd; t += 30 * 60000) {
if (t < now.getTime() + 30 * 60000) continue; // pasado / demasiado pronto
const slotStart = t;
const slotEnd = t + dur * 60000;
let chosen: { id: number; name: string } | null = null;
if (useRanking) {
chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd);
} else {
const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs));
if (!busy) {
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(employeeId) as any;
chosen = { id: employeeId, name: emp?.name ?? "" };
}
}
if (chosen) {
slots.push({
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
iso: new Date(t).toISOString(),
employee_id: chosen.id,
employee_name: chosen.name,
});
}
if (slots.length >= 40) break;
}
res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
}
function overlapsRange(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
return aStart < bEnd && aEnd > bStart;
}
```
> Nota: `overlapsRange` es local para no cambiar imports; equivale a `overlaps` de scheduling. (Se puede reemplazar por el import si se prefiere.)
- [ ] **Step 4: Verificar tipos**
Run: `npm run typecheck` → 0 errores.
- [ ] **Step 5: Commit (opcional)**
```bash
git add server/routes/booking.ts
git commit -m "feat(booking): slots honor real working hours + rank best specialist per slot"
```
---
## Task 5: backend `booking.ts` — `POST /book` con autoAssign + guard transaccional
**Files:**
- Modify: `server/routes/booking.ts` (endpoint `POST /:slug/book`, líneas 100-153)
**Interfaces:**
- Consumes: `autoAssign`, `isAvailable`, `runInTransaction`, `parseWorkingHours`, `isoDateStr` de scheduling.
- Produces: `POST /book` responde 409 en conflicto; asigna vía algoritmo cuando `employee_id` ausente o cuando `auto_assign_specialist=1`; incluye `reasons` en la respuesta.
- [ ] **Step 1: Ampliar imports de scheduling**
Añadir a la lista de imports de Task 4:
```ts
import { autoAssign, isAvailable, runInTransaction } from "../lib/scheduling.ts";
```
- [ ] **Step 2: Reescribir `POST /:slug/book`**
Reemplazar todo el handler (líneas 100-153) por:
```ts
// Create a booking from the public site (no auth): creates/finds client + appointment
bookingRouter.post("/:slug/book", (req, res) => {
const biz = publicBusiness(req.params.slug);
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
const { service_id, employee_id, start_at, client } = req.body ?? {};
if (!service_id || !start_at || !client?.name) return res.status(400).json({ error: "Faltan datos (servicio, hora o nombre)" });
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(Number(service_id), biz.id) as any;
if (!service) return res.status(400).json({ error: "Servicio no válido" });
const start = new Date(start_at);
if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });
const startMs = start.getTime();
const endMs = startMs + service.duration_min * 60000;
// ¿El cliente forzó especialista? Si auto_assign_specialist=1, se ignora al cliente y se auto-asigna.
const autoAssignOn = !!biz.auto_assign_specialist;
const clientChose = !autoAssignOn && employee_id ? Number(employee_id) : null;
try {
const result = runInTransaction(db, () => {
// 1) Resolver especialista
let empId: number | null = clientChose;
let reasons: string[] = [];
if (!empId) {
const bizWh = parseWorkingHours(biz.working_hours) as any;
const aa = autoAssign(db, {
businessId: biz.id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
startMs, endMs, bizWh,
});
if (!aa) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
empId = aa.employeeId;
reasons = aa.reasons;
} else {
// validar que ofrece el servicio
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(service.id, empId);
if (!ok) throw { status: 400, error: "El especialista no ofrece este servicio" };
// guard anti doble reserva
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
reasons = ["Tu especialista elegido"];
}
// 2) Resolver cliente (por phone/email o crear)
let clientId: number | null = null;
const match = client.phone
? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND phone = ?`).get(biz.id, client.phone) as any)
: client.email
? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND email = ?`).get(biz.id, client.email) as any)
: null;
if (match) clientId = match.id;
else {
const created = db
.prepare(`INSERT INTO clients (business_id, name, email, phone, tags) VALUES (?, ?, ?, ?, 'Online') RETURNING id`)
.get(biz.id, client.name, client.email || null, client.phone || null) as any;
clientId = created.id;
}
const endIso = new Date(endMs).toISOString().replace(/\.\d{3}Z$/, "Z");
const startIso = new Date(startMs).toISOString().replace(/\.\d{3}Z$/, "Z");
const appt = db
.prepare(
`INSERT INTO appointments (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes)
VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) RETURNING *`
)
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, client.notes || "Reserva online") as any;
const empName = (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name;
return { appt, empId: empId as number, empName, reasons, clientCreated: !match };
});
res.status(201).json({
appointment: {
id: result.appt.id,
start_at: result.appt.start_at,
end_at: result.appt.end_at,
price: result.appt.price,
service_name: service.name,
employee_id: result.empId,
employee_name: result.empName,
reasons: result.reasons,
},
business: { name: biz.name, currency_symbol: biz.currency_symbol },
client_created: result.clientCreated,
});
} catch (e: any) {
if (e && typeof e === "object" && e.status) {
return res.status(e.status).json({ error: e.error === "ESE_HORARIO_OCUPADO" ? "Esa hora acaba de ocuparse, elige otra." : e.error });
}
throw e;
}
});
```
- [ ] **Step 3: Verificar tipos**
Run: `npm run typecheck` → 0 errores.
- [ ] **Step 4: Commit (opcional)**
```bash
git add server/routes/booking.ts
git commit -m "feat(booking): auto-assign specialist + transactional double-booking guard (409)"
```
---
## Task 6: backend `appointments.ts` — guard anti doble reserva
**Files:**
- Modify: `server/routes/appointments.ts` (handler `POST /`, líneas 75-167)
**Interfaces:**
- Consumes: `isAvailable`, `autoAssign`, `runInTransaction`, `parseWorkingHours` de scheduling.
- [ ] **Step 1: Importar scheduling al inicio de `appointments.ts`**
```ts
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
```
- [ ] **Step 2: Añadir guard dentro del `POST /`**
Localizar el bloque que calcula `endIso`/`startIso` (líneas 129-134) e insertar, justo antes del `INSERT` (línea 139), el guard envolviendo en transacción. Reemplazar las líneas 129-167 (desde `const start = new Date(start_at);` hasta el final del handler) por:
```ts
const start = new Date(start_at);
if (isNaN(start.getTime())) return err(res, 400, "Fecha de inicio inválida");
const dur = Number(duration_min) || service.duration_min;
const end = new Date(start.getTime() + dur * 60000);
const endIso = end.toISOString().replace(/\.\d{3}Z$/, "Z");
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const startMs = start.getTime();
const endMs = end.getTime();
const finalStatus = status || "scheduled";
const price = price_override !== undefined ? Number(price_override) : service.price;
try {
const r = runInTransaction(db, () => {
// Resolver empleado (igual que antes, pero usando autoAssign si ninguno)
let empId = employee_id ? Number(employee_id) : null;
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
empId = req.user!.employee_id;
}
if (!empId) {
const bizRow = db.prepare(`SELECT working_hours wh FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
const aa = autoAssign(db, {
businessId: req.user!.business_id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any,
});
empId = aa?.employeeId ?? null;
}
if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };
// Guard anti doble reserva
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
const inserted = db
.prepare(
`INSERT INTO appointments
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(req.user!.business_id, Number(service_id), empId, clientId, startIso, endIso, finalStatus, price, notes || null, req.user!.id) as any;
if (finalStatus === "completed") {
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
).run(req.user!.business_id, inserted.id, clientId, empId, Number(service_id), price);
}
joinAppointment(inserted);
return inserted;
});
res.status(201).json({ appointment: r });
} catch (e: any) {
if (e && typeof e === "object" && e.status) return err(res, e.status, e.error);
throw e;
}
});
```
- [ ] **Step 3: Verificar tipos**
Run: `npm run typecheck` → 0 errores.
- [ ] **Step 4: Commit (opcional)**
```bash
git add server/routes/appointments.ts
git commit -m "feat(appointments): transactional conflict guard + auto-assign fallback"
```
---
## Task 7: backend settings + employees (campos nuevos) + script de integración
**Files:**
- Modify: `server/routes/settings.ts` (FIELDS + SELECTs)
- Modify: `server/routes/employees.ts` (POST/PATCH + attachServices)
- Create: `server/scripts/booking-e2e.mjs`
- Modify: `package.json` (script `test:booking`)
- [ ] **Step 1: `settings.ts` — ampliar FIELDS y SELECTs**
En `FIELDS` (líneas 8-20) añadir `"auto_assign_specialist"` y `"working_hours"`:
```ts
const FIELDS = [
"name", "industry", "phone", "address", "slug",
"booking_enabled", "cancel_window_hours", "cancel_penalty_pct",
"require_deposit", "deposit_pct", "timezone",
"auto_assign_specialist", "working_hours",
] as const;
```
Añadir los campos a las dos SELECT del archivo. En `GET /` (líneas 24-28) y en la SELECT final del `PATCH /` (líneas 53-54), añadir `, auto_assign_specialist, working_hours` a la lista de columnas.
GET `/`:
```ts
const b = db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled,
cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone,
auto_assign_specialist, working_hours
FROM businesses WHERE id = ?`
)
.get(req.user!.business_id) as any;
```
PATCH (SELECT final):
```ts
const updated = db
.prepare(`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone, auto_assign_specialist, working_hours FROM businesses WHERE id = ?`)
.get(req.user!.business_id);
```
Además, en `PATCH`, serializar `working_hours` a JSON si viene como objeto. Tras el bucle `for (const f of FIELDS)` (antes del check de slug), añadir:
```ts
if (merged.working_hours && typeof merged.working_hours !== "string") {
merged.working_hours = JSON.stringify(merged.working_hours);
}
if (merged.auto_assign_specialist !== undefined) {
merged.auto_assign_specialist = merged.auto_assign_specialist ? 1 : 0;
}
```
- [ ] **Step 2: `employees.ts` — añadir campos en POST y PATCH**
En `POST /` (línea 76) ampliar destructuring y el INSERT:
```ts
employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
const { name, role, email, phone, color, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
if (!name) return err(res, 400, "El nombre es obligatorio");
const r = db
.prepare(
`INSERT INTO employees (business_id, name, role, email, phone, color, specialties, working_hours, efficiency_score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
name,
role || "Especialista",
email || null,
phone || null,
color || "#3b66ff",
JSON.stringify(Array.isArray(specialties) ? specialties : []),
working_hours ? (typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours)) : null,
typeof efficiency_score === "number" ? efficiency_score : 50
) as any;
if (Array.isArray(service_ids)) {
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const sid of service_ids) ins.run(r.id, Number(sid));
}
attachServices(r);
r.stats = computeStats(r.id, req.user!.business_id as number);
res.status(201).json({ employee: r });
});
```
En `PATCH /:id` (líneas 99-126), ampliar destructuring y el UPDATE. Reemplazar el cuerpo por:
```ts
employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(id, req.user!.business_id as number) as any;
if (!existing) return err(res, 404, "Empleado no encontrado");
const { name, role, email, phone, color, active, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
const merged = {
name: name ?? existing.name,
role: role ?? existing.role,
email: email ?? existing.email,
phone: phone ?? existing.phone,
color: color ?? existing.color,
active: active === undefined ? existing.active : active ? 1 : 0,
specialties: specialties !== undefined
? (typeof specialties === "string" ? specialties : JSON.stringify(Array.isArray(specialties) ? specialties : []))
: existing.specialties,
working_hours: working_hours !== undefined
? (working_hours === null ? null : typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours))
: existing.working_hours,
efficiency_score: efficiency_score !== undefined ? Number(efficiency_score) : (existing.efficiency_score ?? 50),
};
db.prepare(
`UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ?, specialties = ?, working_hours = ?, efficiency_score = ? WHERE id = ?`
).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, merged.specialties, merged.working_hours, merged.efficiency_score, id);
if (Array.isArray(service_ids)) {
db.prepare(`DELETE FROM employee_services WHERE employee_id = ?`).run(id);
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const sid of service_ids) ins.run(id, Number(sid));
}
const updated = db.prepare(`SELECT * FROM employees WHERE id = ?`).get(id) as any;
attachServices(updated);
updated.stats = computeStats(updated.id, req.user!.business_id as number);
res.json({ employee: updated });
});
```
- [ ] **Step 3: Crear `server/scripts/booking-e2e.mjs`**
```js
// server/scripts/booking-e2e.mjs — requiere el server dev corriendo (npm run dev)
const BASE = "http://localhost:5173/api";
let pass = 0, fail = 0;
async function req(method, path, body, token) {
const headers = { "Content-Type": "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, json };
}
function check(name, cond, extra = "") {
if (cond) { pass++; console.log(` \u2713 ${name}`); }
else { fail++; console.log(` \u2717 ${name} ${extra}`); }
}
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
const t = login.token;
check("login owner", !!t);
// 1) Slug del negocio
const { json: settings } = await req("GET", "/settings", null, t);
const slug = settings.settings?.slug;
check("tiene slug", !!slug, String(slug));
// 2) Datos públicos + servicios
const { json: pub } = await req("GET", `/public/${slug}`);
check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0);
const svc = pub.services[0];
const tomorrow = new Date(Date.now() + 86400000);
const date = tomorrow.toISOString().slice(0, 10);
// 3) Slots disponibles
const { json: slotsRes } = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${date}`);
check("slots devuelve lista", Array.isArray(slotsRes.slots));
check("hay slots disponibles hoy/mañana", slotsRes.slots.length > 0, `got ${slotsRes.slots.length}`);
// 4) Reservar (auto-asignación, sin employee_id)
const slot = slotsRes.slots[0];
const { status: bookStatus, json: booked } = await req("POST", `/public/${slug}/book`, {
service_id: svc.id,
start_at: slot.iso,
client: { name: "Cliente Prueba Auto", phone: "+525500000001" },
});
check("reserva creada (auto-assign)", bookStatus === 201 && !!booked.appointment?.employee_id, `status=${bookStatus}`);
check("response trae reasons", Array.isArray(booked.appointment?.reasons) && booked.appointment.reasons.length > 0);
// 5) Mismo slot otra vez → 409 (doble reserva del mismo especialista)
const { status: conflict } = await req("POST", `/public/${slug}/book`, {
service_id: svc.id,
employee_id: booked.appointment.employee_id,
start_at: slot.iso,
client: { name: "Cliente Conflictivo", phone: "+525500000002" },
});
check("doble reserva → 409", conflict === 409, `status=${conflict}`);
// 6) Activar auto_assign_specialist y verificar que la respuesta pública lo expone
await req("PATCH", "/settings", { auto_assign_specialist: 1 }, t);
const { json: pub2 } = await req("GET", `/public/${slug}`);
check("auto_assign_specialist expuesto en público", pub2.business?.auto_assign_specialist === 1);
await req("PATCH", "/settings", { auto_assign_specialist: 0 }, t); // cleanup
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
process.exit(fail ? 1 : 0);
```
- [ ] **Step 4: Añadir script en `package.json`**
```json
"test:booking": "node server/scripts/booking-e2e.mjs",
```
- [ ] **Step 5: Correr la integración (con el server levantado)**
Run (en otra terminal): `npm run dev`
Run: `npm run test:booking`
Esperado: todos PASS. También correr `npm run test:e2e` para confirmar que no se rompió nada existente (puede haber un falil si la cita del seed choca — ver nota).
> **Nota de seed:** los tests e2e existentes crean citas en `2026-09-15T11:00:00Z` etc. El nuevo guard NO debe afectarlos salvo duplicidad exacta. Si `test:e2e` falla por la cita ya existente de una corrida anterior, ejecutar con `RESET_DB=1 npm run seed` antes.
- [ ] **Step 6: Commit (opcional)**
```bash
git add server/routes/settings.ts server/routes/employees.ts server/scripts/booking-e2e.mjs package.json
git commit -m "feat(settings,employees): expose auto-assign + working hours + specialties; add booking e2e"
```
---
## Task 8: Fix de iconos solapados (CSS `@layer components` + `pl-10`)
**Files:**
- Modify: `src/index.css` (líneas 272-290)
- Modify: `src/pages/public/BookingPage.tsx` (`DetailsForm`, líneas 664, 678, 692: `pl-9``pl-10`)
**Interfaces:** ninguno (sólo estilos).
- [ ] **Step 1: Envolver `.input/.select/.textarea` en `@layer components`**
En `src/index.css`, reemplazar el bloque de líneas 272-290:
```css
.input,
.select,
.textarea {
width: 100%;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.65rem;
padding: 0.55rem 0.75rem;
font-size: 0.875rem;
color: #0f172a;
transition: border-color 0.15s, box-shadow 0.15s;
outline: none;
}
.input:focus,
.select:focus,
.textarea:focus {
border-color: #3b66ff;
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
}
```
por:
```css
@layer components {
.input,
.select,
.textarea {
width: 100%;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 0.65rem;
padding: 0.55rem 0.75rem;
font-size: 0.875rem;
color: #0f172a;
transition: border-color 0.15s, box-shadow 0.15s;
outline: none;
}
.input:focus,
.select:focus,
.textarea:focus {
border-color: #3b66ff;
box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
}
}
```
> Así las utilidades `pl-*`/`pr-*` del layer `utilities` **siempre** ganan sobre el padding del componente.
- [ ] **Step 2: Bump `pl-9` → `pl-10` en `DetailsForm`**
En `src/pages/public/BookingPage.tsx`, en la función `DetailsForm` (líneas 664, 678, 692), cambiar las tres ocurrencias de `className="input pl-9"` por `className="input pl-10"`. (Tres inputs: Nombre, Teléfono, Correo.)
- [ ] **Step 3: Verificar visualmente**
Run: `npm run dev` → abrir `/b/<slug>` → avanzar a "Tus datos". El icono ya **no** se solapa con el placeholder (texto arranca a 40px, icono ocupa 1228px). Confirmar en al menos otro input con icono (p. ej. `LoginPage`) que el padding lateral también se respeta.
- [ ] **Step 4: Commit (opcional)**
```bash
git add src/index.css src/pages/public/BookingPage.tsx
git commit -m "fix(ui): wrap inputs in @layer components so pl-* wins; bump icon padding (pl-10)"
```
---
## Task 9: `publicApi.ts` — tipos actualizados
**Files:**
- Modify: `src/lib/publicApi.ts`
**Interfaces:**
- Produces: `PublicBusiness.auto_assign_specialist`, `BookPayload.employee_id` opcional, `BookResponse.appointment.employee_id/reasons`.
- [ ] **Step 1: Ampliar interfaces**
En `PublicBusiness` (líneas 3-15) añadir:
```ts
auto_assign_specialist: number | boolean;
```
En `BookPayload` (líneas 59-64) hacer `employee_id` opcional:
```ts
export interface BookPayload {
service_id: number;
employee_id?: number;
start_at: string;
client: BookClient;
}
```
En `BookResponse.appointment` (líneas 66-76) añadir `employee_id` y `reasons`:
```ts
export interface BookResponse {
appointment: {
id: number;
start_at: string;
price: number;
service_name: string;
employee_id: number;
employee_name: string;
reasons: string[];
};
business: { name: string; currency_symbol: string };
client_created: boolean;
}
```
- [ ] **Step 2: Verificar tipos**
Run: `npm run typecheck` → 0 errores. (Puede marcar errores en `BookingPage.tsx` por el `employee_id` ahora requerido en el slot — se resuelve en Task 12. Si aparece, continuar; Task 12 lo corrige.)
- [ ] **Step 3: Commit (opcional)**
```bash
git add src/lib/publicApi.ts
git commit -m "feat(types): public booking types for auto-assign + reasons"
```
---
## Task 10: Componente `MonthCalendar`
**Files:**
- Create: `src/components/MonthCalendar.tsx`
**Interfaces:**
- Produces: `MonthCalendar({ value, min, max, availableDates, onSelect })`.
- [ ] **Step 1: Crear el componente**
```tsx
// src/components/MonthCalendar.tsx
import { useMemo, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "../lib/format";
const WD = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
const MONTHS = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
function toIso(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function isoToDate(iso: string): Date {
const [y, m, d] = iso.split("-").map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
function atMidnight(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function MonthCalendar({
value,
min,
max,
availableDates,
onSelect,
}: {
value: string;
min?: string;
max?: string;
availableDates?: Set<string>;
onSelect: (iso: string) => void;
}) {
const initial = value ? isoToDate(value) : new Date();
const [cursor, setCursor] = useState(new Date(initial.getFullYear(), initial.getMonth(), 1));
const todayIso = toIso(new Date());
const minDate = min ? atMidnight(isoToDate(min)) : null;
const maxDate = max ? atMidnight(isoToDate(max)) : null;
const cells = useMemo(() => {
const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const lead = (first.getDay() + 6) % 7; // Lun primero
const start = new Date(first);
start.setDate(first.getDate() - lead);
const out: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
out.push(d);
}
return out;
}, [cursor]);
const inMonth = (d: Date) => d.getMonth() === cursor.getMonth();
const selectable = (d: Date) => {
const dm = atMidnight(d);
if (minDate && dm < minDate) return false;
if (maxDate && dm > maxDate) return false;
return true;
};
const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= firstOfMonth;
const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);
return (
<div className="select-none">
<div className="mb-3 flex items-center justify-between">
<button type="button" onClick={() => move(-1)} disabled={!canPrev}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
aria-label="Mes anterior">
<ChevronLeft className="h-4 w-4" />
</button>
<div className="text-sm font-extrabold text-slate-800">{MONTHS[cursor.getMonth()]} {cursor.getFullYear()}</div>
<button type="button" onClick={() => move(1)} disabled={!canNext}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
aria-label="Mes siguiente">
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="mb-1 grid grid-cols-7 gap-1">
{WD.map((w) => (
<div key={w} className="text-center text-[11px] font-bold uppercase tracking-wide text-slate-400">{w}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((d, i) => {
const dIso = toIso(d);
const sel = dIso === value;
const on = selectable(d);
const inM = inMonth(d);
const isToday = dIso === todayIso;
const hasAvail = availableDates?.has(dIso);
const weekend = d.getDay() === 0 || d.getDay() === 6;
return (
<button key={i} type="button" disabled={!on} onClick={() => onSelect(dIso)}
className={cn(
"relative flex aspect-square items-center justify-center rounded-xl text-sm font-bold transition-all",
!inM && "text-slate-300",
inM && !sel && on && "text-slate-700 hover:bg-brand-50 hover:text-brand-700",
inM && !on && "cursor-not-allowed text-slate-300",
sel && "bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30",
weekend && inM && !sel && on && "text-slate-500"
)}>
{d.getDate()}
{hasAvail && !sel && (
<span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-accent-500" aria-hidden />
)}
{isToday && !sel && (
<span className="absolute top-1 h-1.5 w-1.5 rounded-full bg-brand-400" aria-hidden />
)}
</button>
);
})}
</div>
</div>
);
}
```
- [ ] **Step 2: Verificar tipos**
Run: `npm run typecheck` → 0 errores en el nuevo archivo.
- [ ] **Step 3: Commit (opcional)**
```bash
git add src/components/MonthCalendar.tsx
git commit -m "feat(ui): MonthCalendar — always-visible month grid with availability dots"
```
---
## Task 11: `DateTimePicker` — calendario + 2 columnas + agrupación Mañana/Tarde
**Files:**
- Modify: `src/pages/public/BookingPage.tsx` (componente `DateTimePicker`, líneas 555-642)
**Interfaces:**
- Consumes: `MonthCalendar` (Task 10).
- Produces: `DateTimePicker` con nueva firma idéntica (mismos props) para no romper el caller; muestra calendario a la izquierda y slots a la derecha en `md+`.
- [ ] **Step 1: Añadir import de `MonthCalendar`**
En los imports de `BookingPage.tsx` (junto al de `ui`), añadir:
```ts
import { MonthCalendar } from "../../components/MonthCalendar";
```
- [ ] **Step 2: Reescribir `DateTimePicker`**
Reemplazar toda la función `DateTimePicker` (líneas 555-642) por:
```tsx
function DateTimePicker({
date,
onDate,
min,
max,
slots,
loading,
isError,
selectedSlot,
onPick,
}: {
date: string;
onDate: (d: string) => void;
min: string;
max?: string;
slots: PublicSlot[];
loading: boolean;
isError: boolean;
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
}) {
// agrupar slots en Mañana (< 12:00) / Tarde (>= 12:00) usando la hora del texto "HH:MM AM/PM"
const { morning, afternoon } = useMemo(() => {
const morning: PublicSlot[] = [];
const afternoon: PublicSlot[] = [];
for (const s of slots) {
const m = /(\d{1,2}):(\d{2})/.exec(s.time);
const h = m ? Number(m[1]) : 0;
const pm = /PM/i.test(s.time);
const hour24 = pm && h !== 12 ? h + 12 : !pm && h === 12 ? 0 : h;
(hour24 < 12 ? morning : afternoon).push(s);
}
return { morning, afternoon };
}, [slots]);
return (
<div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
{/* Columna izquierda: calendario */}
<div className="card p-4">
<label className="label flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
</label>
<MonthCalendar value={date} min={min} max={max} onSelect={(d) => onDate(d)} />
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-accent-500" /> Con cupo</span>
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-brand-400" /> Hoy</span>
</div>
</div>
{/* Columna derecha: horarios */}
<div className="mt-4 md:mt-0">
<label className="label flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
</label>
<div className="card p-4">
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-slate-400">
<Spinner className="text-brand-500" />
<span className="text-sm font-medium">Buscando horarios</span>
</div>
)}
{!loading && isError && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<AlertCircle className="h-6 w-6 text-rose-400" />
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
</div>
)}
{!loading && !isError && slots.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<CalendarDays className="h-6 w-6 text-slate-300" />
<p className="text-sm font-medium text-slate-600">Sin horarios disponibles</p>
<p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
</div>
)}
{!loading && !isError && slots.length > 0 && (
<div className="space-y-4">
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} />
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} />
</div>
)}
</div>
</div>
</div>
);
}
function SlotGroup({
title,
slots,
selectedSlot,
onPick,
}: {
title: string;
slots: PublicSlot[];
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
}) {
if (slots.length === 0) return null;
return (
<div>
<div className="mb-2 flex items-center gap-2">
<span className="rounded-full bg-accent-50 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent-700">{title}</span>
<span className="text-[11px] font-medium text-slate-400">{slots.length} opciones</span>
</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{slots.map((slot) => {
const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id;
return (
<button
key={`${slot.iso}-${slot.employee_id}`}
type="button"
onClick={() => onPick(slot)}
className={cn(
"rounded-xl border px-2 py-2.5 text-sm font-bold transition-all",
active
? "border-brand-500 bg-brand-500 text-white shadow-soft"
: "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50"
)}
>
{slot.time}
</button>
);
})}
</div>
</div>
);
}
```
- [ ] **Step 3: Verificar tipos**
Run: `npm run typecheck` → puede quedar algún error por el `max` prop nuevo en el caller; se resuelve en Task 12. Si aparecen errores sólo por el caller, continuar.
- [ ] **Step 4: Commit (opcional)**
```bash
git add src/pages/public/BookingPage.tsx
git commit -m "feat(ui): DateTimePicker 2-col + month calendar + morning/afternoon grouping"
```
---
## Task 12: Wizard 3/4 pasos + `Confirmation` con reasons + anclar `main` ancho
**Files:**
- Modify: `src/pages/public/BookingPage.tsx` (`STEPS`, `TopBar`, `BookingPage` main/orquestación, paso Especialista condicional, `ActionBar`, `Confirmation`, pase de `max` y `employeeId` al book).
**Interfaces:**
- Consumes: `business.auto_assign_specialist` (de `publicApi`).
- Produce: wizard que omite el paso Especialista cuando `auto_assign_specialist`; confirmación muestra `reasons`.
- [ ] **Step 1: Hacer dinámico el array `STEPS` y el total**
Reemplazar las constantes `STEPS` (líneas 36-41) por una función y un helper:
```ts
const FULL_STEPS = [
{ n: 1, label: "Servicio" },
{ n: 2, label: "Especialista" },
{ n: 3, label: "Horario" },
{ n: 4, label: "Datos" },
] as const;
function stepsFor(autoAssign: boolean) {
return autoAssign
? FULL_STEPS.filter((s) => s.label !== "Especialista").map((s, i) => ({ n: i + 1, label: s.label, original: s.n }))
: FULL_STEPS.map((s, i) => ({ n: i + 1, label: s.label, original: s.n }));
}
```
- [ ] **Step 2: En `BookingPage`, calcular `autoAssign` y `steps`, y mapear pasos internos**
Dentro del componente `BookingPage`, tras obtener `business`, añadir (antes de `const selectedService`):
```ts
const autoAssign = !!business?.auto_assign_specialist;
const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]);
const stepCount = steps.length;
// "original" = número de sección lógica: 1 Servicio, 3 Horario, 4 Datos (2 Especialista sólo si no auto)
const currentOriginal = steps.find((s) => s.n === step)?.original;
```
Reemplazar `goNext`/`goBack` (líneas 154-155) por versiones acotadas al rango dinámico:
```ts
const goNext = () => setStep((s) => Math.min(stepCount, s + 1));
const goBack = () => setStep((s) => Math.max(1, s - 1));
```
- [ ] **Step 3: Reemplazar el `return` principal (líneas 200-301)**
Reemplazar todo el bloque `return (...)` del componente por:
```tsx
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]">
<TopBar name={business.name} industry={business.industry} step={step} stepCount={stepCount} steps={steps} />
<main className={cn(
"mx-auto w-full flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28",
currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"
)}>
{currentOriginal === 1 && <Hero business={business} />}
<div className="mt-4">
{currentOriginal === 1 && (
<StepShell icon={<Sparkles className="h-4 w-4" />} title="Elige un servicio" subtitle="Selecciona el servicio que deseas reservar.">
<ServiceGrid services={services} currency={currency} selectedId={serviceId} onPick={pickService} />
</StepShell>
)}
{currentOriginal === 2 && (
<StepShell icon={<User className="h-4 w-4" />} title="Elige un especialista" subtitle="¿Tienes una preferencia? O déjalo en cualquiera.">
<EmployeeList employees={employees} selectedId={employeeId} onPick={pickEmployee} />
</StepShell>
)}
{currentOriginal === 3 && selectedService && (
<StepShell icon={<CalendarDays className="h-4 w-4" />} title="Elige fecha y hora" subtitle={`${selectedService.name} · ${selectedService.duration_min} min · ${formatCurrency(selectedService.price, currency)}`}>
<DateTimePicker
date={selectedDate}
onDate={(d) => { setSelectedDate(d); setSelectedSlot(null); }}
min={todayStr()}
max={maxBookableDate()}
slots={slots}
loading={slotsQuery.isLoading}
isError={slotsQuery.isError}
selectedSlot={selectedSlot}
onPick={pickSlot}
/>
</StepShell>
)}
{currentOriginal === 4 && selectedService && selectedSlot && (
<StepShell icon={<CalendarCheck className="h-4 w-4" />} title="Tus datos" subtitle="Necesitamos algunos datos para confirmar tu cita.">
<DetailsForm
client={client}
setClient={setClient}
summary={
<>
<SummaryRow label="Servicio" value={selectedService.name} />
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
</>
}
/>
</StepShell>
)}
</div>
</main>
<ActionBar
hidden={!!result}
step={step}
stepCount={stepCount}
goBack={goBack}
onPrimary={onPrimary}
primaryLabel={primaryLabel}
canContinue={canContinue}
busy={bookMut.isPending}
priceLabel={selectedService ? formatCurrency(selectedService.price, currency) : undefined}
serviceName={selectedService?.name}
slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short" })} · ${selectedSlot.time}` : undefined}
/>
<FooterNote name={business.name} />
</div>
);
```
Añadir helper `maxBookableDate` (junto a `todayStr`):
```ts
function maxBookableDate() {
const d = new Date();
d.setMonth(d.getMonth() + 3);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
```
- [ ] **Step 4: Actualizar `canContinue`, `onPrimary`/`primaryLabel` y `handleBook`**
Reemplazar `pickSlot` (líneas 169-172), `handleBook` (174-188), `canContinue` (190-194) y `primaryLabel`/`onPrimary` (197-198) por versiones que usen `currentOriginal` y `stepCount`:
```ts
const pickSlot = (slot: PublicSlot) => {
setSelectedSlot(slot);
// avanzar al siguiente paso (Datos)
setStep((s) => Math.min(stepCount, s + 1));
};
const handleBook = () => {
if (!selectedService || !selectedSlot) return;
if (!client.name.trim()) return;
bookMut.mutate({
service_id: selectedService.id,
employee_id: autoAssign ? undefined : selectedSlot.employee_id,
start_at: selectedSlot.iso,
client: {
name: client.name.trim(),
email: client.email.trim() || undefined,
phone: client.phone.trim() || undefined,
notes: client.notes.trim() || undefined,
},
});
};
const canContinue =
(currentOriginal === 1 && !!serviceId) ||
currentOriginal === 2 ||
(currentOriginal === 3 && !!selectedSlot) ||
(currentOriginal === 4 && client.name.trim().length > 0);
const currency = business.currency_symbol || "$";
const primaryLabel = currentOriginal === 4 ? "Confirmar reserva" : "Continuar";
const onPrimary = currentOriginal === 4 ? handleBook : goNext;
```
Y en `reset()` (líneas 91-100) cambiar el orden de pasos no afecta; dejar igual.
- [ ] **Step 5: Actualizar `slotsQuery` enabled**
En `slotsQuery` (línea 74) cambiar la condición para usar `currentOriginal`:
```ts
enabled: currentOriginal === 3 && !!serviceId && !!selectedDate && !result,
```
> Nota: como `currentOriginal` se calcula más abajo en el código actual, mover su cálculo (Step 2) a **antes** de `slotsQuery` para que la closure lo vea.
- [ ] **Step 6: Actualizar `TopBar` y `ActionBar` para stepCount dinámico**
Reemplazar firma y render de `TopBar` (líneas 304-354) — resumido: aceptar `stepCount: number` y `steps: {n,label}[]`, y reemplazar "Paso {step} de 4" por "Paso {step} de {stepCount}", iterar `steps` (no `STEPS`) en el indicador de escritorio, y en móvil mostrar `/ {stepCount}`.
```tsx
function TopBar({ name, industry, step, stepCount, steps }: { name: string; industry: string; step: number; stepCount: number; steps: { n: number; label: string }[]; }) {
const current = steps.find((s) => s.n === step);
return (
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-extrabold text-slate-900">{name}</div>
<div className="truncate text-[11px] text-slate-500">{industry}</div>
</div>
</div>
{step > 0 && (
<div className="flex items-center gap-1.5">
<span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">Paso {step} de {stepCount}</span>
<div className="hidden items-center gap-1 md:flex">
{steps.map((s) => (
<div key={s.n} className={cn(
"flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors",
s.n === step ? "bg-brand-500 text-white" : s.n < step ? "bg-brand-50 text-brand-700" : "bg-slate-100 text-slate-400"
)}>
{s.n < step ? <Check className="h-3 w-3" /> : <span>{s.n}</span>}
<span>{s.label}</span>
</div>
))}
</div>
<div className="flex items-center gap-1.5 rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700 md:hidden">
<span>{step}</span><span className="font-semibold text-brand-400">/</span><span className="text-brand-400">{stepCount}</span>
<span className="text-brand-300">·</span><span>{current?.label}</span>
</div>
</div>
)}
</div>
</header>
);
}
```
En `ActionBar` (líneas 725-782): aceptar `stepCount`, cambiar `max-w-2xl``max-w-5xl` en el div interno (línea 751), y mostrar "Paso {step}/{stepCount}" si se desea (opcional). Firma actualizada:
```tsx
function ActionBar({ hidden, step, stepCount, goBack, onPrimary, primaryLabel, canContinue, busy, priceLabel, serviceName, slotLabel }: {
hidden: boolean; step: number; stepCount: number; goBack: () => void; onPrimary: () => void; primaryLabel: string; canContinue: boolean; busy: boolean; priceLabel?: string; serviceName?: string; slotLabel?: string;
}) {
if (hidden) return null;
return (
<div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:px-6">
{step > 1 && (<button onClick={goBack} className="btn btn-ghost shrink-0" type="button"><ChevronLeft className="h-4 w-4" /> <span className="hidden sm:inline">Atrás</span></button>)}
<div className="min-w-0 flex-1">
{serviceName ? (
<>
<div className="truncate text-sm font-bold text-slate-900">{serviceName}</div>
<div className="truncate text-xs text-slate-500">{slotLabel ? slotLabel : priceLabel}</div>
</>
) : (<div className="text-sm font-medium text-slate-400">Selecciona para continuar</div>)}
</div>
<button onClick={onPrimary} disabled={!canContinue || busy} className="btn btn-primary shrink-0" type="button">
{busy ? <Spinner /> : <>{primaryLabel} <ChevronRight className="h-4 w-4" /></>}
</button>
</div>
</div>
);
}
```
- [ ] **Step 7: `Confirmation` con `reasons`**
Reemplazar el bloque del especialista en `Confirmation` (líneas 820-828) para mostrar `reasons` cuando existan. Tras `<SummaryRow label="Especialista" value={appointment.employee_name} />` añadir:
```tsx
{appointment.reasons && appointment.reasons.length > 0 && (
<div className="px-3 pb-2 pt-1">
<div className="flex flex-wrap gap-1.5">
{appointment.reasons.map((r) => (
<span key={r} className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold text-brand-700">
<Sparkles className="h-3 w-3" /> {r}
</span>
))}
</div>
</div>
)}
```
- [ ] **Step 8: Verificar tipos y runtime**
Run: `npm run typecheck` → 0 errores.
Run: `npm run dev` → probar flujo completo con `auto_assign_specialist=1` (3 pasos) y `=0` (4 pasos), y revisar la confirmación con `reasons`.
- [ ] **Step 9: Commit (opcional)**
```bash
git add src/pages/public/BookingPage.tsx
git commit -m "feat(booking): dynamic 3/4-step wizard + wider layout + confirmation reasons"
```
---
## Task 13: `SettingsPage` — check auto-assign + editor de horario del negocio
**Files:**
- Modify: `src/pages/SettingsPage.tsx`
**Interfaces:**
- Consumes: `api.settings.update` con `auto_assign_specialist` y `working_hours`.
- [ ] **Step 1: Añadir un editor de horario reutilizable y normalizar `working_hours`**
Al inicio del archivo, tras los imports, añadir un helper y un subcomponente:
```tsx
import { Wand2, Clock } from "lucide-react";
const DEFAULT_WH = {
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
} as Record<number, { start: string; end: string } | null>;
function parseWh(v: any): Record<number, { start: string; end: string } | null> {
let obj = v;
if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } }
if (!obj || typeof obj !== "object") return { ...DEFAULT_WH };
const out: Record<number, any> = {};
for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
return out;
}
const DAYS = [
{ n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" },
{ n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" },
];
function WorkingHoursEditor({ value, onChange }: { value: Record<number, { start: string; end: string } | null>; onChange: (v: Record<number, { start: string; end: string } | null>) => void }) {
const toggle = (n: number) => onChange({ ...value, [n]: value[n] ? null : { start: "09:00", end: "18:00" } });
const set = (n: number, field: "start" | "end", v: string) => {
const cur = value[n] ?? { start: "09:00", end: "18:00" };
onChange({ ...value, [n]: { ...cur, [field]: v } });
};
return (
<div className="space-y-1.5">
{DAYS.map(({ n, label }) => {
const on = !!value[n];
return (
<div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
<label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
<input type="checkbox" className="h-4 w-4 accent-brand-500" checked={on} onChange={() => toggle(n)} />
{label}
</label>
{on ? (
<div className="flex items-center gap-1.5">
<input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.start} onChange={(e) => set(n, "start", e.target.value)} />
<span className="text-xs text-slate-400">a</span>
<input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.end} onChange={(e) => set(n, "end", e.target.value)} />
</div>
) : (
<span className="text-xs font-medium text-slate-400">Cerrado</span>
)}
</div>
);
})}
</div>
);
}
```
- [ ] **Step 2: Estado local de working hours + incluir en el mutation**
En el componente `SettingsPage`, tras `const [saved, setSaved] = useState(false);` añadir:
```tsx
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
useEffect(() => {
if (data?.settings) { setF(data.settings); setWh(parseWh(data.settings.working_hours)); }
}, [data]);
```
(Reemplazar el `useEffect` existente que sólo hace `setF`.)
En el `mut` (mutationFn), añadir al payload:
```tsx
auto_assign_specialist: f.auto_assign_specialist ? 1 : 0,
working_hours: wh,
```
- [ ] **Step 3: UI — check auto-assign y editor de horario**
Dentro de la card "Reservas online" (tras el bloque de la URL pública, antes de cerrar el `</div>` de la card — línea ~100), añadir:
```tsx
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.auto_assign_specialist} onChange={(e) => set("auto_assign_specialist", e.target.checked)} />
<div>
<div className="flex items-center gap-1.5 text-sm font-bold text-slate-800"><Wand2 className="h-4 w-4 text-brand-500" /> Asignar especialista automáticamente</div>
<p className="text-xs text-slate-500">El cliente no elige especialista: el sistema lo asigna según especialidad, eficiencia y disponibilidad. Evita dobles reservas.</p>
</div>
</label>
<div>
<label className="label flex items-center gap-1.5"><Clock className="h-3.5 w-3.5 text-slate-400" /> Horario del negocio</label>
<WorkingHoursEditor value={wh} onChange={setWh} />
<p className="mt-1 text-[11px] text-slate-500">Define qué días y horas se ofrecen citas. Los especialistas pueden tener su propio horario.</p>
</div>
```
- [ ] **Step 4: Verificar tipos y runtime**
Run: `npm run typecheck` → 0 errores.
Run: `npm run dev` → abrir `/settings`, cambiar el check y el horario, guardar, recargar y confirmar persistencia.
- [ ] **Step 5: Commit (opcional)**
```bash
git add src/pages/SettingsPage.tsx
git commit -m "feat(settings): auto-assign checkbox + business working-hours editor"
```
---
## Task 14: `EmployeeModal` — especialidades, horario, eficiencia
**Files:**
- Modify: `src/pages/EmployeesPage.tsx` (`EmployeeModal`, líneas 176-313)
**Interfaces:**
- Consumes: `api.employees.update/create` con `specialties`, `working_hours`, `efficiency_score`.
- [ ] **Step 1: Estado local + sincronización**
En `EmployeeModal`, tras `const [svcIds, setSvcIds] = useState<number[]>([]);` (línea 195) añadir:
```tsx
const [specialties, setSpecialties] = useState<string[]>([]);
const [specialtyInput, setSpecialtyInput] = useState("");
const [efficiency, setEfficiency] = useState(50);
const [inheritHours, setInheritHours] = useState(true);
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
```
Importar `DEFAULT_WH`, `DAYS`, `parseWh` desde donde se definieron en `SettingsPage`, o **duplicarlos** como ayudantes locales en `EmployeesPage.tsx` (recomendado: moverlos a `src/lib/workingHours.ts` y compartir). Para mantener el cambio contenido, crear:
```ts
// src/lib/workingHours.ts
export const DEFAULT_WH: Record<number, { start: string; end: string } | null> = {
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
};
export const DAYS = [
{ n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" },
{ n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" },
];
export function parseWh(v: any): Record<number, { start: string; end: string } | null> {
let obj = v;
if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } }
if (!obj || typeof obj !== "object") return { ...DEFAULT_WH };
const out: Record<number, any> = {};
for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
return out;
}
```
Y refactorizar `SettingsPage.tsx` (Task 13) para importar de `src/lib/workingHours.ts` en lugar de duplicar (ajustar el import).
En `EmployeeModal`, sincronizar en el `useEffect` (líneas 197-216). Para el caso `employee`, añadir:
```tsx
setSpecialties(Array.isArray(employee.specialties) ? employee.specialties : []);
setEfficiency(typeof employee.efficiency_score === "number" ? employee.efficiency_score : 50);
const parsed = parseWh(employee.working_hours);
const hasOwn = !!employee.working_hours && Object.values(parsed).some((v) => v !== null);
setInheritHours(!hasOwn);
setWh(hasOwn ? parsed : { ...DEFAULT_WH });
```
Y en el `else` (nuevo):
```tsx
setSpecialties([]); setEfficiency(50); setInheritHours(true); setWh({ ...DEFAULT_WH });
```
- [ ] **Step 2: Payload del mutation**
En el `mut.mutationFn` (líneas 219-223) ampliar el payload:
```tsx
const payload = {
name, role, email, phone, color, active, service_ids: svcIds,
specialties,
efficiency_score: efficiency,
working_hours: inheritHours ? null : wh,
};
```
- [ ] **Step 3: UI — especialidades, horario, eficiencia**
Tras el bloque "Servicios que ofrece" (cerrar antes del cierre del Modal, ~línea 308), añadir:
```tsx
<div>
<label className="label">Especialidades</label>
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-100 p-2">
{specialties.map((sp) => (
<span key={sp} className="chip bg-brand-50 text-brand-700">
{sp}
<button type="button" onClick={() => setSpecialties((a) => a.filter((x) => x !== sp))} className="ml-0.5 text-brand-400 hover:text-brand-700">×</button>
</span>
))}
<input
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none"
placeholder="Ej. Coloración y pulsa Enter"
value={specialtyInput}
onChange={(e) => setSpecialtyInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && specialtyInput.trim()) { e.preventDefault(); setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]); setSpecialtyInput(""); }
}}
/>
</div>
<p className="mt-1 text-[11px] text-slate-500">Etiquetas que el algoritmo compara con la categoría y el nombre del servicio.</p>
</div>
<div>
<label className="label">Eficiencia <span className="ml-1 text-brand-700">{efficiency}</span></label>
<input type="range" min={0} max={100} value={efficiency} onChange={(e) => setEfficiency(Number(e.target.value))} className="w-full accent-brand-500" />
<p className="mt-1 text-[11px] text-slate-500">Qué tan rápido/hábil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.</p>
</div>
<div>
<label className="flex cursor-pointer items-center gap-2">
<input type="checkbox" className="h-4 w-4 accent-brand-500" checked={inheritHours} onChange={(e) => setInheritHours(e.target.checked)} />
<span className="text-sm font-bold text-slate-800">Heredar horario del negocio</span>
</label>
{!inheritHours && (
<div className="mt-2 space-y-1.5">
{DAYS.map(({ n, label }) => {
const on = !!wh[n];
return (
<div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
<label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
<input type="checkbox" className="h-4 w-4 accent-brand-500" checked={on} onChange={() => setWh({ ...wh, [n]: wh[n] ? null : { start: "09:00", end: "18:00" } })} />
{label}
</label>
{on ? (
<div className="flex items-center gap-1.5">
<input type="time" className="input !w-auto !py-1 text-xs" value={wh[n]!.start} onChange={(e) => setWh({ ...wh, [n]: { ...(wh[n] as any), start: e.target.value } })} />
<span className="text-xs text-slate-400">a</span>
<input type="time" className="input !w-auto !py-1 text-xs" value={wh[n]!.end} onChange={(e) => setWh({ ...wh, [n]: { ...(wh[n] as any), end: e.target.value } })} />
</div>
) : <span className="text-xs font-medium text-slate-400">Cerrado</span>}
</div>
);
})}
</div>
)}
</div>
```
- [ ] **Step 4: Verificar tipos y runtime**
Run: `npm run typecheck` → 0 errores.
Run: `npm run dev` → editar un empleado, añadir especialidades, ajustar eficiencia, definir horario propio, guardar y recargar.
- [ ] **Step 5: Commit (opcional)**
```bash
git add src/pages/EmployeesPage.tsx src/lib/workingHours.ts src/pages/SettingsPage.tsx
git commit -m "feat(employees): specialties, efficiency score, per-employee working hours"
```
---
## Task 15: Verificación final (tipos, lint, unit, e2e, build)
**Files:** ninguno (sólo validación).
- [ ] **Step 1: Typecheck**
Run: `npm run typecheck`
Esperado: 0 errores.
- [ ] **Step 2: Lint**
Run: `npm run lint`
Esperado: 0 errores (avisos aceptables). Si aparecen `react-hooks/exhaustive-deps` por las nuevas dependencias en `useEffect`/`useMemo`, añadirlas al array de dependencias.
- [ ] **Step 3: Unit tests**
Run: `npm run test:unit`
Esperado: todos PASS.
- [ ] **Step 4: Build de producción**
Run: `npm run build`
Esperado: compila sin errores.
- [ ] **Step 5: Integración de reservas (con server levantado)**
Run: `npm run dev` (otra terminal)
Run: `npm run test:booking`
Run: `npm run test:e2e`
Esperado: todos PASS.
- [ ] **Step 6: Recorrido visual manual**
- `/b/<slug>` con `auto_assign_specialist=1`: 3 pasos, calendario de mes visible, layout 2 columnas en desktop, sin solape de iconos en "Tus datos", confirmación con badges de `reasons`.
- `/b/<slug>` con `auto_assign_specialist=0`: 4 pasos, card "Cualquiera" usa el ranking.
- Mobile (`<md`): stack vertical en Horario (sin romper).
- Intentar reservar el mismo horario dos veces → 409 visible para el cliente.
- [ ] **Step 7: Commit final (opcional)**
```bash
git add -A
git commit -m "test: full verification — typecheck, lint, unit, booking e2e, build"
```
---
## Self-Review (post-escritura)
**Spec coverage:**
- Check "omitir especialista" → Task 1 (col), Task 5 (server), Task 12 (wizard). ✓
- Algoritmo matemático (especialidad/eficiencia/disponibilidad/tiempo) → Task 2-3. ✓
- Sin doble agenda (hard constraint + guard tx) → Task 3 (`hasConflict`), Task 5-6 (409). ✓
- Calendario de mes siempre visible → Task 10, integrado en Task 11. ✓
- Layout 2 columnas escritorio → Task 11 (`md:grid`), `max-w-5xl` en Task 12. ✓
- Colores llamativos + accesibilidad → `accent` dots, badges `reasons`, celdas >=aspect-square, estilos reforzados en Tasks 10-12. ✓
- Mantener bloques de horario → Task 11 conserva `grid-cols-3 sm:grid-cols-4` y los mismos estilos seleccionado/disponible. ✓
- Mobile intacto → Task 11 mantiene stack `<md`. ✓
- Fix iconos solapados → Task 8. ✓
- Captura de datos del algoritmo (especialidades/horario/eficiencia) → Tasks 7 (backend), 13-14 (UI). ✓
**Placeholder scan:** sin TBD/TODO; todos los pasos tienen código completo o comandos exactos.
**Type consistency:** `WorkingHoursMap`/`CandidateInfo`/`AutoAssignResult` definidos en Task 2-3 y consumidos con los mismos nombres en Tasks 4-6. `stepsFor`/`currentOriginal` en Task 12 coinciden entre `TopBar`/`ActionBar`/`BookingPage`. `BookPayload.employee_id` opcional (Task 9) consumido en Task 12.
**Notas de riesgo:** `runInTransaction` usa `BEGIN IMMEDIATE`; con `node:sqlite` síncrono y un solo proceso, el bloqueo es efectivo. Si dos requests concurrentes compiten por la misma cita, el segundo recibe SQLITE_BUSY al `BEGIN IMMEDIATE` — manejar con un reintento simple si se observa (no requerido para el scope actual, single-process).