# 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@agendamax.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 `