Add Oudelaa dashboard API integration
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-05-25 20:36:52 +03:00
الأصل 367fce6557
التزام 8863f61d00
90 ملفات معدلة مع 16694 إضافات و1 حذوفات

عرض الملف

@@ -0,0 +1,246 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { ChannelPieChart, InsightBarChart } from "@/components/dashboard/charts";
import { PageHeader } from "@/components/dashboard/page-header";
import { StatCard } from "@/components/dashboard/stat-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useToast } from "@/components/ui/toast";
import { getSuperAdminCharts, getSuperAdminOverview } from "@/lib/api/superadmin";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import type {
SuperAdminBreakdownItem,
SuperAdminChartsResponse,
SuperAdminChartPoint,
SuperAdminOverviewResponse,
} from "@/types/api";
import type { ChannelPoint, Insight, StatMetric } from "@/types";
type AnalyticsSnapshot = {
overview: SuperAdminOverviewResponse | null;
charts: SuperAdminChartsResponse | null;
};
function toInsight(points: SuperAdminChartPoint[]): Insight[] {
return points.map((point) => ({ label: point.label, value: point.count }));
}
function toInsightBreakdown(items: SuperAdminBreakdownItem[]): Insight[] {
return items.map((item) => ({ label: item.label, value: item.value }));
}
function toChannel(items: SuperAdminBreakdownItem[]): ChannelPoint[] {
return items.map((item) => ({ name: item.label, value: item.value }));
}
export default function AnalyticsPage() {
const { permissions } = useSuperAdminSession();
const [snapshot, setSnapshot] = useState<AnalyticsSnapshot>({
overview: null,
charts: null,
});
const [loading, setLoading] = useState(true);
const { toast } = useToast();
const canReadAnalytics = hasPermission(permissions, SUPERADMIN_PERMISSIONS.ANALYTICS_READ);
const canReadOverview = hasPermission(permissions, SUPERADMIN_PERMISSIONS.OVERVIEW_READ);
useEffect(() => {
let active = true;
const loadAnalytics = async () => {
if (!canReadAnalytics) {
setLoading(false);
return;
}
setLoading(true);
try {
const [overview, charts] = await Promise.all([
canReadOverview ? getSuperAdminOverview() : Promise.resolve(null),
getSuperAdminCharts({ range: "30d" }),
]);
if (!active) return;
setSnapshot({ overview, charts });
} catch (error) {
if (!active) return;
toast({ title: "Failed to load analytics", description: String(error), variant: "danger" });
} finally {
if (active) setLoading(false);
}
};
void loadAnalytics();
return () => {
active = false;
};
}, [canReadAnalytics, canReadOverview, toast]);
const metrics = snapshot.overview?.metrics;
const dashboardMetrics: StatMetric[] = useMemo(
() => [
{
id: "users",
label: "Total users",
value: loading ? "..." : String(metrics?.usersCount ?? 0),
delta: `${metrics?.adminsCount ?? 0} admin accounts`,
trend: "up",
},
{
id: "posts",
label: "Published content",
value: loading ? "..." : String(metrics?.postsCount ?? 0),
delta: `${metrics?.commentsCount ?? 0} tracked comments`,
trend: "up",
},
{
id: "marketplace",
label: "Marketplace",
value: loading ? "..." : String(metrics?.marketplaceListingsCount ?? 0),
delta: `${metrics?.repairShopsCount ?? 0} repair shops`,
trend: "neutral",
},
{
id: "moderation",
label: "Moderation load",
value: loading
? "..."
: String((metrics?.flaggedPostsCount ?? 0) + (metrics?.flaggedCommentsCount ?? 0)),
delta: `${metrics?.hiddenPostsCount ?? 0} hidden posts and ${metrics?.hiddenCommentsCount ?? 0} hidden comments`,
trend:
(metrics?.flaggedPostsCount ?? 0) + (metrics?.flaggedCommentsCount ?? 0) > 0
? "down"
: "neutral",
},
],
[loading, metrics],
);
const charts = snapshot.charts;
const userSeries = toInsight(charts?.series.users ?? []);
const postSeries = toInsight(charts?.series.posts ?? []);
const listingSeries = toInsight(charts?.series.listings ?? []);
const roleBreakdown = toChannel(charts?.breakdowns.userRoles ?? []);
const listingBreakdown = toInsightBreakdown(charts?.breakdowns.listingCategories ?? []);
const moderationBreakdown = toInsightBreakdown(charts?.breakdowns.moderation ?? []);
if (!canReadAnalytics) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Analytics"
subtitle="Charts and operational trends for users, content, moderation, and marketplace activity."
/>
<NoPermissionState description="This page needs the analytics.read permission." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Analytics"
subtitle="Operational charts for growth, moderation, and marketplace activity."
/>
{canReadOverview ? (
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{dashboardMetrics.map((metric) => (
<StatCard key={metric.id} metric={metric} />
))}
</section>
) : null}
<section className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-6">
<CardHeader>
<CardTitle>User signups - last 30 days</CardTitle>
</CardHeader>
<CardContent>
{userSeries.length ? (
<InsightBarChart data={userSeries} />
) : (
<EmptyState title="No data" description="Not enough user activity to draw this chart." />
)}
</CardContent>
</Card>
<Card className="xl:col-span-6">
<CardHeader>
<CardTitle>Published content - last 30 days</CardTitle>
</CardHeader>
<CardContent>
{postSeries.length ? (
<InsightBarChart data={postSeries} />
) : (
<EmptyState title="No data" description="Not enough content activity to draw this chart." />
)}
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-5">
<CardHeader>
<CardTitle>User role distribution</CardTitle>
</CardHeader>
<CardContent>
{roleBreakdown.length ? (
<ChannelPieChart data={roleBreakdown} />
) : (
<EmptyState title="No role data" description="No role breakdown is currently available." />
)}
</CardContent>
</Card>
<Card className="xl:col-span-7">
<CardHeader>
<CardTitle>Marketplace categories</CardTitle>
</CardHeader>
<CardContent>
{listingBreakdown.length ? (
<InsightBarChart data={listingBreakdown} />
) : (
<EmptyState title="No data" description="No category breakdown is available right now." />
)}
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-6">
<CardHeader>
<CardTitle>Marketplace creation - last 30 days</CardTitle>
</CardHeader>
<CardContent>
{listingSeries.length ? (
<InsightBarChart data={listingSeries} />
) : (
<EmptyState title="No data" description="Not enough marketplace activity to draw this chart." />
)}
</CardContent>
</Card>
<Card className="xl:col-span-6">
<CardHeader>
<CardTitle>Current moderation states</CardTitle>
</CardHeader>
<CardContent>
{moderationBreakdown.length ? (
<InsightBarChart data={moderationBreakdown} />
) : (
<EmptyState title="No data" description="No moderation breakdown is available right now." />
)}
</CardContent>
</Card>
</section>
</div>
);
}

عرض الملف

