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
+35
View File
@@ -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": "تعديل الحجز",
+27
View File
@@ -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",
+478 -7
View File
@@ -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<TabKey, string> = {
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 (
<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") {
const children = TABS.filter(
(x) => x.key === "gifts" || x.key === "issues",
@@ -1699,16 +1791,30 @@ export default function ProtocolPage() {
<EmptyState text={t("protocol.rooms.empty")} />
)}
</div>
{c?.canManageRooms && (
<SlotsAdminSection
isAr={isAr}
onChanged={() => invalidate("slots")}
onError={onApiError}
/>
)}
</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" && (
<section className="space-y-4">
<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({
data,
nameOf,