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
|
// 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");
|
||||||
|
});
|
||||||
@@ -1667,6 +1667,9 @@
|
|||||||
"confirmDelete": "لا يمكن التراجع عن هذا الإجراء.",
|
"confirmDelete": "لا يمكن التراجع عن هذا الإجراء.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"dashboard": "لوحة المعلومات",
|
"dashboard": "لوحة المعلومات",
|
||||||
|
"roomsGroup": "إدارة القاعات",
|
||||||
|
"roomsDashboard": "لوحة القاعات",
|
||||||
|
"bookingSettings": "إعدادات الحجوزات",
|
||||||
"bookings": "حجز القاعات",
|
"bookings": "حجز القاعات",
|
||||||
"external": "اجتماعات خارجية",
|
"external": "اجتماعات خارجية",
|
||||||
"giftsGroup": "الهدايا",
|
"giftsGroup": "الهدايا",
|
||||||
@@ -1709,6 +1712,38 @@
|
|||||||
"roomsAvailableNow": "القاعات المتاحة الآن",
|
"roomsAvailableNow": "القاعات المتاحة الآن",
|
||||||
"giftsIssuedThisMonth": "الدروع المصروفة هذا الشهر"
|
"giftsIssuedThisMonth": "الدروع المصروفة هذا الشهر"
|
||||||
},
|
},
|
||||||
|
"roomsDash": {
|
||||||
|
"title": "لوحة بيانات القاعات",
|
||||||
|
"roomsCount": "عدد القاعات",
|
||||||
|
"roomsBreakdown": "{{active}} نشطة · {{inactive}} معطلة",
|
||||||
|
"todayBookings": "حجوزات اليوم",
|
||||||
|
"weekBookings": "حجوزات الأسبوع",
|
||||||
|
"pendingBookings": "طلبات معلّقة",
|
||||||
|
"nextBooking": "أقرب حجز قادم",
|
||||||
|
"noUpcoming": "لا توجد حجوزات قادمة",
|
||||||
|
"startingNow": "يبدأ الآن",
|
||||||
|
"inDays_zero": "بعد أقل من يوم",
|
||||||
|
"inDays_one": "بعد يوم واحد",
|
||||||
|
"inDays_two": "بعد يومين",
|
||||||
|
"inDays_few": "بعد {{count}} أيام",
|
||||||
|
"inDays_many": "بعد {{count}} يوماً",
|
||||||
|
"inDays_other": "بعد {{count}} يوم",
|
||||||
|
"inHoursMinutes": "بعد {{hours}} ساعة و{{minutes}} دقيقة",
|
||||||
|
"inMinutes_zero": "بعد أقل من دقيقة",
|
||||||
|
"inMinutes_one": "بعد دقيقة واحدة",
|
||||||
|
"inMinutes_two": "بعد دقيقتين",
|
||||||
|
"inMinutes_few": "بعد {{count}} دقائق",
|
||||||
|
"inMinutes_many": "بعد {{count}} دقيقة",
|
||||||
|
"inMinutes_other": "بعد {{count}} دقيقة",
|
||||||
|
"bookingsOverTime": "الحجوزات عبر الأيام",
|
||||||
|
"lastNDays": "آخر {{count}} يوماً",
|
||||||
|
"bookings": "حجوزات",
|
||||||
|
"byRoom": "التوزيع حسب القاعة",
|
||||||
|
"byStatus": "التوزيع حسب الحالة",
|
||||||
|
"noData": "لا توجد بيانات بعد",
|
||||||
|
"noAccess": "ليس لديك صلاحية للوصول إلى هذه الصفحة.",
|
||||||
|
"windowHint": "تشمل الرسوم البيانية حجوزات آخر 30 يوماً بتوقيت الرياض."
|
||||||
|
},
|
||||||
"bookings": {
|
"bookings": {
|
||||||
"new": "حجز جديد",
|
"new": "حجز جديد",
|
||||||
"edit": "تعديل الحجز",
|
"edit": "تعديل الحجز",
|
||||||
|
|||||||
@@ -1540,6 +1540,9 @@
|
|||||||
"confirmDelete": "This action cannot be undone.",
|
"confirmDelete": "This action cannot be undone.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
"roomsGroup": "Rooms Management",
|
||||||
|
"roomsDashboard": "Rooms Dashboard",
|
||||||
|
"bookingSettings": "Booking Settings",
|
||||||
"bookings": "Room Bookings",
|
"bookings": "Room Bookings",
|
||||||
"external": "External Meetings",
|
"external": "External Meetings",
|
||||||
"giftsGroup": "Gifts",
|
"giftsGroup": "Gifts",
|
||||||
@@ -1582,6 +1585,30 @@
|
|||||||
"roomsAvailableNow": "Rooms available now",
|
"roomsAvailableNow": "Rooms available now",
|
||||||
"giftsIssuedThisMonth": "Gifts issued this month"
|
"giftsIssuedThisMonth": "Gifts issued this month"
|
||||||
},
|
},
|
||||||
|
"roomsDash": {
|
||||||
|
"title": "Rooms Dashboard",
|
||||||
|
"roomsCount": "Rooms",
|
||||||
|
"roomsBreakdown": "{{active}} active · {{inactive}} inactive",
|
||||||
|
"todayBookings": "Today's bookings",
|
||||||
|
"weekBookings": "This week's bookings",
|
||||||
|
"pendingBookings": "Pending requests",
|
||||||
|
"nextBooking": "Next upcoming booking",
|
||||||
|
"noUpcoming": "No upcoming bookings",
|
||||||
|
"startingNow": "Starting now",
|
||||||
|
"inDays_one": "In {{count}} day",
|
||||||
|
"inDays_other": "In {{count}} days",
|
||||||
|
"inHoursMinutes": "In {{hours}}h {{minutes}}m",
|
||||||
|
"inMinutes_one": "In {{count}} minute",
|
||||||
|
"inMinutes_other": "In {{count}} minutes",
|
||||||
|
"bookingsOverTime": "Bookings over time",
|
||||||
|
"lastNDays": "Last {{count}} days",
|
||||||
|
"bookings": "bookings",
|
||||||
|
"byRoom": "By room",
|
||||||
|
"byStatus": "By status",
|
||||||
|
"noData": "No data yet",
|
||||||
|
"noAccess": "You don't have permission to access this page.",
|
||||||
|
"windowHint": "Charts cover bookings from the last 30 days (Riyadh time)."
|
||||||
|
},
|
||||||
"bookings": {
|
"bookings": {
|
||||||
"new": "New booking",
|
"new": "New booking",
|
||||||
"edit": "Edit booking",
|
"edit": "Edit booking",
|
||||||
|
|||||||
@@ -31,7 +31,24 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Download,
|
Download,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Clock3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
ResponsiveContainer,
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
CartesianGrid,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -252,8 +269,10 @@ type ReportRow = {
|
|||||||
|
|
||||||
type TabKey =
|
type TabKey =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
|
| "roomsDashboard"
|
||||||
| "rooms"
|
| "rooms"
|
||||||
| "bookings"
|
| "bookings"
|
||||||
|
| "bookingSettings"
|
||||||
| "external"
|
| "external"
|
||||||
| "gifts"
|
| "gifts"
|
||||||
| "issues"
|
| "issues"
|
||||||
@@ -264,8 +283,10 @@ type TabKey =
|
|||||||
// URL slugs for each tab so pages are deep-linkable under /protocol/*.
|
// URL slugs for each tab so pages are deep-linkable under /protocol/*.
|
||||||
const TAB_SLUGS: Record<TabKey, string> = {
|
const TAB_SLUGS: Record<TabKey, string> = {
|
||||||
dashboard: "dashboard",
|
dashboard: "dashboard",
|
||||||
|
roomsDashboard: "rooms-dashboard",
|
||||||
rooms: "rooms",
|
rooms: "rooms",
|
||||||
bookings: "bookings",
|
bookings: "bookings",
|
||||||
|
bookingSettings: "booking-settings",
|
||||||
external: "external-meetings",
|
external: "external-meetings",
|
||||||
gifts: "gifts",
|
gifts: "gifts",
|
||||||
issues: "gift-issues",
|
issues: "gift-issues",
|
||||||
@@ -853,6 +874,7 @@ export default function ProtocolPage() {
|
|||||||
open: false,
|
open: false,
|
||||||
});
|
});
|
||||||
const [giftsGroupOpen, setGiftsGroupOpen] = useState(false);
|
const [giftsGroupOpen, setGiftsGroupOpen] = useState(false);
|
||||||
|
const [roomsGroupOpen, setRoomsGroupOpen] = useState(false);
|
||||||
|
|
||||||
const bookingAction = useMutation({
|
const bookingAction = useMutation({
|
||||||
mutationFn: (args: { id: number; action: "approve" | "cancel" }) =>
|
mutationFn: (args: { id: number; action: "approve" | "cancel" }) =>
|
||||||
@@ -959,8 +981,18 @@ export default function ProtocolPage() {
|
|||||||
|
|
||||||
const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [
|
const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [
|
||||||
{ key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard },
|
{ key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard },
|
||||||
|
{ key: "roomsDashboard", label: t("protocol.tabs.roomsDashboard"), icon: LayoutDashboard },
|
||||||
{ key: "rooms", label: t("protocol.tabs.rooms"), icon: DoorOpen },
|
{ key: "rooms", label: t("protocol.tabs.rooms"), icon: DoorOpen },
|
||||||
{ key: "bookings", label: t("protocol.tabs.bookings"), icon: CalendarClock },
|
{ key: "bookings", label: t("protocol.tabs.bookings"), icon: CalendarClock },
|
||||||
|
...(c?.canManageRooms
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "bookingSettings" as TabKey,
|
||||||
|
label: t("protocol.tabs.bookingSettings"),
|
||||||
|
icon: SettingsIcon,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{ key: "external", label: t("protocol.tabs.external"), icon: Handshake },
|
{ key: "external", label: t("protocol.tabs.external"), icon: Handshake },
|
||||||
{ key: "gifts", label: t("protocol.tabs.gifts"), icon: Warehouse },
|
{ key: "gifts", label: t("protocol.tabs.gifts"), icon: Warehouse },
|
||||||
{ key: "issues", label: t("protocol.tabs.issues"), icon: Gift },
|
{ key: "issues", label: t("protocol.tabs.issues"), icon: Gift },
|
||||||
@@ -1375,6 +1407,66 @@ export default function ProtocolPage() {
|
|||||||
{TABS.map((item) => {
|
{TABS.map((item) => {
|
||||||
const { key, label, icon: Icon } = item;
|
const { key, label, icon: Icon } = item;
|
||||||
if (key === "issues") return null;
|
if (key === "issues") return null;
|
||||||
|
if (
|
||||||
|
key === "rooms" ||
|
||||||
|
key === "bookings" ||
|
||||||
|
key === "bookingSettings"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (key === "roomsDashboard") {
|
||||||
|
// "إدارة القاعات" collapsible group (same pattern as gifts).
|
||||||
|
const children = TABS.filter(
|
||||||
|
(x) =>
|
||||||
|
x.key === "roomsDashboard" ||
|
||||||
|
x.key === "rooms" ||
|
||||||
|
x.key === "bookings" ||
|
||||||
|
x.key === "bookingSettings",
|
||||||
|
);
|
||||||
|
const groupActive = children.some((x) => x.key === tab);
|
||||||
|
const open = roomsGroupOpen || groupActive;
|
||||||
|
return (
|
||||||
|
<div key="roomsGroup" className="flex md:flex-col gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRoomsGroupOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm whitespace-nowrap transition-colors md:w-full md:justify-start",
|
||||||
|
groupActive
|
||||||
|
? "text-sky-600 font-medium"
|
||||||
|
: "text-slate-600 hover:bg-slate-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DoorOpen size={16} className="shrink-0" />
|
||||||
|
{t("protocol.tabs.roomsGroup")}
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={cn(
|
||||||
|
"ms-auto shrink-0 transition-transform",
|
||||||
|
open && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
children.map(({ key: ck, label: cl, icon: CIcon }) => (
|
||||||
|
<button
|
||||||
|
key={ck}
|
||||||
|
onClick={() => setTab(ck)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-lg text-sm whitespace-nowrap transition-colors md:w-full md:justify-start md:ps-6",
|
||||||
|
tab === ck
|
||||||
|
? "bg-sky-500 text-white"
|
||||||
|
: "text-slate-600 hover:bg-slate-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CIcon size={16} className="shrink-0" />
|
||||||
|
{cl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (key === "gifts") {
|
if (key === "gifts") {
|
||||||
const children = TABS.filter(
|
const children = TABS.filter(
|
||||||
(x) => x.key === "gifts" || x.key === "issues",
|
(x) => x.key === "gifts" || x.key === "issues",
|
||||||
@@ -1699,16 +1791,30 @@ export default function ProtocolPage() {
|
|||||||
<EmptyState text={t("protocol.rooms.empty")} />
|
<EmptyState text={t("protocol.rooms.empty")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{c?.canManageRooms && (
|
|
||||||
<SlotsAdminSection
|
|
||||||
isAr={isAr}
|
|
||||||
onChanged={() => invalidate("slots")}
|
|
||||||
onError={onApiError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "bookingSettings" && c?.canManageRooms && (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="font-semibold text-slate-700">
|
||||||
|
{t("protocol.tabs.bookingSettings")}
|
||||||
|
</h2>
|
||||||
|
<SlotsAdminSection
|
||||||
|
isAr={isAr}
|
||||||
|
onChanged={() => invalidate("slots")}
|
||||||
|
onError={onApiError}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "roomsDashboard" && (
|
||||||
|
<RoomsDashboardView isAr={isAr} now={now} goTab={setTab} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "bookingSettings" && !c?.canManageRooms && (
|
||||||
|
<EmptyState text={t("protocol.roomsDash.noAccess")} />
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "external" && (
|
{tab === "external" && (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -2865,6 +2971,371 @@ function DashboardView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rooms dashboard (لوحة بيانات القاعات) — recharts KPI + charts view.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type RoomsStats = {
|
||||||
|
rooms: { active: number; inactive: number };
|
||||||
|
todayBookings: number;
|
||||||
|
weekBookings: number;
|
||||||
|
pendingBookings: number;
|
||||||
|
nextBooking: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
roomNameAr: string;
|
||||||
|
roomNameEn: string;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
} | null;
|
||||||
|
daily: Array<{ date: string; count: number }>;
|
||||||
|
byRoom: Array<{
|
||||||
|
roomId: number;
|
||||||
|
nameAr: string;
|
||||||
|
nameEn: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
byStatus: Array<{ status: BookingStatus; count: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CHART_COLORS: Record<string, string> = {
|
||||||
|
pending: "#f59e0b",
|
||||||
|
approved: "#10b981",
|
||||||
|
rejected: "#f43f5e",
|
||||||
|
cancelled: "#94a3b8",
|
||||||
|
};
|
||||||
|
|
||||||
|
function RoomsDashboardView({
|
||||||
|
isAr,
|
||||||
|
now,
|
||||||
|
goTab,
|
||||||
|
}: {
|
||||||
|
isAr: boolean;
|
||||||
|
now: Date;
|
||||||
|
goTab: (k: TabKey) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const stats = useQuery<RoomsStats>({
|
||||||
|
queryKey: ["protocol", "rooms-stats"],
|
||||||
|
queryFn: () => apiJson<RoomsStats>(`${API}/protocol/rooms-stats`),
|
||||||
|
});
|
||||||
|
const d = stats.data;
|
||||||
|
const [range, setRange] = useState<14 | 30>(14);
|
||||||
|
|
||||||
|
const nameOf = (ar: string, en: string) => (isAr ? ar : en || ar);
|
||||||
|
const locale = isAr ? "ar-u-nu-latn" : "en";
|
||||||
|
|
||||||
|
// Countdown to the next upcoming booking: "بعد X يوم" or "بعد X ساعة ودقيقة".
|
||||||
|
const countdown = useMemo(() => {
|
||||||
|
if (!d?.nextBooking) return null;
|
||||||
|
const ms = new Date(d.nextBooking.startsAt).getTime() - now.getTime();
|
||||||
|
if (ms <= 0) return t("protocol.roomsDash.startingNow");
|
||||||
|
const totalMinutes = Math.floor(ms / 60_000);
|
||||||
|
const days = Math.floor(totalMinutes / (24 * 60));
|
||||||
|
if (days >= 1) return t("protocol.roomsDash.inDays", { count: days });
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
if (hours >= 1) {
|
||||||
|
return t("protocol.roomsDash.inHoursMinutes", { hours, minutes });
|
||||||
|
}
|
||||||
|
return t("protocol.roomsDash.inMinutes", { count: minutes });
|
||||||
|
}, [d?.nextBooking, now, t]);
|
||||||
|
|
||||||
|
const dailyData = useMemo(() => {
|
||||||
|
const rows = d?.daily ?? [];
|
||||||
|
const window = rows.slice(-range);
|
||||||
|
return window.map((r) => ({
|
||||||
|
...r,
|
||||||
|
label: new Intl.DateTimeFormat(locale, {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
}).format(new Date(`${r.date}T00:00:00+03:00`)),
|
||||||
|
}));
|
||||||
|
}, [d?.daily, range, locale]);
|
||||||
|
|
||||||
|
const roomData = useMemo(
|
||||||
|
() =>
|
||||||
|
(d?.byRoom ?? []).map((r) => ({
|
||||||
|
name: nameOf(r.nameAr, r.nameEn),
|
||||||
|
count: r.count,
|
||||||
|
})),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[d?.byRoom, isAr],
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusData = useMemo(
|
||||||
|
() =>
|
||||||
|
(d?.byStatus ?? [])
|
||||||
|
.filter((s) => s.count > 0)
|
||||||
|
.map((s) => ({
|
||||||
|
name: t(`protocol.status.${s.status}`),
|
||||||
|
status: s.status,
|
||||||
|
value: s.count,
|
||||||
|
})),
|
||||||
|
[d?.byStatus, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fmtNextTime = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleString(isAr ? "ar" : "en", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
|
const kpis: Array<{ label: string; value?: number; sub?: string; tab: TabKey }> = [
|
||||||
|
{
|
||||||
|
label: t("protocol.roomsDash.roomsCount"),
|
||||||
|
value: d ? d.rooms.active + d.rooms.inactive : undefined,
|
||||||
|
sub: d
|
||||||
|
? t("protocol.roomsDash.roomsBreakdown", {
|
||||||
|
active: d.rooms.active,
|
||||||
|
inactive: d.rooms.inactive,
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
tab: "rooms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("protocol.roomsDash.todayBookings"),
|
||||||
|
value: d?.todayBookings,
|
||||||
|
tab: "bookings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("protocol.roomsDash.weekBookings"),
|
||||||
|
value: d?.weekBookings,
|
||||||
|
tab: "bookings",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("protocol.roomsDash.pendingBookings"),
|
||||||
|
value: d?.pendingBookings,
|
||||||
|
tab: "bookings",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="font-semibold text-slate-700">
|
||||||
|
{t("protocol.roomsDash.title")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
|
||||||
|
{kpis.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => goTab(k.tab)}
|
||||||
|
className="bg-white rounded-2xl border border-slate-200 p-4 text-start hover:border-sky-300 hover:shadow-sm transition"
|
||||||
|
>
|
||||||
|
<div className="text-3xl font-bold text-sky-600">
|
||||||
|
{k.value ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-500 mt-1">{k.label}</div>
|
||||||
|
{k.sub && (
|
||||||
|
<div className="text-xs text-slate-400 mt-0.5">{k.sub}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-l from-sky-500 to-sky-600 rounded-2xl p-4 text-white shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-sky-100 text-sm">
|
||||||
|
<Clock3 size={16} />
|
||||||
|
{t("protocol.roomsDash.nextBooking")}
|
||||||
|
</div>
|
||||||
|
{d?.nextBooking ? (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-bold text-lg truncate">
|
||||||
|
{d.nextBooking.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-sky-100">
|
||||||
|
{nameOf(d.nextBooking.roomNameAr, d.nextBooking.roomNameEn)}
|
||||||
|
{" · "}
|
||||||
|
{fmtNextTime(d.nextBooking.startsAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white/15 px-4 py-2 text-xl font-bold whitespace-nowrap">
|
||||||
|
{countdown}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-sky-100 text-sm">
|
||||||
|
{stats.isLoading
|
||||||
|
? t("common.loading")
|
||||||
|
: t("protocol.roomsDash.noUpcoming")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-semibold text-slate-700 text-sm">
|
||||||
|
{t("protocol.roomsDash.bookingsOverTime")}
|
||||||
|
</h3>
|
||||||
|
<div className="flex rounded-lg border border-slate-200 overflow-hidden">
|
||||||
|
{([14, 30] as const).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRange(r)}
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 text-xs",
|
||||||
|
range === r
|
||||||
|
? "bg-sky-500 text-white"
|
||||||
|
: "text-slate-600 hover:bg-slate-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("protocol.roomsDash.lastNDays", { count: r })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-56" dir="ltr">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={dailyData} margin={{ top: 4, left: -20, right: 4 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="roomsDashArea" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#0ea5e9" stopOpacity={0.35} />
|
||||||
|
<stop offset="100%" stopColor="#0ea5e9" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e2e8f0" }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
formatter={(v: number) => [
|
||||||
|
v,
|
||||||
|
t("protocol.roomsDash.bookings"),
|
||||||
|
]}
|
||||||
|
labelStyle={{ color: "#334155" }}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="count"
|
||||||
|
stroke="#0ea5e9"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#roomsDashArea)"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<h3 className="font-semibold text-slate-700 text-sm mb-3">
|
||||||
|
{t("protocol.roomsDash.byRoom")}
|
||||||
|
</h3>
|
||||||
|
{roomData.length === 0 ? (
|
||||||
|
<EmptyState text={t("protocol.roomsDash.noData")} />
|
||||||
|
) : (
|
||||||
|
<div className="h-56" dir="ltr">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={roomData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 4, left: 8, right: 16 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="#e2e8f0"
|
||||||
|
horizontal={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
allowDecimals={false}
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e2e8f0" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={110}
|
||||||
|
tick={{ fontSize: 11, fill: "#334155" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
formatter={(v: number) => [
|
||||||
|
v,
|
||||||
|
t("protocol.roomsDash.bookings"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="#0ea5e9"
|
||||||
|
radius={[0, 6, 6, 0]}
|
||||||
|
barSize={18}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-4">
|
||||||
|
<h3 className="font-semibold text-slate-700 text-sm mb-3">
|
||||||
|
{t("protocol.roomsDash.byStatus")}
|
||||||
|
</h3>
|
||||||
|
{statusData.length === 0 ? (
|
||||||
|
<EmptyState text={t("protocol.roomsDash.noData")} />
|
||||||
|
) : (
|
||||||
|
<div className="h-56" dir="ltr">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={55}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
strokeWidth={0}
|
||||||
|
>
|
||||||
|
{statusData.map((s) => (
|
||||||
|
<Cell
|
||||||
|
key={s.status}
|
||||||
|
fill={STATUS_CHART_COLORS[s.status] ?? "#0ea5e9"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Legend
|
||||||
|
formatter={(value: string) => (
|
||||||
|
<span style={{ color: "#334155", fontSize: 12 }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<RechartsTooltip
|
||||||
|
formatter={(v: number) => [
|
||||||
|
v,
|
||||||
|
t("protocol.roomsDash.bookings"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{t("protocol.roomsDash.windowHint")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReportsView({
|
function ReportsView({
|
||||||
data,
|
data,
|
||||||
nameOf,
|
nameOf,
|
||||||
|
|||||||
Reference in New Issue
Block a user