Files
AgendaPro/.superpowers/sdd/final-review-diff.txt
T
AgendaPro Dev d796538fd9 feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed
- Rebrand all user-facing text to AgendaMax
- Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0)
- Fix calendar toolbar hover: active buttons keep readable contrast
- Sticky calendar toolbar (month/week/day always visible on scroll)
- Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
2026-07-27 10:11:16 -06:00

1503 lines
134 KiB
Plaintext

git : warning: in the working copy of 'e2e-test.mjs', LF will be replaced by CRLF the next time Git touches it
En línea: 1 Carácter: 1
+ git diff e8d2435 -- server/db.ts shared/types.ts server/lib/schedulin ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (warning: in the... Git touches it:String) [], RemoteException
+ FullyQualifiedErrorId : NativeCommandError
warning: in the working copy of 'package.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'server/db.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'server/routes/appointments.ts', LF will be replaced by CRLF the next time Git touches
it
warning: in the working copy of 'server/routes/booking.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'server/routes/employees.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'server/routes/settings.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'shared/types.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'src/index.css', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'src/lib/publicApi.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'src/pages/EmployeesPage.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'src/pages/SettingsPage.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'src/pages/public/BookingPage.tsx', LF will be replaced by CRLF the next time Git
touches it
diff --git a/e2e-test.mjs b/e2e-test.mjs
index 3e2094b..b4bf86c 100644
--- a/e2e-test.mjs
+++ b/e2e-test.mjs
@@ -13,6 +13,19 @@ function check(name, cond, extra = "") {
else { fail++; console.log(` Ô£ù ${name} ${extra}`); }
}
+// Fetch the first available slot (iso) for a service over the next `maxDays` days.
+// Slots already filter out non-working days and double-booked specialists, so any
+// returned iso is safe to use as start_at for either autoAssign or self-assign paths.
+async function findSlot(slug, serviceId, maxDays = 30) {
+ for (let i = 1; i <= maxDays; i++) {
+ const d = new Date(Date.now() + i * 86400000);
+ const date = d.toISOString().slice(0, 10);
+ const { json } = await req("GET", `/public/${slug}/slots?service_id=${serviceId}&date=${date}`);
+ if (Array.isArray(json.slots) && json.slots.length > 0) return json.slots[0].iso;
+ }
+ return null;
+}
+
// /me now works (the bug we fixed) ÔÇö login first to get a real token
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
@@ -20,6 +33,11 @@ const t = login.token;
const { json: me } = await req("GET", "/auth/me", null, t);
check("/api/auth/me returns user", me.user?.email === "[email protected]", JSON.stringify(me).slice(0, 100));
+// Slug del negocio (para consultar slots p├║blicos)
+const { json: settings } = await req("GET", "/settings", null, t);
+const slug = settings.settings?.slug;
+check("tiene slug", !!slug, String(slug));
+
const checks = [
["business", "/business", (j) => j.business?.name === "Lumi├¿re Est├®tica & Spa"],
["employees+stats", "/employees", (j) => j.employees?.length === 6 && !!j.employees[0].stats],
@@ -42,13 +60,21 @@ for (const [name, path, cond] of checks) {
}
// Lifecycle
-const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: "2026-09-15T11:00:00Z" }, t);
+const slotOwner = await findSlot(slug, 1);
+check("found slot for service 1", !!slotOwner);
+const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: slotOwner }, t);
check("create appointment", !!created.appointment?.id);
const { json: completed } = await req("POST", `/appointments/${created.appointment.id}/complete`, { tip: 50, payment_method: "cash" }, t);
check("complete -> ticket", completed.appointment?.status === "completed");
const { json: empLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
-const { json: empAppt } = await req("POST", "/appointments", { service_id: 5, client_id: 2, start_at: "2026-09-20T10:00:00Z" }, empLogin.token);
-check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id);
+// Mateo offers services "Corte caballero" (5) and "Corte + arreglo de barba" (6).
+// Try 5 first (keeps the original assertion intent); fall back to 6 if 5 has no slots.
+let empSvcId = 5;
+let slotEmp = await findSlot(slug, empSvcId);
+if (!slotEmp) { empSvcId = 6; slotEmp = await findSlot(slug, empSvcId); }
+check("found slot for employee service", !!slotEmp);
+const { json: empAppt } = await req("POST", "/appointments", { service_id: empSvcId, client_id: 2, start_at: slotEmp }, empLogin.token);
+check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id, JSON.stringify(empAppt).slice(0, 150));
const { status: badLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "wrong" });
check("bad login 401", badLogin === 401);
const { status: noAuth } = await req("GET", "/employees");
diff --git a/package.json b/package.json
index 818d3bd..e10aadc 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,8 @@
"seed": "tsx server/scripts/seed.ts",
"test:e2e": "node e2e-test.mjs",
"test:admin": "node admin-test.mjs",
+ "test:unit": "node --import tsx --test server/lib/scheduling.test.ts",
+ "test:booking": "node server/scripts/booking-e2e.mjs",
"audit:visual": "node visual-audit.mjs"
},
"dependencies": {
diff --git a/server/db.ts b/server/db.ts
index 7029fcc..478105c 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -222,11 +222,46 @@ function migrateV1ToV2() {
setMeta("schema_version", "2");
}
+/** 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");
+}
+
export function runMigrations() {
// Ensure base schema exists (no-op on existing tables)
db.exec(SCHEMA);
migrateV1ToV2();
migrateV2ToV3();
+ migrateV3ToV4();
}
/** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */
diff --git a/server/routes/appointments.ts b/server/routes/appointments.ts
index c710c24..58d3562 100644
--- a/server/routes/appointments.ts
+++ b/server/routes/appointments.ts
@@ -2,6 +2,7 @@ import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
+import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
export const appointmentsRouter = Router();
@@ -114,56 +115,60 @@ appointmentsRouter.post("/", (req: AuthedRequest, res) => {
if (!clientId) return err(res, 400, "Selecciona o crea un cliente");
// Resolve employee: auto-assign to self for employees; default to first eligible for owner
- 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 eligible = db
- .prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`)
- .get(Number(service_id)) as { id: number } | undefined;
- empId = eligible?.id ?? null;
- }
- if (!empId) return err(res, 400, "No hay empleado asignado a este servicio");
-
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;
- const r = 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;
+ 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: Number(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" };
- 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, r.id, clientId, empId, Number(service_id), price);
- }
+ // 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;
- joinAppointment(r);
- res.status(201).json({ appointment: r });
+ 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;
+ }
});
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
diff --git a/server/routes/booking.ts b/server/routes/booking.ts
index a80bf75..1044127 100644
--- a/server/routes/booking.ts
+++ b/server/routes/booking.ts
@@ -1,5 +1,10 @@
import { Router } from "express";
import { db } from "../db.ts";
+import {
+ parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy,
+ pickBestSlotEmployee, autoAssign, isAvailable, runInTransaction,
+ type WorkingHoursMap,
+} from "../lib/scheduling.ts";
export const bookingRouter = Router();
@@ -7,7 +12,8 @@ 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
+ 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;
@@ -47,55 +53,75 @@ bookingRouter.get("/:slug/slots", (req, res) => {
} 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] : [];
+ const anyEmp = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
+ candidates = anyEmp ? [anyEmp.id] : [];
}
}
if (candidates.length === 0) return res.json({ slots: [] });
- // business hours 09:00ÔÇô20:00, 30-min grid; reject past times
- const dayStart = new Date(`${date}T09:00:00`);
- const dayEnd = new Date(`${date}T20:00:00`);
+ 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;
- // gather existing appointments that day for these employees
- const startOfDayIso = `${date}T00:00:00`;
- const endOfDayIso = `${date}T23:59:59`;
- const existing = db
- .prepare(
- `SELECT employee_id, start_at, end_at FROM appointments
- WHERE business_id = ? AND employee_id IN (${candidates.map(() => "?").join(",")})
- AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
- )
- .all(biz.id, ...candidates, startOfDayIso, endOfDayIso) as any[];
+ 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;
- for (let t = dayStart.getTime(); t + dur * 60000 <= dayEnd.getTime(); t += 30 * 60000) {
- if (t < now.getTime() + 30 * 60000) continue; // skip past / too-soon
+ // 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;
- // find an employee free in this window
- for (const empId of candidates) {
- const busy = existing.some(
- (b) => b.employee_id === empId && slotStart < new Date(b.end_at).getTime() && slotEnd > new Date(b.start_at).getTime()
- );
+ 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(empId) as any;
- slots.push({
- time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
- iso: new Date(t).toISOString(),
- employee_id: empId,
- employee_name: emp?.name,
- });
- break; // one free employee per slot is enough
+ 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;
+}
+
// 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);
@@ -107,48 +133,82 @@ bookingRouter.post("/:slug/book", (req, res) => {
const start = new Date(start_at);
if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });
- // resolve employee
- let empId = employee_id ? Number(employee_id) : null;
- if (!empId) {
- const cand = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`).get(Number(service_id)) as any;
- empId = cand?.id ?? null;
- }
- if (!empId) return res.status(400).json({ error: "Sin especialista disponible" });
-
- // resolve client (by phone/email or create)
- 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 startMs = start.getTime();
+ const endMs = startMs + service.duration_min * 60000;
- const endIso = new Date(start.getTime() + service.duration_min * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
- const startIso = start.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;
-
- res.status(201).json({
- appointment: {
- id: appt.id,
- start_at: appt.start_at,
- end_at: appt.end_at,
- price: appt.price,
- service_name: service.name,
- employee_name: (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name,
- },
- business: { name: biz.name, currency_symbol: biz.currency_symbol },
- client_created: !match,
- });
+ // ┬┐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;
+ }
});
diff --git a/server/routes/employees.ts b/server/routes/employees.ts
index abd5e77..00468de 100644
--- a/server/routes/employees.ts
+++ b/server/routes/employees.ts
@@ -73,11 +73,12 @@ employeesRouter.get("/:id", (req: AuthedRequest, res) => {
});
employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
- const { name, role, email, phone, color, service_ids } = req.body ?? {};
+ 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) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
+ `INSERT INTO employees (business_id, name, role, email, phone, color, specialties, working_hours, efficiency_score)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
@@ -85,7 +86,10 @@ employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
role || "Especialista",
email || null,
phone || null,
- color || "#3b66ff"
+ 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 (?, ?)`);
@@ -98,11 +102,9 @@ employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
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;
+ 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 } = req.body ?? {};
+ 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,
@@ -110,10 +112,17 @@ employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
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 = ? WHERE id = ?`
- ).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, id);
+ `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 (?, ?)`);
diff --git a/server/routes/settings.ts b/server/routes/settings.ts
index 37483b4..c571d2c 100644
--- a/server/routes/settings.ts
+++ b/server/routes/settings.ts
@@ -17,13 +17,16 @@ const FIELDS = [
"require_deposit",
"deposit_pct",
"timezone",
+ "auto_assign_specialist",
+ "working_hours",
] as const;
settingsRouter.get("/", (req: AuthedRequest, res) => {
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
+ 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;
@@ -39,6 +42,12 @@ settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
for (const f of FIELDS) {
if (body[f] !== undefined) merged[f] = body[f];
}
+ 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;
+ }
// slug uniqueness check
if (merged.slug !== undefined) {
merged.slug = String(merged.slug).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
@@ -51,7 +60,7 @@ settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
merged.id = req.user!.business_id;
db.prepare(`UPDATE businesses SET ${sets.join(", ")} WHERE id = @id`).run(merged);
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 FROM businesses WHERE id = ?`)
+ .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);
res.json({ settings: updated });
});
diff --git a/shared/types.ts b/shared/types.ts
index 7998658..67ec739 100644
--- a/shared/types.ts
+++ b/shared/types.ts
@@ -14,9 +14,20 @@ export interface Business {
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;
}
+export type WorkingDay = { start: string; end: string };
+export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun  7=Dom
+
export interface User {
id: number;
business_id: number | null;
@@ -39,6 +50,9 @@ export interface Employee {
rating: number;
hire_date: string | null;
service_ids?: number[];
+ specialties?: string[];
+ working_hours?: WorkingHoursMap | string | null;
+ efficiency_score?: number;
stats?: EmployeeStats;
}
diff --git a/src/index.css b/src/index.css
index f562a6f..8e4f89e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -136,6 +136,7 @@ body {
font-weight: 600 !important;
cursor: pointer;
transition: transform 0.12s, box-shadow 0.12s;
+ overflow: hidden;
}
.fc-event:hover {
transform: translateY(-1px);
@@ -149,6 +150,16 @@ body {
}
.fc-timegrid-event {
overflow: hidden;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
+}
+.fc-timegrid-event-harness {
+ margin-right: 2px !important;
+}
+/* When slotEventOverlap is false, FullCalendar puts a small gap between
+ side-by-side events; tighten it a touch so each event gets more readable
+ width without bleeding into its neighbour. */
+.fc .fc-timegrid-col-events {
+ column-gap: 2px;
}
/* List view (great on mobile) */
@@ -258,24 +269,26 @@ body {
background: #fecaca;
}
-.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);
+@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);
+ }
}
.label {
display: block;
diff --git a/src/lib/publicApi.ts b/src/lib/publicApi.ts
index aba501c..04f5503 100644
--- a/src/lib/publicApi.ts
+++ b/src/lib/publicApi.ts
@@ -12,6 +12,7 @@ export interface PublicBusiness {
cancel_penalty_pct: number;
require_deposit: boolean;
deposit_pct: number;
+ auto_assign_specialist: number | boolean;
}
export interface PublicService {
@@ -58,7 +59,7 @@ export interface BookClient {
export interface BookPayload {
service_id: number;
- employee_id: number;
+ employee_id?: number;
start_at: string;
client: BookClient;
}
@@ -69,7 +70,9 @@ export interface BookResponse {
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;
diff --git a/src/pages/EmployeesPage.tsx b/src/pages/EmployeesPage.tsx
index 2d94462..dc41bfc 100644
--- a/src/pages/EmployeesPage.tsx
+++ b/src/pages/EmployeesPage.tsx
@@ -16,6 +16,7 @@ import type { Employee } from "../../shared/types";
import { PageHeader, Avatar, Spinner, EmptyState } from "../components/ui";
import { Modal } from "../components/Modal";
import { formatCurrency, cn } from "../lib/format";
+import { DEFAULT_WH, DAYS, parseWh } from "../lib/workingHours";
const COLORS = ["#3b66ff", "#f17616", "#10b981", "#a855f7", "#ec4899", "#14b8a6", "#f59e0b", "#6366f1"];
@@ -193,6 +194,11 @@ function EmployeeModal({
const [color, setColor] = useState(COLORS[0]);
const [active, setActive] = useState(true);
const [svcIds, setSvcIds] = useState<number[]>([]);
+ 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 });
useEffect(() => {
if (!open) return;
@@ -204,6 +210,12 @@ function EmployeeModal({
setColor(employee.color);
setActive(!!employee.active);
setSvcIds(employee.service_ids ?? []);
+ 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 });
} else {
setName("");
setRole("Especialista");
@@ -212,12 +224,28 @@ function EmployeeModal({
setColor(COLORS[Math.floor(Math.random() * COLORS.length)]);
setActive(true);
setSvcIds([]);
+ setSpecialties([]);
+ setSpecialtyInput("");
+ setEfficiency(50);
+ setInheritHours(true);
+ setWh({ ...DEFAULT_WH });
}
}, [open, employee]);
const mut = useMutation({
mutationFn: async () => {
- const payload = { name, role, email, phone, color, active, service_ids: svcIds };
+ const payload = {
+ name,
+ role,
+ email,
+ phone,
+ color,
+ active,
+ service_ids: svcIds,
+ specialties,
+ efficiency_score: efficiency,
+ working_hours: inheritHours ? null : wh,
+ };
if (employee) return api.employees.update(employee.id, payload);
return api.employees.create(payload);
},
@@ -303,6 +331,107 @@ function EmployeeModal({
})}
</div>
</div>
+ <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 { start: string; end: string }), 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 { start: string; end: string }), end: e.target.value } })
+ }
+ />
+ </div>
+ ) : (
+ <span className="text-xs font-medium text-slate-400">Cerrado</span>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Activo (puede recibir citas)
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 519a50c..c14236c 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -10,9 +10,12 @@ import {
Copy,
Check,
ExternalLink,
+ Wand2,
+ Clock,
} from "lucide-react";
import { api } from "../lib/api";
import { PageHeader, Spinner } from "../components/ui";
+import { DEFAULT_WH, DAYS, parseWh } from "../lib/workingHours";
export function SettingsPage() {
const qc = useQueryClient();
@@ -20,9 +23,10 @@ export function SettingsPage() {
const [f, setF] = useState<any>(null);
const [copied, setCopied] = useState(false);
const [saved, setSaved] = useState(false);
+ const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });
useEffect(() => {
- if (data?.settings) setF(data.settings);
+ if (data?.settings) { setF(data.settings); setWh(parseWh(data.settings.working_hours)); }
}, [data]);
const mut = useMutation({
@@ -39,6 +43,8 @@ export function SettingsPage() {
require_deposit: f.require_deposit ? 1 : 0,
deposit_pct: Number(f.deposit_pct),
timezone: f.timezone,
+ auto_assign_specialist: f.auto_assign_specialist ? 1 : 0,
+ working_hours: wh,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["settings"] });
@@ -97,6 +103,19 @@ export function SettingsPage() {
<input className="input" value={f.slug} onChange={(e) => set("slug", e.target.value)} placeholder="mi-negocio" />
</div>
</div>
+ <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>
</div>
</div>
@@ -175,3 +194,35 @@ function SectionTitle({ icon: Icon, color, title, subtitle }: { icon: any; color
</div>
);
}
+
+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>
+ );
+}
diff --git a/src/pages/public/BookingPage.tsx b/src/pages/public/BookingPage.tsx
index 947a5b8..1427d00 100644
--- a/src/pages/public/BookingPage.tsx
+++ b/src/pages/public/BookingPage.tsx
@@ -25,6 +25,7 @@ import {
type BookResponse,
} from "../../lib/publicApi";
import { Spinner } from "../../components/ui";
+import { MonthCalendar } from "../../components/MonthCalendar";
import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format";
function todayStr() {
@@ -33,12 +34,25 @@ function todayStr() {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
-const STEPS = [
+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())}`;
+}
+
+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 }));
+}
export default function BookingPage() {
const { slug } = useParams<{ slug: string }>();
@@ -63,6 +77,11 @@ export default function BookingPage() {
const services = data?.services ?? [];
const employees = data?.employees ?? [];
+ const autoAssign = !!business?.auto_assign_specialist;
+ const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]);
+ const stepCount = steps.length;
+ const currentOriginal = steps.find((s) => s.n === step)?.original;
+
const selectedService = useMemo<PublicService | undefined>(
() => services.find((s) => s.id === serviceId),
[services, serviceId]
@@ -71,7 +90,7 @@ export default function BookingPage() {
const slotsQuery = useQuery({
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
- enabled: step >= 3 && !!serviceId && !!selectedDate && !result,
+ enabled: currentOriginal === 3 && !!serviceId && !!selectedDate && !result,
});
const slots = slotsQuery.data?.slots ?? [];
@@ -130,7 +149,7 @@ export default function BookingPage() {
if (!business.booking_enabled) {
return (
<div className="flex min-h-screen flex-col bg-[#f6f7fb]">
- <TopBar name={business.name} industry={business.industry} step={0} />
+ <TopBar name={business.name} industry={business.industry} step={0} stepCount={4} steps={[]} />
<main className="flex flex-1 items-center justify-center px-5 py-10">
<div className="card w-full max-w-md p-8 text-center shadow-card animate-slide-up">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600">
@@ -151,7 +170,7 @@ export default function BookingPage() {
);
}
- const goNext = () => setStep((s) => Math.min(4, s + 1));
+ const goNext = () => setStep((s) => Math.min(stepCount, s + 1));
const goBack = () => setStep((s) => Math.max(1, s - 1));
const pickService = (id: number) => {
@@ -168,7 +187,7 @@ export default function BookingPage() {
const pickSlot = (slot: PublicSlot) => {
setSelectedSlot(slot);
- setStep(4);
+ setStep((s) => Math.min(stepCount, s + 1));
};
const handleBook = () => {
@@ -176,7 +195,7 @@ export default function BookingPage() {
if (!client.name.trim()) return;
bookMut.mutate({
service_id: selectedService.id,
- employee_id: selectedSlot.employee_id,
+ employee_id: autoAssign ? undefined : selectedSlot.employee_id,
start_at: selectedSlot.iso,
client: {
name: client.name.trim(),
@@ -188,26 +207,27 @@ export default function BookingPage() {
};
const canContinue =
- (step === 1 && !!serviceId) ||
- step === 2 ||
- (step === 3 && !!selectedSlot) ||
- (step === 4 && client.name.trim().length > 0);
+ (currentOriginal === 1 && !!serviceId) ||
+ currentOriginal === 2 ||
+ (currentOriginal === 3 && !!selectedSlot) ||
+ (currentOriginal === 4 && client.name.trim().length > 0);
const currency = business.currency_symbol || "$";
- const primaryLabel = step === 4 ? "Confirmar reserva" : "Continuar";
- const onPrimary = step === 4 ? handleBook : goNext;
+ const primaryLabel = currentOriginal === 4 ? "Confirmar reserva" : "Continuar";
+ const onPrimary = currentOriginal === 4 ? handleBook : goNext;
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} />
+ <TopBar name={business.name} industry={business.industry} step={step} stepCount={stepCount} steps={steps} />
- <main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28">
- {step === 1 && (
- <Hero business={business} />
- )}
+ <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">
- {step === 1 && (
+ {currentOriginal === 1 && (
<StepShell
icon={<Sparkles className="h-4 w-4" />}
title="Elige un servicio"
@@ -222,7 +242,7 @@ export default function BookingPage() {
</StepShell>
)}
- {step === 2 && (
+ {currentOriginal === 2 && (
<StepShell
icon={<User className="h-4 w-4" />}
title="Elige un especialista"
@@ -236,7 +256,7 @@ export default function BookingPage() {
</StepShell>
)}
- {step === 3 && selectedService && (
+ {currentOriginal === 3 && selectedService && (
<StepShell
icon={<CalendarDays className="h-4 w-4" />}
title="Elige fecha y hora"
@@ -249,6 +269,7 @@ export default function BookingPage() {
setSelectedSlot(null);
}}
min={todayStr()}
+ max={maxBookableDate()}
slots={slots}
loading={slotsQuery.isLoading}
isError={slotsQuery.isError}
@@ -258,7 +279,7 @@ export default function BookingPage() {
</StepShell>
)}
- {step === 4 && selectedService && selectedSlot && (
+ {currentOriginal === 4 && selectedService && selectedSlot && (
<StepShell
icon={<CalendarCheck className="h-4 w-4" />}
title="Tus datos"
@@ -272,7 +293,7 @@ export default function BookingPage() {
<SummaryRow label="Servicio" value={selectedService.name} />
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
- <SummaryRow label="Especialista" value={selectedSlot.employee_name} />
+ {!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
</>
}
/>
@@ -284,6 +305,7 @@ export default function BookingPage() {
<ActionBar
hidden={!!result}
step={step}
+ stepCount={stepCount}
goBack={goBack}
onPrimary={onPrimary}
primaryLabel={primaryLabel}
@@ -301,11 +323,11 @@ export default function BookingPage() {
);
}
-function TopBar({ name, industry, step }: { name: string; industry: string; step: number }) {
- const current = STEPS.find((s) => s.n === step);
+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-3xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
+ <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" />
@@ -319,10 +341,10 @@ function TopBar({ name, industry, step }: { name: string; industry: string; step
{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 4
+ Paso {step} de {stepCount}
</span>
<div className="hidden items-center gap-1 md:flex">
- {STEPS.map((s) => (
+ {steps.map((s) => (
<div
key={s.n}
className={cn(
@@ -342,7 +364,7 @@ function TopBar({ name, industry, step }: { name: string; industry: string; step
<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">4</span>
+ <span className="text-brand-400">{stepCount}</span>
<span className="text-brand-300">┬À</span>
<span>{current?.label}</span>
</div>
@@ -556,6 +578,7 @@ function DateTimePicker({
date,
onDate,
min,
+ max,
slots,
loading,
isError,
@@ -565,28 +588,42 @@ function DateTimePicker({
date: string;
onDate: (d: string) => void;
min: string;
+ max?: string;
slots: PublicSlot[];
loading: boolean;
isError: boolean;
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
}) {
+ 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="space-y-4">
- <div>
+ <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>
- <input
- type="date"
- className="input"
- value={date}
- min={min}
- onChange={(e) => e.target.value && onDate(e.target.value)}
- />
+ <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>
- <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>
@@ -597,14 +634,12 @@ function DateTimePicker({
<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" />
@@ -612,27 +647,10 @@ function DateTimePicker({
<p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
</div>
)}
-
{!loading && !isError && slots.length > 0 && (
- <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 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>
@@ -641,6 +659,48 @@ function DateTimePicker({
);
}
+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>
+ );
+}
+
function DetailsForm({
client,
setClient,
@@ -661,7 +721,7 @@ function DetailsForm({
<div className="relative">
<User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
- className="input pl-9"
+ className="input pl-10"
value={client.name}
onChange={(e) => setClient({ ...client, name: e.target.value })}
placeholder="Tu nombre"
@@ -675,7 +735,7 @@ function DetailsForm({
<div className="relative">
<Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
- className="input pl-9"
+ className="input pl-10"
inputMode="tel"
value={client.phone}
onChange={(e) => setClient({ ...client, phone: e.target.value })}
@@ -689,7 +749,7 @@ function DetailsForm({
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="email"
- className="input pl-9"
+ className="input pl-10"
value={client.email}
onChange={(e) => setClient({ ...client, email: e.target.value })}
placeholder="[email protected]"
@@ -725,6 +785,7 @@ function SummaryRow({ label, value }: { label: string; value: string }) {
function ActionBar({
hidden,
step,
+ stepCount,
goBack,
onPrimary,
primaryLabel,
@@ -736,6 +797,7 @@ function ActionBar({
}: {
hidden: boolean;
step: number;
+ stepCount: number;
goBack: () => void;
onPrimary: () => void;
primaryLabel: string;
@@ -748,7 +810,7 @@ function ActionBar({
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-2xl items-center gap-3 px-4 py-3 sm:px-6">
+ <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>
@@ -764,7 +826,7 @@ function ActionBar({
</>
) : (
<div className="text-sm font-medium text-slate-400">
- {step === 1 ? "Selecciona un servicio" : "Contin├║a para reservar"}
+ {step === 1 ? "Selecciona un servicio" : `Paso ${step} de ${stepCount} ┬À Contin├║a para reservar`}
</div>
)}
</div>
@@ -802,7 +864,7 @@ function Confirmation({
const currency = result.business.currency_symbol || "$";
return (
<div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/60 to-[#f6f7fb]">
- <TopBar name={businessName} industry="Reserva confirmada" step={0} />
+ <TopBar name={businessName} industry="Reserva confirmada" step={0} stepCount={4} steps={[]} />
<main className="flex flex-1 items-center justify-center px-5 py-10">
<div className="w-full max-w-md animate-slide-up">
<div className="card overflow-hidden p-0 text-center shadow-card">
@@ -825,6 +887,17 @@ function Confirmation({
<SummaryRow label="Hora" value={formatTime(appointment.start_at)} />
<div className="h-px bg-slate-100" />
<SummaryRow label="Especialista" value={appointment.employee_name} />
+ {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>
+ )}
<div className="h-px bg-slate-100" />
<div className="flex items-center justify-between gap-3 px-3 py-3">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">Total</span>