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