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