464 أسطر
16 KiB
TypeScript
464 أسطر
16 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Boxes, RefreshCcw, ShieldAlert, Store, Users2 } from "lucide-react";
|
|
|
|
import { NoPermissionState } from "@/components/auth/no-permission-state";
|
|
import { PostPreviewCard } from "@/components/dashboard/post-preview-card";
|
|
import { useSuperAdminSession } from "@/components/auth/session-context";
|
|
import { PageHeader } from "@/components/dashboard/page-header";
|
|
import { StatCard } from "@/components/dashboard/stat-card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { useToast } from "@/components/ui/toast";
|
|
import { searchAdminUsers } from "@/lib/api/admin-users";
|
|
import { getItems } from "@/lib/api/core";
|
|
import { listModerationListings } from "@/lib/api/marketplace";
|
|
import { listModerationPosts } from "@/lib/api/posts";
|
|
import { getSuperAdminOverview, getSuperAdminRecentActivity } from "@/lib/api/superadmin";
|
|
import { refreshSuperAdmin } from "@/lib/auth/client";
|
|
import { formatCurrency, formatDateTime } from "@/lib/format";
|
|
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
|
|
import type {
|
|
ApiPost,
|
|
ApiUser,
|
|
MarketplaceListing,
|
|
MarketplaceResponse,
|
|
PostsResponse,
|
|
SuperAdminOverviewResponse,
|
|
SuperAdminRecentActivityItem,
|
|
SuperAdminRecentActivityResponse,
|
|
UsersResponse,
|
|
} from "@/types/api";
|
|
import type { StatMetric } from "@/types";
|
|
|
|
type DashboardSnapshot = {
|
|
overview: SuperAdminOverviewResponse | null;
|
|
users: ApiUser[];
|
|
latestPosts: ApiPost[];
|
|
listings: MarketplaceListing[];
|
|
recentActivity: SuperAdminRecentActivityItem[];
|
|
};
|
|
|
|
const EMPTY_USERS: UsersResponse = { items: [], data: [] };
|
|
const EMPTY_LISTINGS: MarketplaceResponse = { items: [], data: [] };
|
|
const EMPTY_POSTS: PostsResponse = { items: [], data: [] };
|
|
const EMPTY_ACTIVITY: SuperAdminRecentActivityResponse = { items: [] };
|
|
|
|
function ShortcutLink({
|
|
href,
|
|
icon,
|
|
label,
|
|
}: {
|
|
href: string;
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<Link
|
|
href={href}
|
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 py-2 text-sm font-semibold text-foreground transition hover:bg-secondary/60"
|
|
>
|
|
{icon}
|
|
{label}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
const { permissions } = useSuperAdminSession();
|
|
const [snapshot, setSnapshot] = useState<DashboardSnapshot>({
|
|
overview: null,
|
|
users: [],
|
|
latestPosts: [],
|
|
listings: [],
|
|
recentActivity: [],
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
const { toast } = useToast();
|
|
|
|
const canReadOverview = hasPermission(permissions, SUPERADMIN_PERMISSIONS.OVERVIEW_READ);
|
|
const canReadAnalytics = hasPermission(permissions, SUPERADMIN_PERMISSIONS.ANALYTICS_READ);
|
|
const canReadUsers = hasPermission(permissions, SUPERADMIN_PERMISSIONS.USERS_READ);
|
|
const canModerateContent = hasPermission(
|
|
permissions,
|
|
SUPERADMIN_PERMISSIONS.CONTENT_MODERATE,
|
|
);
|
|
const canManageMarketplace = hasPermission(
|
|
permissions,
|
|
SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE,
|
|
);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
|
|
const loadDashboard = async () => {
|
|
if (!canReadOverview) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const [overview, recentActivity, usersResponse, postsResponse, listingsResponse] = await Promise.all([
|
|
getSuperAdminOverview(),
|
|
canReadAnalytics
|
|
? getSuperAdminRecentActivity({ limit: 8 })
|
|
: Promise.resolve(EMPTY_ACTIVITY),
|
|
canReadUsers
|
|
? searchAdminUsers({ page: 1, limit: 8, sortBy: "createdAt", sortOrder: "desc" })
|
|
: Promise.resolve(EMPTY_USERS),
|
|
canModerateContent
|
|
? listModerationPosts({ page: 1, limit: 6, sortBy: "createdAt", sortOrder: "desc" })
|
|
: Promise.resolve(EMPTY_POSTS),
|
|
canManageMarketplace
|
|
? listModerationListings({ page: 1, limit: 6, sortBy: "createdAt", sortOrder: "desc" })
|
|
: Promise.resolve(EMPTY_LISTINGS),
|
|
]);
|
|
|
|
if (!active) return;
|
|
|
|
setSnapshot({
|
|
overview,
|
|
recentActivity: recentActivity.items ?? [],
|
|
users: getItems(usersResponse),
|
|
latestPosts: getItems(postsResponse) as ApiPost[],
|
|
listings: getItems(listingsResponse),
|
|
});
|
|
} catch (error) {
|
|
if (!active) return;
|
|
toast({
|
|
title: "Failed to load dashboard",
|
|
description: String(error),
|
|
variant: "danger",
|
|
});
|
|
} finally {
|
|
if (active) setLoading(false);
|
|
}
|
|
};
|
|
|
|
void loadDashboard();
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [canManageMarketplace, canModerateContent, canReadAnalytics, canReadOverview, canReadUsers, toast]);
|
|
|
|
const metrics = snapshot.overview?.metrics;
|
|
|
|
const listingValue = useMemo(
|
|
() => snapshot.listings.reduce((sum, item) => sum + (item.price ?? 0), 0),
|
|
[snapshot.listings],
|
|
);
|
|
|
|
const dashboardMetrics: StatMetric[] = [
|
|
{
|
|
id: "users",
|
|
label: "Users",
|
|
value: loading ? "..." : String(metrics?.usersCount ?? 0),
|
|
delta: "Total user accounts on the platform",
|
|
trend: "up",
|
|
},
|
|
{
|
|
id: "admins",
|
|
label: "Admins",
|
|
value: loading ? "..." : String(metrics?.adminsCount ?? 0),
|
|
delta: "Administrative accounts under platform control",
|
|
trend: "up",
|
|
},
|
|
{
|
|
id: "listings",
|
|
label: "Marketplace",
|
|
value: loading ? "..." : String(metrics?.marketplaceListingsCount ?? 0),
|
|
delta: `Value of loaded listings ${formatCurrency(listingValue)}`,
|
|
trend: "neutral",
|
|
},
|
|
{
|
|
id: "alerts",
|
|
label: "Unread alerts",
|
|
value: loading ? "..." : String(metrics?.unreadNotificationsCount ?? 0),
|
|
delta: `${metrics?.flaggedPostsCount ?? 0} flagged posts and ${metrics?.flaggedCommentsCount ?? 0} flagged comments`,
|
|
trend: (metrics?.unreadNotificationsCount ?? 0) > 0 ? "down" : "neutral",
|
|
},
|
|
];
|
|
|
|
const shortcuts = [
|
|
canReadUsers
|
|
? {
|
|
key: "users",
|
|
href: "/users",
|
|
icon: <Users2 className="h-4 w-4" />,
|
|
label: "Manage users",
|
|
}
|
|
: null,
|
|
canManageMarketplace
|
|
? {
|
|
key: "marketplace",
|
|
href: "/marketplace",
|
|
icon: <Store className="h-4 w-4" />,
|
|
label: "Review marketplace",
|
|
}
|
|
: null,
|
|
hasPermission(permissions, SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)
|
|
? {
|
|
key: "content",
|
|
href: "/content",
|
|
icon: <Boxes className="h-4 w-4" />,
|
|
label: "Moderate content",
|
|
}
|
|
: null,
|
|
hasPermission(permissions, SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE) ||
|
|
hasPermission(permissions, SUPERADMIN_PERMISSIONS.AUDIT_READ) ||
|
|
hasPermission(permissions, SUPERADMIN_PERMISSIONS.OPS_READ)
|
|
? {
|
|
key: "security",
|
|
href: "/security",
|
|
icon: <ShieldAlert className="h-4 w-4" />,
|
|
label: "Security and sessions",
|
|
}
|
|
: null,
|
|
].filter(Boolean) as Array<{ key: string; href: string; icon: React.ReactNode; label: string }>;
|
|
|
|
if (!canReadOverview) {
|
|
return (
|
|
<div className="space-y-5 pb-8">
|
|
<PageHeader
|
|
title="SuperAdmin dashboard"
|
|
subtitle="A high-level summary of the platform, moderation activity, and operator status."
|
|
/>
|
|
<NoPermissionState description="This page needs the overview.read permission." />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-5 pb-8">
|
|
<PageHeader
|
|
title="SuperAdmin dashboard"
|
|
subtitle="A high-level summary of platform health, moderation, and operational activity."
|
|
actions={
|
|
<Button
|
|
variant="outline"
|
|
onClick={async () => {
|
|
try {
|
|
await refreshSuperAdmin();
|
|
toast({
|
|
title: "Session refreshed",
|
|
description: "SuperAdmin cookies were refreshed successfully.",
|
|
variant: "success",
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Refresh failed",
|
|
description: String(error),
|
|
variant: "danger",
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<RefreshCcw className="h-4 w-4" />
|
|
Refresh session
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
{dashboardMetrics.map((metric) => (
|
|
<StatCard key={metric.id} metric={metric} />
|
|
))}
|
|
</section>
|
|
|
|
<section className="grid gap-4 xl:grid-cols-12">
|
|
{canReadUsers ? (
|
|
<Card className="xl:col-span-7">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>Latest users</CardTitle>
|
|
<Link href="/users" className="text-sm text-primary">
|
|
View all
|
|
</Link>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!snapshot.users.length ? (
|
|
<EmptyState
|
|
title="No user data"
|
|
description="Recent users will appear here when the backend returns them."
|
|
/>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Role</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{snapshot.users.slice(0, 6).map((user) => (
|
|
<TableRow key={user._id}>
|
|
<TableCell>{user.name ?? user.username ?? "-"}</TableCell>
|
|
<TableCell>{user.email}</TableCell>
|
|
<TableCell>{user.role ?? "user"}</TableCell>
|
|
<TableCell>
|
|
<Badge variant={user.isDisabled ? "danger" : "success"}>
|
|
{user.isDisabled ? "Disabled" : "Active"}
|
|
</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Card className={canReadUsers ? "xl:col-span-5" : "xl:col-span-12"}>
|
|
<CardHeader>
|
|
<CardTitle>Quick actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{shortcuts.length ? (
|
|
shortcuts.map((item) => (
|
|
<ShortcutLink key={item.key} href={item.href} icon={item.icon} label={item.label} />
|
|
))
|
|
) : (
|
|
<EmptyState
|
|
title="No shortcuts available"
|
|
description="The current session does not expose any additional dashboard areas."
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
|
|
<section className="grid gap-4 xl:grid-cols-12">
|
|
{canModerateContent ? (
|
|
<Card className={(canManageMarketplace || canReadAnalytics) ? "xl:col-span-6" : "xl:col-span-12"}>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>Latest posts</CardTitle>
|
|
<Link href="/content" className="text-sm text-primary">
|
|
Open moderation
|
|
</Link>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!snapshot.latestPosts.length ? (
|
|
<EmptyState
|
|
title="No posts available"
|
|
description="Recent posts will appear here when the backend returns them."
|
|
/>
|
|
) : (
|
|
<div className="grid gap-4">
|
|
{snapshot.latestPosts.slice(0, 4).map((post) => (
|
|
<PostPreviewCard key={post._id} post={post} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{canManageMarketplace ? (
|
|
<Card
|
|
className={
|
|
canModerateContent
|
|
? "xl:col-span-6"
|
|
: canReadAnalytics
|
|
? "xl:col-span-6"
|
|
: "xl:col-span-12"
|
|
}
|
|
>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>Latest marketplace listings</CardTitle>
|
|
<Link href="/marketplace" className="text-sm text-primary">
|
|
Manage marketplace
|
|
</Link>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!snapshot.listings.length ? (
|
|
<EmptyState
|
|
title="No listings available"
|
|
description="No marketplace listings matched the current backend response."
|
|
/>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Title</TableHead>
|
|
<TableHead>Category</TableHead>
|
|
<TableHead>Store</TableHead>
|
|
<TableHead>Price</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{snapshot.listings.slice(0, 5).map((listing) => (
|
|
<TableRow key={listing._id}>
|
|
<TableCell>{listing.title}</TableCell>
|
|
<TableCell>{listing.listingCategory ?? "-"}</TableCell>
|
|
<TableCell>{listing.storeName ?? "-"}</TableCell>
|
|
<TableCell>{formatCurrency(listing.price, listing.currency ?? "SAR")}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{canReadAnalytics ? (
|
|
<Card
|
|
className={
|
|
canManageMarketplace || canModerateContent ? "xl:col-span-6" : "xl:col-span-12"
|
|
}
|
|
>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>Recent activity</CardTitle>
|
|
<Link href="/analytics" className="text-sm text-primary">
|
|
Activity feeds
|
|
</Link>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!snapshot.recentActivity.length ? (
|
|
<EmptyState
|
|
title="No recent activity"
|
|
description="Operational and moderation activity will appear here."
|
|
/>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{snapshot.recentActivity.map((item, index) => (
|
|
<div
|
|
key={`${item.type}-${index}`}
|
|
className="rounded-xl border border-border/70 bg-secondary/20 p-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-sm font-medium text-foreground">{item.title}</div>
|
|
<Badge
|
|
variant={
|
|
item.status === "flagged" || item.status === "disabled"
|
|
? "warning"
|
|
: "muted"
|
|
}
|
|
>
|
|
{item.type}
|
|
</Badge>
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
{item.subtitle} • {formatDateTime(item.createdAt)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|