Multi-tenant scheduling SaaS (AgendaPro-equivalent): - Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee), versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank). - Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop, Recharts dashboard, mobile-first (day/list views on mobile). - Features: calendar+services+employees+clients+tickets, dashboard with best employee/service, top tickets, top/frequent clients, commissions, cancellation policy + no-show tracking, public online booking (/b/:slug), cash register (cierre de caja), reminders/notifications center. - Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors), typecheck clean, vite build OK.
155 lines
7.3 KiB
TypeScript
155 lines
7.3 KiB
TypeScript
import { Router } from "express";
|
||
import { db } from "../db.ts";
|
||
|
||
export const bookingRouter = Router();
|
||
|
||
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
|
||
FROM businesses WHERE slug = ? AND status = 'active'`
|
||
)
|
||
.get(slug) as any;
|
||
}
|
||
|
||
// Public business info + services + employees
|
||
bookingRouter.get("/:slug", (req, res) => {
|
||
const biz = publicBusiness(req.params.slug);
|
||
if (!biz) return res.status(404).json({ error: "Negocio no encontrado" });
|
||
if (!biz.booking_enabled) return res.status(403).json({ error: "Las reservas online están desactivadas" });
|
||
const services = db
|
||
.prepare(`SELECT id, name, description, category, duration_min, price, color FROM services WHERE business_id = ? AND active = 1 ORDER BY category, name`)
|
||
.all(biz.id);
|
||
const employees = db
|
||
.prepare(`SELECT id, name, role, color FROM employees WHERE business_id = ? AND active = 1 ORDER BY name`)
|
||
.all(biz.id);
|
||
res.json({ business: { ...biz, currency_symbol: biz.currency_symbol || "$" }, services, employees });
|
||
});
|
||
|
||
// Available slots for a service + employee on a given date
|
||
bookingRouter.get("/:slug/slots", (req, res) => {
|
||
const biz = publicBusiness(req.params.slug);
|
||
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
|
||
const serviceId = Number(req.query.service_id);
|
||
const employeeId = req.query.employee_id ? Number(req.query.employee_id) : null;
|
||
const date = req.query.date as string; // YYYY-MM-DD
|
||
if (!serviceId || !date) return res.status(400).json({ error: "service_id y date son obligatorios" });
|
||
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(serviceId, biz.id) as any;
|
||
if (!service) return res.status(400).json({ error: "Servicio no válido" });
|
||
|
||
// candidate employees
|
||
let candidates: number[];
|
||
if (employeeId) {
|
||
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(serviceId, employeeId);
|
||
if (!ok) return res.status(400).json({ error: "El especialista no ofrece este servicio" });
|
||
candidates = [employeeId];
|
||
} else {
|
||
candidates = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ?`).all(serviceId).map((r: any) => r.id);
|
||
if (candidates.length === 0) {
|
||
const any = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
|
||
candidates = any ? [any.id] : [];
|
||
}
|
||
}
|
||
if (candidates.length === 0) return res.json({ slots: [] });
|
||
|
||
// 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 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[];
|
||
|
||
for (let t = dayStart.getTime(); t + dur * 60000 <= dayEnd.getTime(); t += 30 * 60000) {
|
||
if (t < now.getTime() + 30 * 60000) continue; // skip past / too-soon
|
||
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()
|
||
);
|
||
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
|
||
}
|
||
}
|
||
if (slots.length >= 40) break;
|
||
}
|
||
res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
|
||
});
|
||
|
||
// Create a booking from the public site (no auth): creates/finds client + appointment
|
||
bookingRouter.post("/:slug/book", (req, res) => {
|
||
const biz = publicBusiness(req.params.slug);
|
||
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
|
||
const { service_id, employee_id, start_at, client } = req.body ?? {};
|
||
if (!service_id || !start_at || !client?.name) return res.status(400).json({ error: "Faltan datos (servicio, hora o nombre)" });
|
||
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(Number(service_id), biz.id) as any;
|
||
if (!service) return res.status(400).json({ error: "Servicio no válido" });
|
||
const start = new Date(start_at);
|
||
if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });
|
||
|
||
// 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 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,
|
||
});
|
||
});
|