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:
AgendaPro Dev
2026-07-25 13:45:53 -06:00
commit e8d2435cc2
63 changed files with 16539 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
import type { ClientStats } from "../../shared/types.ts";
export const clientsRouter = Router();
function statsFor(clientId: number): ClientStats {
const agg = db
.prepare(
`SELECT
COUNT(*) c,
COALESCE(SUM(amount+tip),0) s,
MAX(created_at) last,
COALESCE(SUM(CASE WHEN amount > 0 THEN 1 ELSE 0 END)*1.0 / NULLIF(COUNT(*),0),0) ar
FROM tickets WHERE client_id = ?`
)
.get(clientId) as { c: number; s: number; last: string | null; ar: number };
const noShow = db
.prepare(`SELECT COUNT(*) c FROM appointments WHERE client_id = ? AND status = 'no_show'`)
.get(clientId) as { c: number };
return {
visits: agg.c,
total_spent: Math.round(agg.s * 100) / 100,
last_visit: agg.last,
avg_ticket: agg.c > 0 ? Math.round((agg.s / agg.c) * 100) / 100 : 0,
no_show_count: noShow.c,
};
}
clientsRouter.get("/", (req: AuthedRequest, res) => {
const q = (req.query.q as string | undefined)?.trim();
let rows: any[];
if (q) {
const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
rows = db
.prepare(
`SELECT * FROM clients
WHERE business_id = ? AND (name LIKE ? ESCAPE '\\' OR phone LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\')
ORDER BY name LIMIT 50`
)
.all(req.user!.business_id, like, like, like);
} else {
rows = db
.prepare(`SELECT * FROM clients WHERE business_id = ? ORDER BY name LIMIT 50`)
.all(req.user!.business_id);
}
rows.forEach((r) => (r.stats = statsFor(r.id)));
res.json({ clients: rows });
});
clientsRouter.get("/:id", (req: AuthedRequest, res) => {
const c = db
.prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`)
.get(Number(req.params.id), req.user!.business_id) as any;
if (!c) return err(res, 404, "Cliente no encontrado");
c.stats = statsFor(c.id);
res.json({ client: c });
});
clientsRouter.post("/", (req: AuthedRequest, res) => {
const { name, email, phone, notes, tags } = req.body ?? {};
if (!name) return err(res, 400, "El nombre es obligatorio");
const r = db
.prepare(
`INSERT INTO clients (business_id, name, email, phone, notes, tags) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
name,
email || null,
phone || null,
notes || null,
tags || null
) as any;
r.stats = statsFor(r.id);
res.status(201).json({ client: r });
});
clientsRouter.patch("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Cliente no encontrado");
const { name, email, phone, notes, tags } = req.body ?? {};
db.prepare(
`UPDATE clients SET name = ?, email = ?, phone = ?, notes = ?, tags = ? WHERE id = ?`
).run(
name ?? existing.name,
email ?? existing.email,
phone ?? existing.phone,
notes ?? existing.notes,
tags ?? existing.tags,
id
);
const updated = db.prepare(`SELECT * FROM clients WHERE id = ?`).get(id) as any;
updated.stats = statsFor(id);
res.json({ client: updated });
});
clientsRouter.delete("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM clients WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id);
if (!existing) return err(res, 404, "Cliente no encontrado");
db.prepare(`DELETE FROM clients WHERE id = ?`).run(id);
res.json({ ok: true });
});