From 4227db0c8bdccfc561b2e8f11dc9e9969175cafc Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Sun, 26 Jul 2026 16:00:04 -0600 Subject: [PATCH] docs: spec + implementation plan for specialist auto-assignment Design doc (docs/superpowers/specs) and task-by-task implementation plan (docs/superpowers/plans) for the auto-assign + booking redesign. --- ...auto-assign-specialist-booking-redesign.md | 2269 +++++++++++++++++ ...sign-specialist-booking-redesign-design.md | 190 ++ 2 files changed, 2459 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md create mode 100644 docs/superpowers/specs/2026-07-26-auto-assign-specialist-booking-redesign-design.md diff --git a/docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md b/docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md new file mode 100644 index 0000000..2ffe265 --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md @@ -0,0 +1,2269 @@ +# 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·(1−loadBalance)`. 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 `= "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; // 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; // 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([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·(1−loadBalance), 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 { + const map = new Map(); + 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, + 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(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: "owner@agendapro.demo", 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/` → avanzar a "Tus datos". El icono ya **no** se solapa con el placeholder (texto arranca a 40px, icono ocupa 12–28px). 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; + 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 ( +
+
+ +
{MONTHS[cursor.getMonth()]} {cursor.getFullYear()}
+ +
+
+ {WD.map((w) => ( +
{w}
+ ))} +
+
+ {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 ( + + ); + })} +
+
+ ); +} +``` + +- [ ] **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 ( +
+ {/* Columna izquierda: calendario */} +
+ + onDate(d)} /> +
+ Con cupo + Hoy +
+
+ + {/* Columna derecha: horarios */} +
+ +
+ {loading && ( +
+ + Buscando horarios… +
+ )} + {!loading && isError && ( +
+ +

No pudimos cargar los horarios. Intenta otra fecha.

+
+ )} + {!loading && !isError && slots.length === 0 && ( +
+ +

Sin horarios disponibles

+

Prueba con otra fecha o especialista.

+
+ )} + {!loading && !isError && slots.length > 0 && ( +
+ + +
+ )} +
+
+
+ ); +} + +function SlotGroup({ + title, + slots, + selectedSlot, + onPick, +}: { + title: string; + slots: PublicSlot[]; + selectedSlot: PublicSlot | null; + onPick: (s: PublicSlot) => void; +}) { + if (slots.length === 0) return null; + return ( +
+
+ {title} + {slots.length} opciones +
+
+ {slots.map((slot) => { + const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id; + return ( + + ); + })} +
+
+ ); +} +``` + +- [ ] **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 ( +
+ + +
+ {currentOriginal === 1 && } + +
+ {currentOriginal === 1 && ( + } title="Elige un servicio" subtitle="Selecciona el servicio que deseas reservar."> + + + )} + + {currentOriginal === 2 && ( + } title="Elige un especialista" subtitle="¿Tienes una preferencia? O déjalo en cualquiera."> + + + )} + + {currentOriginal === 3 && selectedService && ( + } title="Elige fecha y hora" subtitle={`${selectedService.name} · ${selectedService.duration_min} min · ${formatCurrency(selectedService.price, currency)}`}> + { setSelectedDate(d); setSelectedSlot(null); }} + min={todayStr()} + max={maxBookableDate()} + slots={slots} + loading={slotsQuery.isLoading} + isError={slotsQuery.isError} + selectedSlot={selectedSlot} + onPick={pickSlot} + /> + + )} + + {currentOriginal === 4 && selectedService && selectedSlot && ( + } title="Tus datos" subtitle="Necesitamos algunos datos para confirmar tu cita."> + + + + + {!autoAssign && } + + } + /> + + )} +
+
+ +
+ ); +``` + +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 ( +
+
+
+
+ +
+
+
{name}
+
{industry}
+
+
+ {step > 0 && ( +
+ Paso {step} de {stepCount} +
+ {steps.map((s) => ( +
+ {s.n < step ? : {s.n}} + {s.label} +
+ ))} +
+
+ {step}/{stepCount} + ·{current?.label} +
+
+ )} +
+
+ ); +} +``` + +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 ( +
+
+ {step > 1 && ()} +
+ {serviceName ? ( + <> +
{serviceName}
+
{slotLabel ? slotLabel : priceLabel}
+ + ) : (
Selecciona para continuar
)} +
+ +
+
+ ); +} +``` + +- [ ] **Step 7: `Confirmation` con `reasons`** + +Reemplazar el bloque del especialista en `Confirmation` (líneas 820-828) para mostrar `reasons` cuando existan. Tras `` añadir: + +```tsx + {appointment.reasons && appointment.reasons.length > 0 && ( +
+
+ {appointment.reasons.map((r) => ( + + {r} + + ))} +
+
+ )} +``` + +- [ ] **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; + +function parseWh(v: any): Record { + 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 = {}; + 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; onChange: (v: Record) => 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 ( +
+ {DAYS.map(({ n, label }) => { + const on = !!value[n]; + return ( +
+ + {on ? ( +
+ set(n, "start", e.target.value)} /> + a + set(n, "end", e.target.value)} /> +
+ ) : ( + Cerrado + )} +
+ ); + })} +
+ ); +} +``` + +- [ ] **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>({ ...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 `` de la card — línea ~100), añadir: + +```tsx + + +
+ + +

Define qué días y horas se ofrecen citas. Los especialistas pueden tener su propio horario.

+
+``` + +- [ ] **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([]);` (línea 195) añadir: + +```tsx + const [specialties, setSpecialties] = useState([]); + const [specialtyInput, setSpecialtyInput] = useState(""); + const [efficiency, setEfficiency] = useState(50); + const [inheritHours, setInheritHours] = useState(true); + const [wh, setWh] = useState>({ ...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 = { + 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 { + 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 = {}; + 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 +
+ +
+ {specialties.map((sp) => ( + + {sp} + + + ))} + setSpecialtyInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && specialtyInput.trim()) { e.preventDefault(); setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]); setSpecialtyInput(""); } + }} + /> +
+

Etiquetas que el algoritmo compara con la categoría y el nombre del servicio.

+
+ +
+ + setEfficiency(Number(e.target.value))} className="w-full accent-brand-500" /> +

Qué tan rápido/hábil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.

+
+ +
+ + {!inheritHours && ( +
+ {DAYS.map(({ n, label }) => { + const on = !!wh[n]; + return ( +
+ + {on ? ( +
+ setWh({ ...wh, [n]: { ...(wh[n] as any), start: e.target.value } })} /> + a + setWh({ ...wh, [n]: { ...(wh[n] as any), end: e.target.value } })} /> +
+ ) : Cerrado} +
+ ); + })} +
+ )} +
+``` + +- [ ] **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/` 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/` con `auto_assign_specialist=0`: 4 pasos, card "Cualquiera" usa el ranking. +- Mobile (`=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 `` 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).