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,119 @@
|
||||
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 cashRouter = Router();
|
||||
cashRouter.use(ownerOnly);
|
||||
|
||||
function sessionStats(sessionId: number, businessId: number) {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END),0) income,
|
||||
COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END),0) expense,
|
||||
COUNT(*) n
|
||||
FROM cash_entries WHERE session_id = ? AND business_id = ?`
|
||||
)
|
||||
.get(sessionId, businessId) as { income: number; expense: number; n: number };
|
||||
const tickets = db
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(amount),0) s, COUNT(*) n FROM cash_entries WHERE session_id = ? AND business_id = ? AND ticket_id IS NOT NULL`
|
||||
)
|
||||
.get(sessionId, businessId) as { s: number; n: number };
|
||||
return {
|
||||
income: Math.round(row.income * 100) / 100,
|
||||
expense: Math.round(row.expense * 100) / 100,
|
||||
entries: row.n,
|
||||
ticket_income: Math.round(tickets.s * 100) / 100,
|
||||
ticket_count: tickets.n,
|
||||
};
|
||||
}
|
||||
|
||||
cashRouter.get("/session", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const s = db
|
||||
.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open' ORDER BY opened_at DESC LIMIT 1`)
|
||||
.get(bid) as any;
|
||||
if (!s) return res.json({ session: null });
|
||||
res.json({ session: s, stats: sessionStats(s.id, bid) });
|
||||
});
|
||||
|
||||
cashRouter.get("/sessions", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const limit = Math.min(60, Number(req.query.limit) || 30);
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? ORDER BY opened_at DESC LIMIT ?`)
|
||||
.all(bid, limit) as any[];
|
||||
const out = rows.map((s) => ({ session: s, stats: sessionStats(s.id, bid) }));
|
||||
res.json({ sessions: out });
|
||||
});
|
||||
|
||||
cashRouter.post("/open", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const open = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid);
|
||||
if (open) return err(res, 400, "Ya hay una caja abierta — ciérrala primero");
|
||||
const { opening_amount = 0, note } = req.body ?? {};
|
||||
const s = db
|
||||
.prepare(
|
||||
`INSERT INTO cash_sessions (business_id, opened_by_user_id, opening_amount, note) VALUES (?, ?, ?, ?) RETURNING *`
|
||||
)
|
||||
.get(bid, req.user!.id, Number(opening_amount) || 0, note || null) as any;
|
||||
res.status(201).json({ session: s, stats: sessionStats(s.id, bid) });
|
||||
});
|
||||
|
||||
cashRouter.post("/close", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const s = db.prepare(`SELECT * FROM cash_sessions WHERE business_id = ? AND status = 'open'`).get(bid) as any;
|
||||
if (!s) return err(res, 400, "No hay caja abierta");
|
||||
const { closing_amount, note } = req.body ?? {};
|
||||
const stats = sessionStats(s.id, bid);
|
||||
const expected = Number(s.opening_amount) + stats.income - stats.expense;
|
||||
db.prepare(`UPDATE cash_sessions SET status='closed', closed_at=datetime('now'), closing_amount=?, expected_amount=?, note=COALESCE(?,note) WHERE id=?`).run(
|
||||
Number(closing_amount) || 0,
|
||||
Math.round(expected * 100) / 100,
|
||||
note || null,
|
||||
s.id
|
||||
);
|
||||
const updated = db.prepare(`SELECT * FROM cash_sessions WHERE id=?`).get(s.id) as any;
|
||||
res.json({ session: updated, stats, expected: Math.round(expected * 100) / 100 });
|
||||
});
|
||||
|
||||
cashRouter.get("/entries", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const sessionId = req.query.session_id ? Number(req.query.session_id) : null;
|
||||
let rows: any[];
|
||||
if (sessionId) {
|
||||
rows = db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, sessionId);
|
||||
} else {
|
||||
const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any;
|
||||
rows = s
|
||||
? db.prepare(`SELECT * FROM cash_entries WHERE business_id = ? AND session_id = ? ORDER BY created_at DESC`).all(bid, s.id)
|
||||
: [];
|
||||
}
|
||||
res.json({ entries: rows });
|
||||
});
|
||||
|
||||
cashRouter.post("/entries", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const s = db.prepare(`SELECT id FROM cash_sessions WHERE business_id = ? AND status='open'`).get(bid) as any;
|
||||
if (!s) return err(res, 400, "No hay caja abierta");
|
||||
const { type, amount, concept, payment_method } = req.body ?? {};
|
||||
if (!["income", "expense"].includes(type)) return err(res, 400, "Tipo inválido (income/expense)");
|
||||
if (!amount || !concept) return err(res, 400, "Monto y concepto son obligatorios");
|
||||
const r = db
|
||||
.prepare(
|
||||
`INSERT INTO cash_entries (business_id, session_id, type, amount, concept, payment_method) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
|
||||
)
|
||||
.get(bid, s.id, type, Number(amount), concept, payment_method || null) as any;
|
||||
res.status(201).json({ entry: r, stats: sessionStats(s.id, bid) });
|
||||
});
|
||||
|
||||
cashRouter.delete("/entries/:id", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const id = Number(req.params.id);
|
||||
const e = db.prepare(`SELECT session_id FROM cash_entries WHERE id = ? AND business_id = ?`).get(id, bid) as any;
|
||||
if (!e) return err(res, 404, "Movimiento no encontrado");
|
||||
db.prepare(`DELETE FROM cash_entries WHERE id = ?`).run(id);
|
||||
res.json({ ok: true, stats: sessionStats(e.session_id, bid) });
|
||||
});
|
||||
Reference in New Issue
Block a user