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.
120 lines
5.4 KiB
TypeScript
120 lines
5.4 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 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) });
|
|
});
|