349 أسطر
13 KiB
TypeScript
349 أسطر
13 KiB
TypeScript
"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>
|
|
);
|
|
}
|