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