feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed
- Rebrand all user-facing text to AgendaMax - Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0) - Fix calendar toolbar hover: active buttons keep readable contrast - Sticky calendar toolbar (month/week/day always visible on scroll) - Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
This commit is contained in:
@@ -26,7 +26,11 @@ function joinAppointment(a: any) {
|
||||
.prepare(`SELECT id, name, color, role FROM employees WHERE id = ?`)
|
||||
.get(a.employee_id);
|
||||
a.client = db
|
||||
.prepare(`SELECT id, name, phone, email, tags FROM clients WHERE id = ?`)
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.phone, c.email, c.notes, c.tags,
|
||||
(SELECT COUNT(*) FROM appointments a2 WHERE a2.client_id = c.id AND a2.status = 'no_show') AS no_show_count
|
||||
FROM clients c WHERE c.id = ?`
|
||||
)
|
||||
.get(a.client_id);
|
||||
return a;
|
||||
}
|
||||
@@ -177,6 +181,9 @@ appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id) as any;
|
||||
if (!existing) return err(res, 404, "Cita no encontrada");
|
||||
if (req.user!.role === "employee" && existing.employee_id !== req.user!.employee_id) {
|
||||
return err(res, 403, "No puedes modificar una cita que no es tuya");
|
||||
}
|
||||
|
||||
const {
|
||||
service_id,
|
||||
@@ -255,6 +262,9 @@ appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
|
||||
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id) as any;
|
||||
if (!appt) return err(res, 404, "Cita no encontrada");
|
||||
if (req.user!.role === "employee" && appt.employee_id !== req.user!.employee_id) {
|
||||
return err(res, 403, "No puedes modificar una cita que no es tuya");
|
||||
}
|
||||
const { tip = 0, payment_method = "card" } = req.body ?? {};
|
||||
db.prepare(`UPDATE appointments SET status = 'completed', updated_at = datetime('now') WHERE id = ?`).run(id);
|
||||
const hasTicket = db.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`).get(id);
|
||||
@@ -279,9 +289,12 @@ appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
|
||||
appointmentsRouter.delete("/:id", (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id);
|
||||
.prepare(`SELECT id, employee_id FROM appointments WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id) as any;
|
||||
if (!existing) return err(res, 404, "Cita no encontrada");
|
||||
if (req.user!.role === "employee" && existing.employee_id !== req.user!.employee_id) {
|
||||
return err(res, 403, "No puedes modificar una cita que no es tuya");
|
||||
}
|
||||
db.prepare(`DELETE FROM appointments WHERE id = ?`).run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -178,6 +178,16 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
clientId = created.id;
|
||||
}
|
||||
|
||||
// F18: soft no-show enforcement (nunca bloquea la reserva)
|
||||
const nsCount = (db.prepare("SELECT COUNT(*) c FROM appointments WHERE client_id = ? AND status = 'no_show'").get(clientId) as { c: number })?.c ?? 0;
|
||||
const ns = typeof nsCount === "number" ? nsCount : Number(nsCount) || 0;
|
||||
const riskFlag = ns >= 2;
|
||||
let notesBase = client.notes || "Reserva online";
|
||||
if (ns >= 3) {
|
||||
notesBase = `[Riesgo de no-show] ${notesBase}`;
|
||||
db.prepare(`UPDATE clients SET tags = 'Riesgo' WHERE id = ? AND (tags IS NULL OR tags NOT LIKE '%Riesgo%')`).run(clientId);
|
||||
}
|
||||
|
||||
const endIso = new Date(endMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startIso = new Date(startMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const appt = db
|
||||
@@ -185,10 +195,10 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
`INSERT INTO appointments (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) RETURNING *`
|
||||
)
|
||||
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, client.notes || "Reserva online") as any;
|
||||
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, notesBase) as any;
|
||||
|
||||
const empName = (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name;
|
||||
return { appt, empId: empId as number, empName, reasons, clientCreated: !match };
|
||||
return { appt, empId: empId as number, empName, reasons, clientCreated: !match, noShowCount: ns, riskFlag };
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
@@ -204,6 +214,8 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
},
|
||||
business: { name: biz.name, currency_symbol: biz.currency_symbol },
|
||||
client_created: result.clientCreated,
|
||||
no_show_count: result.noShowCount,
|
||||
risk_flag: result.riskFlag,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e && typeof e === "object" && e.status) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { err } from "../lib/auth.ts";
|
||||
import { err, ownerOnly } from "../lib/auth.ts";
|
||||
import type { ClientStats } from "../../shared/types.ts";
|
||||
|
||||
export const clientsRouter = Router();
|
||||
@@ -78,7 +78,7 @@ clientsRouter.post("/", (req: AuthedRequest, res) => {
|
||||
res.status(201).json({ client: r });
|
||||
});
|
||||
|
||||
clientsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
clientsRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT * FROM clients WHERE id = ? AND business_id = ?`)
|
||||
@@ -100,7 +100,7 @@ clientsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||
res.json({ client: updated });
|
||||
});
|
||||
|
||||
clientsRouter.delete("/:id", (req: AuthedRequest, res) => {
|
||||
clientsRouter.delete("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT id FROM clients WHERE id = ? AND business_id = ?`)
|
||||
|
||||
+141
-72
@@ -2,73 +2,114 @@ import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { ownerOnly } from "../lib/auth.ts";
|
||||
import {
|
||||
bizDateISO,
|
||||
bizDayBoundsSqlite,
|
||||
bizDayBoundsIso,
|
||||
toIsoUtc,
|
||||
} from "../lib/time.ts";
|
||||
import { cancelRate, noShowRate, completionRate } from "../lib/metrics.ts";
|
||||
|
||||
export const dashboardRouter = Router();
|
||||
|
||||
dashboardRouter.use(ownerOnly);
|
||||
|
||||
const DEFAULT_TZ = "America/Mexico_City";
|
||||
|
||||
/** Fetch the business timezone once per handler. */
|
||||
function bizTz(bid: number | null): string {
|
||||
const row = db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined;
|
||||
return row?.t || DEFAULT_TZ;
|
||||
}
|
||||
|
||||
/** Clamp the range query param into [7, 120], default 30. */
|
||||
function clampRange(raw: unknown): number {
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) return 30;
|
||||
return Math.min(120, Math.max(7, Math.round(n)));
|
||||
}
|
||||
|
||||
dashboardRouter.get("/overview", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0;
|
||||
|
||||
// Business-local windows, expressed as canonical SQLite UTC strings.
|
||||
// created_at is stored in a MIXED format (ISO-Z from seed, space from app default),
|
||||
// so we wrap it with datetime() for correct chronological comparison. start_at is
|
||||
// consistently ISO-Z, so it compares directly against ISO-Z bounds.
|
||||
const todaySql = bizDayBoundsSqlite(tz, 0);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start; // range days ago, biz-local midnight
|
||||
const prevStart = bizDayBoundsSqlite(tz, -range * 2).start; // preceding equal-length window
|
||||
const todayIso = bizDayBoundsIso(tz, 0);
|
||||
const weekStartIso = bizDayBoundsIso(tz, -7).start;
|
||||
const rangeStartIso = bizDayBoundsIso(tz, -range).start;
|
||||
const nowIso = toIsoUtc(new Date());
|
||||
|
||||
const revenue_today = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND date(created_at) = date('now')`,
|
||||
bid
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets
|
||||
WHERE business_id = ? AND datetime(created_at) >= ? AND datetime(created_at) <= ?`,
|
||||
bid, todaySql.start, todaySql.end
|
||||
);
|
||||
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_range = scalar(
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
bid, rangeStart
|
||||
);
|
||||
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
|
||||
`SELECT COALESCE(SUM(amount+tip),0) v FROM tickets
|
||||
WHERE business_id = ? AND datetime(created_at) >= ? AND datetime(created_at) < ?`,
|
||||
bid, prevStart, rangeStart
|
||||
);
|
||||
const revenue_change_pct =
|
||||
revenue_prev > 0 ? Math.round(((revenue_month - revenue_prev) / revenue_prev) * 1000) / 10 : 0;
|
||||
revenue_prev > 0 ? Math.round(((revenue_range - 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
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ? AND start_at <= ?`,
|
||||
bid, todayIso.start, todayIso.end
|
||||
);
|
||||
const appts_week = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= datetime('now','-7 days')`,
|
||||
bid
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ?`,
|
||||
bid, weekStartIso
|
||||
);
|
||||
const appts_upcoming = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= datetime('now')`,
|
||||
bid
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'scheduled' AND start_at >= ?`,
|
||||
bid, nowIso
|
||||
);
|
||||
const active_clients = scalar(
|
||||
`SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
`SELECT COUNT(DISTINCT client_id) v FROM tickets WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
bid, bizDayBoundsSqlite(tz, -30).start
|
||||
);
|
||||
const avg_ticket = scalar(
|
||||
`SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
`SELECT COALESCE(AVG(amount+tip),0) v FROM tickets WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
bid, rangeStart
|
||||
);
|
||||
const total_range = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`,
|
||||
bid
|
||||
|
||||
// Rates over appointments scheduled (start_at) in the window.
|
||||
const apptWindowTotal = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
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 cancelledCount = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'cancelled' AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
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 noShowCount = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'no_show' AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const occupancy_pct = total_range > 0 ? Math.round((completed / total_range) * 100) : 0;
|
||||
const completedCount = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND status = 'completed' AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const cancel_rate = cancelRate(cancelledCount, apptWindowTotal);
|
||||
const no_show_rate = noShowRate(noShowCount, apptWindowTotal);
|
||||
const completion_pct = completionRate(completedCount, apptWindowTotal);
|
||||
|
||||
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_range: Math.round(revenue_range * 100) / 100,
|
||||
revenue_prev: Math.round(revenue_prev * 100) / 100,
|
||||
revenue_change_pct,
|
||||
appts_today,
|
||||
appts_week,
|
||||
@@ -76,52 +117,59 @@ dashboardRouter.get("/overview", (req: AuthedRequest, res) => {
|
||||
active_clients,
|
||||
avg_ticket: Math.round(avg_ticket * 100) / 100,
|
||||
cancel_rate,
|
||||
occupancy_pct,
|
||||
no_show_rate,
|
||||
completion_pct,
|
||||
range,
|
||||
});
|
||||
});
|
||||
|
||||
dashboardRouter.get("/best-employees", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const ratingSince = bizDayBoundsSqlite(tz, -90).start;
|
||||
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
|
||||
COALESCE((SELECT AVG(rating) FROM reviews WHERE employee_id = e.id AND datetime(created_at) >= ?), 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')
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND datetime(t.created_at) >= ?
|
||||
WHERE e.business_id = ?
|
||||
GROUP BY e.id
|
||||
ORDER BY revenue DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(ratingSince, rangeStart, 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,
|
||||
share_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 range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
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')
|
||||
LEFT JOIN tickets t ON t.service_id = s.id AND datetime(t.created_at) >= ?
|
||||
WHERE s.business_id = ?
|
||||
GROUP BY s.id
|
||||
ORDER BY revenue DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(rangeStart, bid) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
service: { id: r.id, name: r.name, category: r.category, color: r.color },
|
||||
count: r.count,
|
||||
@@ -133,7 +181,9 @@ dashboardRouter.get("/best-services", (req: AuthedRequest, res) => {
|
||||
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 range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT t.*, s.name service_name, s.category service_category, s.color service_color,
|
||||
@@ -143,11 +193,11 @@ dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
|
||||
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')
|
||||
WHERE t.business_id = ? AND datetime(t.created_at) >= ?
|
||||
ORDER BY (t.amount + t.tip) DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(bid, limit) as any[];
|
||||
.all(bid, rangeStart, limit) as any[];
|
||||
const out = rows.map((t) => ({
|
||||
ticket: {
|
||||
id: t.id,
|
||||
@@ -171,7 +221,9 @@ dashboardRouter.get("/top-tickets", (req: AuthedRequest, res) => {
|
||||
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 range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.tags, c.phone,
|
||||
@@ -179,14 +231,14 @@ dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
|
||||
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')
|
||||
LEFT JOIN tickets t ON t.client_id = c.id AND datetime(t.created_at) >= ?
|
||||
WHERE c.business_id = ?
|
||||
GROUP BY c.id
|
||||
HAVING visits > 0
|
||||
ORDER BY total DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(bid, limit) as any[];
|
||||
.all(rangeStart, 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,
|
||||
@@ -199,7 +251,9 @@ dashboardRouter.get("/top-clients", (req: AuthedRequest, res) => {
|
||||
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 range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.tags, c.phone,
|
||||
@@ -207,13 +261,13 @@ dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
|
||||
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')
|
||||
JOIN tickets t ON t.client_id = c.id AND datetime(t.created_at) >= ?
|
||||
WHERE c.business_id = ?
|
||||
GROUP BY c.id
|
||||
ORDER BY visits DESC, total DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(bid, limit) as any[];
|
||||
.all(rangeStart, 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,
|
||||
@@ -225,24 +279,35 @@ dashboardRouter.get("/frequent-clients", (req: AuthedRequest, res) => {
|
||||
|
||||
dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const days = Math.min(120, Number(req.query.days) || 30);
|
||||
const days = Math.min(120, Math.max(1, Number(req.query.days) || 30));
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -days).start;
|
||||
// Pull raw tickets and bucket by BUSINESS-LOCAL date in JS (SQLite date() would use UTC,
|
||||
// misattributing the evening rush to the next day).
|
||||
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`
|
||||
`SELECT created_at, amount, tip FROM tickets
|
||||
WHERE business_id = ? AND datetime(created_at) >= ?`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
// Fill missing days with 0
|
||||
const map = new Map(rows.map((r) => [r.date, r]));
|
||||
.all(bid, rangeStart) as { created_at: string; amount: number; tip: number }[];
|
||||
const map = new Map<string, { revenue: number; appts: number }>();
|
||||
for (const r of rows) {
|
||||
// Stored created_at may be "YYYY-MM-DD HH:MM:SS" (space) or "YYYY-MM-DDTHH:MM:SSZ";
|
||||
// normalize to an ISO UTC instant before converting to the biz date.
|
||||
const iso = typeof r.created_at === "string" && r.created_at.includes("T")
|
||||
? r.created_at
|
||||
: String(r.created_at).replace(" ", "T") + "Z";
|
||||
const key = bizDateISO(new Date(iso), tz);
|
||||
const cur = map.get(key) ?? { revenue: 0, appts: 0 };
|
||||
cur.revenue += Number(r.amount) + Number(r.tip);
|
||||
cur.appts += 1;
|
||||
map.set(key, cur);
|
||||
}
|
||||
const out: { date: string; revenue: number; appts: number }[] = [];
|
||||
const today = new Date();
|
||||
const now = 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 d = new Date(now.getTime() - i * 86400000);
|
||||
const key = bizDateISO(d, tz);
|
||||
const row = map.get(key);
|
||||
out.push({
|
||||
date: key,
|
||||
@@ -255,17 +320,19 @@ dashboardRouter.get("/revenue-trend", (req: AuthedRequest, res) => {
|
||||
|
||||
dashboardRouter.get("/revenue-by-category", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
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')
|
||||
WHERE t.business_id = ? AND datetime(t.created_at) >= ?
|
||||
GROUP BY s.category
|
||||
ORDER BY revenue DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(bid, rangeStart) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
category: r.category,
|
||||
count: r.count,
|
||||
@@ -312,7 +379,9 @@ dashboardRouter.get("/tickets", (req: AuthedRequest, res) => {
|
||||
|
||||
dashboardRouter.get("/commissions", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id;
|
||||
const range = (req.query.range as string) || "30";
|
||||
const range = clampRange(req.query.range);
|
||||
const tz = bizTz(bid);
|
||||
const rangeStart = bizDayBoundsSqlite(tz, -range).start;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT e.id, e.name, e.color, e.role,
|
||||
@@ -320,12 +389,12 @@ dashboardRouter.get("/commissions", (req: AuthedRequest, res) => {
|
||||
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')
|
||||
LEFT JOIN tickets t ON t.employee_id = e.id AND datetime(t.created_at) >= ?
|
||||
WHERE e.business_id = ?
|
||||
GROUP BY e.id
|
||||
ORDER BY commission DESC`
|
||||
)
|
||||
.all(bid) as any[];
|
||||
.all(rangeStart, bid) as any[];
|
||||
const out = rows.map((r) => ({
|
||||
employee: { id: r.id, name: r.name, color: r.color, role: r.role },
|
||||
sales: r.sales,
|
||||
|
||||
@@ -6,6 +6,13 @@ import type { EmployeeStats } from "../../shared/types.ts";
|
||||
|
||||
export const employeesRouter = Router();
|
||||
|
||||
// commission_pct is sensitive (drives payouts) — only owner/admin may read it.
|
||||
const EMP_PUBLIC_COLS =
|
||||
"id, business_id, name, email, phone, color, role, active, rating, hire_date, created_at, specialties, working_hours, efficiency_score";
|
||||
function empCols(req: AuthedRequest): string {
|
||||
return req.user!.role === "owner" || req.user!.role === "admin" ? `${EMP_PUBLIC_COLS}, commission_pct` : EMP_PUBLIC_COLS;
|
||||
}
|
||||
|
||||
function attachServices(emp: any) {
|
||||
const ids = db
|
||||
.prepare(`SELECT service_id FROM employee_services WHERE employee_id = ?`)
|
||||
@@ -45,13 +52,13 @@ function computeStats(employeeId: number, businessId: number): EmployeeStats {
|
||||
appointments_completed: completed.c,
|
||||
revenue_total: completed.s,
|
||||
avg_rating: Math.round((rating.r ?? 5) * 10) / 10,
|
||||
utilization_pct: utilization,
|
||||
share_pct: utilization,
|
||||
};
|
||||
}
|
||||
|
||||
employeesRouter.get("/", (req: AuthedRequest, res) => {
|
||||
const rows = db
|
||||
.prepare(`SELECT * FROM employees WHERE business_id = ? ORDER BY active DESC, name`)
|
||||
.prepare(`SELECT ${empCols(req)} FROM employees WHERE business_id = ? ORDER BY active DESC, name`)
|
||||
.all(req.user!.business_id) as any[];
|
||||
const out = rows.map((r) => {
|
||||
attachServices(r);
|
||||
@@ -62,7 +69,7 @@ employeesRouter.get("/", (req: AuthedRequest, res) => {
|
||||
});
|
||||
|
||||
employeesRouter.get("/:id", (req: AuthedRequest, res) => {
|
||||
const emp = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(
|
||||
const emp = db.prepare(`SELECT ${empCols(req)} FROM employees WHERE id = ? AND business_id = ?`).get(
|
||||
Number(req.params.id),
|
||||
req.user!.business_id
|
||||
) as any;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { authRequired } from "../lib/auth.ts";
|
||||
import { bizDayBoundsSqlite, bizDayBoundsIso, toIsoUtc } from "../lib/time.ts";
|
||||
|
||||
export const meRouter = Router();
|
||||
meRouter.use(authRequired);
|
||||
|
||||
const DEFAULT_TZ = "America/Mexico_City";
|
||||
|
||||
/** Fetch the business timezone once per handler. */
|
||||
function bizTz(bid: number | null): string {
|
||||
const row = db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined;
|
||||
return row?.t || DEFAULT_TZ;
|
||||
}
|
||||
|
||||
const EMPTY_PERF = {
|
||||
appointments_today: 0,
|
||||
upcoming: 0,
|
||||
revenue_30d: 0,
|
||||
avg_rating: 0,
|
||||
share_pct: 0,
|
||||
next_appointment: null,
|
||||
};
|
||||
|
||||
/** Employee self-service analytics, tz-correct via the business timezone. */
|
||||
meRouter.get("/performance", (req: AuthedRequest, res) => {
|
||||
const eid = req.user!.employee_id;
|
||||
const bid = req.user!.business_id;
|
||||
if (!eid) return res.json(EMPTY_PERF);
|
||||
|
||||
const tz = bizTz(bid);
|
||||
// start_at is stored ISO-Z → pair with ISO bounds.
|
||||
const todayIso = bizDayBoundsIso(tz, 0);
|
||||
const rangeStartIso = bizDayBoundsIso(tz, -30).start;
|
||||
const nowIso = toIsoUtc(new Date());
|
||||
// created_at is stored in a mixed format → wrap with datetime() and use SQLite bounds.
|
||||
const rangeStartSql = bizDayBoundsSqlite(tz, -30).start;
|
||||
const ratingStartSql = bizDayBoundsSqlite(tz, -90).start;
|
||||
|
||||
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(...args) as any)?.v ?? 0;
|
||||
|
||||
const appointments_today = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments
|
||||
WHERE employee_id = ? AND start_at >= ? AND start_at <= ? AND status != 'cancelled'`,
|
||||
eid, todayIso.start, todayIso.end
|
||||
);
|
||||
const upcoming = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments
|
||||
WHERE employee_id = ? AND status = 'scheduled' AND start_at >= ?`,
|
||||
eid, nowIso
|
||||
);
|
||||
const revenue_30d = scalar(
|
||||
`SELECT COALESCE(SUM(amount + tip), 0) v FROM tickets
|
||||
WHERE employee_id = ? AND datetime(created_at) >= ?`,
|
||||
eid, rangeStartSql
|
||||
);
|
||||
// Avg of last-90d reviews, falling back to the employee's stored rating.
|
||||
const avg_rating = scalar(
|
||||
`SELECT COALESCE(
|
||||
(SELECT AVG(rating) FROM reviews WHERE employee_id = ? AND datetime(created_at) >= ?),
|
||||
(SELECT rating FROM employees WHERE id = ?)
|
||||
) v`,
|
||||
eid, ratingStartSql, eid
|
||||
);
|
||||
const empAppts = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE employee_id = ? AND start_at >= ?`,
|
||||
eid, rangeStartIso
|
||||
);
|
||||
const bizAppts = scalar(
|
||||
`SELECT COUNT(*) v FROM appointments WHERE business_id = ? AND start_at >= ?`,
|
||||
bid, rangeStartIso
|
||||
);
|
||||
const share_pct = bizAppts > 0 ? Math.round((empAppts / bizAppts) * 100) : 0;
|
||||
|
||||
const next = db
|
||||
.prepare(
|
||||
`SELECT a.start_at, s.name service_name, c.name client_name
|
||||
FROM appointments a
|
||||
JOIN services s ON s.id = a.service_id
|
||||
JOIN clients c ON c.id = a.client_id
|
||||
WHERE a.employee_id = ? AND a.status = 'scheduled' AND a.start_at >= ?
|
||||
ORDER BY a.start_at ASC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(eid, nowIso) as { start_at: string; service_name: string; client_name: string } | undefined;
|
||||
|
||||
res.json({
|
||||
appointments_today,
|
||||
upcoming,
|
||||
revenue_30d: Math.round(revenue_30d * 100) / 100,
|
||||
avg_rating: Math.round((avg_rating ?? 0) * 10) / 10,
|
||||
share_pct,
|
||||
next_appointment: next
|
||||
? { start_at: next.start_at, service_name: next.service_name, client_name: next.client_name }
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
/** Per-ticket commissions for the signed-in employee, last 30 business-local days. */
|
||||
meRouter.get("/commissions", (req: AuthedRequest, res) => {
|
||||
const eid = req.user!.employee_id;
|
||||
if (!eid) return res.json({ rows: [], total: 0 });
|
||||
|
||||
const bid = req.user!.business_id;
|
||||
const tz = bizTz(bid);
|
||||
const rangeStartSql = bizDayBoundsSqlite(tz, -30).start;
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT t.appointment_id, s.name service_name, c.name client_name,
|
||||
t.amount, t.commission, t.created_at
|
||||
FROM tickets t
|
||||
JOIN services s ON s.id = t.service_id
|
||||
JOIN clients c ON c.id = t.client_id
|
||||
WHERE t.employee_id = ? AND datetime(t.created_at) >= ?
|
||||
ORDER BY datetime(t.created_at) DESC
|
||||
LIMIT 100`
|
||||
)
|
||||
.all(eid, rangeStartSql) as any[];
|
||||
|
||||
const out = rows.map((r) => ({
|
||||
appointment_id: r.appointment_id,
|
||||
service_name: r.service_name,
|
||||
client_name: r.client_name,
|
||||
amount: Math.round(Number(r.amount) * 100) / 100,
|
||||
commission: Math.round(Number(r.commission) * 100) / 100,
|
||||
created_at: r.created_at,
|
||||
}));
|
||||
const total = Math.round(out.reduce((a, r) => a + r.commission, 0) * 100) / 100;
|
||||
|
||||
res.json({ rows: out, total });
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import type { AuthedRequest } from "../lib/auth.ts";
|
||||
import { err } from "../lib/auth.ts";
|
||||
import { bizDayBoundsSqlite } from "../lib/time.ts";
|
||||
|
||||
export const notificationsRouter = Router();
|
||||
|
||||
@@ -30,7 +31,10 @@ function buildMessage(biz: any, appt: any, kind: string): string {
|
||||
export function scheduleReminders(businessId: number) {
|
||||
const biz = db.prepare(`SELECT * FROM businesses WHERE id = ?`).get(businessId) as any;
|
||||
if (!biz) return;
|
||||
// upcoming scheduled appointments in next 7 days
|
||||
// upcoming scheduled appointments in next 7 days (UTC instants — the window is
|
||||
// instant-relative, so we bind clean UTC values instead of SQLite's date('now')).
|
||||
const nowUtc = new Date().toISOString().slice(0, 19) + "Z";
|
||||
const plus7Utc = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19) + "Z";
|
||||
const appts = db
|
||||
.prepare(
|
||||
`SELECT a.id, a.start_at, a.status, a.client_id, c.name client_name, c.phone, c.email,
|
||||
@@ -40,10 +44,10 @@ export function scheduleReminders(businessId: number) {
|
||||
JOIN services s ON s.id = a.service_id
|
||||
LEFT JOIN employees e ON e.id = a.employee_id
|
||||
WHERE a.business_id = ? AND a.status = 'scheduled'
|
||||
AND a.start_at >= datetime('now') AND a.start_at <= datetime('now','+7 days')
|
||||
AND a.start_at >= ? AND a.start_at <= ?
|
||||
ORDER BY a.start_at ASC`
|
||||
)
|
||||
.all(businessId) as any[];
|
||||
.all(businessId, nowUtc, plus7Utc) as any[];
|
||||
// delete pending reminders that no longer have a matching upcoming scheduled appt
|
||||
db.prepare(
|
||||
`DELETE FROM notifications WHERE business_id = ? AND status = 'pending' AND kind IN ('reminder','confirmation')`
|
||||
@@ -133,7 +137,12 @@ notificationsRouter.post("/:id/cancel", (req: AuthedRequest, res) => {
|
||||
|
||||
notificationsRouter.get("/stats", (req: AuthedRequest, res) => {
|
||||
const bid = req.user!.business_id as number;
|
||||
const scalar = (sql: string) => (db.prepare(sql).get(bid) as any)?.v ?? 0;
|
||||
const tz =
|
||||
(db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined)?.t ||
|
||||
"America/Mexico_City";
|
||||
// tz-correct 30-day window (business-local). created_at is mixed-format, so wrap with datetime().
|
||||
const since30 = bizDayBoundsSqlite(tz, -30).start;
|
||||
const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(bid, ...args) as any)?.v ?? 0;
|
||||
res.json({
|
||||
pending: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending'`),
|
||||
sent: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='sent'`),
|
||||
@@ -141,7 +150,9 @@ notificationsRouter.get("/stats", (req: AuthedRequest, res) => {
|
||||
`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending' AND kind='reminder' AND send_at >= datetime('now')`
|
||||
),
|
||||
no_show_rate: scalar(
|
||||
`SELECT COALESCE(ROUND(100.0 * SUM(CASE WHEN status='no_show' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0),1),0) v FROM appointments WHERE business_id = ? AND created_at >= datetime('now','-30 days')`
|
||||
`SELECT COALESCE(ROUND(100.0 * SUM(CASE WHEN status='no_show' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0),1),0) v
|
||||
FROM appointments WHERE business_id = ? AND datetime(created_at) >= ?`,
|
||||
since30
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user