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:
+3
-1
@@ -17,6 +17,7 @@ import { settingsRouter } from "./routes/settings.ts";
|
||||
import { cashRouter } from "./routes/cash.ts";
|
||||
import { notificationsRouter, scheduleReminders } from "./routes/notifications.ts";
|
||||
import { bookingRouter } from "./routes/booking.ts";
|
||||
import { meRouter } from "./routes/me.ts";
|
||||
import { authRequired } from "./lib/auth.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -64,6 +65,7 @@ app.use("/api/employees", authRequired, employeesRouter);
|
||||
app.use("/api/services", authRequired, servicesRouter);
|
||||
app.use("/api/clients", authRequired, clientsRouter);
|
||||
app.use("/api/appointments", authRequired, appointmentsRouter);
|
||||
app.use("/api/me", authRequired, meRouter);
|
||||
app.use("/api/dashboard", authRequired, dashboardRouter);
|
||||
app.use("/api/admin", authRequired, adminRouter);
|
||||
app.use("/api/settings", authRequired, settingsRouter);
|
||||
@@ -90,5 +92,5 @@ app.use((err: any, _req: express.Request, res: express.Response, _next: express.
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
app.listen(port, host, () => {
|
||||
console.log(`AgendaPro API en http://${host}:${port}`);
|
||||
console.log(`AgendaMax API en http://${host}:${port}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// server/lib/metrics.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { cancelRate, noShowRate, completionRate } from "./metrics.ts";
|
||||
|
||||
test("cancelRate: basic ratio rounded to 1 decimal", () => {
|
||||
assert.equal(cancelRate(1, 4), 25);
|
||||
assert.equal(cancelRate(3, 7), 42.9);
|
||||
assert.equal(cancelRate(0, 10), 0);
|
||||
});
|
||||
|
||||
test("cancelRate: zero total → 0 (no NaN)", () => {
|
||||
assert.equal(cancelRate(0, 0), 0);
|
||||
assert.equal(cancelRate(5, 0), 0);
|
||||
assert.equal(cancelRate(5, -3), 0);
|
||||
});
|
||||
|
||||
test("cancelRate: all cancelled → 100", () => {
|
||||
assert.equal(cancelRate(8, 8), 100);
|
||||
});
|
||||
|
||||
test("noShowRate: basic ratio rounded to 1 decimal", () => {
|
||||
assert.equal(noShowRate(1, 10), 10);
|
||||
assert.equal(noShowRate(1, 3), 33.3);
|
||||
assert.equal(noShowRate(2, 9), 22.2);
|
||||
});
|
||||
|
||||
test("noShowRate: zero total → 0", () => {
|
||||
assert.equal(noShowRate(0, 0), 0);
|
||||
assert.equal(noShowRate(3, 0), 0);
|
||||
});
|
||||
|
||||
test("completionRate: basic ratio rounded to whole percent", () => {
|
||||
assert.equal(completionRate(7, 10), 70);
|
||||
assert.equal(completionRate(1, 3), 33);
|
||||
assert.equal(completionRate(2, 3), 67);
|
||||
});
|
||||
|
||||
test("completionRate: zero total → 0", () => {
|
||||
assert.equal(completionRate(0, 0), 0);
|
||||
assert.equal(completionRate(5, 0), 0);
|
||||
});
|
||||
|
||||
test("completionRate: all completed → 100", () => {
|
||||
assert.equal(completionRate(10, 10), 100);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// server/lib/metrics.ts
|
||||
// Pure KPI metric definitions. All return 0 when there is no data (no divide-by-zero).
|
||||
|
||||
/** Cancellation rate: cancelled / total, rounded to 1 decimal (percent). */
|
||||
export function cancelRate(cancelled: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((cancelled / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
/** No-show rate: noShow / total, rounded to 1 decimal (percent). */
|
||||
export function noShowRate(noShow: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((noShow / total) * 1000) / 10;
|
||||
}
|
||||
|
||||
/** Completion rate: completed / total, rounded to the nearest whole percent. */
|
||||
export function completionRate(completed: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((completed / total) * 100);
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate,
|
||||
normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate,
|
||||
pickBestSlotEmployee, BUSY_STATUSES,
|
||||
} from "./scheduling.ts";
|
||||
import type { CandidateInfo, BusyWindow } from "./scheduling.ts";
|
||||
|
||||
test("parseWorkingHours: json válido → mapa 1..7", () => {
|
||||
const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
|
||||
@@ -69,13 +71,13 @@ test("hasConflict: lista vacía → false", () => {
|
||||
assert.equal(hasConflict([], 100, 200), false);
|
||||
});
|
||||
|
||||
test("scoreCandidate: pesos 50/30/20", () => {
|
||||
test("scoreCandidate: pesos 40/20/40", () => {
|
||||
// todo al máximo → 100
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 1, efficiency: 1, loadBalance: 0 }), 100);
|
||||
// todo al mínimo → 0
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0, efficiency: 0, loadBalance: 1 }), 0);
|
||||
// sólo specialty (0.5) → 25
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 25);
|
||||
// sólo specialty (0.5) → 20 (40·0.5)
|
||||
assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 20);
|
||||
});
|
||||
|
||||
test("scoreCandidate: mayor efficiency → mayor score", () => {
|
||||
@@ -89,3 +91,55 @@ test("scoreCandidate: menor load (más disponible) → mayor score", () => {
|
||||
const free = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.1 });
|
||||
assert.ok(free > busy);
|
||||
});
|
||||
|
||||
test("scoreCandidate: fairness — libre+ineficiente vence a ocupado+eficiente (misma especialidad)", () => {
|
||||
// Con los pesos viejos (50/30/20) el efficiency estático (30) aplastaba al load (20):
|
||||
// un senior ocupado siempre ganaba. Con 40/20/40, el load (40) supera al efficiency (20).
|
||||
// A: especialista ocupado (load 0.8) y muy eficiente (0.9)
|
||||
const a = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.8 });
|
||||
// B: especialista libre (load 0.1) e ineficiente (0.2)
|
||||
const b = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.1 });
|
||||
assert.equal(a, 40 * 0.5 + 20 * 0.9 + 40 * (1 - 0.8)); // 46
|
||||
assert.equal(b, 40 * 0.5 + 20 * 0.2 + 40 * (1 - 0.1)); // 60
|
||||
assert.ok(b > a, "el especialista libre debe vencer al ocupado+eficiente (anti-burnout)");
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no por id)", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 13, 0).getTime(); // lunes, dentro de horario
|
||||
const endMs = new Date(2026, 6, 27, 14, 0).getTime();
|
||||
// A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5)
|
||||
// → 40·1 + 20·0.5 + 40·0.5 = 70
|
||||
// B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0)
|
||||
// → 40·0.5 + 20·0.5 + 40·1 = 70 → empate exacto; menor load gana
|
||||
const A: CandidateInfo = { id: 1, name: "Senior", color: "#fff", role: "stylist", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 2, name: "Junior", color: "#fff", role: "stylist", specialties: [], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([
|
||||
[1, [{ startMs: new Date(2026, 6, 27, 9, 0).getTime(), endMs: new Date(2026, 6, 27, 13, 0).getTime() }]],
|
||||
[2, []],
|
||||
]);
|
||||
const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
assert.equal(best?.id, 2, "el junior libre gana el empate por menor loadBalance, NO por id menor");
|
||||
});
|
||||
|
||||
test("pickBestSlotEmployee: empate total (score+load) → decisión determinista y reproducible", () => {
|
||||
const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } }));
|
||||
const startMs = new Date(2026, 6, 27, 9, 0).getTime();
|
||||
const endMs = new Date(2026, 6, 27, 10, 0).getTime();
|
||||
// Dos candidatos idénticos salvo el id → mismo score y mismo loadBalance → hash decide.
|
||||
const A: CandidateInfo = { id: 10, name: "Diez", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const B: CandidateInfo = { id: 20, name: "Veinte", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null };
|
||||
const busy = new Map<number, BusyWindow[]>([[10, []], [20, []]]);
|
||||
const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs);
|
||||
assert.ok(r1 && (r1.id === 10 || r1.id === 20), "debe elegir uno de los dos candidatos");
|
||||
assert.equal(r1?.id, r2?.id, "misma entrada → misma decisión (hash determinista, reproducible)");
|
||||
});
|
||||
|
||||
test("BUSY_STATUSES: scheduled+completed sí cuentan; no_show NO (silla vacía)", () => {
|
||||
const s: string[] = [...BUSY_STATUSES];
|
||||
assert.ok(s.includes("scheduled"));
|
||||
assert.ok(s.includes("completed"));
|
||||
assert.equal(s.includes("no_show"), false);
|
||||
assert.equal(s.length, 2);
|
||||
});
|
||||
|
||||
+52
-11
@@ -98,12 +98,15 @@ export function hasConflict(existing: BusyWindow[], startMs: number, endMs: numb
|
||||
return existing.some((b) => overlaps(startMs, endMs, b.startMs, b.endMs));
|
||||
}
|
||||
|
||||
/** score = 50·specialtyMatch + 30·efficiency + 20·(1−loadBalance), todo en [0,1]. Rango 0..100. */
|
||||
/** score = 40·specialtyMatch + 20·efficiency + 40·(1−loadBalance), todo en [0,1]. Rango 0..100.
|
||||
* Pesos 40/20/40: specialty sigue mandando en matches reales, pero loadBalance (40) ahora
|
||||
* puede superar la ventaja estática de efficiency (20) → el especialista libre/menos cargado
|
||||
* gana con más frecuencia (equidad, anti-burnout). */
|
||||
export function scoreCandidate(s: ScoreInput): number {
|
||||
const sm = clamp01(s.specialtyMatch);
|
||||
const eff = clamp01(s.efficiency);
|
||||
const inv = clamp01(1 - clamp01(s.loadBalance));
|
||||
return 50 * sm + 30 * eff + 20 * inv;
|
||||
return 40 * sm + 20 * eff + 40 * inv;
|
||||
}
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
@@ -148,18 +151,24 @@ export function getCandidates(db: DatabaseSync, businessId: number, serviceId: n
|
||||
}));
|
||||
}
|
||||
|
||||
/** Estados que ocupan la ventana de trabajo del empleado para load/availability.
|
||||
* NOTA: `no_show` NO está — la silla quedó vacía, así que no cuenta contra la carga
|
||||
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
|
||||
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
|
||||
|
||||
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
|
||||
const map = new Map<number, BusyWindow[]>();
|
||||
if (employeeIds.length === 0) return map;
|
||||
const startOfDay = `${dateIso}T00:00:00`;
|
||||
const endOfDay = `${dateIso}T23:59:59`;
|
||||
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT employee_id, start_at, end_at FROM appointments
|
||||
WHERE employee_id IN (${employeeIds.map(() => "?").join(",")})
|
||||
AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
|
||||
AND status IN (${statusPh}) AND start_at >= ? AND start_at <= ?`
|
||||
)
|
||||
.all(...employeeIds, startOfDay, endOfDay) as any[];
|
||||
.all(...employeeIds, ...BUSY_STATUSES, startOfDay, endOfDay) as any[];
|
||||
for (const r of rows) {
|
||||
const arr = map.get(r.employee_id) ?? [];
|
||||
arr.push({ startMs: new Date(r.start_at).getTime(), endMs: new Date(r.end_at).getTime() });
|
||||
@@ -174,7 +183,7 @@ export function isAvailable(db: DatabaseSync, employeeId: number, startMs: numbe
|
||||
return !hasConflict(busy, startMs, endMs);
|
||||
}
|
||||
|
||||
interface RankedCandidate { info: CandidateInfo; score: number; reasons: string[] }
|
||||
interface RankedCandidate { info: CandidateInfo; score: number; loadBalance: number; reasons: string[] }
|
||||
|
||||
function rankOne(
|
||||
c: CandidateInfo,
|
||||
@@ -195,7 +204,38 @@ function rankOne(
|
||||
if (sm >= 1) reasons.push("Especialidad coincidente");
|
||||
if (c.efficiency_score >= 75) reasons.push("Alta eficiencia");
|
||||
if (loadBalance <= 0.25) reasons.push("Buena disponibilidad");
|
||||
return { info: c, score, reasons };
|
||||
return { info: c, score, loadBalance, reasons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash determinista (xmur3) → uint32. Sin deps. Misma entrada ⇒ misma salida en cualquier
|
||||
* plataforma, para que el desempate por hash sea reproducible por input.
|
||||
* Úselo para repartir empates sin sesgo por id/seniority. */
|
||||
export function hashStr(str: string): number {
|
||||
let h = 1779033703 ^ str.length;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||||
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||||
return (h ^ (h >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
/** Epsilon para considerar dos loadBalance iguales (ratio de ms → posible ruido float). */
|
||||
const LOAD_EPSILON = 1e-9;
|
||||
|
||||
/**
|
||||
* ¿`a` desplaza al actual `best`? Orden de desempate (anti-burnout, sin sesgo por id):
|
||||
* 1) mayor score
|
||||
* 2) (cerca de empate) menor loadBalance — gana el menos cargado
|
||||
* 3) hash determinista de `${dateIso}|${startMs}|${empId}` — mismo slot ⇒ misma decisión,
|
||||
* pero slots distintos reparten entre empleados (no siempre cae en la misma persona).
|
||||
*/
|
||||
function beats(a: RankedCandidate, best: RankedCandidate, dateIso: string, startMs: number): boolean {
|
||||
if (a.score !== best.score) return a.score > best.score;
|
||||
if (Math.abs(a.loadBalance - best.loadBalance) > LOAD_EPSILON) return a.loadBalance < best.loadBalance;
|
||||
return hashStr(`${dateIso}|${startMs}|${a.info.id}`) < hashStr(`${dateIso}|${startMs}|${best.info.id}`);
|
||||
}
|
||||
|
||||
/** Usado por el endpoint de slots: devuelve el mejor candidato libre para un slot concreto. */
|
||||
@@ -208,7 +248,8 @@ export function pickBestSlotEmployee(
|
||||
endMs: number
|
||||
): { id: number; name: string } | null {
|
||||
const date = new Date(startMs);
|
||||
let best: { id: number; name: string; score: number } | null = null;
|
||||
const dateIso = isoDateStr(date);
|
||||
let best: RankedCandidate | null = null;
|
||||
for (const c of candidates) {
|
||||
const wh = getWorkingHoursForDate(c.empWh, bizWh, date);
|
||||
if (!wh) continue;
|
||||
@@ -216,11 +257,11 @@ export function pickBestSlotEmployee(
|
||||
if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
|
||||
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, service);
|
||||
if (!ranked) continue;
|
||||
if (!best || ranked.score > best.score || (ranked.score === best.score && c.id < best.id)) {
|
||||
best = { id: c.id, name: c.name, score: ranked.score };
|
||||
if (!best || beats(ranked, best, dateIso, startMs)) {
|
||||
best = ranked;
|
||||
}
|
||||
}
|
||||
return best ? { id: best.id, name: best.name } : null;
|
||||
return best ? { id: best.info.id, name: best.info.name } : null;
|
||||
}
|
||||
|
||||
export interface AutoAssignResult {
|
||||
@@ -253,7 +294,7 @@ export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResu
|
||||
if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
|
||||
const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, { name: ctx.serviceName, category: ctx.serviceCategory });
|
||||
if (!ranked) continue;
|
||||
if (!best || ranked.score > best.score || (ranked.score === best.score && c.id < best.info.id)) {
|
||||
if (!best || beats(ranked, best, dateIso, ctx.startMs)) {
|
||||
best = ranked;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// server/lib/time.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
bizDateISO,
|
||||
bizTodayISO,
|
||||
bizDayBoundsSqlite,
|
||||
bizDayBoundsIso,
|
||||
wallToUtcISO,
|
||||
wallToUtcDate,
|
||||
toSqliteUtc,
|
||||
toIsoUtc,
|
||||
} from "./time.ts";
|
||||
|
||||
const MX = "America/Mexico_City"; // UTC-6 (no DST since 2023)
|
||||
const BA = "America/Buenos_Aires"; // UTC-3
|
||||
|
||||
test("bizTodayISO: UTC instant just past midnight (00:30Z) is still the previous day in MX", () => {
|
||||
// 2026-07-27T00:30:00Z == 2026-07-26T18:30 local → still the 26th
|
||||
assert.equal(bizTodayISO(MX, new Date("2026-07-27T00:30:00Z")), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizTodayISO: late afternoon UTC (17:00Z) is same biz day in MX", () => {
|
||||
assert.equal(bizTodayISO(MX, new Date("2026-07-26T17:00:00Z")), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDateISO: Buenos Aires UTC-3 midday stays same day", () => {
|
||||
// 17:00Z == 14:00 local
|
||||
assert.equal(bizDateISO(new Date("2026-07-26T17:00:00Z"), BA), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDateISO: Buenos Aires UTC-3 evening stays same day", () => {
|
||||
// 23:00Z == 20:00 local
|
||||
assert.equal(bizDateISO(new Date("2026-07-26T23:00:00Z"), BA), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDateISO: UTC instant rolls to next local day", () => {
|
||||
// 2026-07-27T05:00:00Z == 2026-07-26T23:00 in MX → still 26th
|
||||
assert.equal(bizDateISO(new Date("2026-07-27T05:00:00Z"), MX), "2026-07-26");
|
||||
// 2026-07-27T06:30:00Z == 2026-07-27T00:30 in MX → now 27th
|
||||
assert.equal(bizDateISO(new Date("2026-07-27T06:30:00Z"), MX), "2026-07-27");
|
||||
});
|
||||
|
||||
test("wallToUtcISO: MX wall 18:00 → UTC next-day 00:00", () => {
|
||||
assert.equal(wallToUtcISO(MX, 2026, 7, 26, 18, 0, 0), "2026-07-27T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("wallToUtcISO: MX wall 00:00 → UTC 06:00 same date", () => {
|
||||
assert.equal(wallToUtcISO(MX, 2026, 7, 26, 0, 0, 0), "2026-07-26T06:00:00.000Z");
|
||||
});
|
||||
|
||||
test("wallToUtcDate: round-trip via bizDateISO stays on the wall day", () => {
|
||||
const utc = wallToUtcDate(MX, 2026, 7, 26, 18, 0, 0);
|
||||
assert.equal(bizDateISO(utc, MX), "2026-07-26");
|
||||
});
|
||||
|
||||
test("bizDayBoundsSqlite: today bounds capture the evening rush (UTC-6)", () => {
|
||||
// "now" is 2026-07-26T23:30:00Z == 2026-07-26T17:30 local MX → biz day 2026-07-26
|
||||
const b = bizDayBoundsSqlite(MX, 0, new Date("2026-07-26T23:30:00Z"));
|
||||
// biz-local midnight 2026-07-26 in MX == 06:00Z; end of day 23:59:59 == 2026-07-27T05:59:59Z
|
||||
assert.equal(b.start, "2026-07-26 06:00:00");
|
||||
assert.equal(b.end, "2026-07-27 05:59:59");
|
||||
});
|
||||
|
||||
test("bizDayBoundsIso: ISO-Z bounds for start_at comparisons", () => {
|
||||
const b = bizDayBoundsIso(MX, 0, new Date("2026-07-26T23:30:00Z"));
|
||||
assert.equal(b.start, "2026-07-26T06:00:00Z");
|
||||
assert.equal(b.end, "2026-07-27T05:59:59Z");
|
||||
});
|
||||
|
||||
test("bizDayBoundsSqlite: negative offset reaches into the past", () => {
|
||||
// 30 days before biz day 2026-07-26 → 2026-06-26, midnight MX == 06:00Z
|
||||
const b = bizDayBoundsSqlite(MX, -30, new Date("2026-07-26T23:30:00Z"));
|
||||
assert.equal(b.start, "2026-06-26 06:00:00");
|
||||
});
|
||||
|
||||
test("toSqliteUtc / toIsoUtc format helpers", () => {
|
||||
const d = new Date("2026-07-27T00:00:00.000Z");
|
||||
assert.equal(toSqliteUtc(d), "2026-07-27 00:00:00");
|
||||
assert.equal(toIsoUtc(d), "2026-07-27T00:00:00Z");
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
// server/lib/time.ts
|
||||
// Pure timezone helpers for business-local ("biz") date math.
|
||||
// Mexico is UTC-6, so SQLite date('now') (= UTC) misattributes the evening rush.
|
||||
// All functions are deterministic given an explicit instant (default = new Date()).
|
||||
|
||||
/** Returns the calendar date (YYYY-MM-DD) of the given instant in the given IANA tz. */
|
||||
export function bizDateISO(instant: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz || "UTC",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(instant); // en-CA yields "YYYY-MM-DD"
|
||||
}
|
||||
|
||||
/** Business "today" (YYYY-MM-DD) for a tz, at the given instant (default: server now). */
|
||||
export function bizTodayISO(tz: string, now: Date = new Date()): string {
|
||||
return bizDateISO(now, tz);
|
||||
}
|
||||
|
||||
/** tz offset (in minutes) of the given instant, east of UTC positive. */
|
||||
function tzOffsetMinutes(instant: Date, tz: string): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz || "UTC",
|
||||
timeZoneName: "longOffset",
|
||||
}).formatToParts(instant);
|
||||
const off = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+00:00";
|
||||
// e.g. "GMT-06:00", "GMT+05:30", "GMT+00:00", or bare "GMT" for UTC
|
||||
const m = off.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
||||
if (!m) return 0; // bare "GMT" → UTC
|
||||
const sign = m[1] === "-" ? -1 : 1;
|
||||
const h = parseInt(m[2], 10);
|
||||
const min = m[3] ? parseInt(m[3], 10) : 0;
|
||||
return sign * (h * 60 + min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a business-local wall-clock (year, month, day, hh, mm, ss) in `tz` to a UTC Date.
|
||||
* We first treat the wall-clock as if it were UTC, read the tz offset at that instant,
|
||||
* then subtract the offset: a west tz (negative offset) is "behind" UTC, so the same
|
||||
* wall-clock happens later in UTC → UTC = wall − offset.
|
||||
*/
|
||||
export function wallToUtcDate(
|
||||
tz: string,
|
||||
y: number,
|
||||
mo: number,
|
||||
d: number,
|
||||
hh: number,
|
||||
mm: number,
|
||||
ss: number
|
||||
): Date {
|
||||
const guess = new Date(Date.UTC(y, mo - 1, d, hh, mm, ss));
|
||||
const offsetMin = tzOffsetMinutes(guess, tz);
|
||||
return new Date(guess.getTime() - offsetMin * 60000);
|
||||
}
|
||||
|
||||
/** Business-day UTC bounds (start = local 00:00:00, end = local 23:59:59) for today +/- offset. */
|
||||
export function bizDayBounds(
|
||||
tz: string,
|
||||
dayOffset = 0,
|
||||
now: Date = new Date()
|
||||
): { start: Date; end: Date } {
|
||||
const today = bizDateISO(now, tz);
|
||||
const [y, m, d] = today.split("-").map(Number);
|
||||
const base = new Date(Date.UTC(y, m - 1, d));
|
||||
base.setUTCDate(base.getUTCDate() + dayOffset);
|
||||
const ty = base.getUTCFullYear();
|
||||
const tm = base.getUTCMonth() + 1;
|
||||
const td = base.getUTCDate();
|
||||
return {
|
||||
start: wallToUtcDate(tz, ty, tm, td, 0, 0, 0),
|
||||
end: wallToUtcDate(tz, ty, tm, td, 23, 59, 59),
|
||||
};
|
||||
}
|
||||
|
||||
/** Format a UTC instant as SQLite canonical "YYYY-MM-DD HH:MM:SS" (matches datetime() output). */
|
||||
export function toSqliteUtc(d: Date): string {
|
||||
return d.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
/** Format a UTC instant as ISO "YYYY-MM-DDTHH:MM:SSZ" (matches the stored start_at format). */
|
||||
export function toIsoUtc(d: Date): string {
|
||||
return d.toISOString().slice(0, 19) + "Z";
|
||||
}
|
||||
|
||||
/** Business-day bounds in SQLite canonical format — pair with `datetime(column) >= ?`. */
|
||||
export function bizDayBoundsSqlite(
|
||||
tz: string,
|
||||
dayOffset = 0,
|
||||
now: Date = new Date()
|
||||
): { start: string; end: string } {
|
||||
const b = bizDayBounds(tz, dayOffset, now);
|
||||
return { start: toSqliteUtc(b.start), end: toSqliteUtc(b.end) };
|
||||
}
|
||||
|
||||
/** Business-day bounds in ISO-Z format — pair with raw `start_at >= ?`. */
|
||||
export function bizDayBoundsIso(
|
||||
tz: string,
|
||||
dayOffset = 0,
|
||||
now: Date = new Date()
|
||||
): { start: string; end: string } {
|
||||
const b = bizDayBounds(tz, dayOffset, now);
|
||||
return { start: toIsoUtc(b.start), end: toIsoUtc(b.end) };
|
||||
}
|
||||
|
||||
/** Convenience: convert a business-local wall-clock to a UTC ISO string. */
|
||||
export function wallToUtcISO(
|
||||
tz: string,
|
||||
y: number,
|
||||
mo: number,
|
||||
d: number,
|
||||
hh: number,
|
||||
mm: number,
|
||||
ss: number
|
||||
): string {
|
||||
return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString();
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,9 @@ const { status: bookStatus, json: booked } = await req("POST", `/public/${slug}/
|
||||
});
|
||||
check("reserva creada (auto-assign)", bookStatus === 201 && !!booked.appointment?.employee_id, `status=${bookStatus}`);
|
||||
check("response trae reasons", Array.isArray(booked.appointment?.reasons) && booked.appointment.reasons.length > 0);
|
||||
check("response trae no_show_count", typeof booked.no_show_count === "number", `got ${typeof booked.no_show_count}`);
|
||||
check("response trae risk_flag", typeof booked.risk_flag === "boolean", `got ${typeof booked.risk_flag}`);
|
||||
check("cliente nuevo → sin riesgo", booked.no_show_count === 0 && booked.risk_flag === false, `ns=${booked.no_show_count} risk=${booked.risk_flag}`);
|
||||
|
||||
// 5) Mismo slot otra vez → 409 (doble reserva del mismo especialista)
|
||||
const { status: conflict } = await req("POST", `/public/${slug}/book`, {
|
||||
|
||||
+21
-2
@@ -204,14 +204,14 @@ export function seedBusiness(opts: SeedBusinessOptions) {
|
||||
let pastCount = 0;
|
||||
let upCount = 0;
|
||||
|
||||
const makeAppt = (dayOffset: number, svcIdx: number, status: string, createdBy: number | null) => {
|
||||
const makeAppt = (dayOffset: number, svcIdx: number, status: string, createdBy: number | null, fixedHour?: number) => {
|
||||
const svc = template.services[svcIdx];
|
||||
if (!svc) return null;
|
||||
const empLocalIdx = pick(svc.employees);
|
||||
const empId = empIds[empLocalIdx];
|
||||
const svcId = svcIds[svcIdx];
|
||||
const clientId = pick(clientIds);
|
||||
const hour = pick([9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
|
||||
const hour = fixedHour ?? pick([9, 10, 11, 12, 13, 14, 15, 16, 17, 18]);
|
||||
const minute = pick([0, 30]);
|
||||
const start = isoOffsetDays(dayOffset, hour, minute);
|
||||
const end = addMinutes(start, svc.duration_min);
|
||||
@@ -273,6 +273,25 @@ export function seedBusiness(opts: SeedBusinessOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee visible appointments across the current week (Mon–Sat) regardless of today's weekday
|
||||
{
|
||||
const now = new Date();
|
||||
const dow = now.getDay(); // 0=Sun ... 6=Sat
|
||||
const mondayOffset = dow === 0 ? -6 : 1 - dow;
|
||||
for (let d = 0; d < 6; d++) {
|
||||
const dayOffset = mondayOffset + d;
|
||||
if (dayOffset < -60) continue;
|
||||
const past = dayOffset < 0;
|
||||
const isToday_ = dayOffset === 0;
|
||||
const hours = pickN([10, 12, 14, 16, 18], rint(2, 4));
|
||||
for (const hour of hours) {
|
||||
const svcIdx = rint(0, template.services.length - 1);
|
||||
const status = past ? "completed" : (isToday_ && hour <= now.getHours()) ? "completed" : "scheduled";
|
||||
if (makeAppt(dayOffset, svcIdx, status, ownerRow?.id ?? null, hour)) upCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recompute ratings
|
||||
db.exec(`
|
||||
UPDATE employees SET rating = COALESCE(
|
||||
|
||||
Reference in New Issue
Block a user