diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index 88b87db2..93d57818 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -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`count(*) filter (where ${protocolRoomsTable.isActive})::int`, + inactive: sql`count(*) filter (where not ${protocolRoomsTable.isActive})::int`, + }) + .from(protocolRoomsTable); + + const [todayCount] = await db + .select({ c: sql`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`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`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`to_char(${protocolRoomBookingsTable.startsAt} AT TIME ZONE 'Asia/Riyadh', 'YYYY-MM-DD')`; + const dailyRows = await db + .select({ + day: riyadhDayExpr, + count: sql`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`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`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 // --------------------------------------------------------------------------- diff --git a/artifacts/api-server/tests/protocol-rooms-stats.test.mjs b/artifacts/api-server/tests/protocol-rooms-stats.test.mjs new file mode 100644 index 00000000..907ca92e --- /dev/null +++ b/artifacts/api-server/tests/protocol-rooms-stats.test.mjs @@ -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"); +}); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 3217e5ba..d958c47c 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1667,6 +1667,9 @@ "confirmDelete": "لا يمكن التراجع عن هذا الإجراء.", "tabs": { "dashboard": "لوحة المعلومات", + "roomsGroup": "إدارة القاعات", + "roomsDashboard": "لوحة القاعات", + "bookingSettings": "إعدادات الحجوزات", "bookings": "حجز القاعات", "external": "اجتماعات خارجية", "giftsGroup": "الهدايا", @@ -1709,6 +1712,38 @@ "roomsAvailableNow": "القاعات المتاحة الآن", "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": { "new": "حجز جديد", "edit": "تعديل الحجز", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 4dd0f9ba..9f246d9a 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1540,6 +1540,9 @@ "confirmDelete": "This action cannot be undone.", "tabs": { "dashboard": "Dashboard", + "roomsGroup": "Rooms Management", + "roomsDashboard": "Rooms Dashboard", + "bookingSettings": "Booking Settings", "bookings": "Room Bookings", "external": "External Meetings", "giftsGroup": "Gifts", @@ -1582,6 +1585,30 @@ "roomsAvailableNow": "Rooms available now", "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": { "new": "New booking", "edit": "Edit booking", diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index fcc1cbdf..f3a29974 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -31,7 +31,24 @@ import { Loader2, Download, Image as ImageIcon, + Settings as SettingsIcon, + Clock3, } 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 { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -252,8 +269,10 @@ type ReportRow = { type TabKey = | "dashboard" + | "roomsDashboard" | "rooms" | "bookings" + | "bookingSettings" | "external" | "gifts" | "issues" @@ -264,8 +283,10 @@ type TabKey = // URL slugs for each tab so pages are deep-linkable under /protocol/*. const TAB_SLUGS: Record = { dashboard: "dashboard", + roomsDashboard: "rooms-dashboard", rooms: "rooms", bookings: "bookings", + bookingSettings: "booking-settings", external: "external-meetings", gifts: "gifts", issues: "gift-issues", @@ -853,6 +874,7 @@ export default function ProtocolPage() { open: false, }); const [giftsGroupOpen, setGiftsGroupOpen] = useState(false); + const [roomsGroupOpen, setRoomsGroupOpen] = useState(false); const bookingAction = useMutation({ 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 }> = [ { 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: "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: "gifts", label: t("protocol.tabs.gifts"), icon: Warehouse }, { key: "issues", label: t("protocol.tabs.issues"), icon: Gift }, @@ -1375,6 +1407,66 @@ export default function ProtocolPage() { {TABS.map((item) => { const { key, label, icon: Icon } = item; 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 ( +
+ + {open && + children.map(({ key: ck, label: cl, icon: CIcon }) => ( + + ))} +
+ ); + } if (key === "gifts") { const children = TABS.filter( (x) => x.key === "gifts" || x.key === "issues", @@ -1699,16 +1791,30 @@ export default function ProtocolPage() { )} - {c?.canManageRooms && ( - invalidate("slots")} - onError={onApiError} - /> - )} )} + {tab === "bookingSettings" && c?.canManageRooms && ( +
+

+ {t("protocol.tabs.bookingSettings")} +

+ invalidate("slots")} + onError={onApiError} + /> +
+ )} + + {tab === "roomsDashboard" && ( + + )} + + {tab === "bookingSettings" && !c?.canManageRooms && ( + + )} + {tab === "external" && (
@@ -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 = { + 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({ + queryKey: ["protocol", "rooms-stats"], + queryFn: () => apiJson(`${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 ( +
+

+ {t("protocol.roomsDash.title")} +

+ +
+ {kpis.map((k) => ( + + ))} +
+ +
+
+ + {t("protocol.roomsDash.nextBooking")} +
+ {d?.nextBooking ? ( +
+
+
+ {d.nextBooking.title} +
+
+ {nameOf(d.nextBooking.roomNameAr, d.nextBooking.roomNameEn)} + {" · "} + {fmtNextTime(d.nextBooking.startsAt)} +
+
+
+ {countdown} +
+
+ ) : ( +
+ {stats.isLoading + ? t("common.loading") + : t("protocol.roomsDash.noUpcoming")} +
+ )} +
+ +
+
+

+ {t("protocol.roomsDash.bookingsOverTime")} +

+
+ {([14, 30] as const).map((r) => ( + + ))} +
+
+
+ + + + + + + + + + + + [ + v, + t("protocol.roomsDash.bookings"), + ]} + labelStyle={{ color: "#334155" }} + /> + + + +
+
+ +
+
+

+ {t("protocol.roomsDash.byRoom")} +

+ {roomData.length === 0 ? ( + + ) : ( +
+ + + + + + [ + v, + t("protocol.roomsDash.bookings"), + ]} + /> + + + +
+ )} +
+ +
+

+ {t("protocol.roomsDash.byStatus")} +

+ {statusData.length === 0 ? ( + + ) : ( +
+ + + + {statusData.map((s) => ( + + ))} + + ( + + {value} + + )} + /> + [ + v, + t("protocol.roomsDash.bookings"), + ]} + /> + + +
+ )} +
+
+ +

+ {t("protocol.roomsDash.windowHint")} +

+
+ ); +} + function ReportsView({ data, nameOf,