Add Oudelaa dashboard API integration
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
246
oudelaa_dashboard/app/(dashboard)/analytics/page.tsx
Normal file
246
oudelaa_dashboard/app/(dashboard)/analytics/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
551
oudelaa_dashboard/app/(dashboard)/content/page.tsx
Normal file
551
oudelaa_dashboard/app/(dashboard)/content/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
463
oudelaa_dashboard/app/(dashboard)/dashboard/page.tsx
Normal file
463
oudelaa_dashboard/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
21
oudelaa_dashboard/app/(dashboard)/layout.tsx
Normal file
21
oudelaa_dashboard/app/(dashboard)/layout.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
954
oudelaa_dashboard/app/(dashboard)/marketplace/page.tsx
Normal file
954
oudelaa_dashboard/app/(dashboard)/marketplace/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
161
oudelaa_dashboard/app/(dashboard)/messages/page.tsx
Normal file
161
oudelaa_dashboard/app/(dashboard)/messages/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
193
oudelaa_dashboard/app/(dashboard)/notifications/page.tsx
Normal file
193
oudelaa_dashboard/app/(dashboard)/notifications/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
273
oudelaa_dashboard/app/(dashboard)/orders/page.tsx
Normal file
273
oudelaa_dashboard/app/(dashboard)/orders/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
348
oudelaa_dashboard/app/(dashboard)/reports/page.tsx
Normal file
348
oudelaa_dashboard/app/(dashboard)/reports/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
252
oudelaa_dashboard/app/(dashboard)/security/page.tsx
Normal file
252
oudelaa_dashboard/app/(dashboard)/security/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
389
oudelaa_dashboard/app/(dashboard)/settings/page.tsx
Normal file
389
oudelaa_dashboard/app/(dashboard)/settings/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
875
oudelaa_dashboard/app/(dashboard)/users/page.tsx
Normal file
875
oudelaa_dashboard/app/(dashboard)/users/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم