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.
338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
import { Router } from "express";
|
|
import { db } from "../db.ts";
|
|
import type { AuthedRequest } from "../lib/auth.ts";
|
|
import { ownerOnly } from "../lib/auth.ts";
|
|
|
|
export const dashboardRouter = Router();
|
|
|
|
dashboardRouter.use(ownerOnly);
|
|
|
|
dashboardRouter.get("/overview", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0;
|
|
|
|
const revenue_today = scalar(
|
|
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND date(created_at) = date('now')`,
|
|
bid
|
|
);
|
|
const revenue_week = scalar(
|
|
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-7 days')`,
|
|
bid
|
|
);
|
|
const revenue_month = scalar(
|
|
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
|
bid
|
|
);
|
|
const revenue_prev = scalar(
|
|
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-60 days') AND created_at < datetime('now','-30 days')`,
|
|
bid
|
|
);
|
|
const revenue_change_pct =
|
|
revenue_prev > 0 ? Math.round(((revenue_month - revenue_prev) / revenue_prev) * 1000) / 10 : 0;
|
|
|
|
const appts_today = scalar(
|
|
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND date(start_at) = date('now')`,
|
|
bid
|
|
);
|
|
const appts_week = scalar(
|
|
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= datetime('now','-7 days')`,
|
|
bid
|
|
);
|
|
const appts_upcoming = scalar(
|
|
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= datetime('now')`,
|
|
bid
|
|
);
|
|
const active_clients = scalar(
|
|
`SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
|
bid
|
|
);
|
|
const avg_ticket = scalar(
|
|
`SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
|
bid
|
|
);
|
|
const total_range = scalar(
|
|
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
|
bid
|
|
);
|
|
const cancels = scalar(
|
|
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status IN ('cancelled','no_show') AND created_at >= datetime('now','-30 days')`,
|
|
bid
|
|
);
|
|
const cancel_rate = total_range > 0 ? Math.round((cancels / total_range) * 1000) / 10 : 0;
|
|
const completed = scalar(
|
|
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'completed' AND created_at >= datetime('now','-30 days')`,
|
|
bid
|
|
);
|
|
const occupancy_pct = total_range > 0 ? Math.round((completed / total_range) * 100) : 0;
|
|
|
|
res.json({
|
|
revenue_today: Math.round(revenue_today * 100) / 100,
|
|
revenue_week: Math.round(revenue_week * 100) / 100,
|
|
revenue_month: Math.round(revenue_month * 100) / 100,
|
|
revenue_change_pct,
|
|
appts_today,
|
|
appts_week,
|
|
appts_upcoming,
|
|
active_clients,
|
|
avg_ticket: Math.round(avg_ticket * 100) / 100,
|
|
cancel_rate,
|
|
occupancy_pct,
|
|
});
|
|
});
|
|
|
|
dashboardRouter.get("/best-employees", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const range = (req.query.range as string) || "30";
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT e.id, e.name, e.color, e.role,
|
|
COUNT(t.id) appts,
|
|
COALESCE(SUM(t.amount+t.tip),0) revenue,
|
|
COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = e.id), e.rating) avg_rating
|
|
FROM employees e
|
|
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
|
WHERE e.business_id = ?
|
|
GROUP BY e.id
|
|
ORDER BY revenue DESC`
|
|
)
|
|
.all(bid) as any[];
|
|
const totalAppts = rows.reduce((a, r) => a + r.appts, 0);
|
|
const out = rows.map((r) => ({
|
|
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
|
|
appointments: r.appts,
|
|
revenue: Math.round(r.revenue * 100) / 100,
|
|
avg_rating: Math.round(r.avg_rating * 10) / 10,
|
|
utilization_pct: totalAppts > 0 ? Math.round((r.appts / totalAppts) * 100) : 0,
|
|
}));
|
|
res.json({ employees: out });
|
|
});
|
|
|
|
dashboardRouter.get("/best-services", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const range = (req.query.range as string) || "30";
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT s.id, s.name, s.category, s.color,
|
|
COUNT(t.id) count,
|
|
COALESCE(SUM(t.amount+t.tip),0) revenue
|
|
FROM services s
|
|
LEFT JOIN tickets t ON t.service_id = s.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
|
WHERE s.business_id = ?
|
|
GROUP BY s.id
|
|
ORDER BY revenue DESC`
|
|
)
|
|
.all(bid) as any[];
|
|
const out = rows.map((r) => ({
|
|
service: { id: r.id, name: r.name, category: r.category, color: r.color },
|
|
count: r.count,
|
|
revenue: Math.round(r.revenue * 100) / 100,
|
|
}));
|
|
res.json({ services: out });
|
|
});
|
|
|
|
dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const limit = Math.min(50, Number(req.query.limit) || 10);
|
|
const range = (req.query.range as string) || "30";
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT t.*, s.name service_name, s.category service_category, s.color service_color,
|
|
e.name employee_name, e.color employee_color,
|
|
c.name client_name
|
|
FROM tickets t
|
|
JOIN services s ON s.id = t.service_id
|
|
JOIN employees e ON e.id = t.employee_id
|
|
JOIN clients c ON c.id = t.client_id
|
|
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
|
|
ORDER BY (t.amount + t.tip) DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(bid, limit) as any[];
|
|
const out = rows.map((t) => ({
|
|
ticket: {
|
|
id: t.id,
|
|
business_id: t.business_id,
|
|
appointment_id: t.appointment_id,
|
|
client_id: t.client_id,
|
|
employee_id: t.employee_id,
|
|
service_id: t.service_id,
|
|
amount: t.amount,
|
|
tip: t.tip,
|
|
payment_method: t.payment_method,
|
|
created_at: t.created_at,
|
|
service: { id: t.service_id, name: t.service_name, category: t.service_category, color: t.service_color },
|
|
employee: { id: t.employee_id, name: t.employee_name, color: t.employee_color },
|
|
client: { id: t.client_id, name: t.client_name },
|
|
},
|
|
}));
|
|
res.json({ tickets: out });
|
|
});
|
|
|
|
dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const limit = Math.min(50, Number(req.query.limit) || 10);
|
|
const range = (req.query.range as string) || "30";
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT c.id, c.name, c.tags, c.phone,
|
|
COUNT(t.id) visits,
|
|
COALESCE(SUM(t.amount+t.tip),0) total,
|
|
MAX(t.created_at) last_visit
|
|
FROM clients c
|
|
LEFT JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
|
WHERE c.business_id = ?
|
|
GROUP BY c.id
|
|
HAVING visits > 0
|
|
ORDER BY total DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(bid, limit) as any[];
|
|
const out = rows.map((r) => ({
|
|
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
|
|
visits: r.visits,
|
|
total_spent: Math.round(r.total * 100) / 100,
|
|
last_visit: r.last_visit,
|
|
}));
|
|
res.json({ clients: out });
|
|
});
|
|
|
|
dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const limit = Math.min(50, Number(req.query.limit) || 10);
|
|
const range = (req.query.range as string) || "30";
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT c.id, c.name, c.tags, c.phone,
|
|
COUNT(t.id) visits,
|
|
COALESCE(SUM(t.amount+t.tip),0) total,
|
|
MAX(t.created_at) last_visit
|
|
FROM clients c
|
|
JOIN tickets t ON t.client_id = c.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
|
WHERE c.business_id = ?
|
|
GROUP BY c.id
|
|
ORDER BY visits DESC, total DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(bid, limit) as any[];
|
|
const out = rows.map((r) => ({
|
|
client: { id: r.id, name: r.name, tags: r.tags, phone: r.phone },
|
|
visits: r.visits,
|
|
total_spent: Math.round(r.total * 100) / 100,
|
|
last_visit: r.last_visit,
|
|
}));
|
|
res.json({ clients: out });
|
|
});
|
|
|
|
dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const days = Math.min(120, Number(req.query.days) || 30);
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT date(created_at) date, COALESCE(SUM(amount+tip),0) revenue, COUNT(*) appts
|
|
FROM tickets
|
|
WHERE business_id = ? AND created_at >= datetime('now','-${days} days')
|
|
GROUP BY date(created_at)
|
|
ORDER BY date ASC`
|
|
)
|
|
.all(bid) as any[];
|
|
// Fill missing days with 0
|
|
const map = new Map(rows.map((r) => [r.date, r]));
|
|
const out: { date: string; revenue: number; appts: number }[] = [];
|
|
const today = new Date();
|
|
for (let i = days - 1; i >= 0; i--) {
|
|
const d = new Date(today);
|
|
d.setDate(d.getDate() - i);
|
|
const key = d.toISOString().slice(0, 10);
|
|
const row = map.get(key);
|
|
out.push({
|
|
date: key,
|
|
revenue: row ? Math.round(row.revenue * 100) / 100 : 0,
|
|
appts: row ? row.appts : 0,
|
|
});
|
|
}
|
|
res.json({ points: out });
|
|
});
|
|
|
|
dashboardRouter.get("/revenue-by-category", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const range = (req.query.range as string) || "30";
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT s.category, COUNT(t.id) count, COALESCE(SUM(t.amount+t.tip),0) revenue
|
|
FROM tickets t
|
|
JOIN services s ON s.id = t.service_id
|
|
WHERE t.business_id = ? AND t.created_at >= datetime('now','-${Number(range)} days')
|
|
GROUP BY s.category
|
|
ORDER BY revenue DESC`
|
|
)
|
|
.all(bid) as any[];
|
|
const out = rows.map((r) => ({
|
|
category: r.category,
|
|
count: r.count,
|
|
revenue: Math.round(r.revenue * 100) / 100,
|
|
}));
|
|
res.json({ slices: out });
|
|
});
|
|
|
|
dashboardRouter.get("/tickets", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const limit = Math.min(200, Number(req.query.limit) || 50);
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT t.*, s.name service_name, s.category service_category, s.color service_color,
|
|
e.name employee_name, e.color employee_color,
|
|
c.name client_name
|
|
FROM tickets t
|
|
JOIN services s ON s.id = t.service_id
|
|
JOIN employees e ON e.id = t.employee_id
|
|
JOIN clients c ON c.id = t.client_id
|
|
WHERE t.business_id = ?
|
|
ORDER BY t.created_at DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(bid, limit) as any[];
|
|
const out = rows.map((t) => ({
|
|
id: t.id,
|
|
business_id: t.business_id,
|
|
appointment_id: t.appointment_id,
|
|
client_id: t.client_id,
|
|
employee_id: t.employee_id,
|
|
service_id: t.service_id,
|
|
amount: t.amount,
|
|
tip: t.tip,
|
|
commission: t.commission,
|
|
payment_method: t.payment_method,
|
|
created_at: t.created_at,
|
|
service: { id: t.service_id, name: t.service_name, category: t.service_category, color: t.service_color },
|
|
employee: { id: t.employee_id, name: t.employee_name, color: t.employee_color },
|
|
client: { id: t.client_id, name: t.client_name },
|
|
}));
|
|
res.json({ tickets: out });
|
|
});
|
|
|
|
dashboardRouter.get("/commissions", (req: AuthedRequest, res) => {
|
|
const bid = req.user!.business_id;
|
|
const range = (req.query.range as string) || "30";
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT e.id, e.name, e.color, e.role,
|
|
COUNT(t.id) sales,
|
|
COALESCE(SUM(t.amount + t.tip), 0) revenue,
|
|
COALESCE(SUM(t.commission), 0) commission
|
|
FROM employees e
|
|
LEFT JOIN tickets t ON t.employee_id = e.id AND t.created_at >= datetime('now','-${Number(range)} days')
|
|
WHERE e.business_id = ?
|
|
GROUP BY e.id
|
|
ORDER BY commission DESC`
|
|
)
|
|
.all(bid) as any[];
|
|
const out = rows.map((r) => ({
|
|
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
|
|
sales: r.sales,
|
|
revenue: Math.round(r.revenue * 100) / 100,
|
|
commission: Math.round(r.commission * 100) / 100,
|
|
}));
|
|
const total = out.reduce((a, r) => a + r.commission, 0);
|
|
res.json({ rows: out, total: Math.round(total * 100) / 100 });
|
|
});
|