@@ -0,0 +1,551 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { PageHeader } from "@/components/dashboard/page-header";
import { PaginationControls } from "@/components/dashboard/pagination-controls";
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 { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useToast } from "@/components/ui/toast";
import { listModerationComments } from "@/lib/api/comments";
import { getItems, getPagination } from "@/lib/api/core";
import { listModerationPosts } from "@/lib/api/posts";
import {
deleteSuperAdminComment,
deleteSuperAdminPost,
getSuperAdminReports,
performSuperAdminBulkAction,
updateSuperAdminCommentStatus,
updateSuperAdminPostStatus,
} from "@/lib/api/superadmin";
import { formatDateTime } from "@/lib/format";
import { getCommentAuthor, getPostAuthor, getUserLabel } from "@/lib/post-utils";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import type {
ApiComment,
ApiPost,
CommentsResponse,
ModerationStatus,
PostsResponse,
SuperAdminReportsResponse,
} from "@/types/api";
type BulkTarget = "post" | "comment";
type BulkAction = "activate" | "flag" | "hide" | "delete";
const EMPTY_REPORTS: SuperAdminReportsResponse = {
summary: {
flaggedPostsCount: 0,
flaggedCommentsCount: 0,
disabledUsersCount: 0,
inactiveListingsCount: 0,
inactiveRepairShopsCount: 0,
openCasesCount: 0,
failedOutboxEventsCount: 0,
pendingOutboxEventsCount: 0,
},
flaggedPosts: [],
flaggedComments: [],
disabledUsers: [],
inactiveListings: [],
inactiveRepairShops: [],
};
function promptReason(actionLabel: string) {
if (typeof window === "undefined") {
return "";
}
const result = window.prompt(`Optional reason for ${actionLabel}`, "");
if (result === null) {
return null;
}
return result.trim();
}
export default function ContentPage() {
const { permissions } = useSuperAdminSession();
const [query, setQuery] = useState("");
const [postType, setPostType] = useState("all");
const [visibility, setVisibility] = useState("all");
const [moderationStatus, setModerationStatus] = useState("all");
const [postsResponse, setPostsResponse] = useState<PostsResponse | null>(null);
const [commentsResponse, setCommentsResponse] = useState<CommentsResponse | null>(null);
const [reports, setReports] = useState<SuperAdminReportsResponse>(EMPTY_REPORTS);
const [loading, setLoading] = useState(true);
const [postsPage, setPostsPage] = useState(1);
const [commentsPage, setCommentsPage] = useState(1);
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
const [selectedCommentIds, setSelectedCommentIds] = useState<string[]>([]);
const [bulkTarget, setBulkTarget] = useState<BulkTarget>("post");
const [bulkAction, setBulkAction] = useState<BulkAction>("flag");
const [bulkReason, setBulkReason] = useState("");
const [bulkLoading, setBulkLoading] = useState(false);
const { toast } = useToast();
const filtersRef = useRef({ query, postType, visibility, moderationStatus });
const canModerateContent = hasPermission(
permissions,
SUPERADMIN_PERMISSIONS.CONTENT_MODERATE,
);
const canReadAnalytics = hasPermission(permissions, SUPERADMIN_PERMISSIONS.ANALYTICS_READ);
const canUseBulkActions =
canModerateContent && hasPermission(permissions, SUPERADMIN_PERMISSIONS.CASES_MANAGE);
filtersRef.current = { query, postType, visibility, moderationStatus };
const loadContent = useCallback(async () => {
if (!canModerateContent) {
setLoading(false);
return;
}
const {
query: currentQuery,
postType: currentPostType,
visibility: currentVisibility,
moderationStatus: currentModerationStatus,
} = filtersRef.current;
setLoading(true);
try {
const [posts, comments, nextReports] = await Promise.all([
listModerationPosts({
page: postsPage,
limit: 10,
q: currentQuery || undefined,
postType: currentPostType === "all" ? undefined : currentPostType,
visibility: currentVisibility === "all" ? undefined : currentVisibility,
moderationStatus:
currentModerationStatus === "all" ? undefined : currentModerationStatus,
sortBy: "createdAt",
sortOrder: "desc",
}),
listModerationComments({
page: commentsPage,
limit: 10,
q: currentQuery || undefined,
moderationStatus:
currentModerationStatus === "all" ? undefined : currentModerationStatus,
sortOrder: "desc",
}),
canReadAnalytics ? getSuperAdminReports({ limit: 6 }) : Promise.resolve(EMPTY_REPORTS),
]);
setPostsResponse(posts);
setCommentsResponse(comments);
setReports(nextReports);
} catch (error) {
toast({ title: "Failed to load content", description: String(error), variant: "danger" });
} finally {
setLoading(false);
}
}, [canModerateContent, canReadAnalytics, commentsPage, postsPage, toast]);
useEffect(() => {
void loadContent();
}, [loadContent]);
const applyFilters = () => {
const shouldReloadDirectly = postsPage === 1 && commentsPage === 1;
setPostsPage(1);
setCommentsPage(1);
if (shouldReloadDirectly) {
void loadContent();
}
};
const posts = getItems(postsResponse) as ApiPost[];
const comments = getItems(commentsResponse) as ApiComment[];
const bulkIds = bulkTarget === "post" ? selectedPostIds : selectedCommentIds;
const updatePostStatus = async (postId: string, status: ModerationStatus) => {
const reason = promptReason(`post ${status}`);
if (reason === null) return;
try {
await updateSuperAdminPostStatus(postId, status, reason || undefined);
await loadContent();
toast({ title: "Post updated", description: `${postId} -> ${status}`, variant: "success" });
} catch (error) {
toast({ title: "Post update failed", description: String(error), variant: "danger" });
}
};
const updateCommentStatus = async (commentId: string, status: ModerationStatus) => {
const reason = promptReason(`comment ${status}`);
if (reason === null) return;
try {
await updateSuperAdminCommentStatus(commentId, status, reason || undefined);
await loadContent();
toast({ title: "Comment updated", description: `${commentId} -> ${status}`, variant: "success" });
} catch (error) {
toast({ title: "Comment update failed", description: String(error), variant: "danger" });
}
};
const toggleSelection = (kind: BulkTarget, id: string) => {
const setter = kind === "post" ? setSelectedPostIds : setSelectedCommentIds;
setter((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
};
if (!canModerateContent) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Content moderation"
subtitle="Review posts and comments, update moderation states, and run bulk actions."
/>
<NoPermissionState description="This page needs the content.moderate permission." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Content moderation"
subtitle="Review posts and comments, update moderation states, and run bulk actions."
actions={
<Button variant="outline" onClick={applyFilters}>
Apply filters
</Button>
}
/>
{canReadAnalytics ? (
<section className="grid gap-4 md:grid-cols-4 xl:grid-cols-7">
<Card>
<CardHeader>
<CardTitle>Flagged posts</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{reports.summary.flaggedPostsCount}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Flagged comments</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{reports.summary.flaggedCommentsCount}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Disabled users</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{reports.summary.disabledUsersCount}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Inactive listings</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{reports.summary.inactiveListingsCount}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Inactive shops</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{reports.summary.inactiveRepairShopsCount}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Open cases</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{reports.summary.openCasesCount}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Failed outbox</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{reports.summary.failedOutboxEventsCount}
</CardContent>
</Card>
</section>
) : null}
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-5">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search text or identity" />
<Select value={postType} onValueChange={setPostType}>
<SelectTrigger>
<SelectValue placeholder="Post type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All post types</SelectItem>
<SelectItem value="text">text</SelectItem>
<SelectItem value="image">image</SelectItem>
<SelectItem value="video">video</SelectItem>
<SelectItem value="audio">audio</SelectItem>
</SelectContent>
</Select>
<Select value={visibility} onValueChange={setVisibility}>
<SelectTrigger>
<SelectValue placeholder="Visibility" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All visibility</SelectItem>
<SelectItem value="public">public</SelectItem>
<SelectItem value="followers">followers</SelectItem>
<SelectItem value="private">private</SelectItem>
</SelectContent>
</Select>
<Select value={moderationStatus} onValueChange={setModerationStatus}>
<SelectTrigger>
<SelectValue placeholder="Moderation state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All states</SelectItem>
<SelectItem value="active">active</SelectItem>
<SelectItem value="hidden">hidden</SelectItem>
<SelectItem value="flagged">flagged</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={applyFilters}>
Apply
</Button>
</CardContent>
</Card>
{canUseBulkActions ? (
<Card>
<CardHeader>
<CardTitle>Bulk actions</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-4">
<Select value={bulkTarget} onValueChange={(value) => setBulkTarget(value as BulkTarget)}>
<SelectTrigger>
<SelectValue placeholder="Target type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="post">posts</SelectItem>
<SelectItem value="comment">comments</SelectItem>
</SelectContent>
</Select>
<Select value={bulkAction} onValueChange={(value) => setBulkAction(value as BulkAction)}>
<SelectTrigger>
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="activate">activate</SelectItem>
<SelectItem value="flag">flag</SelectItem>
<SelectItem value="hide">hide</SelectItem>
<SelectItem value="delete">delete</SelectItem>
</SelectContent>
</Select>
<Input value={bulkReason} onChange={(event) => setBulkReason(event.target.value)} placeholder="Reason for audit/case log" />
<Button
disabled={bulkLoading || !bulkIds.length}
onClick={async () => {
setBulkLoading(true);
try {
await performSuperAdminBulkAction({
resourceType: bulkTarget,
targetIds: bulkIds,
action: bulkAction,
reason: bulkReason || undefined,
assignToMe: true,
});
setSelectedPostIds([]);
setSelectedCommentIds([]);
setBulkReason("");
await loadContent();
toast({ title: "Bulk action applied", description: `${bulkIds.length} items processed`, variant: "success" });
} catch (error) {
toast({ title: "Bulk action failed", description: String(error), variant: "danger" });
} finally {
setBulkLoading(false);
}
}}
>
Apply to {bulkIds.length} selected
</Button>
</CardContent>
</Card>
) : null}
<section className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-7">
<CardHeader>
<CardTitle>Posts</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!posts.length && !loading ? (
<EmptyState title="No posts" description="No posts matched the current filters." />
) : (
<Table>
<TableHeader>
<TableRow>
{canUseBulkActions ? <TableHead /> : null}
<TableHead>Content</TableHead>
<TableHead>Author</TableHead>
<TableHead>Type</TableHead>
<TableHead>State</TableHead>
<TableHead>Engagement</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{posts.map((post) => (
<TableRow key={post._id}>
{canUseBulkActions ? (
<TableCell>
<input
type="checkbox"
checked={selectedPostIds.includes(post._id)}
onChange={() => toggleSelection("post", post._id)}
/>
</TableCell>
) : null}
<TableCell className="max-w-[240px] truncate">{post.content || "-"}</TableCell>
<TableCell>{getUserLabel(getPostAuthor(post))}</TableCell>
<TableCell>{post.postType ?? "-"}</TableCell>
<TableCell>
<Badge
variant={
post.moderationStatus === "flagged"
? "warning"
: post.moderationStatus === "hidden"
? "danger"
: "success"
}
>
{post.moderationStatus ?? "active"}
</Badge>
</TableCell>
<TableCell>
{post.likesCount ?? 0}/{post.commentsCount ?? 0}/{post.shareCount ?? 0}
</TableCell>
<TableCell className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => void updatePostStatus(post._id, "active")}>
Activate
</Button>
<Button size="sm" variant="outline" onClick={() => void updatePostStatus(post._id, "flagged")}>
Flag
</Button>
<Button size="sm" variant="outline" onClick={() => void updatePostStatus(post._id, "hidden")}>
Hide
</Button>
<Button
size="sm"
variant="danger"
onClick={async () => {
try {
await deleteSuperAdminPost(post._id);
await loadContent();
toast({ title: "Post deleted", description: post._id, variant: "warning" });
} catch (error) {
toast({ title: "Delete failed", description: String(error), variant: "danger" });
}
}}
>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationControls pagination={getPagination(postsResponse)} loading={loading} onPageChange={setPostsPage} />
</CardContent>
</Card>
<Card className="xl:col-span-5">
<CardHeader>
<CardTitle>Comments</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!comments.length && !loading ? (
<EmptyState title="No comments" description="No comments matched the current filters." />
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment._id} className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{getUserLabel(getCommentAuthor(comment))}
</p>
<p className="mt-1 text-xs text-muted-foreground">{formatDateTime(comment.createdAt)}</p>
</div>
<div className="flex items-center gap-2">
<Badge
variant={
comment.moderationStatus === "flagged"
? "warning"
: comment.moderationStatus === "hidden"
? "danger"
: "muted"
}
>
{comment.moderationStatus ?? "comment"}
</Badge>
{canUseBulkActions ? (
<input
type="checkbox"
checked={selectedCommentIds.includes(comment._id)}
onChange={() => toggleSelection("comment", comment._id)}
/>
) : null}
</div>
</div>
<p className="mt-3 text-sm text-foreground">{comment.content}</p>
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => void updateCommentStatus(comment._id, "active")}>
Activate
</Button>
<Button size="sm" variant="outline" onClick={() => void updateCommentStatus(comment._id, "flagged")}>
Flag
</Button>
<Button size="sm" variant="outline" onClick={() => void updateCommentStatus(comment._id, "hidden")}>
Hide
</Button>
<Button
size="sm"
variant="danger"
onClick={async () => {
try {
await deleteSuperAdminComment(comment._id);
await loadContent();
toast({ title: "Comment deleted", description: comment._id, variant: "warning" });
} catch (error) {
toast({ title: "Delete failed", description: String(error), variant: "danger" });
}
}}
>
Delete
</Button>
</div>
</div>
))}
</div>
)}
<PaginationControls pagination={getPagination(commentsResponse)} loading={loading} onPageChange={setCommentsPage} />
</CardContent>
</Card>
</section>
</div>
);
}

عرض الملف

@@ -0,0 +1,463 @@
"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>
);
}

عرض الملف

@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import { MobileNav } from "@/components/dashboard/mobile-nav";
import { DashboardSidebar } from "@/components/dashboard/sidebar";
import { DashboardTopbar } from "@/components/dashboard/topbar";
import { AuthGuard } from "@/components/auth/auth-guard";
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<AuthGuard>
<div className="mx-auto flex max-w-[1500px] gap-4 p-4">
<DashboardSidebar />
<main className="min-h-screen flex-1">
<DashboardTopbar />
<MobileNav />
{children}
</main>
</div>
</AuthGuard>
);
}

عرض الملف

