Design doc (docs/superpowers/specs) and task-by-task implementation plan (docs/superpowers/plans) for the auto-assign + booking redesign.
91 KiB
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:sqlitesíncrono. Transacciones víadb.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
<mdse conserva. - No commitear sin pedirlo: la política del repo es no hacer commit salvo petición explícita. Cada tarea termina con un paso "Commit" opcional — solo ejecutarlo si el usuario lo pide; si no, dejar cambios sin commitear.
File Structure
Nuevos:
server/lib/scheduling.ts— funciones puras (parseWorkingHours, getWorkingHoursForDate, specialtyMatch, overlaps, hasConflict, scoreCandidate, isoDayOfWeek, isoDateStr, normalizeText, tokens) + funciones con DB (getCandidates, getExistingBusy, isAvailable, pickBestSlotEmployee, autoAssign, runInTransaction).server/lib/scheduling.test.ts— unit tests de las funciones puras (node:test).src/components/MonthCalendar.tsx— grilla de mes siempre visible.server/scripts/booking-e2e.mjs— tests de integración HTTP de reservas (auto-asignación + doble-booking).
Modificados:
server/db.ts—migrateV3ToV4(2 columnas enbusinesses, 3 enemployees).shared/types.ts—Business.auto_assign_specialist,Business.working_hours,Employee.specialties/working_hours/efficiency_score, tipoWorkingHoursMap.server/routes/booking.ts— slots leeworking_hours, usapickBestSlotEmployee;POST /bookusaautoAssign+ guard transaccional; exponeauto_assign_specialist.server/routes/appointments.ts— guardisAvailableenPOST;autoAssigncuando no hay empleado.server/routes/settings.ts— whitelist + selects con los campos nuevos.server/routes/employees.ts— CRUD conspecialties/working_hours/efficiency_score.src/lib/publicApi.ts— tipos (auto_assign_specialist, employee_id opcional, reasons en BookResponse).src/lib/api.ts— sin cambios de firma (usaany), solo documentado.src/index.css— envolver.input/.select/.textareaen@layer components.src/pages/public/BookingPage.tsx— wizard 3/4 pasos,DateTimePicker2-col +MonthCalendar, agrupación Mañana/Tarde,DetailsFormpl-10,Confirmationcon reasons.src/pages/SettingsPage.tsx— check auto-assign + editor de horario del negocio.src/pages/EmployeesPage.tsx—EmployeeModalcon especialidades, horario, eficiencia.package.json— scripttest:unit.
Task 1: Migración V3→V4 + tipos compartidos
Files:
- Modify:
server/db.ts(añadirmigrateV3ToV4y llamarla enrunMigrations) - Modify:
shared/types.ts(Business, Employee, WorkingHoursMap) - Test: arranque del servidor +
npm run typecheck
Interfaces:
-
Produces: columnas DB
businesses.auto_assign_specialist,businesses.working_hours,employees.specialties,employees.working_hours,employees.efficiency_score; tipos TSWorkingHoursMap, y los campos enBusiness/Employee. -
Step 1: Añadir
migrateV3ToV4enserver/db.ts
Insertar antes de export function runMigrations() (línea ~225) la nueva función, y registrarla en runMigrations:
/** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */
function migrateV3ToV4() {
if (getMeta("schema_version") >= "4") return;
const bizCols: [string, string][] = [
["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"],
["working_hours", "TEXT"],
];
for (const [col, def] of bizCols) {
if (!columnExists("businesses", col)) db.exec(`ALTER TABLE businesses ADD COLUMN ${col} ${def};`);
}
const empCols: [string, string][] = [
["specialties", "TEXT"],
["working_hours", "TEXT"],
["efficiency_score", "REAL NOT NULL DEFAULT 50"],
];
for (const [col, def] of empCols) {
if (!columnExists("employees", col)) db.exec(`ALTER TABLE employees ADD COLUMN ${col} ${def};`);
}
// Backfill business working_hours: Lun-Vie 09:00-20:00 (replica del hardcodeado anterior)
const DEFAULT_WH = JSON.stringify({
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
});
const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL`).all() as { id: number }[];
const upd = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
for (const b of noWh) upd.run(DEFAULT_WH, b.id);
setMeta("schema_version", "4");
}
Y en runMigrations() añadir la llamada (después de migrateV2ToV3();):
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?:
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:
export type WorkingDay = { start: string; end: string };
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
Y en Employee (líneas 30-43) añadir tras service_ids?:
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):
npm run typecheck
Esperado: 0 errores.
- Step 4: Commit (opcional, solo si el usuario lo pide)
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 scripttest:unit)
Interfaces:
-
Produces:
WorkingDay,WorkingHoursMap,BusyWindow,ScoreInput,parseWorkingHours,isoDayOfWeek,getWorkingHoursForDate,normalizeText,tokens,specialtyMatch,overlaps,hasConflict,scoreCandidate,isoDateStr,clamp01. -
Step 1: Crear
server/lib/scheduling.tscon las funciones puras
// server/lib/scheduling.ts
// Disponibilidad + scoring de especialistas. Las funciones puras de aquí
// se unit-testean en scheduling.test.ts; las que tocan la DB se añaden más abajo.
export interface WorkingDay { start: string; end: string; } // "HH:mm"
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom
export interface BusyWindow { startMs: number; endMs: number; }
export interface ScoreInput {
specialtyMatch: number; // 0..1
efficiency: number; // 0..1
loadBalance: number; // 0..1 (fracción de jornada ocupada)
}
export function clamp01(n: number): number {
return Math.max(0, Math.min(1, n));
}
export function parseWorkingHours(json: string | null | undefined): WorkingHoursMap | null {
if (!json) return null;
try {
const obj = JSON.parse(json);
if (typeof obj !== "object" || obj === null) return null;
const out: WorkingHoursMap = {};
for (let d = 1; d <= 7; d++) {
const v = obj[String(d)];
if (v && typeof v.start === "string" && typeof v.end === "string") out[d] = { start: v.start, end: v.end };
else out[d] = null;
}
return out;
} catch {
return null;
}
}
/** JS getDay(): 0=Dom..6=Sáb → ISO 1=Lun..7=Dom. */
export function isoDayOfWeek(date: Date): number {
const j = date.getDay();
return j === 0 ? 7 : j;
}
export function isoDateStr(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Resuelve el horario (empleado tiene prioridad; si no, negocio) para esa fecha. Devuelve ms o null si es día cerrado. */
export function getWorkingHoursForDate(
empWh: WorkingHoursMap | null,
bizWh: WorkingHoursMap | null,
date: Date
): { startMs: number; endMs: number } | null {
const dow = isoDayOfWeek(date);
const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
if (!day) return null;
const [sh, sm] = day.start.split(":").map(Number);
const [eh, em] = day.end.split(":").map(Number);
const startMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm).getTime();
const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime();
if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) return null;
return { startMs, endMs };
}
export function normalizeText(s: string): string {
return (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
}
export function tokens(s: string): string[] {
return normalizeText(s).split(/[^a-z0-9]+/).filter(Boolean);
}
/**
* 1.0 si una especialidad coincide con la categoría o un token del nombre del servicio.
* 0.5 si no hay etiquetas o no coincide (el caller ya garantiza que ofrece el servicio).
*/
export function specialtyMatch(specialties: string[], service: { name: string; category: string }): number {
if (!specialties || specialties.length === 0) return 0.5;
const catN = normalizeText(service.category);
const nameTokens = tokens(service.name);
const hay = new Set<string>([catN, ...nameTokens]);
for (const sp of specialties) {
const spn = normalizeText(sp);
if (!spn) continue;
if (hay.has(spn)) return 1;
if (catN && (catN.includes(spn) || spn.includes(catN))) return 1;
for (const t of nameTokens) {
if (t.length >= 4 && (t.includes(spn) || spn.includes(t))) return 1;
}
}
return 0.5;
}
export function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
return aStart < bEnd && aEnd > bStart;
}
export function hasConflict(existing: BusyWindow[], startMs: number, endMs: number): boolean {
return existing.some((b) => overlaps(startMs, endMs, b.startMs, b.endMs));
}
/** score = 50·specialtyMatch + 30·efficiency + 20·(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
// 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:unitenpackage.json
En la sección "scripts" añadir:
"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)
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.tssigue 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
import type { DatabaseSync } from "node:sqlite";
export interface CandidateInfo {
id: number;
name: string;
color: string;
role: string;
specialties: string[];
efficiency_score: number;
empWh: WorkingHoursMap | null;
}
function safeArr(json: unknown): string[] {
if (typeof json !== "string" || !json) return [];
try {
const a = JSON.parse(json);
return Array.isArray(a) ? a.map(String) : [];
} catch {
return [];
}
}
export function getCandidates(db: DatabaseSync, businessId: number, serviceId: number): CandidateInfo[] {
const rows = db
.prepare(
`SELECT e.id, e.name, e.color, e.role, e.specialties, e.working_hours wh, e.efficiency_score
FROM employees e
JOIN employee_services es ON es.employee_id = e.id
WHERE e.business_id = ? AND es.service_id = ? AND e.active = 1`
)
.all(businessId, serviceId) as any[];
return rows.map((r) => ({
id: r.id,
name: r.name,
color: r.color,
role: r.role,
specialties: safeArr(r.specialties),
efficiency_score: typeof r.efficiency_score === "number" ? r.efficiency_score : 50,
empWh: parseWorkingHours(r.wh),
}));
}
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
const map = new Map<number, BusyWindow[]>();
if (employeeIds.length === 0) return map;
const startOfDay = `${dateIso}T00:00:00`;
const endOfDay = `${dateIso}T23:59:59`;
const rows = db
.prepare(
`SELECT employee_id, start_at, end_at FROM appointments
WHERE employee_id IN (${employeeIds.map(() => "?").join(",")})
AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
)
.all(...employeeIds, startOfDay, endOfDay) as any[];
for (const r of rows) {
const arr = map.get(r.employee_id) ?? [];
arr.push({ startMs: new Date(r.start_at).getTime(), endMs: new Date(r.end_at).getTime() });
map.set(r.employee_id, arr);
}
return map;
}
export function isAvailable(db: DatabaseSync, employeeId: number, startMs: number, endMs: number): boolean {
const dateIso = isoDateStr(new Date(startMs));
const busy = getExistingBusy(db, [employeeId], dateIso).get(employeeId) ?? [];
return !hasConflict(busy, startMs, endMs);
}
interface RankedCandidate { info: CandidateInfo; score: number; reasons: string[] }
function rankOne(
c: CandidateInfo,
busy: BusyWindow[],
wh: { startMs: number; endMs: number },
service: { name: string; category: string }
): RankedCandidate | null {
const workingMs = Math.max(1, wh.endMs - wh.startMs);
let occupiedMs = 0;
for (const b of busy) {
occupiedMs += Math.min(b.endMs, wh.endMs) - Math.max(b.startMs, wh.startMs);
if (occupiedMs < 0) occupiedMs = 0;
}
const loadBalance = clamp01(occupiedMs / workingMs);
const sm = specialtyMatch(c.specialties, service);
const score = scoreCandidate({ specialtyMatch: sm, efficiency: c.efficiency_score / 100, loadBalance });
const reasons: string[] = [];
if (sm >= 1) reasons.push("Especialidad coincidente");
if (c.efficiency_score >= 75) reasons.push("Alta eficiencia");
if (loadBalance <= 0.25) reasons.push("Buena disponibilidad");
return { info: c, score, reasons };
}
/** Usado por el endpoint de slots: devuelve el mejor candidato libre para un slot concreto. */
export function pickBestSlotEmployee(
candidates: CandidateInfo[],
busyMap: Map<number, BusyWindow[]>,
bizWh: WorkingHoursMap | null,
service: { name: string; category: string },
startMs: number,
endMs: number
): { id: number; name: string } | null {
const date = new Date(startMs);
let best: { id: number; name: string; score: number } | null = null;
for (const c of candidates) {
const wh = getWorkingHoursForDate(c.empWh, bizWh, date);
if (!wh) continue;
if (startMs < wh.startMs || endMs > wh.endMs) continue;
if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, service);
if (!ranked) continue;
if (!best || ranked.score > best.score || (ranked.score === best.score && c.id < best.id)) {
best = { id: c.id, name: c.name, score: ranked.score };
}
}
return best ? { id: best.id, name: best.name } : null;
}
export interface AutoAssignResult {
employeeId: number;
score: number;
reasons: string[];
}
export interface AutoAssignCtx {
businessId: number;
serviceId: number;
serviceName: string;
serviceCategory: string;
startMs: number;
endMs: number;
bizWh: WorkingHoursMap | null;
}
/** Asigna el mejor especialista disponible (hard constraints + score). Llamar dentro de transacción. */
export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResult | null {
const candidates = getCandidates(db, ctx.businessId, ctx.serviceId);
if (candidates.length === 0) return null;
const dateIso = isoDateStr(new Date(ctx.startMs));
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso);
let best: RankedCandidate | null = null;
for (const c of candidates) {
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs));
if (!wh) continue;
if (ctx.startMs < wh.startMs || ctx.endMs > wh.endMs) continue;
if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, { name: ctx.serviceName, category: ctx.serviceCategory });
if (!ranked) continue;
if (!best || ranked.score > best.score || (ranked.score === best.score && c.info.id < best.info.id)) {
best = ranked;
}
}
if (!best) return null;
return {
employeeId: best.info.id,
score: best.score,
reasons: best.reasons.length ? best.reasons : ["Disponible"],
};
}
/** Ejecuta fn dentro de una transacción BEGIN IMMEDIATE…COMMIT; ROLLBACK en error. */
export function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
db.exec("BEGIN IMMEDIATE");
try {
const r = fn();
db.exec("COMMIT");
return r;
} catch (err) {
try { db.exec("ROLLBACK"); } catch { /* ignore */ }
throw err;
}
}
- Step 2: Verificar tipos y tests
Run: npm run typecheck → 0 errores.
Run: npm run test:unit → sigue verde.
- Step 3: Commit (opcional)
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(endpointGET /:slug/slotsypublicBusiness) - Test: arranque + integración (Task 7 cubre el script HTTP).
Interfaces:
-
Consumes:
getWorkingHoursForDate,pickBestSlotEmployee,getCandidates,getExistingBusy,parseWorkingHours,isoDateStrdeserver/lib/scheduling.ts. -
Produces:
publicBusiness()devuelveauto_assign_specialistyworking_hours; slots respetan horario real. -
Step 1: Importar scheduling en
server/routes/booking.ts
Al inicio del archivo (tras import { db }):
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:
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:
// 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:
overlapsRangees local para no cambiar imports; equivale aoverlapsde scheduling. (Se puede reemplazar por el import si se prefiere.)
- Step 4: Verificar tipos
Run: npm run typecheck → 0 errores.
- Step 5: Commit (opcional)
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(endpointPOST /:slug/book, líneas 100-153)
Interfaces:
-
Consumes:
autoAssign,isAvailable,runInTransaction,parseWorkingHours,isoDateStrde scheduling. -
Produces:
POST /bookresponde 409 en conflicto; asigna vía algoritmo cuandoemployee_idausente o cuandoauto_assign_specialist=1; incluyereasonsen la respuesta. -
Step 1: Ampliar imports de scheduling
Añadir a la lista de imports de Task 4:
import { autoAssign, isAvailable, runInTransaction } from "../lib/scheduling.ts";
- Step 2: Reescribir
POST /:slug/book
Reemplazar todo el handler (líneas 100-153) por:
// 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)
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(handlerPOST /, líneas 75-167)
Interfaces:
-
Consumes:
isAvailable,autoAssign,runInTransaction,parseWorkingHoursde scheduling. -
Step 1: Importar scheduling al inicio de
appointments.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:
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)
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(scripttest:booking) -
Step 1:
settings.ts— ampliar FIELDS y SELECTs
En FIELDS (líneas 8-20) añadir "auto_assign_specialist" y "working_hours":
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 /:
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):
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:
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:
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:
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
// server/scripts/booking-e2e.mjs — requiere el server dev corriendo (npm run dev)
const BASE = "http://localhost:5173/api";
let pass = 0, fail = 0;
async function req(method, path, body, token) {
const headers = { "Content-Type": "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, json };
}
function check(name, cond, extra = "") {
if (cond) { pass++; console.log(` \u2713 ${name}`); }
else { fail++; console.log(` \u2717 ${name} ${extra}`); }
}
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
const t = login.token;
check("login owner", !!t);
// 1) Slug del negocio
const { json: settings } = await req("GET", "/settings", null, t);
const slug = settings.settings?.slug;
check("tiene slug", !!slug, String(slug));
// 2) Datos públicos + servicios
const { json: pub } = await req("GET", `/public/${slug}`);
check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0);
const svc = pub.services[0];
const tomorrow = new Date(Date.now() + 86400000);
const date = tomorrow.toISOString().slice(0, 10);
// 3) Slots disponibles
const { json: slotsRes } = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${date}`);
check("slots devuelve lista", Array.isArray(slotsRes.slots));
check("hay slots disponibles hoy/mañana", slotsRes.slots.length > 0, `got ${slotsRes.slots.length}`);
// 4) Reservar (auto-asignación, sin employee_id)
const slot = slotsRes.slots[0];
const { status: bookStatus, json: booked } = await req("POST", `/public/${slug}/book`, {
service_id: svc.id,
start_at: slot.iso,
client: { name: "Cliente Prueba Auto", phone: "+525500000001" },
});
check("reserva creada (auto-assign)", bookStatus === 201 && !!booked.appointment?.employee_id, `status=${bookStatus}`);
check("response trae reasons", Array.isArray(booked.appointment?.reasons) && booked.appointment.reasons.length > 0);
// 5) Mismo slot otra vez → 409 (doble reserva del mismo especialista)
const { status: conflict } = await req("POST", `/public/${slug}/book`, {
service_id: svc.id,
employee_id: booked.appointment.employee_id,
start_at: slot.iso,
client: { name: "Cliente Conflictivo", phone: "+525500000002" },
});
check("doble reserva → 409", conflict === 409, `status=${conflict}`);
// 6) Activar auto_assign_specialist y verificar que la respuesta pública lo expone
await req("PATCH", "/settings", { auto_assign_specialist: 1 }, t);
const { json: pub2 } = await req("GET", `/public/${slug}`);
check("auto_assign_specialist expuesto en público", pub2.business?.auto_assign_specialist === 1);
await req("PATCH", "/settings", { auto_assign_specialist: 0 }, t); // cleanup
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
process.exit(fail ? 1 : 0);
- Step 4: Añadir script en
package.json
"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:00Zetc. El nuevo guard NO debe afectarlos salvo duplicidad exacta. Sitest:e2efalla por la cita ya existente de una corrida anterior, ejecutar conRESET_DB=1 npm run seedantes.
- Step 6: Commit (opcional)
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/.textareaen@layer components
En src/index.css, reemplazar el bloque de líneas 272-290:
.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:
@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 layerutilitiessiempre ganan sobre el padding del componente.
- Step 2: Bump
pl-9→pl-10enDetailsForm
En src/pages/public/BookingPage.tsx, en la función DetailsForm (líneas 664, 678, 692), cambiar las tres ocurrencias de className="input pl-9" por className="input pl-10". (Tres inputs: Nombre, Teléfono, Correo.)
- Step 3: Verificar visualmente
Run: npm run dev → abrir /b/<slug> → avanzar a "Tus datos". El icono ya no se solapa con el placeholder (texto arranca a 40px, icono ocupa 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)
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_idopcional,BookResponse.appointment.employee_id/reasons. -
Step 1: Ampliar interfaces
En PublicBusiness (líneas 3-15) añadir:
auto_assign_specialist: number | boolean;
En BookPayload (líneas 59-64) hacer employee_id opcional:
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:
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)
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
// src/components/MonthCalendar.tsx
import { useMemo, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "../lib/format";
const WD = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
const MONTHS = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
function toIso(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function isoToDate(iso: string): Date {
const [y, m, d] = iso.split("-").map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
function atMidnight(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function MonthCalendar({
value,
min,
max,
availableDates,
onSelect,
}: {
value: string;
min?: string;
max?: string;
availableDates?: Set<string>;
onSelect: (iso: string) => void;
}) {
const initial = value ? isoToDate(value) : new Date();
const [cursor, setCursor] = useState(new Date(initial.getFullYear(), initial.getMonth(), 1));
const todayIso = toIso(new Date());
const minDate = min ? atMidnight(isoToDate(min)) : null;
const maxDate = max ? atMidnight(isoToDate(max)) : null;
const cells = useMemo(() => {
const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const lead = (first.getDay() + 6) % 7; // Lun primero
const start = new Date(first);
start.setDate(first.getDate() - lead);
const out: Date[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
out.push(d);
}
return out;
}, [cursor]);
const inMonth = (d: Date) => d.getMonth() === cursor.getMonth();
const selectable = (d: Date) => {
const dm = atMidnight(d);
if (minDate && dm < minDate) return false;
if (maxDate && dm > maxDate) return false;
return true;
};
const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= firstOfMonth;
const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);
return (
<div className="select-none">
<div className="mb-3 flex items-center justify-between">
<button type="button" onClick={() => move(-1)} disabled={!canPrev}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
aria-label="Mes anterior">
<ChevronLeft className="h-4 w-4" />
</button>
<div className="text-sm font-extrabold text-slate-800">{MONTHS[cursor.getMonth()]} {cursor.getFullYear()}</div>
<button type="button" onClick={() => move(1)} disabled={!canNext}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
aria-label="Mes siguiente">
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="mb-1 grid grid-cols-7 gap-1">
{WD.map((w) => (
<div key={w} className="text-center text-[11px] font-bold uppercase tracking-wide text-slate-400">{w}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((d, i) => {
const dIso = toIso(d);
const sel = dIso === value;
const on = selectable(d);
const inM = inMonth(d);
const isToday = dIso === todayIso;
const hasAvail = availableDates?.has(dIso);
const weekend = d.getDay() === 0 || d.getDay() === 6;
return (
<button key={i} type="button" disabled={!on} onClick={() => onSelect(dIso)}
className={cn(
"relative flex aspect-square items-center justify-center rounded-xl text-sm font-bold transition-all",
!inM && "text-slate-300",
inM && !sel && on && "text-slate-700 hover:bg-brand-50 hover:text-brand-700",
inM && !on && "cursor-not-allowed text-slate-300",
sel && "bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30",
weekend && inM && !sel && on && "text-slate-500"
)}>
{d.getDate()}
{hasAvail && !sel && (
<span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-accent-500" aria-hidden />
)}
{isToday && !sel && (
<span className="absolute top-1 h-1.5 w-1.5 rounded-full bg-brand-400" aria-hidden />
)}
</button>
);
})}
</div>
</div>
);
}
- Step 2: Verificar tipos
Run: npm run typecheck → 0 errores en el nuevo archivo.
- Step 3: Commit (opcional)
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(componenteDateTimePicker, líneas 555-642)
Interfaces:
-
Consumes:
MonthCalendar(Task 10). -
Produces:
DateTimePickercon nueva firma idéntica (mismos props) para no romper el caller; muestra calendario a la izquierda y slots a la derecha enmd+. -
Step 1: Añadir import de
MonthCalendar
En los imports de BookingPage.tsx (junto al de ui), añadir:
import { MonthCalendar } from "../../components/MonthCalendar";
- Step 2: Reescribir
DateTimePicker
Reemplazar toda la función DateTimePicker (líneas 555-642) por:
function DateTimePicker({
date,
onDate,
min,
max,
slots,
loading,
isError,
selectedSlot,
onPick,
}: {
date: string;
onDate: (d: string) => void;
min: string;
max?: string;
slots: PublicSlot[];
loading: boolean;
isError: boolean;
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
}) {
// agrupar slots en Mañana (< 12:00) / Tarde (>= 12:00) usando la hora del texto "HH:MM AM/PM"
const { morning, afternoon } = useMemo(() => {
const morning: PublicSlot[] = [];
const afternoon: PublicSlot[] = [];
for (const s of slots) {
const m = /(\d{1,2}):(\d{2})/.exec(s.time);
const h = m ? Number(m[1]) : 0;
const pm = /PM/i.test(s.time);
const hour24 = pm && h !== 12 ? h + 12 : !pm && h === 12 ? 0 : h;
(hour24 < 12 ? morning : afternoon).push(s);
}
return { morning, afternoon };
}, [slots]);
return (
<div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
{/* Columna izquierda: calendario */}
<div className="card p-4">
<label className="label flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
</label>
<MonthCalendar value={date} min={min} max={max} onSelect={(d) => onDate(d)} />
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-accent-500" /> Con cupo</span>
<span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-brand-400" /> Hoy</span>
</div>
</div>
{/* Columna derecha: horarios */}
<div className="mt-4 md:mt-0">
<label className="label flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
</label>
<div className="card p-4">
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-slate-400">
<Spinner className="text-brand-500" />
<span className="text-sm font-medium">Buscando horarios…</span>
</div>
)}
{!loading && isError && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<AlertCircle className="h-6 w-6 text-rose-400" />
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
</div>
)}
{!loading && !isError && slots.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<CalendarDays className="h-6 w-6 text-slate-300" />
<p className="text-sm font-medium text-slate-600">Sin horarios disponibles</p>
<p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
</div>
)}
{!loading && !isError && slots.length > 0 && (
<div className="space-y-4">
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} />
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} />
</div>
)}
</div>
</div>
</div>
);
}
function SlotGroup({
title,
slots,
selectedSlot,
onPick,
}: {
title: string;
slots: PublicSlot[];
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
}) {
if (slots.length === 0) return null;
return (
<div>
<div className="mb-2 flex items-center gap-2">
<span className="rounded-full bg-accent-50 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent-700">{title}</span>
<span className="text-[11px] font-medium text-slate-400">{slots.length} opciones</span>
</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{slots.map((slot) => {
const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id;
return (
<button
key={`${slot.iso}-${slot.employee_id}`}
type="button"
onClick={() => onPick(slot)}
className={cn(
"rounded-xl border px-2 py-2.5 text-sm font-bold transition-all",
active
? "border-brand-500 bg-brand-500 text-white shadow-soft"
: "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50"
)}
>
{slot.time}
</button>
);
})}
</div>
</div>
);
}
- Step 3: Verificar tipos
Run: npm run typecheck → puede quedar algún error por el max prop nuevo en el caller; se resuelve en Task 12. Si aparecen errores sólo por el caller, continuar.
- Step 4: Commit (opcional)
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,BookingPagemain/orquestación, paso Especialista condicional,ActionBar,Confirmation, pase demaxyemployeeIdal book).
Interfaces:
-
Consumes:
business.auto_assign_specialist(depublicApi). -
Produce: wizard que omite el paso Especialista cuando
auto_assign_specialist; confirmación muestrareasons. -
Step 1: Hacer dinámico el array
STEPSy el total
Reemplazar las constantes STEPS (líneas 36-41) por una función y un helper:
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, calcularautoAssignysteps, y mapear pasos internos
Dentro del componente BookingPage, tras obtener business, añadir (antes de const selectedService):
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:
const goNext = () => setStep((s) => Math.min(stepCount, s + 1));
const goBack = () => setStep((s) => Math.max(1, s - 1));
- Step 3: Reemplazar el
returnprincipal (líneas 200-301)
Reemplazar todo el bloque return (...) del componente por:
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]">
<TopBar name={business.name} industry={business.industry} step={step} stepCount={stepCount} steps={steps} />
<main className={cn(
"mx-auto w-full flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28",
currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"
)}>
{currentOriginal === 1 && <Hero business={business} />}
<div className="mt-4">
{currentOriginal === 1 && (
<StepShell icon={<Sparkles className="h-4 w-4" />} title="Elige un servicio" subtitle="Selecciona el servicio que deseas reservar.">
<ServiceGrid services={services} currency={currency} selectedId={serviceId} onPick={pickService} />
</StepShell>
)}
{currentOriginal === 2 && (
<StepShell icon={<User className="h-4 w-4" />} title="Elige un especialista" subtitle="¿Tienes una preferencia? O déjalo en cualquiera.">
<EmployeeList employees={employees} selectedId={employeeId} onPick={pickEmployee} />
</StepShell>
)}
{currentOriginal === 3 && selectedService && (
<StepShell icon={<CalendarDays className="h-4 w-4" />} title="Elige fecha y hora" subtitle={`${selectedService.name} · ${selectedService.duration_min} min · ${formatCurrency(selectedService.price, currency)}`}>
<DateTimePicker
date={selectedDate}
onDate={(d) => { setSelectedDate(d); setSelectedSlot(null); }}
min={todayStr()}
max={maxBookableDate()}
slots={slots}
loading={slotsQuery.isLoading}
isError={slotsQuery.isError}
selectedSlot={selectedSlot}
onPick={pickSlot}
/>
</StepShell>
)}
{currentOriginal === 4 && selectedService && selectedSlot && (
<StepShell icon={<CalendarCheck className="h-4 w-4" />} title="Tus datos" subtitle="Necesitamos algunos datos para confirmar tu cita.">
<DetailsForm
client={client}
setClient={setClient}
summary={
<>
<SummaryRow label="Servicio" value={selectedService.name} />
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
</>
}
/>
</StepShell>
)}
</div>
</main>
<ActionBar
hidden={!!result}
step={step}
stepCount={stepCount}
goBack={goBack}
onPrimary={onPrimary}
primaryLabel={primaryLabel}
canContinue={canContinue}
busy={bookMut.isPending}
priceLabel={selectedService ? formatCurrency(selectedService.price, currency) : undefined}
serviceName={selectedService?.name}
slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short" })} · ${selectedSlot.time}` : undefined}
/>
<FooterNote name={business.name} />
</div>
);
Añadir helper maxBookableDate (junto a todayStr):
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/primaryLabelyhandleBook
Reemplazar pickSlot (líneas 169-172), handleBook (174-188), canContinue (190-194) y primaryLabel/onPrimary (197-198) por versiones que usen currentOriginal y stepCount:
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
slotsQueryenabled
En slotsQuery (línea 74) cambiar la condición para usar currentOriginal:
enabled: currentOriginal === 3 && !!serviceId && !!selectedDate && !result,
Nota: como
currentOriginalse calcula más abajo en el código actual, mover su cálculo (Step 2) a antes deslotsQuerypara que la closure lo vea.
- Step 6: Actualizar
TopBaryActionBarpara 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}.
function TopBar({ name, industry, step, stepCount, steps }: { name: string; industry: string; step: number; stepCount: number; steps: { n: number; label: string }[]; }) {
const current = steps.find((s) => s.n === step);
return (
<header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
<Sparkles className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-extrabold text-slate-900">{name}</div>
<div className="truncate text-[11px] text-slate-500">{industry}</div>
</div>
</div>
{step > 0 && (
<div className="flex items-center gap-1.5">
<span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">Paso {step} de {stepCount}</span>
<div className="hidden items-center gap-1 md:flex">
{steps.map((s) => (
<div key={s.n} className={cn(
"flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors",
s.n === step ? "bg-brand-500 text-white" : s.n < step ? "bg-brand-50 text-brand-700" : "bg-slate-100 text-slate-400"
)}>
{s.n < step ? <Check className="h-3 w-3" /> : <span>{s.n}</span>}
<span>{s.label}</span>
</div>
))}
</div>
<div className="flex items-center gap-1.5 rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700 md:hidden">
<span>{step}</span><span className="font-semibold text-brand-400">/</span><span className="text-brand-400">{stepCount}</span>
<span className="text-brand-300">·</span><span>{current?.label}</span>
</div>
</div>
)}
</div>
</header>
);
}
En ActionBar (líneas 725-782): aceptar stepCount, cambiar max-w-2xl → max-w-5xl en el div interno (línea 751), y mostrar "Paso {step}/{stepCount}" si se desea (opcional). Firma actualizada:
function ActionBar({ hidden, step, stepCount, goBack, onPrimary, primaryLabel, canContinue, busy, priceLabel, serviceName, slotLabel }: {
hidden: boolean; step: number; stepCount: number; goBack: () => void; onPrimary: () => void; primaryLabel: string; canContinue: boolean; busy: boolean; priceLabel?: string; serviceName?: string; slotLabel?: string;
}) {
if (hidden) return null;
return (
<div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md">
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:px-6">
{step > 1 && (<button onClick={goBack} className="btn btn-ghost shrink-0" type="button"><ChevronLeft className="h-4 w-4" /> <span className="hidden sm:inline">Atrás</span></button>)}
<div className="min-w-0 flex-1">
{serviceName ? (
<>
<div className="truncate text-sm font-bold text-slate-900">{serviceName}</div>
<div className="truncate text-xs text-slate-500">{slotLabel ? slotLabel : priceLabel}</div>
</>
) : (<div className="text-sm font-medium text-slate-400">Selecciona para continuar</div>)}
</div>
<button onClick={onPrimary} disabled={!canContinue || busy} className="btn btn-primary shrink-0" type="button">
{busy ? <Spinner /> : <>{primaryLabel} <ChevronRight className="h-4 w-4" /></>}
</button>
</div>
</div>
);
}
- Step 7:
Confirmationconreasons
Reemplazar el bloque del especialista en Confirmation (líneas 820-828) para mostrar reasons cuando existan. Tras <SummaryRow label="Especialista" value={appointment.employee_name} /> añadir:
{appointment.reasons && appointment.reasons.length > 0 && (
<div className="px-3 pb-2 pt-1">
<div className="flex flex-wrap gap-1.5">
{appointment.reasons.map((r) => (
<span key={r} className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold text-brand-700">
<Sparkles className="h-3 w-3" /> {r}
</span>
))}
</div>
</div>
)}
- Step 8: Verificar tipos y runtime
Run: npm run typecheck → 0 errores.
Run: npm run dev → probar flujo completo con auto_assign_specialist=1 (3 pasos) y =0 (4 pasos), y revisar la confirmación con reasons.
- Step 9: Commit (opcional)
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.updateconauto_assign_specialistyworking_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:
import { Wand2, Clock } from "lucide-react";
const DEFAULT_WH = {
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
} as Record<number, { start: string; end: string } | null>;
function parseWh(v: any): Record<number, { start: string; end: string } | null> {
let obj = v;
if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } }
if (!obj || typeof obj !== "object") return { ...DEFAULT_WH };
const out: Record<number, any> = {};
for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
return out;
}
const DAYS = [
{ n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" },
{ n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" },
];
function WorkingHoursEditor({ value, onChange }: { value: Record<number, { start: string; end: string } | null>; onChange: (v: Record<number, { start: string; end: string } | null>) => void }) {
const toggle = (n: number) => onChange({ ...value, [n]: value[n] ? null : { start: "09:00", end: "18:00" } });
const set = (n: number, field: "start" | "end", v: string) => {
const cur = value[n] ?? { start: "09:00", end: "18:00" };
onChange({ ...value, [n]: { ...cur, [field]: v } });
};
return (
<div className="space-y-1.5">
{DAYS.map(({ n, label }) => {
const on = !!value[n];
return (
<div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
<label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
<input type="checkbox" className="h-4 w-4 accent-brand-500" checked={on} onChange={() => toggle(n)} />
{label}
</label>
{on ? (
<div className="flex items-center gap-1.5">
<input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.start} onChange={(e) => set(n, "start", e.target.value)} />
<span className="text-xs text-slate-400">a</span>
<input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.end} onChange={(e) => set(n, "end", e.target.value)} />
</div>
) : (
<span className="text-xs font-medium text-slate-400">Cerrado</span>
)}
</div>
);
})}
</div>
);
}
- Step 2: Estado local de working hours + incluir en el mutation
En el componente SettingsPage, tras const [saved, setSaved] = useState(false); añadir:
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
useEffect(() => {
if (data?.settings) { setF(data.settings); setWh(parseWh(data.settings.working_hours)); }
}, [data]);
(Reemplazar el useEffect existente que sólo hace setF.)
En el mut (mutationFn), añadir al payload:
auto_assign_specialist: f.auto_assign_specialist ? 1 : 0,
working_hours: wh,
- Step 3: UI — check auto-assign y editor de horario
Dentro de la card "Reservas online" (tras el bloque de la URL pública, antes de cerrar el </div> de la card — línea ~100), añadir:
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.auto_assign_specialist} onChange={(e) => set("auto_assign_specialist", e.target.checked)} />
<div>
<div className="flex items-center gap-1.5 text-sm font-bold text-slate-800"><Wand2 className="h-4 w-4 text-brand-500" /> Asignar especialista automáticamente</div>
<p className="text-xs text-slate-500">El cliente no elige especialista: el sistema lo asigna según especialidad, eficiencia y disponibilidad. Evita dobles reservas.</p>
</div>
</label>
<div>
<label className="label flex items-center gap-1.5"><Clock className="h-3.5 w-3.5 text-slate-400" /> Horario del negocio</label>
<WorkingHoursEditor value={wh} onChange={setWh} />
<p className="mt-1 text-[11px] text-slate-500">Define qué días y horas se ofrecen citas. Los especialistas pueden tener su propio horario.</p>
</div>
- Step 4: Verificar tipos y runtime
Run: npm run typecheck → 0 errores.
Run: npm run dev → abrir /settings, cambiar el check y el horario, guardar, recargar y confirmar persistencia.
- Step 5: Commit (opcional)
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/createconspecialties,working_hours,efficiency_score. -
Step 1: Estado local + sincronización
En EmployeeModal, tras const [svcIds, setSvcIds] = useState<number[]>([]); (línea 195) añadir:
const [specialties, setSpecialties] = useState<string[]>([]);
const [specialtyInput, setSpecialtyInput] = useState("");
const [efficiency, setEfficiency] = useState(50);
const [inheritHours, setInheritHours] = useState(true);
const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
Importar DEFAULT_WH, DAYS, parseWh desde donde se definieron en SettingsPage, o duplicarlos como ayudantes locales en EmployeesPage.tsx (recomendado: moverlos a src/lib/workingHours.ts y compartir). Para mantener el cambio contenido, crear:
// src/lib/workingHours.ts
export const DEFAULT_WH: Record<number, { start: string; end: string } | null> = {
1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
};
export const DAYS = [
{ n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" },
{ n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" },
];
export function parseWh(v: any): Record<number, { start: string; end: string } | null> {
let obj = v;
if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } }
if (!obj || typeof obj !== "object") return { ...DEFAULT_WH };
const out: Record<number, any> = {};
for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
return out;
}
Y refactorizar SettingsPage.tsx (Task 13) para importar de src/lib/workingHours.ts en lugar de duplicar (ajustar el import).
En EmployeeModal, sincronizar en el useEffect (líneas 197-216). Para el caso employee, añadir:
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):
setSpecialties([]); setEfficiency(50); setInheritHours(true); setWh({ ...DEFAULT_WH });
- Step 2: Payload del mutation
En el mut.mutationFn (líneas 219-223) ampliar el payload:
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:
<div>
<label className="label">Especialidades</label>
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-100 p-2">
{specialties.map((sp) => (
<span key={sp} className="chip bg-brand-50 text-brand-700">
{sp}
<button type="button" onClick={() => setSpecialties((a) => a.filter((x) => x !== sp))} className="ml-0.5 text-brand-400 hover:text-brand-700">×</button>
</span>
))}
<input
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none"
placeholder="Ej. Coloración y pulsa Enter"
value={specialtyInput}
onChange={(e) => setSpecialtyInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && specialtyInput.trim()) { e.preventDefault(); setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]); setSpecialtyInput(""); }
}}
/>
</div>
<p className="mt-1 text-[11px] text-slate-500">Etiquetas que el algoritmo compara con la categoría y el nombre del servicio.</p>
</div>
<div>
<label className="label">Eficiencia <span className="ml-1 text-brand-700">{efficiency}</span></label>
<input type="range" min={0} max={100} value={efficiency} onChange={(e) => setEfficiency(Number(e.target.value))} className="w-full accent-brand-500" />
<p className="mt-1 text-[11px] text-slate-500">Qué tan rápido/hábil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.</p>
</div>
<div>
<label className="flex cursor-pointer items-center gap-2">
<input type="checkbox" className="h-4 w-4 accent-brand-500" checked={inheritHours} onChange={(e) => setInheritHours(e.target.checked)} />
<span className="text-sm font-bold text-slate-800">Heredar horario del negocio</span>
</label>
{!inheritHours && (
<div className="mt-2 space-y-1.5">
{DAYS.map(({ n, label }) => {
const on = !!wh[n];
return (
<div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
<label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
<input type="checkbox" className="h-4 w-4 accent-brand-500" checked={on} onChange={() => setWh({ ...wh, [n]: wh[n] ? null : { start: "09:00", end: "18:00" } })} />
{label}
</label>
{on ? (
<div className="flex items-center gap-1.5">
<input type="time" className="input !w-auto !py-1 text-xs" value={wh[n]!.start} onChange={(e) => setWh({ ...wh, [n]: { ...(wh[n] as any), start: e.target.value } })} />
<span className="text-xs text-slate-400">a</span>
<input type="time" className="input !w-auto !py-1 text-xs" value={wh[n]!.end} onChange={(e) => setWh({ ...wh, [n]: { ...(wh[n] as any), end: e.target.value } })} />
</div>
) : <span className="text-xs font-medium text-slate-400">Cerrado</span>}
</div>
);
})}
</div>
)}
</div>
- Step 4: Verificar tipos y runtime
Run: npm run typecheck → 0 errores.
Run: npm run dev → editar un empleado, añadir especialidades, ajustar eficiencia, definir horario propio, guardar y recargar.
- Step 5: Commit (opcional)
git add src/pages/EmployeesPage.tsx src/lib/workingHours.ts src/pages/SettingsPage.tsx
git commit -m "feat(employees): specialties, efficiency score, per-employee working hours"
Task 15: Verificación final (tipos, lint, unit, e2e, build)
Files: ninguno (sólo validación).
- Step 1: Typecheck
Run: npm run typecheck
Esperado: 0 errores.
- Step 2: Lint
Run: npm run lint
Esperado: 0 errores (avisos aceptables). Si aparecen react-hooks/exhaustive-deps por las nuevas dependencias en useEffect/useMemo, añadirlas al array de dependencias.
- Step 3: Unit tests
Run: npm run test:unit
Esperado: todos PASS.
- Step 4: Build de producción
Run: npm run build
Esperado: compila sin errores.
- Step 5: Integración de reservas (con server levantado)
Run: npm run dev (otra terminal)
Run: npm run test:booking
Run: npm run test:e2e
Esperado: todos PASS.
-
Step 6: Recorrido visual manual
-
/b/<slug>conauto_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 dereasons. -
/b/<slug>conauto_assign_specialist=0: 4 pasos, card "Cualquiera" usa el ranking. -
Mobile (
<md): stack vertical en Horario (sin romper). -
Intentar reservar el mismo horario dos veces → 409 visible para el cliente.
-
Step 7: Commit final (opcional)
git add -A
git commit -m "test: full verification — typecheck, lint, unit, booking e2e, build"
Self-Review (post-escritura)
Spec coverage:
- Check "omitir especialista" → Task 1 (col), Task 5 (server), Task 12 (wizard). ✓
- Algoritmo matemático (especialidad/eficiencia/disponibilidad/tiempo) → Task 2-3. ✓
- Sin doble agenda (hard constraint + guard tx) → Task 3 (
hasConflict), Task 5-6 (409). ✓ - Calendario de mes siempre visible → Task 10, integrado en Task 11. ✓
- Layout 2 columnas escritorio → Task 11 (
md:grid),max-w-5xlen Task 12. ✓ - Colores llamativos + accesibilidad →
accentdots, badgesreasons, celdas >=aspect-square, estilos reforzados en Tasks 10-12. ✓ - Mantener bloques de horario → Task 11 conserva
grid-cols-3 sm:grid-cols-4y los mismos estilos seleccionado/disponible. ✓ - Mobile intacto → Task 11 mantiene stack
<md. ✓ - Fix iconos solapados → Task 8. ✓
- Captura de datos del algoritmo (especialidades/horario/eficiencia) → Tasks 7 (backend), 13-14 (UI). ✓
Placeholder scan: sin TBD/TODO; todos los pasos tienen código completo o comandos exactos.
Type consistency: WorkingHoursMap/CandidateInfo/AutoAssignResult definidos en Task 2-3 y consumidos con los mismos nombres en Tasks 4-6. stepsFor/currentOriginal en Task 12 coinciden entre TopBar/ActionBar/BookingPage. BookPayload.employee_id opcional (Task 9) consumido en Task 12.
Notas de riesgo: runInTransaction usa BEGIN IMMEDIATE; con node:sqlite síncrono y un solo proceso, el bloqueo es efectivo. Si dos requests concurrentes compiten por la misma cita, el segundo recibe SQLITE_BUSY al BEGIN IMMEDIATE — manejar con un reintento simple si se observa (no requerido para el scope actual, single-process).