Task #714: rooms management submenu + rooms dashboard

- protocol.tsx: added collapsible "إدارة القاعات" nav group (giftsGroup pattern)
  containing لوحة القاعات (roomsDashboard, new), القاعات (rooms),
  حجز القاعات (bookings), إعدادات الحجوزات (bookingSettings, canManageRooms only).
- Deep-link slugs: /protocol/rooms-dashboard, /protocol/booking-settings.
- SlotsAdminSection moved from rooms tab to booking-settings tab; non-manager
  deep-link shows no-access empty state.
- New RoomsDashboardView (recharts): KPI cards, next-booking countdown,
  14/30-day area chart, by-room bar chart, by-status pie.
- API: new GET /api/protocol/rooms-stats (requireProtocolAccess), Riyadh-tz
  day boundaries, 30-day zero-filled daily series, byRoom/byStatus, nextBooking.
- i18n keys ar/en (tabs + protocol.roomsDash.* with Arabic plurals).
- Tests: artifacts/api-server/tests/protocol-rooms-stats.test.mjs (2 pass).
- Architect review passed; typechecks clean (pre-existing errors in
  executive-meetings.ts/push.ts untouched).
This commit is contained in:
Replit Agent
2026-07-08 20:39:29 +00:00
parent 81dd7b708f
commit 89331004f3
5 changed files with 917 additions and 7 deletions
+169
View File
@@ -2537,6 +2537,175 @@ router.get(
},
);
// Rooms dashboard stats (لوحة بيانات القاعات). All day boundaries are Riyadh
// calendar days (fixed +03:00, no DST). Read-only; same guard as /dashboard.
router.get(
"/protocol/rooms-stats",
requireProtocolAccess,
async (_req: Request, res: Response) => {
const now = new Date();
// Riyadh "today" as YYYY-MM-DD, then absolute day boundaries.
const todayYmd = new Intl.DateTimeFormat("en-CA", {
timeZone: RIYADH_TZ,
}).format(now);
const startOfToday = new Date(`${todayYmd}T00:00:00+03:00`);
const dayMs = 24 * 60 * 60 * 1000;
const endOfToday = new Date(startOfToday.getTime() + dayMs);
// "This week" = the next 7 Riyadh days starting today (inclusive).
const endOfWeek = new Date(startOfToday.getTime() + 7 * dayMs);
// Time-series / distribution window: the last 30 Riyadh days incl. today.
const seriesStart = new Date(startOfToday.getTime() - 29 * dayMs);
const OCCUPYING = ["pending", "approved"] as const;
const [roomCounts] = await db
.select({
active: sql<number>`count(*) filter (where ${protocolRoomsTable.isActive})::int`,
inactive: sql<number>`count(*) filter (where not ${protocolRoomsTable.isActive})::int`,
})
.from(protocolRoomsTable);
const [todayCount] = await db
.select({ c: sql<number>`count(*)::int` })
.from(protocolRoomBookingsTable)
.where(
and(
inArray(protocolRoomBookingsTable.status, OCCUPYING as unknown as string[]),
gte(protocolRoomBookingsTable.startsAt, startOfToday),
lt(protocolRoomBookingsTable.startsAt, endOfToday),
),
);
const [weekCount] = await db
.select({ c: sql<number>`count(*)::int` })
.from(protocolRoomBookingsTable)
.where(
and(
inArray(protocolRoomBookingsTable.status, OCCUPYING as unknown as string[]),
gte(protocolRoomBookingsTable.startsAt, startOfToday),
lt(protocolRoomBookingsTable.startsAt, endOfWeek),
),
);
const [pendingCount] = await db
.select({ c: sql<number>`count(*)::int` })
.from(protocolRoomBookingsTable)
.where(eq(protocolRoomBookingsTable.status, "pending"));
// Nearest upcoming booking (approved first if tied is unnecessary —
// simple earliest start wins).
const [next] = await db
.select({
id: protocolRoomBookingsTable.id,
title: protocolRoomBookingsTable.title,
startsAt: protocolRoomBookingsTable.startsAt,
endsAt: protocolRoomBookingsTable.endsAt,
status: protocolRoomBookingsTable.status,
roomNameAr: protocolRoomsTable.nameAr,
roomNameEn: protocolRoomsTable.nameEn,
})
.from(protocolRoomBookingsTable)
.innerJoin(
protocolRoomsTable,
eq(protocolRoomBookingsTable.roomId, protocolRoomsTable.id),
)
.where(
and(
inArray(protocolRoomBookingsTable.status, OCCUPYING as unknown as string[]),
gte(protocolRoomBookingsTable.startsAt, now),
),
)
.orderBy(asc(protocolRoomBookingsTable.startsAt))
.limit(1);
// Bookings per Riyadh day over the last 30 days (pending + approved).
const riyadhDayExpr = sql<string>`to_char(${protocolRoomBookingsTable.startsAt} AT TIME ZONE 'Asia/Riyadh', 'YYYY-MM-DD')`;
const dailyRows = await db
.select({
day: riyadhDayExpr,
count: sql<number>`count(*)::int`,
})
.from(protocolRoomBookingsTable)
.where(
and(
inArray(protocolRoomBookingsTable.status, OCCUPYING as unknown as string[]),
gte(protocolRoomBookingsTable.startsAt, seriesStart),
lt(protocolRoomBookingsTable.startsAt, endOfToday),
),
)
.groupBy(riyadhDayExpr)
.orderBy(riyadhDayExpr);
// Zero-fill every day in the window so charts show gaps correctly.
const byDay = new Map(dailyRows.map((r) => [r.day, r.count]));
const daily: Array<{ date: string; count: number }> = [];
for (let i = 0; i < 30; i++) {
const d = new Date(seriesStart.getTime() + i * dayMs);
const ymd = new Intl.DateTimeFormat("en-CA", {
timeZone: RIYADH_TZ,
}).format(d);
daily.push({ date: ymd, count: byDay.get(ymd) ?? 0 });
}
// Distribution per room + per status over the same 30-day window.
const inWindow = and(
gte(protocolRoomBookingsTable.startsAt, seriesStart),
lt(protocolRoomBookingsTable.startsAt, endOfToday),
);
const byRoom = await db
.select({
roomId: protocolRoomsTable.id,
nameAr: protocolRoomsTable.nameAr,
nameEn: protocolRoomsTable.nameEn,
count: sql<number>`count(${protocolRoomBookingsTable.id})::int`,
})
.from(protocolRoomBookingsTable)
.innerJoin(
protocolRoomsTable,
eq(protocolRoomBookingsTable.roomId, protocolRoomsTable.id),
)
.where(inWindow)
.groupBy(
protocolRoomsTable.id,
protocolRoomsTable.nameAr,
protocolRoomsTable.nameEn,
)
.orderBy(sql`count(${protocolRoomBookingsTable.id}) desc`);
const byStatus = await db
.select({
status: protocolRoomBookingsTable.status,
count: sql<number>`count(*)::int`,
})
.from(protocolRoomBookingsTable)
.where(inWindow)
.groupBy(protocolRoomBookingsTable.status);
res.json({
rooms: {
active: roomCounts?.active ?? 0,
inactive: roomCounts?.inactive ?? 0,
},
todayBookings: todayCount?.c ?? 0,
weekBookings: weekCount?.c ?? 0,
pendingBookings: pendingCount?.c ?? 0,
nextBooking: next
? {
id: next.id,
title: next.title,
roomNameAr: next.roomNameAr,
roomNameEn: next.roomNameEn,
startsAt: next.startsAt.toISOString(),
endsAt: next.endsAt.toISOString(),
status: next.status,
}
: null,
daily,
byRoom,
byStatus,
});
},
);
// ---------------------------------------------------------------------------
// Reports
// ---------------------------------------------------------------------------
@@ -0,0 +1,208 @@
// API contract tests for GET /protocol/rooms-stats (لوحة بيانات القاعات).
// Covers:
// 1. Auth guard: unauthenticated request -> 401.
// 2. Shape: rooms{active,inactive}, todayBookings, weekBookings,
// pendingBookings, nextBooking, daily (30 zero-filled Riyadh days),
// byRoom, byStatus.
// 3. Counters reflect seeded data: a pending booking tomorrow shows up in
// weekBookings + pendingBookings + nextBooking + byRoom/byStatus;
// a cancelled booking is excluded from occupancy counters.
//
// Seeded rows are deleted in `after`; no global state is mutated.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import pg from "pg";
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run these tests");
}
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const created = { roomIds: [], bookingIds: [], userIds: [] };
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
function uniqueName(prefix) {
return `${prefix}_${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 8)}`;
}
async function createSuperAdmin() {
const username = uniqueName("stats_admin");
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, 'Stats Test Admin', 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
);
created.userIds.push(rows[0].id);
for (const r of ["user", "protocol_super_admin"]) {
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = $2
ON CONFLICT DO NOTHING`,
[rows[0].id, r],
);
}
return { username, id: rows[0].id };
}
function extractCookie(res) {
const setCookie = res.headers.get("set-cookie");
if (!setCookie) return null;
return (
setCookie
.split(",")
.map((c) => c.split(";")[0].trim())
.find((c) => c.startsWith("connect.sid=")) ?? null
);
}
async function login(username, password) {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200, `login should succeed for ${username}`);
const cookie = extractCookie(res);
assert.ok(cookie, "login response should set a session cookie");
return cookie;
}
async function api(cookie, method, path) {
return fetch(`${API_BASE}/api${path}`, {
method,
headers: cookie ? { Cookie: cookie } : {},
});
}
let adminCookie = null;
let adminId = null;
let roomId = null;
let baseline = null;
before(async () => {
const admin = await createSuperAdmin();
adminId = admin.id;
adminCookie = await login(admin.username, TEST_PASSWORD);
// Baseline snapshot before seeding, so assertions are delta-based and
// robust against pre-existing data.
const res = await api(adminCookie, "GET", "/protocol/rooms-stats");
assert.equal(res.status, 200);
baseline = await res.json();
const room = await pool.query(
`INSERT INTO protocol_rooms (name_ar, name_en, capacity, is_active)
VALUES ($1, $2, 10, true) RETURNING id`,
[uniqueName("قاعة_stats"), uniqueName("StatsRoom")],
);
roomId = room.rows[0].id;
created.roomIds.push(roomId);
// Pending booking tomorrow 10:00-11:00 Riyadh (counts everywhere).
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const b1 = await pool.query(
`INSERT INTO protocol_room_bookings (room_id, title, starts_at, ends_at, status, created_by, booking_reference)
VALUES ($1, $2, $3::timestamptz, $4::timestamptz, 'pending', $5, $6) RETURNING id`,
[
roomId,
"Stats test booking",
`${tomorrow}T10:00:00+03:00`,
`${tomorrow}T11:00:00+03:00`,
adminId,
uniqueName("REF"),
],
);
created.bookingIds.push(b1.rows[0].id);
// Cancelled booking tomorrow (must NOT count in week/pending/nextBooking).
const b2 = await pool.query(
`INSERT INTO protocol_room_bookings (room_id, title, starts_at, ends_at, status, created_by, booking_reference)
VALUES ($1, $2, $3::timestamptz, $4::timestamptz, 'cancelled', $5, $6) RETURNING id`,
[
roomId,
"Stats cancelled booking",
`${tomorrow}T12:00:00+03:00`,
`${tomorrow}T13:00:00+03:00`,
adminId,
uniqueName("REF"),
],
);
created.bookingIds.push(b2.rows[0].id);
});
after(async () => {
if (created.bookingIds.length) {
await pool.query(`DELETE FROM protocol_room_bookings WHERE id = ANY($1)`, [
created.bookingIds,
]);
}
if (created.roomIds.length) {
await pool.query(`DELETE FROM protocol_rooms WHERE id = ANY($1)`, [
created.roomIds,
]);
}
if (created.userIds.length) {
await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1)`, [
created.userIds,
]);
await pool.query(`DELETE FROM users WHERE id = ANY($1)`, [
created.userIds,
]);
}
await pool.end();
});
test("unauthenticated request is rejected", async () => {
const res = await api(null, "GET", "/protocol/rooms-stats");
assert.equal(res.status, 401);
});
test("stats shape and seeded counters", async () => {
const res = await api(adminCookie, "GET", "/protocol/rooms-stats");
assert.equal(res.status, 200);
const s = await res.json();
// Shape
assert.equal(typeof s.rooms.active, "number");
assert.equal(typeof s.rooms.inactive, "number");
assert.equal(typeof s.todayBookings, "number");
assert.equal(typeof s.weekBookings, "number");
assert.equal(typeof s.pendingBookings, "number");
assert.ok(Array.isArray(s.daily));
assert.equal(s.daily.length, 30, "daily series is zero-filled to 30 days");
for (const d of s.daily) {
assert.match(d.date, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(typeof d.count, "number");
}
assert.ok(Array.isArray(s.byRoom));
assert.ok(Array.isArray(s.byStatus));
// Deltas vs baseline
assert.equal(s.rooms.active, baseline.rooms.active + 1);
assert.equal(s.weekBookings, baseline.weekBookings + 1);
assert.equal(s.pendingBookings, baseline.pendingBookings + 1);
// nextBooking exists and starts in the future; if it is ours, check names.
assert.ok(s.nextBooking, "nextBooking should exist after seeding");
assert.ok(new Date(s.nextBooking.startsAt).getTime() > Date.now());
assert.ok(["pending", "approved"].includes(s.nextBooking.status));
// Seeded room appears in byRoom with 2 bookings (pending + cancelled are
// both inside the 30-day window? cancelled tomorrow is OUTSIDE the window
// [today-29d, today+1d) only the ones starting before end of today count).
// Tomorrow's bookings start after endOfToday, so byRoom must NOT include
// the seeded room unless it had past bookings.
const seeded = s.byRoom.find((r) => r.roomId === roomId);
assert.equal(seeded, undefined, "future bookings are outside the 30-day window");
});