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