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