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: "owner@agendapro.demo", 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 === "owner@agendapro.demo", 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: "mateo.herrera@lumiere.mx", 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: "x@x.com", 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; // 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([]); + const [specialties, setSpecialties] = useState([]); + const [specialtyInput, setSpecialtyInput] = useState(""); + const [efficiency, setEfficiency] = useState(50); + const [inheritHours, setInheritHours] = useState(true); + const [wh, setWh] = useState>({ ...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({ })} +
+ +
+ {specialties.map((sp) => ( + + {sp} + + + ))} + setSpecialtyInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && specialtyInput.trim()) { + e.preventDefault(); + setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]); + setSpecialtyInput(""); + } + }} + /> +
+

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

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

Qu├® tan r├ípido/h├íbil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.

+
+
+ + {!inheritHours && ( +
+ {DAYS.map(({ n, label }) => { + const on = !!wh[n]; + return ( +
+ + {on ? ( +
+ + setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), start: e.target.value } }) + } + /> + a + + setWh({ ...wh, [n]: { ...(wh[n] as { start: string; end: string }), end: e.target.value } }) + } + /> +
+ ) : ( + Cerrado + )} +
+ ); + })} +
+ )} +