277 أسطر
11 KiB
TypeScript
277 أسطر
11 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Bell, MoonStar, RefreshCcw, ShieldCheck, SunMedium } from "lucide-react";
|
|
|
|
import { useSuperAdminSession } from "@/components/auth/session-context";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Drawer } from "@/components/ui/drawer";
|
|
import { useToast } from "@/components/ui/toast";
|
|
import { useTheme } from "@/components/theme/theme-provider";
|
|
import { listSuperAdminSessions } from "@/lib/api/auth";
|
|
import { listPlatformNotifications } from "@/lib/api/notifications";
|
|
import { getSuperAdminOverview, getSuperAdminRecentActivity } from "@/lib/api/superadmin";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
|
|
import type {
|
|
NotificationItem,
|
|
NotificationsResponse,
|
|
SessionItem,
|
|
SessionsResponse,
|
|
SuperAdminOverviewResponse,
|
|
SuperAdminRecentActivityItem,
|
|
SuperAdminRecentActivityResponse,
|
|
} from "@/types/api";
|
|
|
|
const EMPTY_NOTIFICATIONS: NotificationsResponse = { items: [], data: [], unreadCount: 0 };
|
|
const EMPTY_SESSIONS: SessionsResponse = { items: [] };
|
|
const EMPTY_ACTIVITY: SuperAdminRecentActivityResponse = { items: [] };
|
|
|
|
export function DashboardTopbar() {
|
|
const { toast } = useToast();
|
|
const { theme, toggle } = useTheme();
|
|
const { permissions } = useSuperAdminSession();
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [overview, setOverview] = useState<SuperAdminOverviewResponse | null>(null);
|
|
const [recentActivity, setRecentActivity] = useState<SuperAdminRecentActivityItem[]>([]);
|
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
|
const [sessions, setSessions] = useState<SessionItem[]>([]);
|
|
|
|
const canReadOverview = hasPermission(permissions, SUPERADMIN_PERMISSIONS.OVERVIEW_READ);
|
|
const canReadAnalytics = hasPermission(permissions, SUPERADMIN_PERMISSIONS.ANALYTICS_READ);
|
|
const canReadNotifications = hasPermission(permissions, SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ);
|
|
const canManageSessions = hasPermission(permissions, SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE);
|
|
|
|
const todayLabel = useMemo(() => {
|
|
try {
|
|
return new Intl.DateTimeFormat("ar-SA", {
|
|
weekday: "long",
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
}).format(new Date());
|
|
} catch {
|
|
return "Today";
|
|
}
|
|
}, []);
|
|
|
|
const unreadCount =
|
|
overview?.metrics.unreadNotificationsCount ??
|
|
notifications.filter((item) => item.read === false).length;
|
|
const moderationAttentionCount =
|
|
(overview?.metrics.flaggedPostsCount ?? 0) + (overview?.metrics.flaggedCommentsCount ?? 0);
|
|
|
|
const summaryCards = [
|
|
canManageSessions
|
|
? {
|
|
key: "sessions",
|
|
label: "SuperAdmin sessions",
|
|
value: String(sessions.length),
|
|
}
|
|
: null,
|
|
canReadNotifications
|
|
? {
|
|
key: "notifications",
|
|
label: "Unread notifications",
|
|
value: String(unreadCount),
|
|
}
|
|
: null,
|
|
canReadOverview
|
|
? {
|
|
key: "moderation",
|
|
label: "Needs review",
|
|
value: String(moderationAttentionCount),
|
|
}
|
|
: null,
|
|
].filter(Boolean) as Array<{ key: string; label: string; value: string }>;
|
|
const summaryGridClass =
|
|
summaryCards.length >= 3
|
|
? "grid gap-3 sm:grid-cols-3"
|
|
: summaryCards.length === 2
|
|
? "grid gap-3 sm:grid-cols-2"
|
|
: "grid gap-3";
|
|
|
|
const loadOverview = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [overviewResponse, activityResponse, notificationsResponse, sessionsResponse] =
|
|
await Promise.all([
|
|
canReadOverview ? getSuperAdminOverview() : Promise.resolve(null),
|
|
canReadAnalytics
|
|
? getSuperAdminRecentActivity({ limit: 5 })
|
|
: Promise.resolve(EMPTY_ACTIVITY),
|
|
canReadNotifications
|
|
? listPlatformNotifications({ limit: 5, sortOrder: "desc" })
|
|
: Promise.resolve(EMPTY_NOTIFICATIONS),
|
|
canManageSessions ? listSuperAdminSessions() : Promise.resolve(EMPTY_SESSIONS),
|
|
]);
|
|
setOverview(overviewResponse);
|
|
setRecentActivity(activityResponse.items ?? []);
|
|
setNotifications(notificationsResponse.items ?? notificationsResponse.data ?? []);
|
|
setSessions(sessionsResponse.items ?? []);
|
|
} catch (error) {
|
|
toast({ title: "Failed to load command center", description: String(error), variant: "danger" });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canManageSessions, canReadAnalytics, canReadNotifications, canReadOverview, toast]);
|
|
|
|
useEffect(() => {
|
|
void loadOverview();
|
|
}, [loadOverview]);
|
|
|
|
return (
|
|
<>
|
|
<div className="page-enter mb-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/80 p-3 backdrop-blur">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="warning">SuperAdmin</Badge>
|
|
<p className="text-sm text-muted-foreground">{todayLabel}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="ghost" size="icon" onClick={() => void loadOverview()} disabled={loading}>
|
|
<RefreshCcw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
toggle();
|
|
toast({
|
|
title: "Theme updated",
|
|
description: theme === "dark" ? "Switched to light mode." : "Switched to dark mode.",
|
|
});
|
|
}}
|
|
>
|
|
{theme === "dark" ? <SunMedium className="h-4 w-4" /> : <MoonStar className="h-4 w-4" />}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" onClick={() => setDrawerOpen(true)}>
|
|
<Bell className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Drawer
|
|
open={drawerOpen}
|
|
onClose={() => setDrawerOpen(false)}
|
|
title="Operations Center"
|
|
description="A quick view of the latest notifications, activity, and active sessions."
|
|
side="right"
|
|
>
|
|
<div className="space-y-4">
|
|
{summaryCards.length ? (
|
|
<div className={summaryGridClass}>
|
|
{summaryCards.map((card) => (
|
|
<div key={card.key} className="rounded-xl border border-border bg-secondary/40 p-4">
|
|
<div className="text-xs text-muted-foreground">{card.label}</div>
|
|
<div className="mt-2 text-2xl font-bold text-foreground">{card.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{canReadNotifications ? (
|
|
<div className="rounded-xl border border-border bg-secondary/40 p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<p className="text-sm font-semibold text-foreground">Latest notifications</p>
|
|
<Link href="/notifications" className="text-xs font-semibold text-primary">
|
|
Open page
|
|
</Link>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{notifications.length ? (
|
|
notifications.map((item) => (
|
|
<div key={item._id} className="rounded-lg border border-border/60 bg-background/40 p-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-sm font-medium text-foreground">{item.title ?? item.type}</p>
|
|
<Badge variant={item.read ? "muted" : "warning"}>
|
|
{item.read ? "Read" : "New"}
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{item.previewText ?? item.deepLink ?? "-"}
|
|
</p>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No recent notifications.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{canReadAnalytics ? (
|
|
<div className="rounded-xl border border-border bg-secondary/40 p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<p className="text-sm font-semibold text-foreground">Recent activity</p>
|
|
<Link href="/dashboard" className="text-xs font-semibold text-primary">
|
|
Dashboard
|
|
</Link>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{recentActivity.length ? (
|
|
recentActivity.map((item, index) => (
|
|
<div
|
|
key={`${item.type}-${item.createdAt ?? index}`}
|
|
className="rounded-lg border border-border/60 bg-background/40 p-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
|
<Badge
|
|
variant={
|
|
item.status === "flagged"
|
|
? "warning"
|
|
: item.status === "hidden"
|
|
? "danger"
|
|
: "muted"
|
|
}
|
|
>
|
|
{item.status}
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">{item.subtitle}</p>
|
|
<p className="mt-2 text-xs text-muted-foreground">{formatDateTime(item.createdAt)}</p>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No recent activity.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{canManageSessions ? (
|
|
<div className="rounded-xl border border-border bg-secondary/40 p-4">
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<ShieldCheck className="h-4 w-4 text-primary" />
|
|
<p className="text-sm font-semibold text-foreground">Active sessions</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{sessions.length ? (
|
|
sessions.map((item) => (
|
|
<div
|
|
key={item.id ?? item.jti}
|
|
className="rounded-lg border border-border/60 bg-background/40 p-3"
|
|
>
|
|
<div className="text-sm font-medium text-foreground">
|
|
{item.id ?? item.jti ?? "session"}
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
{formatDateTime(item.createdAt)} - {formatDateTime(item.expiresAt)}
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No active sessions to display.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</Drawer>
|
|
</>
|
|
);
|
|
}
|