- 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)
135 lines
4.6 KiB
TypeScript
135 lines
4.6 KiB
TypeScript
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 });
|
|
});
|