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:
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user