@@ -0,0 +1,954 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { PageHeader } from "@/components/dashboard/page-header";
import { PaginationControls } from "@/components/dashboard/pagination-controls";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/toast";
import { getItems, getPagination } from "@/lib/api/core";
import { listPlatformAdmins } from "@/lib/api/admin-users";
import {
createMarketplaceInstrumentForSuperAdmin,
createMarketplaceListingForSuperAdmin,
createMarketplaceRepairShopForSuperAdmin,
deleteModerationListing,
deleteModerationRepairShop,
getMarketplaceHome,
listModerationListings,
listModerationRepairShops,
updateMarketplaceShopProfileForSuperAdmin,
updateModerationListingStatus,
updateModerationRepairShopStatus,
} from "@/lib/api/marketplace";
import { formatCurrency } from "@/lib/format";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import type {
ApiUser,
MarketplaceHomeResponse,
MarketplaceListing,
MarketplaceRepairShop,
MarketplaceRepairShopResponse,
MarketplaceResponse,
UsersResponse,
} from "@/types/api";
type CreateMode = "listing" | "instrument" | "repair_shop" | "shop_profile";
type ListingFormState = {
title: string;
description: string;
price: string;
currency: string;
quantity: string;
listingCategory: string;
condition: string;
instrumentType: string;
isActive: boolean;
imageFiles: File[];
};
type RepairShopFormState = {
name: string;
description: string;
services: string;
phone: string;
whatsapp: string;
location: string;
latitude: string;
longitude: string;
isActive: boolean;
imageFiles: File[];
};
type ShopProfileFormState = {
shopName: string;
shopDescription: string;
shopLocation: string;
shopLatitude: string;
shopLongitude: string;
shopImageFiles: File[];
};
const defaultListingForm: ListingFormState = {
title: "",
description: "",
price: "",
currency: "SAR",
quantity: "1",
listingCategory: "other",
condition: "used",
instrumentType: "",
isActive: true,
imageFiles: [],
};
const defaultRepairShopForm: RepairShopFormState = {
name: "",
description: "",
services: "",
phone: "",
whatsapp: "",
location: "",
latitude: "",
longitude: "",
isActive: true,
imageFiles: [],
};
const defaultShopProfileForm: ShopProfileFormState = {
shopName: "",
shopDescription: "",
shopLocation: "",
shopLatitude: "",
shopLongitude: "",
shopImageFiles: [],
};
function toNumber(value: string) {
return Number(value.trim());
}
function toOptionalNumber(value: string) {
const trimmed = value.trim();
return trimmed ? Number(trimmed) : undefined;
}
function parseCommaSeparated(value: string) {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
function getAdminLabel(admin: ApiUser) {
return admin.shopName || admin.stageName || admin.name || admin.username || admin.email;
}
function promptReason(actionLabel: string) {
if (typeof window === "undefined") {
return "";
}
const result = window.prompt(`Optional reason for ${actionLabel}`, "");
if (result === null) {
return null;
}
return result.trim();
}
export default function MarketplacePage() {
const { permissions } = useSuperAdminSession();
const [query, setQuery] = useState("");
const [listingCategory, setListingCategory] = useState("all");
const [activeFilter, setActiveFilter] = useState("all");
const [listingsPage, setListingsPage] = useState(1);
const [shopsPage, setShopsPage] = useState(1);
const [home, setHome] = useState<MarketplaceHomeResponse | null>(null);
const [listingsResponse, setListingsResponse] = useState<MarketplaceResponse | null>(null);
const [shopsResponse, setShopsResponse] = useState<MarketplaceRepairShopResponse | null>(null);
const [adminsResponse, setAdminsResponse] = useState<UsersResponse | null>(null);
const [ownerAdminId, setOwnerAdminId] = useState("");
const [createMode, setCreateMode] = useState<CreateMode>("listing");
const [listingForm, setListingForm] = useState<ListingFormState>(defaultListingForm);
const [repairShopForm, setRepairShopForm] = useState<RepairShopFormState>(defaultRepairShopForm);
const [shopProfileForm, setShopProfileForm] = useState<ShopProfileFormState>(defaultShopProfileForm);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const { toast } = useToast();
const filtersRef = useRef({ query, listingCategory, activeFilter });
const canManageMarketplace = hasPermission(
permissions,
SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE,
);
const canReadUsers = hasPermission(permissions, SUPERADMIN_PERMISSIONS.USERS_READ);
filtersRef.current = { query, listingCategory, activeFilter };
const loadMarketplace = useCallback(async () => {
if (!canManageMarketplace) {
setLoading(false);
return;
}
setLoading(true);
try {
const {
query: currentQuery,
listingCategory: currentListingCategory,
activeFilter: currentActiveFilter,
} = filtersRef.current;
const isActive =
currentActiveFilter === "all" ? undefined : currentActiveFilter === "active" ? true : false;
const [homeResponse, listings, shops] = await Promise.all([
getMarketplaceHome(),
listModerationListings({
page: listingsPage,
limit: 10,
q: currentQuery || undefined,
listingCategory: currentListingCategory === "all" ? undefined : currentListingCategory,
isActive,
sortBy: "createdAt",
sortOrder: "desc",
}),
listModerationRepairShops({
page: shopsPage,
limit: 10,
q: currentQuery || undefined,
isActive,
sortBy: "createdAt",
sortOrder: "desc",
}),
]);
setHome(homeResponse);
setListingsResponse(listings);
setShopsResponse(shops);
} catch (error) {
toast({
title: "تعذر تحميل بيانات الماركت",
description: String(error),
variant: "danger",
});
} finally {
setLoading(false);
}
}, [canManageMarketplace, listingsPage, shopsPage, toast]);
const loadAdmins = useCallback(async () => {
if (!canReadUsers) {
setAdminsResponse({ items: [], data: [] });
return;
}
try {
const response = await listPlatformAdmins(1, 100);
setAdminsResponse(response);
const admins = getItems(response) as ApiUser[];
setOwnerAdminId((current) => current || admins[0]?._id || "");
} catch (error) {
toast({
title: "تعذر تحميل قائمة الأدمنز",
description: String(error),
variant: "danger",
});
}
}, [canReadUsers, toast]);
useEffect(() => {
void loadMarketplace();
}, [loadMarketplace]);
useEffect(() => {
void loadAdmins();
}, [loadAdmins]);
const admins = getItems(adminsResponse) as ApiUser[];
const listings = getItems(listingsResponse) as MarketplaceListing[];
const shops = getItems(shopsResponse) as MarketplaceRepairShop[];
const applyFilters = () => {
const shouldReloadDirectly = listingsPage === 1 && shopsPage === 1;
setListingsPage(1);
setShopsPage(1);
if (shouldReloadDirectly) {
void loadMarketplace();
}
};
const handleCreate = async () => {
if (!canReadUsers) {
toast({
title: "Missing permission",
description: "Choosing the owner admin requires users.read.",
variant: "danger",
});
return;
}
if (!ownerAdminId) {
toast({
title: "اختر الأدمن المالك أولًا",
description: "السوبر أدمن ينشئ العنصر على حساب أدمن محدد.",
variant: "danger",
});
return;
}
setSubmitting(true);
try {
if (createMode === "shop_profile") {
await updateMarketplaceShopProfileForSuperAdmin(ownerAdminId, {
shopName: shopProfileForm.shopName,
shopDescription: shopProfileForm.shopDescription,
shopLocation: shopProfileForm.shopLocation,
shopLatitude: toOptionalNumber(shopProfileForm.shopLatitude),
shopLongitude: toOptionalNumber(shopProfileForm.shopLongitude),
shopImageFiles: shopProfileForm.shopImageFiles,
});
setShopProfileForm(defaultShopProfileForm);
toast({ title: "تم تحديث ملف المتجر", description: "أصبح الأدمن جاهزًا لامتلاك عناصر ماركت." });
} else if (createMode === "repair_shop") {
await createMarketplaceRepairShopForSuperAdmin(ownerAdminId, {
name: repairShopForm.name,
description: repairShopForm.description,
services: parseCommaSeparated(repairShopForm.services),
phone: repairShopForm.phone,
whatsapp: repairShopForm.whatsapp,
location: repairShopForm.location,
latitude: toOptionalNumber(repairShopForm.latitude),
longitude: toOptionalNumber(repairShopForm.longitude),
isActive: repairShopForm.isActive,
imageFiles: repairShopForm.imageFiles,
});
setRepairShopForm(defaultRepairShopForm);
toast({ title: "تم إنشاء متجر الصيانة", description: "أضيف المتجر بنجاح تحت الأدمن المحدد." });
} else {
const payload = {
title: listingForm.title,
description: listingForm.description,
price: toNumber(listingForm.price),
currency: listingForm.currency,
quantity: toNumber(listingForm.quantity),
condition: listingForm.condition,
instrumentType: listingForm.instrumentType,
isActive: listingForm.isActive,
imageFiles: listingForm.imageFiles,
...(createMode === "instrument"
? { listingCategory: "musical_instrument" }
: { listingCategory: listingForm.listingCategory }),
};
if (createMode === "instrument") {
await createMarketplaceInstrumentForSuperAdmin(ownerAdminId, payload);
toast({ title: "تم إنشاء الآلة الموسيقية", description: "أضيفت الآلة تحت الأدمن المحدد." });
} else {
await createMarketplaceListingForSuperAdmin(ownerAdminId, payload);
toast({ title: "تم إنشاء العنصر العام", description: "أضيف العنصر إلى الماركت بنجاح." });
}
setListingForm({
...defaultListingForm,
listingCategory: createMode === "listing" ? "other" : "musical_instrument",
});
}
await loadMarketplace();
} catch (error) {
toast({
title: "فشل تنفيذ العملية",
description: String(error),
variant: "danger",
});
} finally {
setSubmitting(false);
}
};
if (!canManageMarketplace) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Marketplace management"
subtitle="Central marketplace moderation for listings, repair shops, and owner-managed creation flows."
/>
<NoPermissionState description="This page needs the marketplace.manage permission." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="إدارة الماركت"
subtitle="إشراف مركزي على عناصر البيع والمتاجر وورش الصيانة، مع قدرة مباشرة للسوبر أدمن على الإنشاء باسم أي أدمن."
actions={
<Button
variant="outline"
onClick={applyFilters}
>
تحديث البيانات
</Button>
}
/>
<section className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>عناصر السوق العامة</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{home?.summary.activeListings ?? 0}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>الآلات الموسيقية</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{home?.summary.activeMusicalInstruments ?? 0}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>ورش ومتاجر الصيانة</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{home?.summary.activeRepairShops ?? 0}
</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle>إنشاء باسم الأدمن</CardTitle>
<CardDescription>
اختر الأدمن المالك ثم أنشئ متجرًا أو آلة أو عنصرًا عامًا أو حدّث ملف متجره. كل الرفع يتم عبر
FormData للصور.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">الأدمن المالك</p>
<Select value={ownerAdminId} onValueChange={setOwnerAdminId}>
<SelectTrigger>
<SelectValue placeholder="اختر الأدمن" />
</SelectTrigger>
<SelectContent>
{admins.map((admin) => (
<SelectItem key={admin._id} value={admin._id}>
{getAdminLabel(admin)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">نوع العملية</p>
<Select
value={createMode}
onValueChange={(value) => setCreateMode(value as CreateMode)}
>
<SelectTrigger>
<SelectValue placeholder="اختر نوع العملية" />
</SelectTrigger>
<SelectContent>
<SelectItem value="listing">عنصر ماركت عام</SelectItem>
<SelectItem value="instrument">آلة موسيقية</SelectItem>
<SelectItem value="repair_shop">متجر/ورشة صيانة</SelectItem>
<SelectItem value="shop_profile">ملف المتجر</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{(createMode === "listing" || createMode === "instrument") && (
<div className="grid gap-3 md:grid-cols-2">
<Input
value={listingForm.title}
onChange={(event) =>
setListingForm((current) => ({ ...current, title: event.target.value }))
}
placeholder="العنوان"
/>
<Input
value={listingForm.instrumentType}
onChange={(event) =>
setListingForm((current) => ({ ...current, instrumentType: event.target.value }))
}
placeholder="نوع الآلة أو المنتج"
/>
<Input
value={listingForm.price}
onChange={(event) =>
setListingForm((current) => ({ ...current, price: event.target.value }))
}
placeholder="السعر"
inputMode="decimal"
/>
<Input
value={listingForm.quantity}
onChange={(event) =>
setListingForm((current) => ({ ...current, quantity: event.target.value }))
}
placeholder="الكمية"
inputMode="numeric"
/>
<Input
value={listingForm.currency}
onChange={(event) =>
setListingForm((current) => ({ ...current, currency: event.target.value.toUpperCase() }))
}
placeholder="العملة"
/>
<Select
value={listingForm.condition}
onValueChange={(value) =>
setListingForm((current) => ({ ...current, condition: value }))
}
>
<SelectTrigger>
<SelectValue placeholder="الحالة" />
</SelectTrigger>
<SelectContent>
<SelectItem value="new">new</SelectItem>
<SelectItem value="used">used</SelectItem>
<SelectItem value="like_new">like_new</SelectItem>
<SelectItem value="refurbished">refurbished</SelectItem>
</SelectContent>
</Select>
{createMode === "listing" && (
<Select
value={listingForm.listingCategory}
onValueChange={(value) =>
setListingForm((current) => ({ ...current, listingCategory: value }))
}
>
<SelectTrigger>
<SelectValue placeholder="فئة العنصر" />
</SelectTrigger>
<SelectContent>
<SelectItem value="accessory">accessory</SelectItem>
<SelectItem value="audio_gear">audio_gear</SelectItem>
<SelectItem value="sheet_music">sheet_music</SelectItem>
<SelectItem value="other">other</SelectItem>
<SelectItem value="musical_instrument">musical_instrument</SelectItem>
</SelectContent>
</Select>
)}
<label className="flex items-center justify-between rounded-lg border border-input bg-background/40 px-3 py-2 text-sm text-foreground">
<span>نشط عند الإنشاء</span>
<Switch
checked={listingForm.isActive}
onCheckedChange={(value) =>
setListingForm((current) => ({ ...current, isActive: value }))
}
/>
</label>
<div className="md:col-span-2">
<Textarea
value={listingForm.description}
onChange={(event) =>
setListingForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="الوصف"
/>
</div>
<div className="space-y-2 md:col-span-2">
<p className="text-sm font-medium text-foreground">صور العنصر</p>
<Input
type="file"
multiple
accept="image/*"
onChange={(event) =>
setListingForm((current) => ({
...current,
imageFiles: Array.from(event.target.files ?? []),
}))
}
/>
<p className="text-xs text-muted-foreground">
الملفات المختارة: {listingForm.imageFiles.length}
</p>
</div>
</div>
)}
{createMode === "repair_shop" && (
<div className="grid gap-3 md:grid-cols-2">
<Input
value={repairShopForm.name}
onChange={(event) =>
setRepairShopForm((current) => ({ ...current, name: event.target.value }))
}
placeholder="اسم المتجر أو الورشة"
/>
<Input
value={repairShopForm.location}
onChange={(event) =>
setRepairShopForm((current) => ({ ...current, location: event.target.value }))
}
placeholder="الموقع"
/>
<Input
value={repairShopForm.phone}
onChange={(event) =>
setRepairShopForm((current) => ({ ...current, phone: event.target.value }))
}
placeholder="الهاتف"
/>
<Input
value={repairShopForm.whatsapp}
onChange={(event) =>
setRepairShopForm((current) => ({ ...current, whatsapp: event.target.value }))
}
placeholder="واتساب"
/>
<Input
value={repairShopForm.latitude}
onChange={(event) =>
setRepairShopForm((current) => ({ ...current, latitude: event.target.value }))
}
placeholder="خط العرض"
inputMode="decimal"
/>
<Input
value={repairShopForm.longitude}
onChange={(event) =>
setRepairShopForm((current) => ({ ...current, longitude: event.target.value }))
}
placeholder="خط الطول"
inputMode="decimal"
/>
<div className="md:col-span-2">
<Input
value={repairShopForm.services}
onChange={(event) =>
setRepairShopForm((current) => ({ ...current, services: event.target.value }))
}
placeholder="الخدمات مفصولة بفواصل، مثال: Oud Repair, Violin Setup"
/>
</div>
<div className="md:col-span-2">
<Textarea
value={repairShopForm.description}
onChange={(event) =>
setRepairShopForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="وصف المتجر"
/>
</div>
<label className="flex items-center justify-between rounded-lg border border-input bg-background/40 px-3 py-2 text-sm text-foreground">
<span>نشط عند الإنشاء</span>
<Switch
checked={repairShopForm.isActive}
onCheckedChange={(value) =>
setRepairShopForm((current) => ({ ...current, isActive: value }))
}
/>
</label>
<div className="space-y-2 md:col-span-2">
<p className="text-sm font-medium text-foreground">صور المتجر</p>
<Input
type="file"
multiple
accept="image/*"
onChange={(event) =>
setRepairShopForm((current) => ({
...current,
imageFiles: Array.from(event.target.files ?? []),
}))
}
/>
<p className="text-xs text-muted-foreground">
الملفات المختارة: {repairShopForm.imageFiles.length}
</p>
</div>
</div>
)}
{createMode === "shop_profile" && (
<div className="grid gap-3 md:grid-cols-2">
<Input
value={shopProfileForm.shopName}
onChange={(event) =>
setShopProfileForm((current) => ({ ...current, shopName: event.target.value }))
}
placeholder="اسم المتجر"
/>
<Input
value={shopProfileForm.shopLocation}
onChange={(event) =>
setShopProfileForm((current) => ({ ...current, shopLocation: event.target.value }))
}
placeholder="موقع المتجر"
/>
<Input
value={shopProfileForm.shopLatitude}
onChange={(event) =>
setShopProfileForm((current) => ({ ...current, shopLatitude: event.target.value }))
}
placeholder="خط العرض"
inputMode="decimal"
/>
<Input
value={shopProfileForm.shopLongitude}
onChange={(event) =>
setShopProfileForm((current) => ({ ...current, shopLongitude: event.target.value }))
}
placeholder="خط الطول"
inputMode="decimal"
/>
<div className="md:col-span-2">
<Textarea
value={shopProfileForm.shopDescription}
onChange={(event) =>
setShopProfileForm((current) => ({
...current,
shopDescription: event.target.value,
}))
}
placeholder="وصف المتجر"
/>
</div>
<div className="space-y-2 md:col-span-2">
<p className="text-sm font-medium text-foreground">صور ملف المتجر</p>
<Input
type="file"
multiple
accept="image/*"
onChange={(event) =>
setShopProfileForm((current) => ({
...current,
shopImageFiles: Array.from(event.target.files ?? []),
}))
}
/>
<p className="text-xs text-muted-foreground">
الملفات المختارة: {shopProfileForm.shopImageFiles.length}
</p>
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleCreate()} disabled={submitting}>
{submitting ? "جارٍ التنفيذ..." : "تنفيذ العملية"}
</Button>
<p className="text-sm text-muted-foreground">
ملاحظة: متجر الصيانة يبقى واحدًا فقط لكل أدمن، بينما العناصر يمكن إضافتها بلا مشكلة بعد تجهيز
ملف المتجر.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>فلاتر الإشراف</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="ابحث بالاسم أو المتجر"
/>
<Select value={listingCategory} onValueChange={setListingCategory}>
<SelectTrigger>
<SelectValue placeholder="الفئة" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">كل الفئات</SelectItem>
<SelectItem value="musical_instrument">musical_instrument</SelectItem>
<SelectItem value="accessory">accessory</SelectItem>
<SelectItem value="audio_gear">audio_gear</SelectItem>
<SelectItem value="sheet_music">sheet_music</SelectItem>
<SelectItem value="other">other</SelectItem>
</SelectContent>
</Select>
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger>
<SelectValue placeholder="الحالة" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">كل الحالات</SelectItem>
<SelectItem value="active">نشط</SelectItem>
<SelectItem value="inactive">موقوف</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
<section className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-7">
<CardHeader>
<CardTitle>عناصر البيع</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!listings.length && !loading ? (
<EmptyState
title="لا توجد عناصر"
description="لم يرجع الخادم أي عناصر بهذه الفلاتر."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>العنوان</TableHead>
<TableHead>الفئة</TableHead>
<TableHead>المتجر</TableHead>
<TableHead>السعر</TableHead>
<TableHead>الحالة</TableHead>
<TableHead>إجراءات</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{listings.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>
<TableCell>
<Badge variant={listing.isActive ? "success" : "danger"}>
{listing.isActive ? "نشط" : "موقوف"}
</Badge>
</TableCell>
<TableCell className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
const reason = promptReason(
listing.isActive ? "listing deactivation" : "listing activation",
);
if (reason === null) return;
try {
await updateModerationListingStatus(
listing._id,
!listing.isActive,
reason || undefined,
);
await loadMarketplace();
} catch (error) {
toast({
title: "فشل تحديث العنصر",
description: String(error),
variant: "danger",
});
}
}}
>
{listing.isActive ? "إيقاف" : "تفعيل"}
</Button>
<Button
size="sm"
variant="danger"
onClick={async () => {
try {
await deleteModerationListing(listing._id);
await loadMarketplace();
} catch (error) {
toast({
title: "فشل حذف العنصر",
description: String(error),
variant: "danger",
});
}
}}
>
حذف
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationControls
pagination={getPagination(listingsResponse)}
loading={loading}
onPageChange={setListingsPage}
/>
</CardContent>
</Card>
<Card className="xl:col-span-5">
<CardHeader>
<CardTitle>المتاجر وورش الصيانة</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!shops.length && !loading ? (
<EmptyState
title="لا توجد متاجر"
description="لا توجد نتائج مطابقة حاليًا."
/>
) : (
<div className="space-y-3">
{shops.map((shop) => (
<div key={shop._id} className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">{shop.name}</p>
<p className="mt-1 text-xs text-muted-foreground">{shop.location ?? "-"}</p>
</div>
<Badge variant={shop.isActive ? "success" : "danger"}>
{shop.isActive ? "نشط" : "موقوف"}
</Badge>
</div>
<p className="mt-3 text-sm text-muted-foreground">{shop.description ?? "-"}</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
const reason = promptReason(
shop.isActive ? "repair shop deactivation" : "repair shop activation",
);
if (reason === null) return;
try {
await updateModerationRepairShopStatus(
shop._id,
!shop.isActive,
reason || undefined,
);
await loadMarketplace();
} catch (error) {
toast({
title: "فشل تحديث المتجر",
description: String(error),
variant: "danger",
});
}
}}
>
{shop.isActive ? "إيقاف" : "تفعيل"}
</Button>
<Button
size="sm"
variant="danger"
onClick={async () => {
try {
await deleteModerationRepairShop(shop._id);
await loadMarketplace();
} catch (error) {
toast({
title: "فشل حذف المتجر",
description: String(error),
variant: "danger",
});
}
}}
>
حذف
</Button>
</div>
</div>
))}
</div>
)}
<PaginationControls
pagination={getPagination(shopsResponse)}
loading={loading}
onPageChange={setShopsPage}
/>
</CardContent>
</Card>
</section>
</div>
);
}

عرض الملف

@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { PageHeader } from "@/components/dashboard/page-header";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { useToast } from "@/components/ui/toast";
import { listModerationComments } from "@/lib/api/comments";
import { getItems } from "@/lib/api/core";
import { listPlatformNotifications } from "@/lib/api/notifications";
import { formatDateTime } from "@/lib/format";
import { getCommentAuthor, getUserLabel } from "@/lib/post-utils";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import type { ApiComment, NotificationItem, NotificationsResponse } from "@/types/api";
const EMPTY_NOTIFICATIONS: NotificationsResponse = { items: [], data: [], unreadCount: 0 };
export default function MessagesPage() {
const { permissions } = useSuperAdminSession();
const [comments, setComments] = useState<ApiComment[]>([]);
const [alerts, setAlerts] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
const canReadNotifications = hasPermission(
permissions,
SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ,
);
const canModerateContent = hasPermission(
permissions,
SUPERADMIN_PERMISSIONS.CONTENT_MODERATE,
);
useEffect(() => {
let active = true;
const loadData = async () => {
if (!canReadNotifications && !canModerateContent) {
setLoading(false);
return;
}
setLoading(true);
try {
const [commentsResponse, notificationsResponse] = await Promise.all([
canModerateContent
? listModerationComments({ page: 1, limit: 8, sortOrder: "desc" })
: Promise.resolve({ items: [], data: [] }),
canReadNotifications
? listPlatformNotifications({ page: 1, limit: 8, sortOrder: "desc" })
: Promise.resolve(EMPTY_NOTIFICATIONS),
]);
if (!active) return;
setComments(getItems(commentsResponse) as ApiComment[]);
setAlerts(
(getItems(notificationsResponse) as NotificationItem[]).filter((item) =>
["message", "mention", "comment"].includes(item.type),
),
);
} catch (error) {
if (!active) return;
toast({ title: "Failed to load engagement follow-up", description: String(error), variant: "danger" });
} finally {
if (active) setLoading(false);
}
};
void loadData();
return () => {
active = false;
};
}, [canModerateContent, canReadNotifications, toast]);
if (!canReadNotifications && !canModerateContent) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Engagement follow-up"
subtitle="A consolidated view of message-related alerts, mentions, and recent comments."
/>
<NoPermissionState description="This page needs notifications.read, content.moderate, or both." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Engagement follow-up"
subtitle="A consolidated view of message-related alerts, mentions, and recent comments."
/>
<section className="grid gap-4 xl:grid-cols-12">
{canReadNotifications ? (
<Card className={canModerateContent ? "xl:col-span-5" : "xl:col-span-12"}>
<CardHeader>
<CardTitle>Interaction alerts</CardTitle>
</CardHeader>
<CardContent>
{!alerts.length && !loading ? (
<EmptyState title="No alerts" description="No message, mention, or comment alerts are available right now." />
) : (
<div className="space-y-3">
{alerts.map((item) => (
<div key={item._id} className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-foreground">{item.title ?? item.type}</p>
<Badge variant={item.read ? "muted" : "warning"}>{item.type}</Badge>
</div>
<p className="mt-2 text-sm text-muted-foreground">{item.previewText ?? "-"}</p>
<p className="mt-2 text-xs text-muted-foreground">{formatDateTime(item.createdAt)}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
) : null}
{canModerateContent ? (
<Card className={canReadNotifications ? "xl:col-span-7" : "xl:col-span-12"}>
<CardHeader>
<CardTitle>Recent public comments</CardTitle>
</CardHeader>
<CardContent>
{!comments.length && !loading ? (
<EmptyState title="No comments" description="No comments are available for follow-up right now." />
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment._id} className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{getUserLabel(getCommentAuthor(comment))}
</p>
<p className="mt-1 text-xs text-muted-foreground">{formatDateTime(comment.createdAt)}</p>
</div>
<Badge variant={comment.mentionUsernames?.length ? "warning" : "muted"}>
{comment.mentionUsernames?.length ? "mention" : "comment"}
</Badge>
</div>
<p className="mt-3 text-sm text-foreground">{comment.content}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
) : null}
</section>
</div>
);
}

عرض الملف

@@ -0,0 +1,193 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { PageHeader } from "@/components/dashboard/page-header";
import { PaginationControls } from "@/components/dashboard/pagination-controls";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/components/ui/toast";
import { getItems, getPagination } from "@/lib/api/core";
import { listPlatformNotifications } from "@/lib/api/notifications";
import { formatDateTime } from "@/lib/format";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import type { NotificationItem, NotificationsResponse } from "@/types/api";
export default function NotificationsPage() {
const { permissions } = useSuperAdminSession();
const [readFilter, setReadFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState("all");
const [resourceTypeFilter, setResourceTypeFilter] = useState("all");
const [page, setPage] = useState(1);
const [response, setResponse] = useState<NotificationsResponse | null>(null);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
const filtersRef = useRef({ readFilter, typeFilter, resourceTypeFilter });
const canReadNotifications = hasPermission(
permissions,
SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ,
);
filtersRef.current = { readFilter, typeFilter, resourceTypeFilter };
const loadNotifications = useCallback(async () => {
if (!canReadNotifications) {
setLoading(false);
return;
}
const {
readFilter: currentReadFilter,
typeFilter: currentTypeFilter,
resourceTypeFilter: currentResourceTypeFilter,
} = filtersRef.current;
const read =
currentReadFilter === "all" ? undefined : currentReadFilter === "read" ? true : false;
setLoading(true);
try {
const result = await listPlatformNotifications({
page,
limit: 15,
read,
type: currentTypeFilter === "all" ? undefined : currentTypeFilter,
resourceType:
currentResourceTypeFilter === "all" ? undefined : currentResourceTypeFilter,
sortOrder: "desc",
});
setResponse(result);
} catch (error) {
toast({ title: "Failed to load notifications", description: String(error), variant: "danger" });
} finally {
setLoading(false);
}
}, [canReadNotifications, page, toast]);
useEffect(() => {
void loadNotifications();
}, [loadNotifications]);
const applyFilters = () => {
if (page === 1) {
void loadNotifications();
return;
}
setPage(1);
};
const items = getItems(response) as NotificationItem[];
if (!canReadNotifications) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Notifications center"
subtitle="Platform notifications with filters for type, read state, and target resource."
/>
<NoPermissionState description="This page needs the notifications.read permission." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Notifications center"
subtitle="Platform notifications with filters for type, read state, and target resource."
/>
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-4">
<Select value={readFilter} onValueChange={setReadFilter}>
<SelectTrigger>
<SelectValue placeholder="Read state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="unread">Unread</SelectItem>
<SelectItem value="read">Read</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="like">like</SelectItem>
<SelectItem value="comment">comment</SelectItem>
<SelectItem value="follow">follow</SelectItem>
<SelectItem value="message">message</SelectItem>
<SelectItem value="save">save</SelectItem>
<SelectItem value="share">share</SelectItem>
<SelectItem value="mention">mention</SelectItem>
</SelectContent>
</Select>
<Select value={resourceTypeFilter} onValueChange={setResourceTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="Target" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All targets</SelectItem>
<SelectItem value="post">post</SelectItem>
<SelectItem value="comment">comment</SelectItem>
<SelectItem value="conversation">conversation</SelectItem>
<SelectItem value="user">user</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={applyFilters}>
Apply
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Notifications</CardTitle>
<Badge variant={response?.unreadCount ? "warning" : "muted"}>
Unread: {response?.unreadCount ?? 0}
</Badge>
</CardHeader>
<CardContent className="space-y-4">
{!items.length && !loading ? (
<EmptyState title="No notifications" description="No results matched the selected filters." />
) : (
<div className="space-y-3">
{items.map((item) => (
<div key={item._id} className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-foreground">{item.title ?? item.type}</p>
<p className="mt-1 text-xs text-muted-foreground">{formatDateTime(item.createdAt)}</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={item.read ? "muted" : "warning"}>
{item.read ? "Read" : "New"}
</Badge>
<Badge>{item.type}</Badge>
<Badge variant="muted">{item.resourceType ?? "-"}</Badge>
</div>
</div>
<p className="mt-3 text-sm text-foreground">{item.previewText ?? item.body ?? "-"}</p>
{item.deepLink ? (
<p className="mt-2 text-xs text-muted-foreground">Deep link: {item.deepLink}</p>
) : null}
</div>
))}
</div>
)}
<PaginationControls pagination={getPagination(response)} loading={loading} onPageChange={setPage} />
</CardContent>
</Card>
</div>
);
}

عرض الملف

@@ -0,0 +1,273 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { PageHeader } from "@/components/dashboard/page-header";
import { PaginationControls } from "@/components/dashboard/pagination-controls";
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 { getItems, getPagination } from "@/lib/api/core";
import {
getSuperAdminCases,
getSuperAdminOps,
restoreSuperAdminComment,
restoreSuperAdminPost,
updateSuperAdminCase,
} from "@/lib/api/superadmin";
import { formatDateTime } from "@/lib/format";
import { useToast } from "@/components/ui/toast";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import type {
SuperAdminCase,
SuperAdminCasesResponse,
SuperAdminOpsResponse,
} from "@/types/api";
const EMPTY_CASES: SuperAdminCasesResponse = { items: [], data: [] };
const EMPTY_OPS: SuperAdminOpsResponse = {
services: {
mongodb: { status: "unknown" },
redis: { enabled: false, status: "unknown" },
queue: { enabled: false },
storage: {},
email: { enabled: false },
websocket: { redisAdapterEnabled: false },
},
queues: {
outbox: { pending: 0, failed: 0 },
},
workload: {
openCasesCount: 0,
activeSuperAdminSessionsCount: 0,
},
};
export default function OrdersPage() {
const { permissions, session } = useSuperAdminSession();
const [casesResponse, setCasesResponse] = useState<SuperAdminCasesResponse | null>(null);
const [ops, setOps] = useState<SuperAdminOpsResponse | null>(null);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
const canManageCases = hasPermission(permissions, SUPERADMIN_PERMISSIONS.CASES_MANAGE);
const canReadOps = hasPermission(permissions, SUPERADMIN_PERMISSIONS.OPS_READ);
const canModerateContent = hasPermission(
permissions,
SUPERADMIN_PERMISSIONS.CONTENT_MODERATE,
);
const loadQueue = useCallback(async () => {
if (!canManageCases && !canReadOps) {
setLoading(false);
return;
}
setLoading(true);
try {
const [casesPayload, opsPayload] = await Promise.all([
canManageCases
? getSuperAdminCases({ page, limit: 12, sortOrder: "desc" })
: Promise.resolve(EMPTY_CASES),
canReadOps ? getSuperAdminOps() : Promise.resolve(EMPTY_OPS),
]);
setCasesResponse(casesPayload);
setOps(opsPayload);
} catch (error) {
toast({ title: "Failed to load operations queue", description: String(error), variant: "danger" });
} finally {
setLoading(false);
}
}, [canManageCases, canReadOps, page, toast]);
useEffect(() => {
void loadQueue();
}, [loadQueue]);
const items = getItems(casesResponse) as SuperAdminCase[];
if (!canManageCases && !canReadOps) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Operations queue"
subtitle="Moderation and operational cases, assignment workflow, and restore actions."
/>
<NoPermissionState description="This page needs cases.manage, ops.read, or both." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Operations queue"
subtitle="Moderation and operational cases, assignment workflow, and restore actions."
/>
{canReadOps ? (
<section className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader>
<CardTitle>Open cases</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{ops?.workload.openCasesCount ?? 0}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Outbox pending</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{ops?.queues.outbox.pending ?? 0}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Outbox failed</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{ops?.queues.outbox.failed ?? 0}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Live sessions</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">
{ops?.workload.activeSuperAdminSessionsCount ?? 0}
</CardContent>
</Card>
</section>
) : null}
{canManageCases ? (
<Card>
<CardHeader>
<CardTitle>Case queue</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!items.length && !loading ? (
<EmptyState title="No open queue items" description="Cases will appear here as actions are recorded." />
) : (
<div className="space-y-3">
{items.map((item) => (
<div key={item._id} className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">{item.title}</div>
<div className="text-xs text-muted-foreground">
{item.resourceType} / {item.resourceId || "-"} / {formatDateTime(item.updatedAt ?? item.createdAt)}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={item.status === "resolved" ? "success" : item.status === "in_review" ? "warning" : "muted"}>
{item.status}
</Badge>
<Badge variant={item.priority === "critical" || item.priority === "high" ? "danger" : "muted"}>
{item.priority}
</Badge>
{item.assignedTo ? <Badge variant="muted">{item.assignedTo}</Badge> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await updateSuperAdminCase(item._id, {
assignedTo: session?.superAdmin.email ?? "superadmin",
status: "in_review",
note: "Claimed from operations queue",
});
await loadQueue();
} catch (error) {
toast({ title: "Claim failed", description: String(error), variant: "danger" });
}
}}
>
Claim
</Button>
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await updateSuperAdminCase(item._id, {
status: "resolved",
note: "Resolved from operations queue",
});
await loadQueue();
} catch (error) {
toast({ title: "Resolve failed", description: String(error), variant: "danger" });
}
}}
>
Resolve
</Button>
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await updateSuperAdminCase(item._id, {
status: "open",
note: "Reopened from operations queue",
});
await loadQueue();
} catch (error) {
toast({ title: "Reopen failed", description: String(error), variant: "danger" });
}
}}
>
Reopen
</Button>
{canModerateContent &&
(item.resourceType === "post" || item.resourceType === "comment") &&
item.resourceId ? (
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
const resourceId = item.resourceId;
if (!resourceId) {
return;
}
if (item.resourceType === "post") {
await restoreSuperAdminPost(resourceId);
} else {
await restoreSuperAdminComment(resourceId);
}
await updateSuperAdminCase(item._id, {
status: "resolved",
note: "Resource restored from operations queue",
});
await loadQueue();
toast({ title: "Resource restored", description: resourceId, variant: "success" });
} catch (error) {
toast({ title: "Restore failed", description: String(error), variant: "danger" });
}
}}
>
Restore resource
</Button>
) : null}
</div>
</div>
{item.description ? (
<p className="mt-3 text-sm text-muted-foreground">{item.description}</p>
) : null}
</div>
))}
</div>
)}
<PaginationControls pagination={getPagination(casesResponse)} loading={loading} onPageChange={setPage} />
</CardContent>
</Card>
) : null}
</div>
);
}

