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