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.
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
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 });
|
|
});
|