عرض الملف

@@ -0,0 +1,348 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckCircle2, RefreshCcw, ShieldAlert } from "lucide-react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { PageHeader } from "@/components/dashboard/page-header";
import { PaginationControls } from "@/components/dashboard/pagination-controls";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/toast";
import { getItems, getPagination } from "@/lib/api/core";
import { listPlatformReports, updatePlatformReportStatus } from "@/lib/api/reports";
import { formatDateTime } from "@/lib/format";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import type { ApiUser, PlatformReport, ReportsResponse, ReportStatus, ReportTargetType } from "@/types/api";
const reportStatuses: Array<{ value: ReportStatus; label: string }> = [
{ value: "open", label: "Open" },
{ value: "in_review", label: "In review" },
{ value: "resolved", label: "Resolved" },
{ value: "rejected", label: "Rejected" },
];
const targetTypes: Array<{ value: ReportTargetType; label: string }> = [
{ value: "user", label: "User" },
{ value: "post", label: "Post" },
{ value: "comment", label: "Comment" },
{ value: "listing", label: "Listing" },
{ value: "repair_shop", label: "Repair shop" },
];
const reasonLabels: Record<string, string> = {
spam: "Spam",
harassment: "Harassment",
hate_speech: "Hate speech",
nudity: "Nudity",
violence: "Violence",
scam: "Scam",
intellectual_property: "Intellectual property",
self_harm: "Self harm",
other: "Other",
};
function reporterLabel(reporter: PlatformReport["reporterId"]) {
if (!reporter || typeof reporter === "string") {
return reporter || "-";
}
const user = reporter as ApiUser;
return user.stageName || user.name || user.username || user.email || user._id || "-";
}
function statusVariant(status: ReportStatus): "success" | "muted" | "warning" | "danger" {
if (status === "resolved") return "success";
if (status === "rejected") return "muted";
if (status === "in_review") return "warning";
return "danger";
}
export default function ReportsPage() {
const { permissions } = useSuperAdminSession();
const [statusFilter, setStatusFilter] = useState("open");
const [targetFilter, setTargetFilter] = useState("all");
const [page, setPage] = useState(1);
const [response, setResponse] = useState<ReportsResponse | null>(null);
const [selectedReport, setSelectedReport] = useState<PlatformReport | null>(null);
const [resolutionNote, setResolutionNote] = useState("");
const [updating, setUpdating] = useState(false);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
const filtersRef = useRef({ statusFilter, targetFilter });
const canModerateContent = hasPermission(permissions, SUPERADMIN_PERMISSIONS.CONTENT_MODERATE);
filtersRef.current = { statusFilter, targetFilter };
const loadReports = useCallback(async () => {
if (!canModerateContent) {
setLoading(false);
return;
}
setLoading(true);
try {
const { statusFilter: currentStatus, targetFilter: currentTarget } = filtersRef.current;
const reports = await listPlatformReports({
page,
limit: 15,
status: currentStatus === "all" ? undefined : currentStatus,
targetType: currentTarget === "all" ? undefined : currentTarget,
sortOrder: "desc",
});
setResponse(reports);
const items = getItems(reports) as PlatformReport[];
setSelectedReport((current) =>
current ? items.find((item) => item._id === current._id) ?? items[0] ?? null : items[0] ?? null,
);
} catch (error) {
toast({ title: "Failed to load reports", description: String(error), variant: "danger" });
} finally {
setLoading(false);
}
}, [canModerateContent, page, toast]);
useEffect(() => {
void loadReports();
}, [loadReports]);
const reports = getItems(response) as PlatformReport[];
const summary = useMemo(
() => ({
open: reports.filter((item) => item.status === "open").length,
inReview: reports.filter((item) => item.status === "in_review").length,
resolved: reports.filter((item) => item.status === "resolved").length,
rejected: reports.filter((item) => item.status === "rejected").length,
}),
[reports],
);
const applyFilters = () => {
if (page === 1) {
void loadReports();
return;
}
setPage(1);
};
const updateStatus = async (status: ReportStatus) => {
if (!selectedReport) return;
setUpdating(true);
try {
const updated = await updatePlatformReportStatus(selectedReport._id, status, resolutionNote);
setSelectedReport(updated);
setResolutionNote("");
await loadReports();
toast({ title: "Report updated", description: `${selectedReport._id} -> ${status}`, variant: "success" });
} catch (error) {
toast({ title: "Report update failed", description: String(error), variant: "danger" });
} finally {
setUpdating(false);
}
};
if (!canModerateContent) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Reports"
subtitle="Review user-submitted reports and update moderation workflow status."
/>
<NoPermissionState description="This page needs the content.moderate permission." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Reports"
subtitle="Review reports with fixed reasons, status filters, and resolution notes."
actions={
<Button variant="outline" onClick={() => void loadReports()} disabled={loading}>
<RefreshCcw className="h-4 w-4" />
Refresh
</Button>
}
/>
<section className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader>
<CardTitle>Open</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{summary.open}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>In review</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{summary.inReview}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Resolved</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{summary.resolved}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Rejected</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{summary.rejected}</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{reportStatuses.map((status) => (
<SelectItem key={status.value} value={status.value}>
{status.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={targetFilter} onValueChange={setTargetFilter}>
<SelectTrigger>
<SelectValue placeholder="Target" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All targets</SelectItem>
{targetTypes.map((target) => (
<SelectItem key={target.value} value={target.value}>
{target.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
Apply
</Button>
</CardContent>
</Card>
<section className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-8">
<CardHeader>
<CardTitle>Report queue</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!reports.length && !loading ? (
<EmptyState title="No reports" description="No reports match the selected filters." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Reporter</TableHead>
<TableHead>Target</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map((report) => (
<TableRow
key={report._id}
className={selectedReport?._id === report._id ? "bg-primary/10" : undefined}
onClick={() => setSelectedReport(report)}
>
<TableCell>{reporterLabel(report.reporterId)}</TableCell>
<TableCell>
<div className="space-y-1">
<Badge variant="muted">{report.targetType}</Badge>
<div className="font-mono text-xs text-muted-foreground">{report.targetId}</div>
</div>
</TableCell>
<TableCell>{reasonLabels[report.reason] ?? report.reason}</TableCell>
<TableCell>
<Badge variant={statusVariant(report.status)}>{report.status}</Badge>
</TableCell>
<TableCell>{formatDateTime(report.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationControls
pagination={getPagination(response)}
loading={loading}
onPageChange={setPage}
/>
</CardContent>
</Card>
<Card className="xl:col-span-4">
<CardHeader>
<CardTitle>Report details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!selectedReport ? (
<EmptyState title="Select a report" description="Choose a report row to review details." />
) : (
<>
<div className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="flex items-center justify-between gap-3">
<Badge variant={statusVariant(selectedReport.status)}>{selectedReport.status}</Badge>
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
</div>
<div className="mt-3 text-sm text-foreground">
{selectedReport.details || "No additional details."}
</div>
<div className="mt-3 break-all font-mono text-xs text-muted-foreground">
{selectedReport._id}
</div>
</div>
<Textarea
placeholder="Resolution note"
value={resolutionNote}
onChange={(event) => setResolutionNote(event.target.value)}
/>
<div className="grid gap-2">
<Button
variant="outline"
disabled={updating}
onClick={() => void updateStatus("in_review")}
>
<ShieldAlert className="h-4 w-4" />
Mark in review
</Button>
<Button disabled={updating} onClick={() => void updateStatus("resolved")}>
<CheckCircle2 className="h-4 w-4" />
Resolve report
</Button>
<Button
variant="danger"
disabled={updating}
onClick={() => void updateStatus("rejected")}
>
Reject report
</Button>
</div>
</>
)}
</CardContent>
</Card>
</section>
</div>
);
}

عرض الملف

@@ -0,0 +1,252 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { PageHeader } from "@/components/dashboard/page-header";
import { PaginationControls } from "@/components/dashboard/pagination-controls";
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 { listAuditLogs } from "@/lib/api/audit";
import { listSuperAdminSessions, revokeSuperAdminSession } from "@/lib/api/auth";
import { getItems, getPagination } from "@/lib/api/core";
import { getSuperAdminOps } from "@/lib/api/superadmin";
import { formatDateTime } from "@/lib/format";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import type {
AuditLogsResponse,
SessionItem,
SessionsResponse,
SuperAdminOpsResponse,
} from "@/types/api";
const EMPTY_SESSIONS: SessionsResponse = { items: [] };
const EMPTY_AUDIT: AuditLogsResponse = { items: [], data: [] };
const EMPTY_OPS: SuperAdminOpsResponse = {
services: {
mongodb: { status: "unknown" },
redis: { enabled: false, status: "unknown" },
queue: { enabled: false },
storage: {},
email: { enabled: false },
websocket: { redisAdapterEnabled: false },
},
queues: {
outbox: { pending: 0, failed: 0 },
},
workload: {
openCasesCount: 0,
activeSuperAdminSessionsCount: 0,
},
};
export default function SecurityPage() {
const { session, permissions } = useSuperAdminSession();
const [sessionsResponse, setSessionsResponse] = useState<SessionsResponse | null>(null);
const [auditResponse, setAuditResponse] = useState<AuditLogsResponse | null>(null);
const [ops, setOps] = useState<SuperAdminOpsResponse | null>(null);
const [auditPage, setAuditPage] = useState(1);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
const canManageSessions = hasPermission(
permissions,
SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE,
);
const canReadAudit = hasPermission(permissions, SUPERADMIN_PERMISSIONS.AUDIT_READ);
const canReadOps = hasPermission(permissions, SUPERADMIN_PERMISSIONS.OPS_READ);
const loadSecurity = useCallback(async () => {
if (!canManageSessions && !canReadAudit && !canReadOps) {
setLoading(false);
return;
}
setLoading(true);
try {
const [sessions, audit, nextOps] = await Promise.all([
canManageSessions ? listSuperAdminSessions() : Promise.resolve(EMPTY_SESSIONS),
canReadAudit
? listAuditLogs({ page: auditPage, limit: 12, sortOrder: "desc" })
: Promise.resolve(EMPTY_AUDIT),
canReadOps ? getSuperAdminOps() : Promise.resolve(EMPTY_OPS),
]);
setSessionsResponse(sessions);
setAuditResponse(audit);
setOps(nextOps);
} catch (error) {
toast({ title: "Failed to load security page", description: String(error), variant: "danger" });
} finally {
setLoading(false);
}
}, [auditPage, canManageSessions, canReadAudit, canReadOps, toast]);
useEffect(() => {
void loadSecurity();
}, [loadSecurity]);
const sessions = (sessionsResponse?.items ?? []) as SessionItem[];
if (!canManageSessions && !canReadAudit && !canReadOps) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Security and sessions"
subtitle="SuperAdmin sessions, granted permissions, audit trail, and live operational status."
/>
<NoPermissionState description="This page needs sessions.manage, audit.read, ops.read, or a combination of them." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Security and sessions"
subtitle="SuperAdmin sessions, granted permissions, audit trail, and live operational status."
/>
<section className="grid gap-4 md:grid-cols-4">
{canManageSessions ? (
<Card>
<CardHeader>
<CardTitle>Active sessions</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{sessions.length}</CardContent>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle>Session strategy</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Badge variant="success">{session?.sessionStrategy ?? "httpOnly_cookies"}</Badge>
<p className="text-sm text-muted-foreground">Proxy-managed secure cookies.</p>
</CardContent>
</Card>
{canReadOps ? (
<>
<Card>
<CardHeader>
<CardTitle>Mongo / Redis</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div>MongoDB: {ops?.services.mongodb.status ?? "-"}</div>
<div>Redis: {ops?.services.redis.status ?? "-"}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Outbox</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div>Pending: {ops?.queues.outbox.pending ?? 0}</div>
<div>Failed: {ops?.queues.outbox.failed ?? 0}</div>
</CardContent>
</Card>
</>
) : null}
</section>
<Card>
<CardHeader>
<CardTitle>Granted permissions</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{(session?.permissions ?? []).map((permission) => (
<Badge key={permission} variant="muted">
{permission}
</Badge>
))}
</CardContent>
</Card>
<section className="grid gap-4 xl:grid-cols-12">
{canManageSessions ? (
<Card className={canReadAudit ? "xl:col-span-5" : "xl:col-span-12"}>
<CardHeader>
<CardTitle>SuperAdmin sessions</CardTitle>
</CardHeader>
<CardContent>
{!sessions.length && !loading ? (
<EmptyState title="No sessions" description="The backend returned no active SuperAdmin sessions." />
) : (
<div className="space-y-3">
{sessions.map((sessionItem) => (
<div key={sessionItem.id ?? sessionItem.jti} className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">{sessionItem.id ?? sessionItem.jti ?? "-"}</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatDateTime(sessionItem.createdAt)} - {formatDateTime(sessionItem.expiresAt)}
</p>
</div>
<Button
size="sm"
variant="danger"
onClick={async () => {
const sessionId = sessionItem.id ?? sessionItem.jti;
if (!sessionId) return;
try {
await revokeSuperAdminSession(sessionId);
await loadSecurity();
toast({ title: "Session revoked", description: sessionId, variant: "warning" });
} catch (error) {
toast({ title: "Failed to revoke session", description: String(error), variant: "danger" });
}
}}
>
Revoke
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
) : null}
{canReadAudit ? (
<Card className={canManageSessions ? "xl:col-span-7" : "xl:col-span-12"}>
<CardHeader>
<CardTitle>Audit log</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!getItems(auditResponse).length && !loading ? (
<EmptyState title="No audit records" description="Recent SuperAdmin actions will appear here." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Action</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Target</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getItems(auditResponse).map((log) => (
<TableRow key={log._id}>
<TableCell>{log.action}</TableCell>
<TableCell>{log.actorIdentifier ?? log.actorType}</TableCell>
<TableCell>{log.targetType}</TableCell>
<TableCell>{formatDateTime(log.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationControls pagination={getPagination(auditResponse)} loading={loading} onPageChange={setAuditPage} />
</CardContent>
</Card>
) : null}
</section>
</div>
);
}

عرض الملف

@@ -0,0 +1,389 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { LogOut, RefreshCcw, RotateCcw, Save } from "lucide-react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { PageHeader } from "@/components/dashboard/page-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/toast";
import { listSuperAdminSessions } from "@/lib/api/auth";
import {
getSuperAdminSettings,
getSuperAdminSettingsHistory,
restoreSuperAdminSettingsHistory,
updateSuperAdminSettings,
} from "@/lib/api/superadmin";
import { logoutSuperAdmin, refreshSuperAdmin } from "@/lib/auth/client";
import { formatDateTime } from "@/lib/format";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import type {
SessionItem,
SessionsResponse,
SuperAdminSettingsHistoryEntry,
SuperAdminSettingsResponse,
} from "@/types/api";
const EMPTY_SESSIONS: SessionsResponse = { items: [] };
export default function SettingsPage() {
const { permissions } = useSuperAdminSession();
const [historyItems, setHistoryItems] = useState<SuperAdminSettingsHistoryEntry[]>([]);
const [sessions, setSessions] = useState<SessionItem[]>([]);
const [settingsResponse, setSettingsResponse] = useState<SuperAdminSettingsResponse | null>(null);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
const canReadSettings = hasPermission(permissions, SUPERADMIN_PERMISSIONS.SETTINGS_READ);
const canWriteSettings = hasPermission(permissions, SUPERADMIN_PERMISSIONS.SETTINGS_WRITE);
const canManageSessions = hasPermission(
permissions,
SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE,
);
useEffect(() => {
let active = true;
const loadData = async () => {
if (!canReadSettings) {
setLoading(false);
return;
}
try {
const [sessionsResponse, nextSettings, nextHistory] = await Promise.all([
canManageSessions ? listSuperAdminSessions() : Promise.resolve(EMPTY_SESSIONS),
getSuperAdminSettings(),
getSuperAdminSettingsHistory({ page: 1, limit: 8, sortOrder: "desc" }),
]);
if (!active) return;
setSessions(sessionsResponse.items ?? []);
setSettingsResponse(nextSettings);
setHistoryItems(nextHistory.items ?? []);
} catch (error) {
if (!active) return;
toast({ title: "Failed to load settings", description: String(error), variant: "danger" });
}
};
void loadData();
return () => {
active = false;
};
}, [canManageSessions, canReadSettings, toast]);
const settings = settingsResponse?.settings;
const runtime = settingsResponse?.runtime;
const updateField = <K extends keyof NonNullable<typeof settings>>(key: K, value: NonNullable<typeof settings>[K]) => {
setSettingsResponse((prev) =>
prev
? {
...prev,
settings: {
...prev.settings,
[key]: value,
},
}
: prev,
);
};
if (!canReadSettings) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Settings"
subtitle="Central SuperAdmin runtime settings with change history and rollback."
/>
<NoPermissionState description="This page needs the settings.read permission." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="Settings"
subtitle="Stored SuperAdmin settings, history, and live runtime visibility."
actions={
<Button
onClick={async () => {
if (!settings || !canWriteSettings) return;
setSaving(true);
try {
const next = await updateSuperAdminSettings(settings);
setSettingsResponse(next);
const history = await getSuperAdminSettingsHistory({ page: 1, limit: 8, sortOrder: "desc" });
setHistoryItems(history.items ?? []);
toast({ title: "Settings saved", description: "Central settings updated successfully.", variant: "success" });
} catch (error) {
toast({ title: "Save failed", description: String(error), variant: "danger" });
} finally {
setSaving(false);
}
}}
disabled={!settings || !canWriteSettings || saving}
>
<Save className="h-4 w-4" />
Save settings
</Button>
}
/>
<section className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-4">
<CardHeader>
<CardTitle>Session controls</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
className="w-full"
disabled={loading}
onClick={async () => {
setLoading(true);
try {
await refreshSuperAdmin();
toast({ title: "Session refreshed", description: "Cookies were refreshed successfully.", variant: "success" });
} catch (error) {
toast({ title: "Refresh failed", description: String(error), variant: "danger" });
} finally {
setLoading(false);
}
}}
>
<RefreshCcw className="h-4 w-4" />
Refresh session
</Button>
<Button
variant="danger"
className="w-full"
disabled={loading}
onClick={async () => {
setLoading(true);
try {
await logoutSuperAdmin();
router.replace("/login");
} catch (error) {
toast({ title: "Logout failed", description: String(error), variant: "danger" });
} finally {
setLoading(false);
}
}}
>
<LogOut className="h-4 w-4" />
Logout
</Button>
{canManageSessions ? (
<div className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="text-xs text-muted-foreground">Visible sessions</div>
<div className="mt-2 text-2xl font-bold text-foreground">{sessions.length}</div>
</div>
) : null}
<div className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="text-xs text-muted-foreground">Session strategy</div>
<div className="mt-2 text-sm text-foreground">{runtime?.sessionStrategy ?? "httpOnly_cookies"}</div>
</div>
</CardContent>
</Card>
<Card className="xl:col-span-8">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Stored settings</CardTitle>
<Badge variant={canWriteSettings ? "warning" : "muted"}>
{canWriteSettings ? "Editable" : "Read only"}
</Badge>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<Input
disabled={!canWriteSettings}
placeholder="Site name"
value={settings?.siteName ?? ""}
onChange={(event) => updateField("siteName", event.target.value)}
/>
<Input
disabled={!canWriteSettings}
placeholder="PUBLIC_BASE_URL"
value={settings?.publicBaseUrl ?? ""}
onChange={(event) => updateField("publicBaseUrl", event.target.value)}
/>
</div>
<Input
disabled={!canWriteSettings}
placeholder="Dashboard API base URL"
value={settings?.dashboardApiBaseUrl ?? ""}
onChange={(event) => updateField("dashboardApiBaseUrl", event.target.value)}
/>
<Input
disabled={!canWriteSettings}
placeholder="CORS origins comma separated"
value={(settings?.corsOrigins ?? []).join(",")}
onChange={(event) =>
updateField(
"corsOrigins",
event.target.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
)
}
/>
<Textarea
disabled={!canWriteSettings}
placeholder="Operational notes"
value={settings?.notes ?? ""}
onChange={(event) => updateField("notes", event.target.value)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center justify-between rounded-xl border border-border/70 bg-secondary/20 p-4">
<div>
<div className="text-sm font-medium text-foreground">Maintenance mode</div>
<div className="text-xs text-muted-foreground">Stored toggle, not an immediate runtime switch.</div>
</div>
<Switch
className={!canWriteSettings ? "pointer-events-none opacity-50" : undefined}
checked={Boolean(settings?.maintenanceMode)}
onCheckedChange={(value) => {
if (!canWriteSettings) return;
updateField("maintenanceMode", value);
}}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-border/70 bg-secondary/20 p-4">
<div>
<div className="text-sm font-medium text-foreground">Email enabled</div>
<div className="text-xs text-muted-foreground">Mirrors stored email capability policy.</div>
</div>
<Switch
className={!canWriteSettings ? "pointer-events-none opacity-50" : undefined}
checked={Boolean(settings?.emailEnabled)}
onCheckedChange={(value) => {
if (!canWriteSettings) return;
updateField("emailEnabled", value);
}}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-border/70 bg-secondary/20 p-4">
<div>
<div className="text-sm font-medium text-foreground">Marketplace auto approve</div>
<div className="text-xs text-muted-foreground">Stored policy for future moderation behavior.</div>
</div>
<Switch
className={!canWriteSettings ? "pointer-events-none opacity-50" : undefined}
checked={Boolean(settings?.marketplaceAutoApprove)}
onCheckedChange={(value) => {
if (!canWriteSettings) return;
updateField("marketplaceAutoApprove", value);
}}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-border/70 bg-secondary/20 p-4">
<div>
<div className="text-sm font-medium text-foreground">Auto hide flagged content</div>
<div className="text-xs text-muted-foreground">Stored moderation policy for future use.</div>
</div>
<Switch
className={!canWriteSettings ? "pointer-events-none opacity-50" : undefined}
checked={Boolean(settings?.contentAutoHideFlagged)}
onCheckedChange={(value) => {
if (!canWriteSettings) return;
updateField("contentAutoHideFlagged", value);
}}
/>
</div>
</div>
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-12">
<Card className="xl:col-span-5">
<CardHeader>
<CardTitle>Live runtime snapshot</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="text-xs text-muted-foreground">API base</div>
<div className="mt-2 break-all text-sm text-foreground">{runtime?.publicBaseUrl ?? "-"}</div>
</div>
<div className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="text-xs text-muted-foreground">Global prefix</div>
<div className="mt-2 text-sm text-foreground">{runtime?.globalPrefix ?? "-"}</div>
</div>
<div className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="text-xs text-muted-foreground">Storage</div>
<div className="mt-2 text-sm text-foreground">
{runtime?.storageProvider ?? "-"} / {runtime?.storageBasePath ?? "-"}
</div>
</div>
<div className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="text-xs text-muted-foreground">Infrastructure</div>
<div className="mt-2 text-sm text-foreground">
queue={String(runtime?.queueEnabled ?? false)} / redis={String(runtime?.redisEnabled ?? false)}
</div>
</div>
</CardContent>
</Card>
<Card className="xl:col-span-7">
<CardHeader>
<CardTitle>Settings history</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{historyItems.map((item) => (
<div key={item._id} className="rounded-xl border border-border/70 bg-secondary/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">{item.updatedBy}</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(item.createdAt)} - {(item.changedFields ?? []).join(", ") || "no fields"}
</div>
</div>
<Button
size="sm"
variant="outline"
disabled={!canWriteSettings}
onClick={async () => {
try {
const restored = await restoreSuperAdminSettingsHistory(item._id);
setSettingsResponse(restored);
const history = await getSuperAdminSettingsHistory({ page: 1, limit: 8, sortOrder: "desc" });
setHistoryItems(history.items ?? []);
toast({ title: "Version restored", description: item._id, variant: "warning" });
} catch (error) {
toast({ title: "Restore failed", description: String(error), variant: "danger" });
}
}}
>
<RotateCcw className="h-4 w-4" />
Restore
</Button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{(item.changedFields ?? []).map((field) => (
<Badge key={field} variant="muted">
{field}
</Badge>
))}
</div>
</div>
))}
</CardContent>
</Card>
</section>
</div>
);
}

عرض الملف

@@ -0,0 +1,875 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Search, ShieldPlus, UserMinus, UserPlus, UserX } from "lucide-react";
import { NoPermissionState } from "@/components/auth/no-permission-state";
import { useSuperAdminSession } from "@/components/auth/session-context";
import { PageHeader } from "@/components/dashboard/page-header";
import { PaginationControls } from "@/components/dashboard/pagination-controls";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Drawer } from "@/components/ui/drawer";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/toast";
import {
createAdminUser,
deleteAdminUser,
deletePlatformAdmin,
getAdminUserById,
getProfileOverviewForSuperAdmin,
searchAdminUsers,
searchPlatformAdmins,
setAdminUserRole,
updateAdminUser,
updatePlatformAdmin,
} from "@/lib/api/admin-users";
import { getItems, getPagination } from "@/lib/api/core";
import { updateSuperAdminUserStatus } from "@/lib/api/superadmin";
import { formatDateTime } from "@/lib/format";
import { resolveMediaUrl } from "@/lib/media-url";
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
import { cn } from "@/lib/utils";
import type {
AdminUpdatePayload,
AdminCreatePayload,
ApiRole,
ApiUser,
PaginatedResponse,
ProfileOverviewResponse,
} from "@/types/api";
type ScopeFilter = "all-users" | "admins";
const emptyCreateState: AdminCreatePayload = {
name: "",
username: "",
email: "",
password: "",
confirmPassword: "",
};
function buildEditPayload(user: ApiUser): Partial<ApiUser> {
return {
name: user.name ?? "",
username: user.username ?? "",
email: user.email ?? "",
role: user.role ?? "user",
stageName: user.stageName ?? "",
bio: user.bio ?? "",
location: user.location ?? "",
isPrivate: user.isPrivate ?? false,
isVerified: user.isVerified ?? false,
};
}
function normalizeComparableString(value: string | null | undefined) {
return (value ?? "").trim();
}
function buildUserUpdatePayload(
selectedUser: ApiUser,
editPayload: Partial<ApiUser>,
): AdminUpdatePayload {
const payload: AdminUpdatePayload = {};
const nextName = normalizeComparableString(
typeof editPayload.name === "string" ? editPayload.name : selectedUser.name,
);
const nextUsername = normalizeComparableString(
typeof editPayload.username === "string" ? editPayload.username : selectedUser.username,
);
const nextEmail = normalizeComparableString(
typeof editPayload.email === "string" ? editPayload.email : selectedUser.email,
);
const currentName = normalizeComparableString(selectedUser.name);
const currentUsername = normalizeComparableString(selectedUser.username);
const currentEmail = normalizeComparableString(selectedUser.email);
if (nextName !== currentName) {
if (!nextName) {
throw new Error("Name cannot be empty.");
}
payload.name = nextName;
}
if (nextUsername !== currentUsername) {
if (!nextUsername) {
throw new Error("Username cannot be empty.");
}
payload.username = nextUsername;
}
if (nextEmail !== currentEmail) {
if (!nextEmail) {
throw new Error("Email cannot be empty.");
}
payload.email = nextEmail;
}
const nextStageName = normalizeComparableString(
typeof editPayload.stageName === "string" ? editPayload.stageName : selectedUser.stageName,
);
const currentStageName = normalizeComparableString(selectedUser.stageName);
if (nextStageName !== currentStageName) {
payload.stageName = nextStageName;
}
const nextBio = normalizeComparableString(
typeof editPayload.bio === "string" ? editPayload.bio : selectedUser.bio,
);
const currentBio = normalizeComparableString(selectedUser.bio);
if (nextBio !== currentBio) {
payload.bio = nextBio;
}
const nextLocation = normalizeComparableString(
typeof editPayload.location === "string" ? editPayload.location : selectedUser.location,
);
const currentLocation = normalizeComparableString(selectedUser.location);
if (nextLocation !== currentLocation) {
payload.location = nextLocation;
}
const nextIsPrivate = Boolean(editPayload.isPrivate ?? selectedUser.isPrivate ?? false);
const currentIsPrivate = Boolean(selectedUser.isPrivate ?? false);
if (nextIsPrivate !== currentIsPrivate) {
payload.isPrivate = nextIsPrivate;
}
const nextIsVerified = Boolean(editPayload.isVerified ?? selectedUser.isVerified ?? false);
const currentIsVerified = Boolean(selectedUser.isVerified ?? false);
if (nextIsVerified !== currentIsVerified) {
payload.isVerified = nextIsVerified;
}
return payload;
}
function FieldRow({
label,
value,
valueDir,
valueClassName,
}: {
label: string;
value: string | number | boolean | null | undefined;
valueDir?: "auto" | "rtl" | "ltr";
valueClassName?: string;
}) {
const normalized =
value === null || value === undefined || value === ""
? "-"
: typeof value === "boolean"
? value
? "نعم"
: "لا"
: String(value);
const isTechnicalValue =
typeof normalized === "string" &&
(normalized.includes("@") || normalized.includes("_") || normalized.includes("-") || normalized.includes("."));
const resolvedDir = valueDir ?? (isTechnicalValue ? "ltr" : "auto");
return (
<div className="min-w-0 rounded-lg border border-border/60 bg-background/30 p-3">
<div className="text-xs text-muted-foreground">{label}</div>
<div
dir={resolvedDir}
className={cn(
"mt-2 min-w-0 break-words text-sm leading-relaxed text-foreground [overflow-wrap:anywhere]",
isTechnicalValue && "text-left font-mono text-xs sm:text-sm",
valueClassName,
)}
>
{normalized}
</div>
</div>
);
}
export default function UsersPage() {
const { permissions } = useSuperAdminSession();
const [scope, setScope] = useState<ScopeFilter>("all-users");
const [search, setSearch] = useState("");
const [verifiedFilter, setVerifiedFilter] = useState("all");
const [page, setPage] = useState(1);
const [response, setResponse] = useState<PaginatedResponse<ApiUser> | null>(null);
const [loading, setLoading] = useState(true);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedUser, setSelectedUser] = useState<ApiUser | null>(null);
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
const [overview, setOverview] = useState<ProfileOverviewResponse | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [editPayload, setEditPayload] = useState<Partial<ApiUser>>({});
const [createOpen, setCreateOpen] = useState(false);
const [createPayload, setCreatePayload] = useState<AdminCreatePayload>(emptyCreateState);
const { toast } = useToast();
const filtersRef = useRef({ search, verifiedFilter });
const canReadUsers = hasPermission(permissions, SUPERADMIN_PERMISSIONS.USERS_READ);
const canManageUsers = hasPermission(permissions, SUPERADMIN_PERMISSIONS.USERS_MANAGE);
filtersRef.current = { search, verifiedFilter };
useEffect(() => {
setAvatarLoadFailed(false);
}, [selectedUser?.avatar]);
const loadUsers = useCallback(async () => {
if (!canReadUsers) {
setLoading(false);
return;
}
setLoading(true);
try {
const { search: currentSearch, verifiedFilter: currentVerifiedFilter } = filtersRef.current;
const isVerified =
currentVerifiedFilter === "all"
? undefined
: currentVerifiedFilter === "verified"
? true
: false;
const nextResponse =
scope === "admins"
? await searchPlatformAdmins({
page,
limit: 12,
q: currentSearch || undefined,
isVerified,
sortBy: "createdAt",
sortOrder: "desc",
})
: await searchAdminUsers({
page,
limit: 12,
q: currentSearch || undefined,
isVerified,
sortBy: "createdAt",
sortOrder: "desc",
});
setResponse(nextResponse);
} catch (error) {
const message = String(error);
toast({ title: "تعذر تحميل المستخدمين", description: message, variant: "danger" });
} finally {
setLoading(false);
}
}, [canReadUsers, page, scope, toast]);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
useEffect(() => {
if (!selectedUserId || !canReadUsers) return;
let active = true;
const loadDetails = async () => {
setDetailLoading(true);
try {
const [user, userOverview] = await Promise.all([
getAdminUserById(selectedUserId),
getProfileOverviewForSuperAdmin(selectedUserId),
]);
if (!active) return;
setSelectedUser(user);
setOverview(userOverview);
setEditPayload(buildEditPayload(user));
} catch (error) {
if (!active) return;
toast({ title: "تعذر تحميل التفاصيل", description: String(error), variant: "danger" });
} finally {
if (active) setDetailLoading(false);
}
};
void loadDetails();
return () => {
active = false;
};
}, [canReadUsers, selectedUserId, toast]);
const users = getItems(response);
const stats = useMemo(() => {
const disabled = users.filter((item) => item.isDisabled).length;
const admins = users.filter((item) => item.role === "admin").length;
return { total: response?.pagination?.total ?? users.length, disabled, admins };
}, [response, users]);
const syncUpdatedUser = (userId: string, patch: Partial<ApiUser>) => {
setResponse((prev) => {
if (!prev) return prev;
const source = getItems(prev);
const updatedItems = source.map((item) => (item._id === userId ? { ...item, ...patch } : item));
return {
...prev,
items: updatedItems,
data: updatedItems,
};
});
setSelectedUser((prev) => (prev && prev._id === userId ? { ...prev, ...patch } : prev));
};
const handleDisable = async (userId: string) => {
const reason =
typeof window === "undefined"
? "Disabled by SuperAdmin dashboard"
: window.prompt("Reason for disabling this user", "Disabled by SuperAdmin dashboard");
if (reason === null) {
return;
}
try {
const updated = await updateSuperAdminUserStatus(
userId,
true,
reason.trim() || "Disabled by SuperAdmin dashboard",
);
syncUpdatedUser(userId, { isDisabled: true, disabledReason: updated.disabledReason });
toast({ title: "تم التعطيل", description: userId, variant: "warning" });
} catch (error) {
toast({ title: "فشل التعطيل", description: String(error), variant: "danger" });
}
};
const handleEnable = async (userId: string) => {
try {
const updated = await updateSuperAdminUserStatus(userId, false);
syncUpdatedUser(userId, { isDisabled: false, disabledReason: updated.disabledReason ?? "" });
toast({ title: "تم التفعيل", description: userId, variant: "success" });
} catch (error) {
toast({ title: "فشل التفعيل", description: String(error), variant: "danger" });
}
};
const handleDelete = async (userId: string) => {
try {
if (scope === "admins") {
await deletePlatformAdmin(userId);
} else {
await deleteAdminUser(userId);
}
if (selectedUserId === userId) {
setSelectedUserId(null);
setSelectedUser(null);
setOverview(null);
}
await loadUsers();
toast({ title: "تم الحذف", description: userId, variant: "warning" });
} catch (error) {
toast({ title: "فشل الحذف", description: String(error), variant: "danger" });
}
};
const handleSave = async () => {
if (!selectedUserId || !selectedUser || !canManageUsers) return;
try {
const updater = scope === "admins" ? updatePlatformAdmin : updateAdminUser;
const updatePayload = buildUserUpdatePayload(selectedUser, editPayload);
const currentRole = selectedUser.role ?? "user";
const nextRole = (editPayload.role as ApiRole | undefined) ?? currentRole;
const roleChanged = nextRole !== currentRole;
const hasProfileChanges = Object.keys(updatePayload).length > 0;
if (!hasProfileChanges && !roleChanged) {
toast({
title: "No changes to save",
description: "Update one or more fields before saving.",
variant: "default",
});
return;
}
if (hasProfileChanges) {
await updater(selectedUserId, updatePayload);
}
if (roleChanged) {
await setAdminUserRole(selectedUserId, nextRole);
}
const [freshUser, freshOverview] = await Promise.all([
getAdminUserById(selectedUserId),
getProfileOverviewForSuperAdmin(selectedUserId),
]);
syncUpdatedUser(selectedUserId, freshUser);
setSelectedUser(freshUser);
setOverview(freshOverview);
setEditPayload(buildEditPayload(freshUser));
await loadUsers();
toast({
title: "تم حفظ التعديلات",
description: "تم تحديث بيانات المستخدم.",
variant: "success",
});
} catch (error) {
toast({ title: "فشل التحديث", description: String(error), variant: "danger" });
}
};
const handleCreate = async () => {
if (!canManageUsers) return;
try {
await createAdminUser(createPayload);
setCreateOpen(false);
setCreatePayload(emptyCreateState);
setScope("admins");
setPage(1);
await loadUsers();
toast({ title: "تم إنشاء الأدمن", description: createPayload.email, variant: "success" });
} catch (error) {
toast({ title: "فشل إنشاء الأدمن", description: String(error), variant: "danger" });
}
};
if (!canReadUsers) {
return (
<div className="space-y-5 pb-8">
<PageHeader
title="User management"
subtitle="Search, review, and manage platform users and admins from one place."
/>
<NoPermissionState description="This page needs the users.read permission." />
</div>
);
}
return (
<div className="space-y-5 pb-8">
<PageHeader
title="إدارة المستخدمين"
subtitle="بحث وترقيم حقيقيان من الخادم مع تفاصيل Profile Overview لكل حساب."
actions={canManageUsers ? (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button>
<ShieldPlus className="h-4 w-4" />
إضافة Admin
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>إنشاء حساب إداري</DialogTitle>
<DialogDescription>
هذا الطلب مرتبط بمسار SuperAdmin المباشر لإنشاء Admin جديد.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<Input
placeholder="الاسم"
value={createPayload.name ?? ""}
onChange={(event) =>
setCreatePayload((prev) => ({ ...prev, name: event.target.value }))
}
/>
<Input
placeholder="اسم المستخدم"
value={createPayload.username}
onChange={(event) =>
setCreatePayload((prev) => ({ ...prev, username: event.target.value }))
}
/>
<Input
placeholder="البريد الإلكتروني"
type="email"
value={createPayload.email}
onChange={(event) =>
setCreatePayload((prev) => ({ ...prev, email: event.target.value }))
}
/>
<Input
placeholder="كلمة المرور"
type="password"
value={createPayload.password}
onChange={(event) =>
setCreatePayload((prev) => ({
...prev,
password: event.target.value,
confirmPassword: event.target.value,
}))
}
/>
<Button onClick={handleCreate}>إنشاء الأدمن</Button>
</div>
</DialogContent>
</Dialog>
) : null}
/>
<section className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>إجمالي النتائج</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{stats.total}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>أدمنز في الصفحة</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{stats.admins}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>حسابات معطلة في الصفحة</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold text-foreground">{stats.disabled}</CardContent>
</Card>
</section>
<Card>
<CardHeader>
<CardTitle>الفلاتر</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-4">
<Select
value={scope}
onValueChange={(value) => {
setScope(value as ScopeFilter);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="النطاق" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all-users">كل المستخدمين</SelectItem>
<SelectItem value="admins">الأدمنز فقط</SelectItem>
</SelectContent>
</Select>
<div className="relative">
<Search className="pointer-events-none absolute right-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="ابحث بالاسم أو البريد أو اليوزر"
className="pr-9"
/>
</div>
<Select value={verifiedFilter} onValueChange={setVerifiedFilter}>
<SelectTrigger>
<SelectValue placeholder="التحقق" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">الكل</SelectItem>
<SelectItem value="verified">موثق</SelectItem>
<SelectItem value="unverified">غير موثق</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
onClick={() => {
setPage(1);
void loadUsers();
}}
>
تطبيق
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>النتائج</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!users.length && !loading ? (
<EmptyState
title="لا توجد نتائج"
description="لم يرجع الخادم أي مستخدمين بهذه الفلاتر."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>الاسم</TableHead>
<TableHead>البريد</TableHead>
<TableHead>الدور</TableHead>
<TableHead>موثق</TableHead>
<TableHead>الحالة</TableHead>
<TableHead>إجراءات</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow
key={user._id}
className="cursor-pointer"
onClick={() => setSelectedUserId(user._id)}
>
<TableCell>{user.name ?? user.username ?? "-"}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.role ?? "user"}</TableCell>
<TableCell>
<Badge variant={user.isVerified ? "success" : "muted"}>
{user.isVerified ? "موثق" : "غير موثق"}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.isDisabled ? "danger" : "success"}>
{user.isDisabled ? "معطل" : "نشط"}
</Badge>
</TableCell>
<TableCell className="flex flex-wrap gap-2">
{user.isDisabled ? (
<Button
size="sm"
variant="outline"
disabled={!canManageUsers}
onClick={(event) => {
event.stopPropagation();
void handleEnable(user._id);
}}
>
<UserPlus className="h-4 w-4" />
تفعيل
</Button>
) : (
<Button
size="sm"
variant="outline"
disabled={!canManageUsers}
onClick={(event) => {
event.stopPropagation();
void handleDisable(user._id);
}}
>
<UserMinus className="h-4 w-4" />
تعطيل
</Button>
)}
<Button
size="sm"
variant="danger"
disabled={!canManageUsers}
onClick={(event) => {
event.stopPropagation();
void handleDelete(user._id);
}}
>
<UserX className="h-4 w-4" />
حذف
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationControls pagination={getPagination(response)} loading={loading} onPageChange={setPage} />
</CardContent>
</Card>
<Drawer
open={Boolean(selectedUserId)}
onClose={() => {
setSelectedUserId(null);
setSelectedUser(null);
setOverview(null);
setEditPayload({});
}}
title="User details"
description="Review the account profile overview, safety state, and editable admin fields."
side="right"
widthClassName="w-full sm:w-[92vw] sm:max-w-2xl"
>
{detailLoading ? (
<div className="text-sm text-muted-foreground">Loading details...</div>
) : selectedUser ? (
<div className="space-y-4">
<Card className="border-border/70 bg-secondary/20">
<CardContent className="grid gap-3 p-5 md:grid-cols-[auto,1fr,auto] md:items-center">
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full border border-border/70 bg-background/30">
{selectedUser.avatar && !avatarLoadFailed ? (
<Image
src={resolveMediaUrl(selectedUser.avatar)}
alt={selectedUser.name ?? selectedUser.username ?? "user"}
width={64}
height={64}
unoptimized
loading="lazy"
onError={() => setAvatarLoadFailed(true)}
className="h-full w-full object-cover"
/>
) : (
<span className="text-xl font-semibold text-muted-foreground">
{(selectedUser.name ?? selectedUser.username ?? "U").charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="min-w-0 space-y-1">
<div className="text-lg font-semibold text-foreground">
{selectedUser.name ?? selectedUser.username ?? "-"}
</div>
<div dir="ltr" className="truncate text-sm text-muted-foreground">
{selectedUser.email}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={selectedUser.isVerified ? "success" : "muted"}>
{selectedUser.isVerified ? "Verified" : "Unverified"}
</Badge>
<Badge variant={selectedUser.isDisabled ? "danger" : "success"}>
{selectedUser.isDisabled ? "Disabled" : "Active"}
</Badge>
</div>
</CardContent>
</Card>
<Card className="border-border/70 bg-secondary/20">
<CardHeader className="pb-2">
<CardTitle className="text-base">Quick edit</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<Input
placeholder="Name"
value={String(editPayload.name ?? "")}
onChange={(event) =>
setEditPayload((prev) => ({ ...prev, name: event.target.value }))
}
/>
<Input
placeholder="Username"
value={String(editPayload.username ?? "")}
onChange={(event) =>
setEditPayload((prev) => ({ ...prev, username: event.target.value }))
}
/>
</div>
<Input
placeholder="Email"
value={String(editPayload.email ?? "")}
onChange={(event) =>
setEditPayload((prev) => ({ ...prev, email: event.target.value }))
}
/>
<div className="grid gap-3 md:grid-cols-2">
<Input
placeholder="Stage name"
value={String(editPayload.stageName ?? "")}
onChange={(event) =>
setEditPayload((prev) => ({ ...prev, stageName: event.target.value }))
}
/>
<Input
placeholder="Location"
value={String(editPayload.location ?? "")}
onChange={(event) =>
setEditPayload((prev) => ({ ...prev, location: event.target.value }))
}
/>
</div>
<Select
disabled={!canManageUsers}
value={String(editPayload.role ?? selectedUser.role ?? "user")}
onValueChange={(value) =>
setEditPayload((prev) => ({ ...prev, role: value as ApiRole }))
}
>
<SelectTrigger>
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">user</SelectItem>
<SelectItem value="admin">admin</SelectItem>
</SelectContent>
</Select>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex items-center justify-between rounded-lg border border-input bg-background/40 px-3 py-2 text-sm text-foreground">
<span>Verified by super admin</span>
<Switch
checked={Boolean(editPayload.isVerified ?? selectedUser.isVerified)}
onCheckedChange={(value) =>
setEditPayload((prev) => ({ ...prev, isVerified: value }))
}
/>
</label>
<label className="flex items-center justify-between rounded-lg border border-input bg-background/40 px-3 py-2 text-sm text-foreground">
<span>Private account</span>
<Switch
checked={Boolean(editPayload.isPrivate ?? selectedUser.isPrivate)}
onCheckedChange={(value) =>
setEditPayload((prev) => ({ ...prev, isPrivate: value }))
}
/>
</label>
</div>
<Textarea
placeholder="Bio"
value={String(editPayload.bio ?? "")}
onChange={(event) =>
setEditPayload((prev) => ({ ...prev, bio: event.target.value }))
}
/>
<Button disabled={!canManageUsers} onClick={() => void handleSave()}>
Save changes
</Button>
</CardContent>
</Card>
<Card className="border-border/70 bg-secondary/20">
<CardHeader className="pb-2">
<CardTitle className="text-base">Profile Overview</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<FieldRow label="Followers" value={overview?.stats.followersCount} />
<FieldRow label="Following" value={overview?.stats.followingCount} />
<FieldRow label="Posts" value={overview?.stats.postsCount} />
<FieldRow label="Collaborations" value={overview?.stats.collaborationsCount} />
<FieldRow label="Audio" value={overview?.contentCounts.audio} />
<FieldRow label="Reels" value={overview?.contentCounts.reels} />
</CardContent>
</Card>
<Card className="border-border/70 bg-secondary/20">
<CardHeader className="pb-2">
<CardTitle className="text-base">General information</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
<FieldRow label="ID" value={selectedUser._id} valueDir="ltr" />
<FieldRow label="Name" value={selectedUser.name} />
<FieldRow label="Username" value={selectedUser.username} valueDir="ltr" />
<FieldRow label="Role" value={selectedUser.role} />
<FieldRow label="Email" value={selectedUser.email} valueDir="ltr" />
<FieldRow label="Location" value={selectedUser.location} />
<FieldRow label="Verified" value={selectedUser.isVerified} />
<FieldRow label="Disabled" value={selectedUser.isDisabled} />
<FieldRow label="Created at" value={formatDateTime(selectedUser.createdAt)} />
<FieldRow label="Updated at" value={formatDateTime(selectedUser.updatedAt)} />
</CardContent>
</Card>
</div>
) : (
<div className="text-sm text-muted-foreground">No details available.</div>
)}
</Drawer>
</div>
);
}