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:
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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: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,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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')`
|
||||
),
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user