AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking

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.
This commit is contained in:
AgendaPro Dev
2026-07-25 13:45:53 -06:00
commit e8d2435cc2
63 changed files with 16539 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
import { Router } from "express";
import { db } from "../db.ts";
import { adminOnly, err, type AuthedRequest } from "../lib/auth.ts";
import { TEMPLATES, getTemplate, type TemplateDef } from "../lib/templates.ts";
import { seedBusiness, clearBusinessData } from "../scripts/seed.ts";
export const adminRouter = Router();
adminRouter.use(adminOnly);
function businessStats(businessId: number) {
const scalar = (sql: string) => (db.prepare(sql).get(businessId) as any)?.v ?? 0;
return {
users: scalar(`SELECT COUNT(*) v FROM users WHERE business_id = ?`),
employees: scalar(`SELECT COUNT(*) v FROM employees WHERE business_id = ?`),
services: scalar(`SELECT COUNT(*) v FROM services WHERE business_id = ?`),
clients: scalar(`SELECT COUNT(*) v FROM clients WHERE business_id = ?`),
appointments: scalar(`SELECT COUNT(*) v FROM appointments WHERE business_id = ?`),
appointments_upcoming: scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status='scheduled' AND start_at >= datetime('now')`
),
revenue_30d: scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`
),
tickets: scalar(`SELECT COUNT(*) v FROM tickets WHERE business_id = ?`),
};
}
function businessRow(id: number) {
return db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at
FROM businesses WHERE id = ?`
)
.get(id) as any;
}
// Platform overview
adminRouter.get("/overview", (_req: AuthedRequest, res) => {
const scalar = (sql: string) => (db.prepare(sql).get() as any)?.v ?? 0;
res.json({
businesses: scalar(`SELECT COUNT(*) v FROM businesses`),
active_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE status='active'`),
trial_businesses: scalar(`SELECT COUNT(*) v FROM businesses WHERE plan='trial'`),
total_users: scalar(`SELECT COUNT(*) v FROM users WHERE role IN ('owner','employee')`),
total_appointments: scalar(`SELECT COUNT(*) v FROM appointments`),
revenue_30d: scalar(`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE created_at >= datetime('now','-30 days')`),
upcoming_appointments: scalar(
`SELECT COUNT(*) v FROM appointments WHERE status='scheduled' AND start_at >= datetime('now')`
),
templates: TEMPLATES.length,
});
});
// List templates
adminRouter.get("/templates", (_req: AuthedRequest, res) => {
const out = TEMPLATES.map((t) => ({
key: t.key,
name: t.name,
industry: t.industry,
description: t.description,
currency: t.currency,
currency_symbol: t.currency_symbol,
employees: t.employees.length,
services: t.services.length,
clients: t.clients.length,
}));
res.json({ templates: out });
});
// List all businesses with stats
adminRouter.get("/businesses", (_req: AuthedRequest, res) => {
const rows = db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, plan, status, template, trial_ends_at, created_at
FROM businesses ORDER BY created_at DESC`
)
.all() as any[];
const out = rows.map((b) => ({ business: b, stats: businessStats(b.id) }));
res.json({ businesses: out });
});
// Business detail
adminRouter.get("/businesses/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const b = businessRow(id);
if (!b) return err(res, 404, "Negocio no encontrado");
res.json({ business: b, stats: businessStats(id) });
});
// Create business + owner + template seed
adminRouter.post("/businesses", (req: AuthedRequest, res) => {
const { name, industry, template, ownerName, ownerEmail, ownerPassword, plan, currency, currency_symbol } = req.body ?? {};
if (!name) return err(res, 400, "El nombre del negocio es obligatorio");
if (!ownerEmail) return err(res, 400, "El correo del dueño es obligatorio");
const tpl: TemplateDef | undefined = template ? getTemplate(template) : getTemplate("blank");
if (!tpl) return err(res, 400, "Plantilla no válida");
const email = String(ownerEmail).toLowerCase().trim();
const existing = db.prepare(`SELECT id FROM users WHERE email = ?`).get(email);
if (existing) return err(res, 409, "Ya existe un usuario con ese correo");
const biz = db
.prepare(
`INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template)
VALUES (?, ?, ?, ?, ?, 'active', ?) RETURNING id`
)
.get(
name,
industry || tpl.industry,
currency || tpl.currency || "MXN",
currency_symbol || tpl.currency_symbol || "$",
plan || "trial",
tpl.key
) as { id: number };
const result = seedBusiness({
businessId: biz.id,
template: tpl,
ownerEmail: email,
ownerName: ownerName || "Dueño",
ownerPassword: ownerPassword || "demo1234",
withHistory: tpl.key !== "blank",
withUpcoming: tpl.key !== "blank",
});
res.status(201).json({
business: businessRow(biz.id),
stats: businessStats(biz.id),
seeded: result,
message: `Negocio "${name}" creado con plantilla "${tpl.name}"`,
});
});
// Update business
adminRouter.patch("/businesses/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = businessRow(id);
if (!existing) return err(res, 404, "Negocio no encontrado");
const { name, industry, phone, address, slug, plan, status, trial_ends_at } = req.body ?? {};
db.prepare(
`UPDATE businesses SET name=?, industry=?, phone=?, address=?, slug=?, plan=?, status=?, trial_ends_at=? WHERE id=?`
).run(
name ?? existing.name,
industry ?? existing.industry,
phone ?? existing.phone,
address ?? existing.address,
slug ?? existing.slug,
plan ?? existing.plan,
status ?? existing.status,
trial_ends_at ?? existing.trial_ends_at,
id
);
res.json({ business: businessRow(id), stats: businessStats(id) });
});
// Delete business + all its data
adminRouter.delete("/businesses/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = businessRow(id);
if (!existing) return err(res, 404, "Negocio no encontrado");
clearBusinessData(id);
// clearBusinessData preserves user accounts; a full delete removes them too
db.prepare(`DELETE FROM users WHERE business_id = ?`).run(id);
db.prepare(`DELETE FROM businesses WHERE id = ?`).run(id);
res.json({ ok: true });
});
// Load template data into an existing business (clearFirst optional)
adminRouter.post("/businesses/:id/seed-template", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const b = businessRow(id);
if (!b) return err(res, 404, "Negocio no encontrado");
const { template, clearFirst = true, ownerEmail, ownerName, ownerPassword, withHistory = true, withUpcoming = true } = req.body ?? {};
const tpl: TemplateDef | undefined = template ? getTemplate(template) : b.template ? getTemplate(b.template) : getTemplate("blank");
if (!tpl) return err(res, 400, "Plantilla no válida");
const result = seedBusiness({
businessId: id,
template: tpl,
ownerEmail: ownerEmail ?? (db.prepare(`SELECT email FROM users WHERE business_id=? AND role='owner'`).get(id) as any)?.email,
ownerName: ownerName ?? "Dueño",
ownerPassword: ownerPassword ?? "demo1234",
clearFirst: clearFirst !== false,
withHistory,
withUpcoming,
});
res.json({ business: businessRow(id), stats: businessStats(id), seeded: result });
});
// Reset demo data (clear + reseed with current template)
adminRouter.post("/businesses/:id/reset-demo", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const b = businessRow(id);
if (!b) return err(res, 404, "Negocio no encontrado");
const tplKey = req.body?.template || b.template || "blank";
const tpl = getTemplate(tplKey);
if (!tpl) return err(res, 400, "Plantilla no válida");
const owner = db.prepare(`SELECT email, name FROM users WHERE business_id=? AND role='owner'`).get(id) as any;
const result = seedBusiness({
businessId: id,
template: tpl,
ownerEmail: owner?.email,
ownerName: owner?.name ?? "Dueño",
clearFirst: true,
withHistory: tpl.key !== "blank",
withUpcoming: tpl.key !== "blank",
});
res.json({ business: businessRow(id), stats: businessStats(id), seeded: result });
});
+303
View File
@@ -0,0 +1,303 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
export const appointmentsRouter = Router();
/** Compute commission for a ticket: service rate, fallback employee rate. */
function computeCommission(businessId: number, serviceId: number, employeeId: number, amount: number): number {
const svc = db.prepare(`SELECT commission_pct p FROM services WHERE id = ? AND business_id = ?`).get(serviceId, businessId) as { p: number } | undefined;
let pct = svc?.p ?? 0;
if (!pct) {
const emp = db.prepare(`SELECT commission_pct p FROM employees WHERE id = ? AND business_id = ?`).get(employeeId, businessId) as { p: number } | undefined;
pct = emp?.p ?? 0;
}
return Math.round(((amount * pct) / 100) * 100) / 100;
}
function joinAppointment(a: any) {
if (!a) return a;
a.service = db
.prepare(`SELECT id, name, category, color, duration_min, price FROM services WHERE id = ?`)
.get(a.service_id);
a.employee = db
.prepare(`SELECT id, name, color, role FROM employees WHERE id = ?`)
.get(a.employee_id);
a.client = db
.prepare(`SELECT id, name, phone, email, tags FROM clients WHERE id = ?`)
.get(a.client_id);
return a;
}
appointmentsRouter.get("/", (req: AuthedRequest, res) => {
const { from, to, employee_id, client_id, status, limit } = req.query;
const conds: string[] = ["business_id = ?"];
const args: any[] = [req.user!.business_id];
if (from) {
conds.push("start_at >= ?");
args.push(String(from));
}
if (to) {
conds.push("start_at <= ?");
args.push(String(to));
}
if (employee_id) {
conds.push("employee_id = ?");
args.push(Number(employee_id));
}
if (client_id) {
conds.push("client_id = ?");
args.push(Number(client_id));
}
if (status) {
conds.push("status = ?");
args.push(String(status));
}
// Employees only see their own appointments (still can see all to plan, but filter by default)
// We let them see all so they can coordinate; UI filters by default.
let sql = `SELECT * FROM appointments WHERE ${conds.join(" AND ")} ORDER BY start_at`;
if (limit) sql += ` LIMIT ${Number(limit)}`;
const rows = db.prepare(sql).all(...args) as any[];
rows.forEach(joinAppointment);
res.json({ appointments: rows });
});
appointmentsRouter.get("/:id", (req: AuthedRequest, res) => {
const a = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(Number(req.params.id), req.user!.business_id) as any;
if (!a) return err(res, 404, "Cita no encontrada");
joinAppointment(a);
res.json({ appointment: a });
});
appointmentsRouter.post("/", (req: AuthedRequest, res) => {
const {
service_id,
employee_id,
client_id,
new_client,
start_at,
duration_min,
price_override,
notes,
status,
} = req.body ?? {};
if (!service_id) return err(res, 400, "Selecciona un servicio");
if (!start_at) return err(res, 400, "Selecciona una fecha y hora");
const service = db
.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ?`)
.get(Number(service_id), req.user!.business_id) as any;
if (!service) return err(res, 400, "Servicio no válido");
// Resolve client: existing id, or create a new one from new_client payload
let clientId = client_id ? Number(client_id) : null;
if (!clientId && new_client) {
if (!new_client.name) return err(res, 400, "El nombre del cliente es obligatorio");
const created = db
.prepare(
`INSERT INTO clients (business_id, name, email, phone, notes, tags) VALUES (?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(
req.user!.business_id,
new_client.name,
new_client.email || null,
new_client.phone || null,
new_client.notes || null,
new_client.tags || null
) as { id: number };
clientId = created.id;
}
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 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;
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);
}
joinAppointment(r);
res.status(201).json({ appointment: r });
});
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Cita no encontrada");
const {
service_id,
employee_id,
client_id,
start_at,
end_at,
duration_min,
status,
price,
notes,
} = req.body ?? {};
let startIso = existing.start_at;
let endIso = existing.end_at;
if (start_at) {
const start = new Date(start_at);
if (isNaN(start.getTime())) return err(res, 400, "Fecha inválida");
startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const svc = service_id
? db.prepare(`SELECT duration_min FROM services WHERE id = ?`).get(Number(service_id)) as any
: { duration_min: null };
const dur = Number(duration_min) || svc?.duration_min || null;
if (end_at) {
endIso = new Date(end_at).toISOString().replace(/\.\d{3}Z$/, "Z");
} else if (dur) {
endIso = new Date(start.getTime() + dur * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
}
} else if (duration_min) {
const start = new Date(existing.start_at);
endIso = new Date(start.getTime() + Number(duration_min) * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
}
const finalStatus = status ?? existing.status;
const finalPrice = price !== undefined ? Number(price) : existing.price;
db.prepare(
`UPDATE appointments
SET service_id = ?, employee_id = ?, client_id = ?, start_at = ?, end_at = ?, status = ?, price = ?, notes = ?, updated_at = datetime('now')
WHERE id = ?`
).run(
service_id !== undefined ? Number(service_id) : existing.service_id,
employee_id !== undefined ? Number(employee_id) : existing.employee_id,
client_id !== undefined ? Number(client_id) : existing.client_id,
startIso,
endIso,
finalStatus,
finalPrice,
notes !== undefined ? notes : existing.notes,
id
);
// If transitioned to completed and no ticket yet, create one
if (finalStatus === "completed" && existing.status !== "completed") {
const hasTicket = db
.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`)
.get(id);
if (!hasTicket) {
const appt = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price);
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card', ?)`
).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, commission);
}
}
const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
joinAppointment(updated);
res.json({ appointment: updated });
});
appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const appt = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!appt) return err(res, 404, "Cita no encontrada");
const { tip = 0, payment_method = "card" } = req.body ?? {};
db.prepare(`UPDATE appointments SET status = 'completed', updated_at = datetime('now') WHERE id = ?`).run(id);
const hasTicket = db.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`).get(id);
if (!hasTicket) {
const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price);
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, Number(tip), payment_method, commission);
} else {
// ticket already exists (e.g. completed via PATCH) — backfill commission + tip if provided
db.prepare(`UPDATE tickets SET commission = COALESCE(NULLIF(commission,0), ?) WHERE appointment_id = ? AND commission = 0`).run(
computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price),
id
);
}
const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
joinAppointment(updated);
res.json({ appointment: updated });
});
appointmentsRouter.delete("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id);
if (!existing) return err(res, 404, "Cita no encontrada");
db.prepare(`DELETE FROM appointments WHERE id = ?`).run(id);
res.json({ ok: true });
});
/** Preview the cancellation policy outcome for an appointment (penalty based on hours left). */
appointmentsRouter.get("/:id/cancellation-policy", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const appt = db.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`).get(id, req.user!.business_id) as any;
if (!appt) return err(res, 404, "Cita no encontrada");
const biz = db
.prepare(`SELECT cancel_window_hours wh, cancel_penalty_pct pen, require_deposit rd, deposit_pct dp FROM businesses WHERE id = ?`)
.get(req.user!.business_id) as any;
const hoursLeft = (new Date(appt.start_at).getTime() - Date.now()) / 3600000;
const withinWindow = hoursLeft < (biz?.wh ?? 24);
const penalty = withinWindow ? Math.round(((appt.price * (biz?.pen ?? 0)) / 100) * 100) / 100 : 0;
res.json({
hours_left: Math.round(hoursLeft),
within_window: withinWindow,
window_hours: biz?.wh ?? 24,
penalty_pct: withinWindow ? biz?.pen ?? 0 : 0,
penalty_amount: penalty,
policy_active: (biz?.pen ?? 0) > 0,
});
});
+32
View File
@@ -0,0 +1,32 @@
import { Router } from "express";
import { db } from "../db.ts";
import { authRequired, type AuthedRequest } from "../lib/auth.ts";
export const authRouter = Router();
authRouter.post("/login", (req, res) => {
const { email, password } = req.body ?? {};
if (!email || !password) return res.status(400).json({ error: "Faltan credenciales" });
const user = db
.prepare(`SELECT id, business_id, email, name, role, employee_id, avatar_color, password FROM users WHERE email = ?`)
.get(String(email).toLowerCase().trim()) as any;
if (!user || user.password !== password) {
return res.status(401).json({ error: "Correo o contraseña incorrectos" });
}
const { password: _pw, ...safe } = user;
res.json({ token: String(user.id), user: safe });
});
authRouter.get("/me", authRequired, (req: AuthedRequest, res) => {
res.json({ user: req.user });
});
authRouter.get("/demo-users", (_req, res) => {
const users = db
.prepare(
`SELECT email, name, role, avatar_color FROM users
ORDER BY CASE role WHEN 'admin' THEN 0 WHEN 'owner' THEN 1 ELSE 2 END, name`
)
.all();
res.json({ users });
});
+154
View File
@@ -0,0 +1,154 @@
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:0020: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,
});
});
+16
View File
@@ -0,0 +1,16 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
export const businessRouter = Router();
businessRouter.get("/", (req: AuthedRequest, res) => {
const b = db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address FROM businesses WHERE id = ?`
)
.get(req.user!.business_id);
if (!b) return err(res, 404, "Negocio no encontrado");
res.json({ business: b });
});
+119
View File
@@ -0,0 +1,119 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err, ownerOnly } from "../lib/auth.ts";
export const cashRouter = Router();
cashRouter.use(ownerOnly);
function sessionStats(sessionId: number, businessId: number) {
const row = db
.prepare(
`SELECT
COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0) income,
COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) expense,
COUNT(*) n
FROM cash_entries WHERE session_id = ? AND business_id = ?`
)
.get(sessionId, businessId) as { income: number; expense: number; n: number };
const tickets = db
.prepare(
`SELECT COALESCE(SUM(amount),0) s, COUNT(*) n FROM cash_entries WHERE session_id = ? AND business_id = ? AND ticket_id IS NOT NULL`
)
.get(sessionId, businessId) as { s: number; n: number };
return {
income: Math.round(row.income * 100) / 100,
expense: Math.round(row.expense * 100) / 100,
entries: row.n,
ticket_income: Math.round(tickets.s * 100) / 100,
ticket_count: tickets.n,
};
}
cashRouter.get("/session", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const s = db
.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open' ORDER BY opened_at DESC LIMIT 1`)
.get(bid) as any;
if (!s) return res.json({ session: null });
res.json({ session: s, stats: sessionStats(s.id, bid) });
});
cashRouter.get("/sessions", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const limit = Math.min(60, Number(req.query.limit) || 30);
const rows = db
.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? ORDER BY opened_at DESC LIMIT ?`)
.all(bid, limit) as any[];
const out = rows.map((s) => ({ session: s, stats: sessionStats(s.id, bid) }));
res.json({ sessions: out });
});
cashRouter.post("/open", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const open = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid);
if (open) return err(res, 400, "Ya hay una caja abierta — ciérrala primero");
const { opening_amount = 0, note } = req.body ?? {};
const s = db
.prepare(
`INSERT INTO cash_sessions (business_id, opened_by_user_id, opening_amount, note) VALUES (?, ?, ?, ?) RETURNING *`
)
.get(bid, req.user!.id, Number(opening_amount) || 0, note || null) as any;
res.status(201).json({ session: s, stats: sessionStats(s.id, bid) });
});
cashRouter.post("/close", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const s = db.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid) as any;
if (!s) return err(res, 400, "No hay caja abierta");
const { closing_amount, note } = req.body ?? {};
const stats = sessionStats(s.id, bid);
const expected = Number(s.opening_amount) + stats.income - stats.expense;
db.prepare(`UPDATE cash_sessions SET status='closed', closed_at=datetime('now'), closing_amount=?, expected_amount=?, note=COALESCE(?,note) WHERE id=?`).run(
Number(closing_amount) || 0,
Math.round(expected * 100) / 100,
note || null,
s.id
);
const updated = db.prepare(`SELECT * FROM cash_sessions WHERE id=?`).get(s.id) as any;
res.json({ session: updated, stats, expected: Math.round(expected * 100) / 100 });
});
cashRouter.get("/entries", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const sessionId = req.query.session_id ? Number(req.query.session_id) : null;
let rows: any[];
if (sessionId) {
rows = db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, sessionId);
} else {
const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any;
rows = s
? db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, s.id)
: [];
}
res.json({ entries: rows });
});
cashRouter.post("/entries", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any;
if (!s) return err(res, 400, "No hay caja abierta");
const { type, amount, concept, payment_method } = req.body ?? {};
if (!["income", "expense"].includes(type)) return err(res, 400, "Tipo inválido (income/expense)");
if (!amount || !concept) return err(res, 400, "Monto y concepto son obligatorios");
const r = db
.prepare(
`INSERT INTO cash_entries (business_id, session_id, type, amount, concept, payment_method) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(bid, s.id, type, Number(amount), concept, payment_method || null) as any;
res.status(201).json({ entry: r, stats: sessionStats(s.id, bid) });
});
cashRouter.delete("/entries/:id", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const id = Number(req.params.id);
const e = db.prepare(`SELECT session_id FROM cash_entries WHERE id = ? AND business_id = ?`).get(id, bid) as any;
if (!e) return err(res, 404, "Movimiento no encontrado");
db.prepare(`DELETE FROM cash_entries WHERE id = ?`).run(id);
res.json({ ok: true, stats: sessionStats(e.session_id, bid) });
});
+111
View File
@@ -0,0 +1,111 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
import type { ClientStats } from "../../shared/types.ts";
export const clientsRouter = Router();
function statsFor(clientId: number): ClientStats {
const agg = db
.prepare(
`SELECT
COUNT(*) c,
COALESCE(SUM(amount+tip),0) s,
MAX(created_at) last,
COALESCE(SUM(CASE WHEN amount > 0 THEN 1 ELSE 0 END)*1.0 / NULLIF(COUNT(*),0),0) ar
FROM tickets WHERE client_id = ?`
)
.get(clientId) as { c: number; s: number; last: string | null; ar: number };
const noShow = db
.prepare(`SELECT COUNT(*) c FROM appointments WHERE client_id = ? AND status = 'no_show'`)
.get(clientId) as { c: number };
return {
visits: agg.c,
total_spent: Math.round(agg.s * 100) / 100,
last_visit: agg.last,
avg_ticket: agg.c > 0 ? Math.round((agg.s / agg.c) * 100) / 100 : 0,
no_show_count: noShow.c,
};
}
clientsRouter.get("/", (req: AuthedRequest, res) => {
const q = (req.query.q as string | undefined)?.trim();
let rows: any[];
if (q) {
const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
rows = db
.prepare(
`SELECT * FROM clients
WHERE business_id = ? AND (name LIKE ? ESCAPE '\\' OR phone LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\')
ORDER BY name LIMIT 50`
)
.all(req.user!.business_id, like, like, like);
} else {
rows = db
.prepare(`SELECT * FROM clients WHERE business_id = ? ORDER BY name LIMIT 50`)
.all(req.user!.business_id);
}
rows.forEach((r) => (r.stats = statsFor(r.id)));
res.json({ clients: rows });
});
clientsRouter.get("/:id", (req: AuthedRequest, res) => {
const c = db
.prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`)
.get(Number(req.params.id), req.user!.business_id) as any;
if (!c) return err(res, 404, "Cliente no encontrado");
c.stats = statsFor(c.id);
res.json({ client: c });
});
clientsRouter.post("/", (req: AuthedRequest, res) => {
const { name, email, phone, notes, tags } = req.body ?? {};
if (!name) return err(res, 400, "El nombre es obligatorio");
const r = db
.prepare(
`INSERT INTO clients (business_id, name, email, phone, notes, tags) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
name,
email || null,
phone || null,
notes || null,
tags || null
) as any;
r.stats = statsFor(r.id);
res.status(201).json({ client: r });
});
clientsRouter.patch("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Cliente no encontrado");
const { name, email, phone, notes, tags } = req.body ?? {};
db.prepare(
`UPDATE clients SET name = ?, email = ?, phone = ?, notes = ?, tags = ? WHERE id = ?`
).run(
name ?? existing.name,
email ?? existing.email,
phone ?? existing.phone,
notes ?? existing.notes,
tags ?? existing.tags,
id
);
const updated = db.prepare(`SELECT * FROM clients WHERE id = ?`).get(id) as any;
updated.stats = statsFor(id);
res.json({ client: updated });
});
clientsRouter.delete("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM clients WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id);
if (!existing) return err(res, 404, "Cliente no encontrado");
db.prepare(`DELETE FROM clients WHERE id = ?`).run(id);
res.json({ ok: true });
});
+337
View File
@@ -0,0 +1,337 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { ownerOnly } from "../lib/auth.ts";
export const dashboardRouter = Router();
dashboardRouter.use(ownerOnly);
dashboardRouter.get("/overview", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0;
const revenue_today = scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND date(created_at) = date('now')`,
bid
);
const revenue_week = scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-7 days')`,
bid
);
const revenue_month = scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
bid
);
const revenue_prev = scalar(
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-60 days') AND created_at < datetime('now','-30 days')`,
bid
);
const revenue_change_pct =
revenue_prev > 0 ? Math.round(((revenue_month - revenue_prev) / revenue_prev) * 1000) / 10 : 0;
const appts_today = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND date(start_at) = date('now')`,
bid
);
const appts_week = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= datetime('now','-7 days')`,
bid
);
const appts_upcoming = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= datetime('now')`,
bid
);
const active_clients = scalar(
`SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
bid
);
const avg_ticket = scalar(
`SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
bid
);
const total_range = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
bid
);
const cancels = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status IN ('cancelled','no_show') AND created_at >= datetime('now','-30 days')`,
bid
);
const cancel_rate = total_range > 0 ? Math.round((cancels / total_range) * 1000) / 10 : 0;
const completed = scalar(
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'completed' AND created_at >= datetime('now','-30 days')`,
bid
);
const occupancy_pct = total_range > 0 ? Math.round((completed / total_range) * 100) : 0;
res.json({
revenue_today: Math.round(revenue_today * 100) / 100,
revenue_week: Math.round(revenue_week * 100) / 100,
revenue_month: Math.round(revenue_month * 100) / 100,
revenue_change_pct,
appts_today,
appts_week,
appts_upcoming,
active_clients,
avg_ticket: Math.round(avg_ticket * 100) / 100,
cancel_rate,
occupancy_pct,
});
});
dashboardRouter.get("/best-employees", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT e.id, e.name, e.color, e.role,
COUNT(t.id) appts,
COALESCE(SUM(t.amount+t.tip),0) revenue,
COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = e.id), e.rating) avg_rating
FROM employees e
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE e.business_id = ?
GROUP BY e.id
ORDER BY revenue DESC`
)
.all(bid) as any[];
const totalAppts = rows.reduce((a, r) => a + r.appts, 0);
const out = rows.map((r) => ({
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
appointments: r.appts,
revenue: Math.round(r.revenue * 100) / 100,
avg_rating: Math.round(r.avg_rating * 10) / 10,
utilization_pct: totalAppts > 0 ? Math.round((r.appts / totalAppts) * 100) : 0,
}));
res.json({ employees: out });
});
dashboardRouter.get("/best-services", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT s.id, s.name, s.category, s.color,
COUNT(t.id) count,
COALESCE(SUM(t.amount+t.tip),0) revenue
FROM services s
LEFT JOIN tickets t ON t.service_id = s.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE s.business_id = ?
GROUP BY s.id
ORDER BY revenue DESC`
)
.all(bid) as any[];
const out = rows.map((r) => ({
service: { id: r.id, name: r.name, category: r.category, color: r.color },
count: r.count,
revenue: Math.round(r.revenue * 100) / 100,
}));
res.json({ services: out });
});
dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const limit = Math.min(50, Number(req.query.limit) || 10);
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT t.*, s.name service_name, s.category service_category, s.color service_color,
e.name employee_name, e.color employee_color,
c.name client_name
FROM tickets t
JOIN services s ON s.id = t.service_id
JOIN employees e ON e.id = t.employee_id
JOIN clients c ON c.id = t.client_id
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
ORDER BY (t.amount + t.tip) DESC
LIMIT ?`
)
.all(bid, limit) as any[];
const out = rows.map((t) => ({
ticket: {
id: t.id,
business_id: t.business_id,
appointment_id: t.appointment_id,
client_id: t.client_id,
employee_id: t.employee_id,
service_id: t.service_id,
amount: t.amount,
tip: t.tip,
payment_method: t.payment_method,
created_at: t.created_at,
service: { id: t.service_id, name: t.service_name, category: t.service_category, color: t.service_color },
employee: { id: t.employee_id, name: t.employee_name, color: t.employee_color },
client: { id: t.client_id, name: t.client_name },
},
}));
res.json({ tickets: out });
});
dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const limit = Math.min(50, Number(req.query.limit) || 10);
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT c.id, c.name, c.tags, c.phone,
COUNT(t.id) visits,
COALESCE(SUM(t.amount+t.tip),0) total,
MAX(t.created_at) last_visit
FROM clients c
LEFT JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE c.business_id = ?
GROUP BY c.id
HAVING visits > 0
ORDER BY total DESC
LIMIT ?`
)
.all(bid, limit) as any[];
const out = rows.map((r) => ({
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
visits: r.visits,
total_spent: Math.round(r.total * 100) / 100,
last_visit: r.last_visit,
}));
res.json({ clients: out });
});
dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const limit = Math.min(50, Number(req.query.limit) || 10);
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT c.id, c.name, c.tags, c.phone,
COUNT(t.id) visits,
COALESCE(SUM(t.amount+t.tip),0) total,
MAX(t.created_at) last_visit
FROM clients c
JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE c.business_id = ?
GROUP BY c.id
ORDER BY visits DESC, total DESC
LIMIT ?`
)
.all(bid, limit) as any[];
const out = rows.map((r) => ({
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
visits: r.visits,
total_spent: Math.round(r.total * 100) / 100,
last_visit: r.last_visit,
}));
res.json({ clients: out });
});
dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const days = Math.min(120, Number(req.query.days) || 30);
const rows = db
.prepare(
`SELECT date(created_at) date, COALESCE(SUM(amount+tip),0) revenue, COUNT(*) appts
FROM tickets
WHERE business_id = ? AND created_at >= datetime('now','-${days} days')
GROUP BY date(created_at)
ORDER BY date ASC`
)
.all(bid) as any[];
// Fill missing days with 0
const map = new Map(rows.map((r) => [r.date, r]));
const out: { date: string; revenue: number; appts: number }[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const key = d.toISOString().slice(0, 10);
const row = map.get(key);
out.push({
date: key,
revenue: row ? Math.round(row.revenue * 100) / 100 : 0,
appts: row ? row.appts : 0,
});
}
res.json({ points: out });
});
dashboardRouter.get("/revenue-by-category", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT s.category, COUNT(t.id) count, COALESCE(SUM(t.amount+t.tip),0) revenue
FROM tickets t
JOIN services s ON s.id = t.service_id
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
GROUP BY s.category
ORDER BY revenue DESC`
)
.all(bid) as any[];
const out = rows.map((r) => ({
category: r.category,
count: r.count,
revenue: Math.round(r.revenue * 100) / 100,
}));
res.json({ slices: out });
});
dashboardRouter.get("/tickets", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const limit = Math.min(200, Number(req.query.limit) || 50);
const rows = db
.prepare(
`SELECT t.*, s.name service_name, s.category service_category, s.color service_color,
e.name employee_name, e.color employee_color,
c.name client_name
FROM tickets t
JOIN services s ON s.id = t.service_id
JOIN employees e ON e.id = t.employee_id
JOIN clients c ON c.id = t.client_id
WHERE t.business_id = ?
ORDER BY t.created_at DESC
LIMIT ?`
)
.all(bid, limit) as any[];
const out = rows.map((t) => ({
id: t.id,
business_id: t.business_id,
appointment_id: t.appointment_id,
client_id: t.client_id,
employee_id: t.employee_id,
service_id: t.service_id,
amount: t.amount,
tip: t.tip,
commission: t.commission,
payment_method: t.payment_method,
created_at: t.created_at,
service: { id: t.service_id, name: t.service_name, category: t.service_category, color: t.service_color },
employee: { id: t.employee_id, name: t.employee_name, color: t.employee_color },
client: { id: t.client_id, name: t.client_name },
}));
res.json({ tickets: out });
});
dashboardRouter.get("/commissions", (req: AuthedRequest, res) => {
const bid = req.user!.business_id;
const range = (req.query.range as string) || "30";
const rows = db
.prepare(
`SELECT e.id, e.name, e.color, e.role,
COUNT(t.id) sales,
COALESCE(SUM(t.amount + t.tip), 0) revenue,
COALESCE(SUM(t.commission), 0) commission
FROM employees e
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
WHERE e.business_id = ?
GROUP BY e.id
ORDER BY commission DESC`
)
.all(bid) as any[];
const out = rows.map((r) => ({
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
sales: r.sales,
revenue: Math.round(r.revenue * 100) / 100,
commission: Math.round(r.commission * 100) / 100,
}));
const total = out.reduce((a, r) => a + r.commission, 0);
res.json({ rows: out, total: Math.round(total * 100) / 100 });
});
+136
View File
@@ -0,0 +1,136 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err, ownerOnly } from "../lib/auth.ts";
import type { EmployeeStats } from "../../shared/types.ts";
export const employeesRouter = Router();
function attachServices(emp: any) {
const ids = db
.prepare(`SELECT service_id FROM employee_services WHERE employee_id = ?`)
.all(emp.id)
.map((r: any) => r.service_id);
emp.service_ids = ids;
return emp;
}
function computeStats(employeeId: number, businessId: number): EmployeeStats {
const completed = db
.prepare(
`SELECT COUNT(*) c, COALESCE(SUM(price),0) s FROM appointments WHERE employee_id = ? AND status = 'completed'`
)
.get(employeeId) as { c: number; s: number };
const total = db
.prepare(`SELECT COUNT(*) c FROM appointments WHERE employee_id = ?`)
.get(employeeId) as { c: number };
const rating = db
.prepare(
`SELECT COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = ?), (SELECT rating FROM employees WHERE id = ?)) r`
)
.get(employeeId, employeeId) as { r: number };
const topEmp = db
.prepare(
`SELECT COUNT(*) c FROM appointments WHERE employee_id = ? AND status IN ('completed','scheduled')`
)
.get(employeeId) as { c: number };
const totalBiz = db
.prepare(
`SELECT COUNT(*) c FROM appointments WHERE business_id = ? AND status IN ('completed','scheduled')`
)
.get(businessId) as { c: number };
const utilization = totalBiz.c > 0 ? Math.round((topEmp.c / totalBiz.c) * 100) : 0;
return {
appointments_total: total.c,
appointments_completed: completed.c,
revenue_total: completed.s,
avg_rating: Math.round((rating.r ?? 5) * 10) / 10,
utilization_pct: utilization,
};
}
employeesRouter.get("/", (req: AuthedRequest, res) => {
const rows = db
.prepare(`SELECT * FROM employees WHERE business_id = ? ORDER BY active DESC, name`)
.all(req.user!.business_id) as any[];
const out = rows.map((r) => {
attachServices(r);
r.stats = computeStats(r.id, req.user!.business_id as number);
return r;
});
res.json({ employees: out });
});
employeesRouter.get("/:id", (req: AuthedRequest, res) => {
const emp = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(
Number(req.params.id),
req.user!.business_id
) as any;
if (!emp) return err(res, 404, "Empleado no encontrado");
attachServices(emp);
emp.stats = computeStats(emp.id, req.user!.business_id as number);
res.json({ employee: emp });
});
employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
const { name, role, email, phone, color, service_ids } = 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 *`
)
.get(
req.user!.business_id,
name,
role || "Especialista",
email || null,
phone || null,
color || "#3b66ff"
) as any;
if (Array.isArray(service_ids)) {
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const sid of service_ids) ins.run(r.id, Number(sid));
}
attachServices(r);
r.stats = computeStats(r.id, req.user!.business_id as number);
res.status(201).json({ employee: r });
});
employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id as number) as any;
if (!existing) return err(res, 404, "Empleado no encontrado");
const { name, role, email, phone, color, active, service_ids } = req.body ?? {};
const merged = {
name: name ?? existing.name,
role: role ?? existing.role,
email: email ?? existing.email,
phone: phone ?? existing.phone,
color: color ?? existing.color,
active: active === undefined ? existing.active : active ? 1 : 0,
};
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);
if (Array.isArray(service_ids)) {
db.prepare(`DELETE FROM employee_services WHERE employee_id = ?`).run(id);
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const sid of service_ids) ins.run(id, Number(sid));
}
const updated = db.prepare(`SELECT * FROM employees WHERE id = ?`).get(id) as any;
attachServices(updated);
updated.stats = computeStats(updated.id, req.user!.business_id as number);
res.json({ employee: updated });
});
employeesRouter.delete("/:id", ownerOnly, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM employees WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id as number);
if (!existing) return err(res, 404, "Empleado no encontrado");
db.prepare(`DELETE FROM employees WHERE id = ?`).run(id);
res.json({ ok: true });
});
+147
View File
@@ -0,0 +1,147 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
export const notificationsRouter = Router();
/** Build a human message for an appointment reminder/confirmation. */
function buildMessage(biz: any, appt: any, kind: string): string {
const when = new Date(appt.start_at).toLocaleString("es-MX", {
weekday: "long",
day: "numeric",
month: "long",
hour: "2-digit",
minute: "2-digit",
timeZone: biz.timezone || "America/Mexico_City",
});
const svc = appt.service_name || "tu servicio";
const emp = appt.employee_name ? ` con ${appt.employee_name}` : "";
if (kind === "confirmation")
return `¡Hola ${appt.client_name}! Tu cita en ${biz.name} está confirmada: ${svc}${emp}, ${when}.`;
if (kind === "cancellation")
return `Hola ${appt.client_name}, tu cita en ${biz.name} (${svc}) fue cancelada. Para reagendar responde a este mensaje.`;
if (kind === "review")
return `¡Gracias por tu visita a ${biz.name}, ${appt.client_name}! ¿Nos calificas? Tu opinión nos ayuda a mejorar.`;
return `Recordatorio ${biz.name}: tienes ${svc}${emp} el ${when}. ¡Te esperamos!`;
}
/** (Re)generate pending notifications for the upcoming appointments of a business. */
export function scheduleReminders(businessId: number) {
const biz = db.prepare(`SELECT * FROM businesses WHERE id = ?`).get(businessId) as any;
if (!biz) return;
// upcoming scheduled appointments in next 7 days
const appts = db
.prepare(
`SELECT a.id, a.start_at, a.status, a.client_id, c.name client_name, c.phone, c.email,
s.name service_name, e.name employee_name
FROM appointments a
JOIN clients c ON c.id = a.client_id
JOIN services s ON s.id = a.service_id
LEFT JOIN employees e ON e.id = a.employee_id
WHERE a.business_id = ? AND a.status = 'scheduled'
AND a.start_at >= datetime('now') AND a.start_at <= datetime('now','+7 days')
ORDER BY a.start_at ASC`
)
.all(businessId) as any[];
// delete pending reminders that no longer have a matching upcoming scheduled appt
db.prepare(
`DELETE FROM notifications WHERE business_id = ? AND status = 'pending' AND kind IN ('reminder','confirmation')`
).run(businessId);
for (const a of appts) {
const start = new Date(a.start_at);
const channel = a.phone ? "whatsapp" : a.email ? "email" : "whatsapp";
// confirmation (immediate)
const confSend = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
db.prepare(
`INSERT INTO notifications (business_id, appointment_id, client_id, channel, kind, send_at, status, message)
VALUES (?, ?, ?, ?, 'confirmation', ?, 'pending', ?)`
).run(businessId, a.id, a.client_id, channel, confSend, buildMessage(biz, a, "confirmation"));
// reminder 24h before
const reminderAt = new Date(start.getTime() - 24 * 3600000);
if (reminderAt.getTime() > Date.now()) {
db.prepare(
`INSERT INTO notifications (business_id, appointment_id, client_id, channel, kind, send_at, status, message)
VALUES (?, ?, ?, ?, 'reminder', ?, 'pending', ?)`
).run(
businessId,
a.id,
a.client_id,
channel,
reminderAt.toISOString().replace(/\.\d{3}Z$/, "Z"),
buildMessage(biz, a, "reminder")
);
}
}
}
notificationsRouter.get("/", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const status = req.query.status as string | undefined;
let rows: any[];
if (status && status !== "all") {
rows = db
.prepare(
`SELECT n.*, c.name client_name, c.phone, c.email, s.name service_name
FROM notifications n
LEFT JOIN clients c ON c.id = n.client_id
LEFT JOIN appointments a ON a.id = n.appointment_id
LEFT JOIN services s ON s.id = a.service_id
WHERE n.business_id = ? AND n.status = ?
ORDER BY n.send_at DESC LIMIT 200`
)
.all(bid, status);
} else {
rows = db
.prepare(
`SELECT n.*, c.name client_name, c.phone, c.email, s.name service_name
FROM notifications n
LEFT JOIN clients c ON c.id = n.client_id
LEFT JOIN appointments a ON a.id = n.appointment_id
LEFT JOIN services s ON s.id = a.service_id
WHERE n.business_id = ?
ORDER BY n.send_at DESC LIMIT 200`
)
.all(bid);
}
res.json({ notifications: rows });
});
notificationsRouter.post("/regenerate", (req: AuthedRequest, res) => {
scheduleReminders(req.user!.business_id as number);
res.json({ ok: true });
});
/** "Send" a notification — in this demo it simulates sending (marks as sent). */
notificationsRouter.post("/:id/send", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const id = Number(req.params.id);
const n = db.prepare(`SELECT * FROM notifications WHERE id = ? AND business_id = ?`).get(id, bid) as any;
if (!n) return err(res, 404, "Notificación no encontrada");
db.prepare(`UPDATE notifications SET status='sent' WHERE id = ?`).run(id);
res.json({ ok: true, simulated: true });
});
notificationsRouter.post("/:id/cancel", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const id = Number(req.params.id);
const n = db.prepare(`SELECT id FROM notifications WHERE id = ? AND business_id = ?`).get(id, bid);
if (!n) return err(res, 404, "Notificación no encontrada");
db.prepare(`UPDATE notifications SET status='canceled' WHERE id = ?`).run(id);
res.json({ ok: true });
});
notificationsRouter.get("/stats", (req: AuthedRequest, res) => {
const bid = req.user!.business_id as number;
const scalar = (sql: string) => (db.prepare(sql).get(bid) as any)?.v ?? 0;
res.json({
pending: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending'`),
sent: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='sent'`),
upcoming_reminders: scalar(
`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending' AND kind='reminder' AND send_at >= datetime('now')`
),
no_show_rate: scalar(
`SELECT COALESCE(ROUND(100.0 * SUM(CASE WHEN status='no_show' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0),1),0) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`
),
});
});
+113
View File
@@ -0,0 +1,113 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err, ownerOnly } from "../lib/auth.ts";
export const servicesRouter = Router();
function attachEmployees(svc: any) {
const ids = db
.prepare(`SELECT employee_id FROM employee_services WHERE service_id = ?`)
.all(svc.id)
.map((r: any) => r.employee_id);
svc.employee_ids = ids;
return svc;
}
servicesRouter.get("/", (req: AuthedRequest, res) => {
const { employee_id, active } = req.query;
let rows: any[];
if (employee_id) {
rows = db
.prepare(
`SELECT s.* FROM services s
JOIN employee_services es ON es.service_id = s.id
WHERE s.business_id = ? AND es.employee_id = ?
ORDER BY s.category, s.name`
)
.all(req.user!.business_id, Number(employee_id));
} else {
rows = db
.prepare(
`SELECT * FROM services WHERE business_id = ? ORDER BY category, name`
)
.all(req.user!.business_id);
}
if (active === "true") rows = rows.filter((r) => r.active === 1);
rows.forEach(attachEmployees);
res.json({ services: rows });
});
servicesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
const { name, category, description, duration_min, price, color, employee_ids, active } = req.body ?? {};
if (!name) return err(res, 400, "El nombre es obligatorio");
const r = db
.prepare(
`INSERT INTO services (business_id, name, category, description, duration_min, price, color, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
name,
category || "General",
description || null,
Number(duration_min) || 60,
Number(price) || 0,
color || "#3b66ff",
active === false ? 0 : 1
) as any;
if (Array.isArray(employee_ids)) {
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const eid of employee_ids) ins.run(Number(eid), r.id);
}
attachEmployees(r);
res.status(201).json({ service: r });
});
servicesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Servicio no encontrado");
const { name, category, description, duration_min, price, color, active, employee_ids } = req.body ?? {};
const merged = {
name: name ?? existing.name,
category: category ?? existing.category,
description: description ?? existing.description,
duration_min: duration_min === undefined ? existing.duration_min : Number(duration_min),
price: price === undefined ? existing.price : Number(price),
color: color ?? existing.color,
active: active === undefined ? existing.active : active ? 1 : 0,
};
db.prepare(
`UPDATE services SET name = ?, category = ?, description = ?, duration_min = ?, price = ?, color = ?, active = ? WHERE id = ?`
).run(
merged.name,
merged.category,
merged.description,
merged.duration_min,
merged.price,
merged.color,
merged.active,
id
);
if (Array.isArray(employee_ids)) {
db.prepare(`DELETE FROM employee_services WHERE service_id = ?`).run(id);
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
for (const eid of employee_ids) ins.run(Number(eid), id);
}
const updated = db.prepare(`SELECT * FROM services WHERE id = ?`).get(id) as any;
attachEmployees(updated);
res.json({ service: updated });
});
servicesRouter.delete("/:id", ownerOnly, (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM services WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id);
if (!existing) return err(res, 404, "Servicio no encontrado");
db.prepare(`DELETE FROM services WHERE id = ?`).run(id);
res.json({ ok: true });
});
+57
View File
@@ -0,0 +1,57 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err, ownerOnly } from "../lib/auth.ts";
export const settingsRouter = Router();
const FIELDS = [
"name",
"industry",
"phone",
"address",
"slug",
"booking_enabled",
"cancel_window_hours",
"cancel_penalty_pct",
"require_deposit",
"deposit_pct",
"timezone",
] 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
FROM businesses WHERE id = ?`
)
.get(req.user!.business_id) as any;
if (!b) return err(res, 404, "Negocio no encontrado");
res.json({ settings: b });
});
settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
const existing = db.prepare(`SELECT * FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
if (!existing) return err(res, 404, "Negocio no encontrado");
const body = req.body ?? {};
const merged: any = {};
for (const f of FIELDS) {
if (body[f] !== undefined) merged[f] = body[f];
}
// slug uniqueness check
if (merged.slug !== undefined) {
merged.slug = String(merged.slug).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
if (!merged.slug) return err(res, 400, "El slug no puede quedar vacío");
const clash = db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id != ?`).get(merged.slug, req.user!.business_id);
if (clash) return err(res, 409, "Esa URL ya está en uso");
}
const sets = Object.keys(merged).map((k) => `${k} = @${k}`);
if (sets.length === 0) return res.json({ settings: existing });
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 = ?`)
.get(req.user!.business_id);
res.json({ settings: updated });
});