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.
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
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,
|
|
});
|
|
});
|