Add Oudelaa dashboard API integration
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
1
.gitignore
مباع
1
.gitignore
مباع
@@ -15,4 +15,3 @@ yarn-error.log*
|
||||
|
||||
# Local workspace
|
||||
.vscode/
|
||||
oudelaa_dashboard/
|
||||
|
||||
3
oudelaa_dashboard/.eslintrc.json
Normal file
3
oudelaa_dashboard/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
14
oudelaa_dashboard/.gitignore
مباع
Normal file
14
oudelaa_dashboard/.gitignore
مباع
Normal file
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
*.log
|
||||
*.err.log
|
||||
*.tsbuildinfo
|
||||
.npm-cache
|
||||
.DS_Store
|
||||
.env*
|
||||
temp_hsl.txt
|
||||
5
oudelaa_dashboard/.npmrc
Normal file
5
oudelaa_dashboard/.npmrc
Normal file
@@ -0,0 +1,5 @@
|
||||
cache=.npm-cache
|
||||
fetch-retries=5
|
||||
fetch-retry-mintimeout=20000
|
||||
fetch-retry-maxtimeout=120000
|
||||
prefer-online=true
|
||||
89
oudelaa_dashboard/README.md
Normal file
89
oudelaa_dashboard/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Oudelaa SuperAdmin Dashboard
|
||||
|
||||
Next.js dashboard for SuperAdmin operations only.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
Set the backend base URL in `.env.local`:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:4001/api/v1
|
||||
API_BASE_URL=http://127.0.0.1:4001/api/v1
|
||||
```
|
||||
|
||||
For LAN/mobile testing, use the machine IP instead of `127.0.0.1`, for example:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_BASE_URL=http://192.168.1.12:4001/api/v1
|
||||
API_BASE_URL=http://192.168.1.12:4001/api/v1
|
||||
```
|
||||
|
||||
The dashboard uses the internal proxy route at `app/api/proxy/[...path]/route.ts`, so the browser never calls the Nest API directly.
|
||||
|
||||
## Authentication Contract
|
||||
|
||||
- Login: `POST /auth/superadmin/login`
|
||||
- Refresh: `POST /auth/superadmin/refresh`
|
||||
- Logout: `POST /auth/superadmin/logout`
|
||||
- Sessions: `GET /auth/superadmin/sessions`
|
||||
- Revoke session: `POST /auth/superadmin/sessions/:sessionId/revoke`
|
||||
|
||||
This dashboard must not depend on user-token routes unless the backend exposes a dedicated `admin` or `superadmin` variant for the same data.
|
||||
|
||||
## Implemented Pages
|
||||
|
||||
- `/dashboard`: executive overview
|
||||
- `/users`: SuperAdmin user management with search, pagination, and profile overview
|
||||
- `/analytics`: platform metrics snapshot
|
||||
- `/content`: post and comment moderation
|
||||
- `/marketplace`: listing and repair-shop moderation
|
||||
- `/notifications`: platform notifications center
|
||||
- `/messages`: interaction follow-up view
|
||||
- `/security`: session management and audit log
|
||||
- `/settings`: live operational settings, connection info, and session controls
|
||||
- `/orders`: marketplace operations queue
|
||||
|
||||
## Key Frontend Contracts
|
||||
|
||||
- Users:
|
||||
- `GET /users/admin`
|
||||
- `GET /users/admin/admins`
|
||||
- `GET /users/admin/discover`
|
||||
- `GET /users/admin/:id/profile-overview`
|
||||
- Content moderation:
|
||||
- `GET /posts/admin/moderation`
|
||||
- `DELETE /posts/admin/:postId`
|
||||
- `GET /comments/admin`
|
||||
- `DELETE /comments/admin/:commentId`
|
||||
- Marketplace moderation:
|
||||
- `GET /marketplace/superadmin/listings`
|
||||
- `PATCH /marketplace/superadmin/listings/:id/status`
|
||||
- `DELETE /marketplace/superadmin/listings/:id`
|
||||
- `GET /marketplace/superadmin/repair-shops`
|
||||
- `PATCH /marketplace/superadmin/repair-shops/:id/status`
|
||||
- `DELETE /marketplace/superadmin/repair-shops/:id`
|
||||
- Platform monitoring:
|
||||
- `GET /notifications/superadmin`
|
||||
- `GET /audit/superadmin/logs`
|
||||
- `GET /superadmin/overview`
|
||||
- `GET /superadmin/charts`
|
||||
- `GET /superadmin/recent-activity`
|
||||
- `GET /superadmin/reports`
|
||||
- `GET /superadmin/settings`
|
||||
- `PATCH /superadmin/settings`
|
||||
- `PATCH /superadmin/posts/:id/status`
|
||||
- `PATCH /superadmin/comments/:id/status`
|
||||
- `PATCH /superadmin/users/:id/status`
|
||||
|
||||
## Notes
|
||||
|
||||
- The dashboard stores SuperAdmin session tokens in secure `httpOnly` cookies through the internal proxy route.
|
||||
- `AuthGuard` attempts refresh when the access token is expired.
|
||||
- Marketplace and content pages assume the backend pagination contract returns a `pagination` object.
|
||||
151
oudelaa_dashboard/app/(auth)/login/page.tsx
Normal file
151
oudelaa_dashboard/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff, Lock, Mail } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { loginSuperAdmin } from "@/lib/auth/client";
|
||||
import { getSuperAdminSession } from "@/lib/api/superadmin";
|
||||
|
||||
function OudelaaMark() {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Oudelaa Logo"
|
||||
width={144}
|
||||
height={144}
|
||||
priority
|
||||
className="h-36 w-36 object-contain drop-shadow-[0_14px_30px_rgba(0,0,0,0.25)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
void getSuperAdminSession()
|
||||
.then(() => {
|
||||
router.replace("/dashboard");
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [router]);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await loginSuperAdmin(email, password);
|
||||
toast({ title: "تم تسجيل الدخول", description: "مرحبًا بك في لوحة SuperAdmin.", variant: "success" });
|
||||
router.replace("/dashboard");
|
||||
} catch (error) {
|
||||
toast({ title: "فشل تسجيل الدخول", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell login-theme flex min-h-screen items-center justify-center p-6">
|
||||
<div className="grid w-full max-w-5xl gap-8 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<Card className="login-card order-2 lg:order-1">
|
||||
<CardContent className="space-y-7 p-9 text-right">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-muted-foreground">Oudelaa SuperAdmin</p>
|
||||
<h1 className="font-heading text-3xl font-bold text-[rgb(88,61,36)]">بوابة الإدارة العليا</h1>
|
||||
<p className="text-xm text-muted-foreground">
|
||||
تحكم فخم في التجربة الموسيقية. ادخل لإدارة المستخدمين، المحتوى، والقرارات الحساسة بأمان.
|
||||
</p>
|
||||
</div>
|
||||
<div className="gold-divider" />
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xm text-muted-foreground">البريد الإلكتروني</label>
|
||||
<div className="relative">
|
||||
<Mail className="pointer-events-none absolute right-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pr-9"
|
||||
type="email"
|
||||
placeholder="superadmin@oudelaa.com"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xm text-muted-foreground">كلمة المرور</label>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute right-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="pr-9 pl-10"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-3 top-3 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
aria-label={showPassword ? "إخفاء كلمة المرور" : "إظهار كلمة المرور"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>تسجيل دخول آمن بتوثيق SuperAdmin</span>
|
||||
<span>{new Intl.DateTimeFormat("ar-SA", { dateStyle: "medium" }).format(new Date())}</span>
|
||||
</div>
|
||||
<Button className="w-full text-base" type="submit" disabled={loading}>
|
||||
{loading ? "جارٍ التحقق..." : "دخول اللوحة"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="login-card order-1 flex flex-col justify-between p-9 lg:order-2">
|
||||
<div className="space-y-5">
|
||||
<OudelaaMark />
|
||||
<div className="text-center">
|
||||
<h2 className="font-heading text-2xl font-bold text-[rgb(88,61,36)]">
|
||||
<b>Oudelaa</b>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Heritage Music Command Suite</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<div className="rounded-2xl border border-border/70 bg-secondary/50 p-4">
|
||||
<p className="text-xl uppercase tracking-[0.2em] text-primary">
|
||||
<b>جلسة إشراف</b>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-foreground">بوابة الإدارة العليا مخصصة للعمليات الحساسة ومراجعة المنصة بالكامل.</p>
|
||||
<p className="mt-1 text-xs">استخدم حساب SuperAdmin الصحيح دون بيانات افتراضية محفوظة داخل الواجهة.</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/70 bg-secondary/30 p-3">
|
||||
<p className="text-xm text-muted-foreground">البيئة</p>
|
||||
<p className="mt-1 text-sm text-foreground">Connected API</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/70 bg-secondary/30 p-3">
|
||||
<p className="text-xm text-muted-foreground">الحالة</p>
|
||||
<p className="mt-1 text-sm text-foreground">Secure</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
248
oudelaa_dashboard/app/api/proxy/[...path]/route.ts
Normal file
248
oudelaa_dashboard/app/api/proxy/[...path]/route.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.API_BASE_URL ?? "";
|
||||
const ACCESS_COOKIE = "oudelaa_sa_access";
|
||||
const REFRESH_COOKIE = "oudelaa_sa_refresh";
|
||||
const ACCESS_MAX_AGE_SECONDS = 15 * 60;
|
||||
const REFRESH_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
function buildTargetUrl(pathSegments: string[], searchParams: URLSearchParams) {
|
||||
const base = API_BASE_URL.replace(/\/$/, "");
|
||||
const path = pathSegments.join("/");
|
||||
const query = searchParams.toString();
|
||||
return `${base}/${path}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function isSuperAdminLogin(pathSegments: string[]) {
|
||||
return pathSegments.join("/") === "auth/superadmin/login";
|
||||
}
|
||||
|
||||
function isSuperAdminRefresh(pathSegments: string[]) {
|
||||
return pathSegments.join("/") === "auth/superadmin/refresh";
|
||||
}
|
||||
|
||||
function isSuperAdminLogout(pathSegments: string[]) {
|
||||
return pathSegments.join("/") === "auth/superadmin/logout";
|
||||
}
|
||||
|
||||
function isJsonContent(contentType: string | null) {
|
||||
return (contentType ?? "").toLowerCase().includes("application/json");
|
||||
}
|
||||
|
||||
function parseJsonSafe<T>(value: string): T | null {
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpstreamHeaders(params: {
|
||||
req: NextRequest;
|
||||
pathSegments: string[];
|
||||
accessToken?: string;
|
||||
contentType: string | null;
|
||||
}) {
|
||||
const headers = new Headers();
|
||||
const { req, pathSegments, accessToken, contentType } = params;
|
||||
|
||||
const accept = req.headers.get("accept");
|
||||
if (accept) {
|
||||
headers.set("accept", accept);
|
||||
}
|
||||
|
||||
if (contentType) {
|
||||
headers.set("content-type", contentType);
|
||||
}
|
||||
|
||||
const authorization = req.headers.get("authorization");
|
||||
if (authorization) {
|
||||
headers.set("authorization", authorization);
|
||||
} else if (accessToken && !isSuperAdminLogin(pathSegments)) {
|
||||
headers.set("authorization", `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function applyAuthCookies(response: NextResponse, payload: {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
}) {
|
||||
const secure = process.env.NODE_ENV === "production";
|
||||
|
||||
if (payload.accessToken) {
|
||||
response.cookies.set(ACCESS_COOKIE, payload.accessToken, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: ACCESS_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.refreshToken) {
|
||||
response.cookies.set(REFRESH_COOKIE, payload.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: REFRESH_MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuthCookies(response: NextResponse) {
|
||||
response.cookies.set(ACCESS_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
response.cookies.set(REFRESH_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function proxyRequest(req: NextRequest, pathSegments: string[] = []) {
|
||||
if (!API_BASE_URL) {
|
||||
return new NextResponse("Missing API base URL", { status: 500 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const targetUrl = buildTargetUrl(pathSegments, url.searchParams);
|
||||
const isReadOnlyMethod = ["GET", "HEAD"].includes(req.method);
|
||||
const contentType = req.headers.get("content-type");
|
||||
const accessToken = req.cookies.get(ACCESS_COOKIE)?.value;
|
||||
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
||||
const headers = buildUpstreamHeaders({
|
||||
req,
|
||||
pathSegments,
|
||||
accessToken,
|
||||
contentType,
|
||||
});
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
|
||||
if (!isReadOnlyMethod) {
|
||||
const bodyBuffer = await req.arrayBuffer();
|
||||
const hasBody = bodyBuffer.byteLength > 0;
|
||||
body = hasBody ? bodyBuffer : undefined;
|
||||
|
||||
const shouldInjectRefreshToken =
|
||||
(isSuperAdminRefresh(pathSegments) || isSuperAdminLogout(pathSegments)) &&
|
||||
isJsonContent(contentType);
|
||||
|
||||
if (shouldInjectRefreshToken) {
|
||||
const rawText = hasBody ? Buffer.from(bodyBuffer).toString("utf8") : "";
|
||||
const parsed = parseJsonSafe<Record<string, unknown>>(rawText) ?? {};
|
||||
if (!parsed.refreshToken && refreshToken) {
|
||||
parsed.refreshToken = refreshToken;
|
||||
}
|
||||
body = JSON.stringify(parsed);
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
} as RequestInit;
|
||||
|
||||
if (body !== undefined) {
|
||||
init.body = body;
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(targetUrl, init);
|
||||
const responseHeaders = new Headers(upstream.headers);
|
||||
responseHeaders.delete("content-encoding");
|
||||
responseHeaders.delete("content-length");
|
||||
|
||||
const isAuthResponse =
|
||||
isSuperAdminLogin(pathSegments) ||
|
||||
isSuperAdminRefresh(pathSegments) ||
|
||||
isSuperAdminLogout(pathSegments);
|
||||
|
||||
if (isAuthResponse && isJsonContent(upstream.headers.get("content-type"))) {
|
||||
const payload = (await upstream.json()) as Record<string, unknown>;
|
||||
const response = NextResponse.json(payload, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
|
||||
if (upstream.ok && (isSuperAdminLogin(pathSegments) || isSuperAdminRefresh(pathSegments))) {
|
||||
applyAuthCookies(response, {
|
||||
accessToken: typeof payload.accessToken === "string" ? payload.accessToken : undefined,
|
||||
refreshToken: typeof payload.refreshToken === "string" ? payload.refreshToken : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (upstream.ok && isSuperAdminLogout(pathSegments)) {
|
||||
clearAuthCookies(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const response = new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
|
||||
if (upstream.ok && isSuperAdminLogout(pathSegments)) {
|
||||
clearAuthCookies(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
return new NextResponse(`Upstream fetch failed: ${message}`, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { path } = await context.params;
|
||||
return proxyRequest(req, path ?? []);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { path } = await context.params;
|
||||
return proxyRequest(req, path ?? []);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { path } = await context.params;
|
||||
return proxyRequest(req, path ?? []);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { path } = await context.params;
|
||||
return proxyRequest(req, path ?? []);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { path } = await context.params;
|
||||
return proxyRequest(req, path ?? []);
|
||||
}
|
||||
31
oudelaa_dashboard/app/globals.css
Normal file
31
oudelaa_dashboard/app/globals.css
Normal file
@@ -0,0 +1,31 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import "./styles/theme.css";
|
||||
@import "./styles/components.css";
|
||||
|
||||
@keyframes shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="password"]::-ms-reveal,
|
||||
input[type="password"]::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
35
oudelaa_dashboard/app/layout.tsx
Normal file
35
oudelaa_dashboard/app/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Cairo, Noto_Naskh_Arabic } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ToastProvider } from "@/components/ui/toast";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
|
||||
const heading = Cairo({
|
||||
subsets: ["arabic", "latin"],
|
||||
variable: "--font-heading",
|
||||
weight: ["600", "700", "800"],
|
||||
});
|
||||
|
||||
const body = Noto_Naskh_Arabic({
|
||||
subsets: ["arabic", "latin"],
|
||||
variable: "--font-body",
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Oudelaa Admin",
|
||||
description: "Premium admin dashboard for Oudelaa",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="ar" dir="rtl" suppressHydrationWarning className="theme-dark">
|
||||
<body className={`${heading.variable} ${body.variable} min-h-screen bg-background font-body text-foreground antialiased`}>
|
||||
<ThemeProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
oudelaa_dashboard/app/page.tsx
Normal file
5
oudelaa_dashboard/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
75
oudelaa_dashboard/app/styles/components.css
Normal file
75
oudelaa_dashboard/app/styles/components.css
Normal file
@@ -0,0 +1,75 @@
|
||||
.frame-panel {
|
||||
@apply rounded-2xl border border-border/90 bg-card/90 backdrop-blur-xl;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.theme-light .frame-panel {
|
||||
box-shadow: 0 20px 45px rgba(31, 22, 12, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.gold-edge {
|
||||
box-shadow: inset 0 0 0 1px rgba(180, 180, 180, 0.28);
|
||||
}
|
||||
|
||||
.ornament-grid {
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.08) 1px, transparent 0),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 24%);
|
||||
background-size: 20px 20px, 100% 100%;
|
||||
}
|
||||
|
||||
.theme-light .ornament-grid {
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(120, 120, 120, 0.08) 1px, transparent 0),
|
||||
linear-gradient(180deg, rgba(170, 170, 170, 0.08), transparent 30%);
|
||||
}
|
||||
|
||||
.page-enter {
|
||||
animation: fadeUp 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.shimmer-empty {
|
||||
background: linear-gradient(100deg, rgba(210, 210, 210, 0.08), rgba(210, 210, 210, 0.16), rgba(210, 210, 210, 0.08));
|
||||
background-size: 220% 100%;
|
||||
animation: shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.login-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(210, 210, 210, 0.35), transparent 40%),
|
||||
radial-gradient(circle at 80% 80%, rgba(160, 160, 160, 0.25), transparent 50%);
|
||||
opacity: 0.8;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
.login-shell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(circle at 1px 1px, rgba(210, 210, 210, 0.14) 1px, transparent 0);
|
||||
background-size: 22px 22px;
|
||||
opacity: 0.6;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
@apply rounded-3xl border border-border/80 bg-card/85 backdrop-blur-2xl;
|
||||
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.25), inset 0 0 0 1px rgba(200, 200, 200, 0.2);
|
||||
}
|
||||
|
||||
.theme-light .login-card {
|
||||
box-shadow: 0 24px 50px rgba(28, 18, 8, 0.12), inset 0 0 0 1px rgba(200, 200, 200, 0.3);
|
||||
}
|
||||
|
||||
.gold-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(190, 190, 190, 0.6), transparent);
|
||||
}
|
||||
91
oudelaa_dashboard/app/styles/theme.css
Normal file
91
oudelaa_dashboard/app/styles/theme.css
Normal file
@@ -0,0 +1,91 @@
|
||||
:root {
|
||||
--background: 28 20 13;
|
||||
--foreground: 246 238 223;
|
||||
--card: 37 27 19;
|
||||
--card-foreground: 238 229 211;
|
||||
--popover: 34 24 17;
|
||||
--popover-foreground: 245 238 224;
|
||||
--primary: 225 180 96;
|
||||
--primary-foreground: 40 29 21;
|
||||
--secondary: 57 41 29;
|
||||
--secondary-foreground: 235 226 209;
|
||||
--muted: 56 44 36;
|
||||
--muted-foreground: 192 176 155;
|
||||
--accent: 83 62 34;
|
||||
--accent-foreground: 243 230 206;
|
||||
--border: 80 63 48;
|
||||
--input: 69 56 43;
|
||||
--ring: 223 178 93;
|
||||
--chart-1: 231 182 90;
|
||||
--chart-2: 219 112 67;
|
||||
--chart-3: 182 152 62;
|
||||
--chart-4: 181 75 48;
|
||||
--chart-5: 208 182 149;
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
--background: 249 246 240;
|
||||
--foreground: 59 43 33;
|
||||
--card: 252 250 248;
|
||||
--card-foreground: 64 48 38;
|
||||
--popover: 250 248 245;
|
||||
--popover-foreground: 64 48 38;
|
||||
--primary: 200 129 30;
|
||||
--primary-foreground: 248 245 241;
|
||||
--secondary: 237 231 222;
|
||||
--secondary-foreground: 67 55 45;
|
||||
--muted: 231 225 218;
|
||||
--muted-foreground: 109 95 85;
|
||||
--accent: 231 221 208;
|
||||
--accent-foreground: 76 58 46;
|
||||
--border: 209 199 189;
|
||||
--input: 213 204 195;
|
||||
--ring: 195 128 34;
|
||||
--chart-1: 200 129 30;
|
||||
--chart-2: 190 88 45;
|
||||
--chart-3: 148 123 66;
|
||||
--chart-4: 163 74 51;
|
||||
--chart-5: 189 152 107;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
background-image:
|
||||
radial-gradient(circle at 85% 15%, rgba(218, 155, 47, 0.08), transparent 30%),
|
||||
radial-gradient(circle at 10% 90%, rgba(190, 84, 39, 0.08), transparent 35%),
|
||||
linear-gradient(120deg, rgb(25, 17, 11), rgb(20, 14, 11));
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.theme-light body {
|
||||
background-image:
|
||||
radial-gradient(circle at 85% 15%, rgba(235, 188, 122, 0.25), transparent 35%),
|
||||
radial-gradient(circle at 10% 90%, rgba(217, 166, 140, 0.18), transparent 40%),
|
||||
linear-gradient(120deg, rgb(249, 246, 240), rgb(244, 240, 235));
|
||||
}
|
||||
|
||||
.login-theme {
|
||||
--background: 244 237 228;
|
||||
--foreground: 88 61 36;
|
||||
--card: 250 246 239;
|
||||
--card-foreground: 62 45 31;
|
||||
--primary: 88 61 36;
|
||||
--primary-foreground: 255 248 239;
|
||||
--secondary: 238 230 220;
|
||||
--secondary-foreground: 68 50 34;
|
||||
--muted: 232 223 212;
|
||||
--muted-foreground: 88 67 48;
|
||||
--border: 210 197 183;
|
||||
--input: 236 228 218;
|
||||
--ring: 125 905 606;
|
||||
}
|
||||
|
||||
.login-theme body {
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 20%, rgba(228, 216, 202, 0.6), transparent 45%),
|
||||
radial-gradient(circle at 85% 80%, rgba(214, 200, 184, 0.55), transparent 45%),
|
||||
linear-gradient(120deg, rgb(246, 240, 231), rgb(238, 230, 219));
|
||||
}
|
||||
175
oudelaa_dashboard/components/auth/auth-guard.tsx
Normal file
175
oudelaa_dashboard/components/auth/auth-guard.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { SuperAdminSessionProvider } from "@/components/auth/session-context";
|
||||
import { getSuperAdminSession } from "@/lib/api/superadmin";
|
||||
import { refreshSuperAdmin } from "@/lib/auth/client";
|
||||
import type { SuperAdminSessionResponse } from "@/types/api";
|
||||
|
||||
const RETRY_DELAY_MS = 5000;
|
||||
|
||||
function isRecoverableSessionError(error: unknown) {
|
||||
const message = String(error).toLowerCase();
|
||||
return [
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
"fetch failed",
|
||||
"failed to fetch",
|
||||
"econnrefused",
|
||||
"timeout",
|
||||
"network",
|
||||
"upstream",
|
||||
].some((token) => message.includes(token));
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
const [session, setSession] = useState<SuperAdminSessionResponse | null>(null);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const [retryCountdown, setRetryCountdown] = useState<number | null>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let retryTimeout: number | null = null;
|
||||
let countdownInterval: number | null = null;
|
||||
|
||||
const clearRetryTimers = () => {
|
||||
if (retryTimeout) {
|
||||
window.clearTimeout(retryTimeout);
|
||||
retryTimeout = null;
|
||||
}
|
||||
|
||||
if (countdownInterval) {
|
||||
window.clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRetry = () => {
|
||||
clearRetryTimers();
|
||||
setRetryCountdown(RETRY_DELAY_MS / 1000);
|
||||
|
||||
countdownInterval = window.setInterval(() => {
|
||||
setRetryCountdown((value) => (value && value > 1 ? value - 1 : 1));
|
||||
}, 1000);
|
||||
|
||||
retryTimeout = window.setTimeout(() => {
|
||||
clearRetryTimers();
|
||||
setRetryCountdown(null);
|
||||
if (active) {
|
||||
setRetryNonce((value) => value + 1);
|
||||
}
|
||||
}, RETRY_DELAY_MS);
|
||||
};
|
||||
|
||||
const ensureSession = async () => {
|
||||
try {
|
||||
const nextSession = await getSuperAdminSession();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRetryTimers();
|
||||
setSession(nextSession);
|
||||
setSessionError(null);
|
||||
setRetryCountdown(null);
|
||||
setReady(true);
|
||||
} catch (initialError) {
|
||||
try {
|
||||
const refreshed = await refreshSuperAdmin();
|
||||
if (!refreshed) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSession = await getSuperAdminSession();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRetryTimers();
|
||||
setSession(nextSession);
|
||||
setSessionError(null);
|
||||
setRetryCountdown(null);
|
||||
setReady(true);
|
||||
} catch (error) {
|
||||
if (String(initialError).includes("401") || String(error).includes("401")) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSession(null);
|
||||
setSessionError(String(error));
|
||||
setReady(true);
|
||||
|
||||
if (isRecoverableSessionError(initialError) || isRecoverableSessionError(error)) {
|
||||
scheduleRetry();
|
||||
} else {
|
||||
clearRetryTimers();
|
||||
setRetryCountdown(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void ensureSession();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
clearRetryTimers();
|
||||
};
|
||||
}, [router, pathname, retryNonce]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="frame-panel mx-auto mt-10 w-full max-w-2xl p-6 text-sm text-muted-foreground">
|
||||
Checking the current SuperAdmin session...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionError && !session) {
|
||||
return (
|
||||
<SuperAdminSessionProvider value={{ session: null, permissions: [] }}>
|
||||
<div className="frame-panel mx-auto mt-10 w-full max-w-2xl space-y-4 p-6 text-sm text-muted-foreground">
|
||||
<div>
|
||||
The dashboard could not verify the current session, so permissions were not loaded.
|
||||
Reload the page after the backend connection is available again.
|
||||
</div>
|
||||
<div className="break-words text-xs text-red-300">{sessionError}</div>
|
||||
{retryCountdown !== null ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Retrying automatically in {retryCountdown}s...
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-border px-4 py-2 text-foreground transition hover:bg-secondary"
|
||||
onClick={() => {
|
||||
setRetryCountdown(null);
|
||||
setRetryNonce((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
Retry now
|
||||
</button>
|
||||
</div>
|
||||
</SuperAdminSessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SuperAdminSessionProvider value={{ session, permissions: session?.permissions ?? [] }}>
|
||||
{children}
|
||||
</SuperAdminSessionProvider>
|
||||
);
|
||||
}
|
||||
18
oudelaa_dashboard/components/auth/no-permission-state.tsx
Normal file
18
oudelaa_dashboard/components/auth/no-permission-state.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
export function NoPermissionState({
|
||||
description,
|
||||
title = "Access restricted",
|
||||
}: {
|
||||
description: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<EmptyState title={title} description={description} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
33
oudelaa_dashboard/components/auth/session-context.tsx
Normal file
33
oudelaa_dashboard/components/auth/session-context.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { SuperAdminSessionResponse } from "@/types/api";
|
||||
|
||||
type SuperAdminSessionContextValue = {
|
||||
session: SuperAdminSessionResponse | null;
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
const SuperAdminSessionContext = createContext<SuperAdminSessionContextValue>({
|
||||
session: null,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
export function SuperAdminSessionProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: SuperAdminSessionContextValue;
|
||||
}) {
|
||||
return (
|
||||
<SuperAdminSessionContext.Provider value={value}>
|
||||
{children}
|
||||
</SuperAdminSessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSuperAdminSession() {
|
||||
return useContext(SuperAdminSessionContext);
|
||||
}
|
||||
76
oudelaa_dashboard/components/dashboard/charts.tsx
Normal file
76
oudelaa_dashboard/components/dashboard/charts.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import type { ChannelPoint, Insight, RevenuePoint } from "@/types";
|
||||
|
||||
const pieColors = ["hsl(var(--chart-1))", "hsl(var(--chart-2))", "hsl(var(--chart-3))", "hsl(var(--chart-4))"];
|
||||
|
||||
export function RevenueAreaChart({ data }: { data: RevenuePoint[] }) {
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid stroke="rgba(180,150,110,0.14)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fill: "#c6b698", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "#a79577", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
cursor={{ stroke: "rgba(214,170,100,0.35)" }}
|
||||
contentStyle={{ background: "#20160f", border: "1px solid #5e4732", borderRadius: "12px" }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area type="monotone" dataKey="revenue" name="الإيراد" stroke="hsl(var(--chart-1))" fill="hsl(var(--chart-1))" fillOpacity={0.2} strokeWidth={2.5} />
|
||||
<Area type="monotone" dataKey="orders" name="الطلبات" stroke="hsl(var(--chart-2))" fill="hsl(var(--chart-2))" fillOpacity={0.16} strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelPieChart({ data }: { data: ChannelPoint[] }) {
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Tooltip contentStyle={{ background: "#20160f", border: "1px solid #5e4732", borderRadius: "12px" }} />
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={58} outerRadius={94} paddingAngle={4}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={pieColors[index % pieColors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InsightBarChart({ data }: { data: Insight[] }) {
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid stroke="rgba(180,150,110,0.14)" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fill: "#c6b698", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "#a79577", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={{ background: "#20160f", border: "1px solid #5e4732", borderRadius: "12px" }} />
|
||||
<Bar dataKey="value" radius={[8, 8, 0, 0]} fill="hsl(var(--chart-1))" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
oudelaa_dashboard/components/dashboard/mobile-nav.tsx
Normal file
42
oudelaa_dashboard/components/dashboard/mobile-nav.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
import { dashboardNav } from "@/lib/navigation";
|
||||
import { matchesPermissions } from "@/lib/permissions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
const { permissions } = useSuperAdminSession();
|
||||
const accessibleNav = useMemo(
|
||||
() =>
|
||||
dashboardNav.filter((item) =>
|
||||
matchesPermissions(permissions, item.requiredPermissions, item.permissionMode),
|
||||
),
|
||||
[permissions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex gap-2 overflow-x-auto pb-1 lg:hidden">
|
||||
{accessibleNav.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"whitespace-nowrap rounded-full border px-3 py-1.5 text-xs",
|
||||
active ? "border-primary/40 bg-primary/20 text-primary" : "border-border bg-card text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
oudelaa_dashboard/components/dashboard/page-header.tsx
Normal file
22
oudelaa_dashboard/components/dashboard/page-header.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, subtitle, actions, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header className={cn("page-enter flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", className)}>
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight text-foreground sm:text-3xl">{title}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PaginationMeta } from "@/types/api";
|
||||
|
||||
type PaginationControlsProps = {
|
||||
pagination?: PaginationMeta | null;
|
||||
loading?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
export function PaginationControls({ pagination, loading, onPageChange }: PaginationControlsProps) {
|
||||
if (!pagination) return null;
|
||||
|
||||
const currentPage = pagination.page ?? 1;
|
||||
const totalPages = pagination.totalPages ?? 1;
|
||||
const previousPage = pagination.previousPage ?? (currentPage > 1 ? currentPage - 1 : null);
|
||||
const nextPage = pagination.nextPage ?? (currentPage < totalPages ? currentPage + 1 : null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages} · Total {pagination.total}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || !previousPage}
|
||||
onClick={() => previousPage && onPageChange(previousPage)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || !nextPage}
|
||||
onClick={() => nextPage && onPageChange(nextPage)}
|
||||
>
|
||||
Next
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
oudelaa_dashboard/components/dashboard/post-preview-card.tsx
Normal file
157
oudelaa_dashboard/components/dashboard/post-preview-card.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AudioLines, Images, PlayCircle } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { getPostAuthor, getPostPreviewMedia, getUserLabel } from "@/lib/post-utils";
|
||||
import type { ApiPost } from "@/types/api";
|
||||
|
||||
function formatDuration(durationSeconds?: number | null) {
|
||||
if (typeof durationSeconds !== "number" || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(durationSeconds / 60);
|
||||
const seconds = Math.floor(durationSeconds % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function PostPreviewCard({ post }: { post: ApiPost }) {
|
||||
const author = getPostAuthor(post);
|
||||
const media = getPostPreviewMedia(post);
|
||||
const likes = post.engagement?.likesCount ?? post.likesCount ?? 0;
|
||||
const comments = post.engagement?.commentsCount ?? post.commentsCount ?? 0;
|
||||
const shares = post.engagement?.shareCount ?? post.shareCount ?? 0;
|
||||
const waveformPeaks = Array.isArray(post.waveformPeaks) && post.waveformPeaks.length ? post.waveformPeaks : [18, 32, 24, 44, 28, 40, 22, 36, 26, 30];
|
||||
const durationLabel = formatDuration(post.durationSeconds);
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
const [sourceFailed, setSourceFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setImageFailed(false);
|
||||
setSourceFailed(false);
|
||||
}, [media.url, media.sourceUrl]);
|
||||
|
||||
const showAudioPreview = media.kind === "audio" && !!media.sourceUrl && !sourceFailed;
|
||||
const showVideoPreview = media.kind === "video" && !!media.sourceUrl && !sourceFailed && (!media.url || imageFailed);
|
||||
const showImagePreview = !!media.url && !imageFailed;
|
||||
const showMediaShell = media.kind !== "text";
|
||||
const showUnavailable = !showImagePreview && !showVideoPreview && !showAudioPreview;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-border/70">
|
||||
<CardContent className="p-0">
|
||||
{showMediaShell ? (
|
||||
<div className="relative aspect-[16/9] border-b border-border/70 bg-secondary/30">
|
||||
{showImagePreview ? (
|
||||
<Image
|
||||
src={media.url}
|
||||
alt={post.content || post.postType || "post"}
|
||||
fill
|
||||
unoptimized
|
||||
loading="lazy"
|
||||
onError={() => setImageFailed(true)}
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{showVideoPreview ? (
|
||||
<video
|
||||
src={media.sourceUrl}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
onError={() => setSourceFailed(true)}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{showAudioPreview ? (
|
||||
<div className="absolute inset-0 flex flex-col justify-between bg-gradient-to-br from-secondary/70 via-background/60 to-secondary/80 p-5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-foreground">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/70 px-3 py-1">
|
||||
<AudioLines className="h-4 w-4" />
|
||||
<span>Audio preview</span>
|
||||
</div>
|
||||
{durationLabel ? <span className="text-xs text-muted-foreground">{durationLabel}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex h-24 items-end justify-center gap-1.5 px-2">
|
||||
{waveformPeaks.slice(0, 48).map((peak, index) => {
|
||||
const safePeak = Number.isFinite(peak) ? Math.max(8, Math.min(100, peak)) : 20;
|
||||
return (
|
||||
<span
|
||||
key={`${post._id}-peak-${index}`}
|
||||
className="w-1.5 rounded-full bg-primary/80"
|
||||
style={{ height: `${safePeak}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<audio
|
||||
src={media.sourceUrl}
|
||||
preload="metadata"
|
||||
controls
|
||||
onError={() => setSourceFailed(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showUnavailable ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm text-muted-foreground">
|
||||
Media preview unavailable
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute left-3 top-3 flex items-center gap-2">
|
||||
<Badge variant="warning">{post.postType ?? "post"}</Badge>
|
||||
{media.kind === "image" && media.count > 1 ? (
|
||||
<Badge variant="muted">
|
||||
<Images className="mr-1 h-3 w-3" />
|
||||
{media.count}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute bottom-3 right-3 rounded-full border border-border/60 bg-background/85 p-2 text-foreground">
|
||||
{media.kind === "audio" ? <AudioLines className="h-4 w-4" /> : <PlayCircle className="h-4 w-4" />}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="muted">{post.visibility ?? "public"}</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
post.moderationStatus === "hidden"
|
||||
? "danger"
|
||||
: post.moderationStatus === "flagged"
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
>
|
||||
{post.moderationStatus ?? "active"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{getUserLabel(author)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{formatDateTime(post.createdAt)}</div>
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-4 text-sm leading-6 text-foreground">
|
||||
{post.content?.trim() || "Media post without caption."}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{likes} likes</span>
|
||||
<span>{comments} comments</span>
|
||||
<span>{shares} shares</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
5
oudelaa_dashboard/components/dashboard/section-grid.tsx
Normal file
5
oudelaa_dashboard/components/dashboard/section-grid.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SectionGrid({ children }: { children: ReactNode }) {
|
||||
return <section className="grid gap-4 xl:grid-cols-12">{children}</section>;
|
||||
}
|
||||
84
oudelaa_dashboard/components/dashboard/sidebar.tsx
Normal file
84
oudelaa_dashboard/components/dashboard/sidebar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Search, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { dashboardNav } from "@/lib/navigation";
|
||||
import { matchesPermissions } from "@/lib/permissions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
const [search, setSearch] = useState("");
|
||||
const { permissions } = useSuperAdminSession();
|
||||
|
||||
const accessibleNav = useMemo(
|
||||
() =>
|
||||
dashboardNav.filter((item) =>
|
||||
matchesPermissions(permissions, item.requiredPermissions, item.permissionMode),
|
||||
),
|
||||
[permissions],
|
||||
);
|
||||
|
||||
const filteredNav = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return accessibleNav;
|
||||
return accessibleNav.filter((item) => item.label.toLowerCase().includes(needle));
|
||||
}, [accessibleNav, search]);
|
||||
|
||||
return (
|
||||
<aside className="frame-panel ornament-grid sticky top-4 hidden h-[calc(100vh-2rem)] w-72 shrink-0 flex-col border-border/70 p-4 lg:flex">
|
||||
<div className="mb-5 rounded-xl border border-border/60 bg-background/70 p-4">
|
||||
<p className="font-heading text-xl font-bold tracking-wide text-primary">Oudelaa</p>
|
||||
<p className="text-xs text-muted-foreground">SuperAdmin Command Console</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search className="pointer-events-none absolute right-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pr-9"
|
||||
placeholder="Search sections..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{filteredNav.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-lg border px-3 py-2 text-sm transition",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/15 text-primary"
|
||||
: "border-transparent text-muted-foreground hover:border-border hover:bg-secondary/70 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto space-y-3 rounded-xl border border-border/70 bg-secondary/40 p-4">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Moderation mode</span>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm text-foreground">
|
||||
This console manages users, marketplace items, content, reports, and SuperAdmin sessions
|
||||
through dedicated admin API routes.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Data is loaded from the live API, not mock data.</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
31
oudelaa_dashboard/components/dashboard/stat-card.tsx
Normal file
31
oudelaa_dashboard/components/dashboard/stat-card.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ArrowDownLeft, ArrowUpRight, Minus } from "lucide-react";
|
||||
|
||||
import type { StatMetric } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const trendStyles = {
|
||||
up: "text-emerald-300",
|
||||
down: "text-rose-300",
|
||||
neutral: "text-amber-200",
|
||||
} as const;
|
||||
|
||||
export function StatCard({ metric }: { metric: StatMetric }) {
|
||||
const Icon = metric.trend === "up" ? ArrowUpRight : metric.trend === "down" ? ArrowDownLeft : Minus;
|
||||
|
||||
return (
|
||||
<Card className="ornament-grid">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold text-muted-foreground">{metric.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-foreground">{metric.value}</div>
|
||||
<p className={cn("mt-1 flex items-center gap-1 text-xs", trendStyles[metric.trend])}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{metric.delta}
|
||||
</p>
|
||||
{metric.note ? <p className="mt-2 text-xs text-muted-foreground">{metric.note}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
276
oudelaa_dashboard/components/dashboard/topbar.tsx
Normal file
276
oudelaa_dashboard/components/dashboard/topbar.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Bell, MoonStar, RefreshCcw, ShieldCheck, SunMedium } from "lucide-react";
|
||||
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer } from "@/components/ui/drawer";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { useTheme } from "@/components/theme/theme-provider";
|
||||
import { listSuperAdminSessions } from "@/lib/api/auth";
|
||||
import { listPlatformNotifications } from "@/lib/api/notifications";
|
||||
import { getSuperAdminOverview, getSuperAdminRecentActivity } from "@/lib/api/superadmin";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
|
||||
import type {
|
||||
NotificationItem,
|
||||
NotificationsResponse,
|
||||
SessionItem,
|
||||
SessionsResponse,
|
||||
SuperAdminOverviewResponse,
|
||||
SuperAdminRecentActivityItem,
|
||||
SuperAdminRecentActivityResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
const EMPTY_NOTIFICATIONS: NotificationsResponse = { items: [], data: [], unreadCount: 0 };
|
||||
const EMPTY_SESSIONS: SessionsResponse = { items: [] };
|
||||
const EMPTY_ACTIVITY: SuperAdminRecentActivityResponse = { items: [] };
|
||||
|
||||
export function DashboardTopbar() {
|
||||
const { toast } = useToast();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { permissions } = useSuperAdminSession();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [overview, setOverview] = useState<SuperAdminOverviewResponse | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<SuperAdminRecentActivityItem[]>([]);
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [sessions, setSessions] = useState<SessionItem[]>([]);
|
||||
|
||||
const canReadOverview = hasPermission(permissions, SUPERADMIN_PERMISSIONS.OVERVIEW_READ);
|
||||
const canReadAnalytics = hasPermission(permissions, SUPERADMIN_PERMISSIONS.ANALYTICS_READ);
|
||||
const canReadNotifications = hasPermission(permissions, SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ);
|
||||
const canManageSessions = hasPermission(permissions, SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE);
|
||||
|
||||
const todayLabel = useMemo(() => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat("ar-SA", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date());
|
||||
} catch {
|
||||
return "Today";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unreadCount =
|
||||
overview?.metrics.unreadNotificationsCount ??
|
||||
notifications.filter((item) => item.read === false).length;
|
||||
const moderationAttentionCount =
|
||||
(overview?.metrics.flaggedPostsCount ?? 0) + (overview?.metrics.flaggedCommentsCount ?? 0);
|
||||
|
||||
const summaryCards = [
|
||||
canManageSessions
|
||||
? {
|
||||
key: "sessions",
|
||||
label: "SuperAdmin sessions",
|
||||
value: String(sessions.length),
|
||||
}
|
||||
: null,
|
||||
canReadNotifications
|
||||
? {
|
||||
key: "notifications",
|
||||
label: "Unread notifications",
|
||||
value: String(unreadCount),
|
||||
}
|
||||
: null,
|
||||
canReadOverview
|
||||
? {
|
||||
key: "moderation",
|
||||
label: "Needs review",
|
||||
value: String(moderationAttentionCount),
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean) as Array<{ key: string; label: string; value: string }>;
|
||||
const summaryGridClass =
|
||||
summaryCards.length >= 3
|
||||
? "grid gap-3 sm:grid-cols-3"
|
||||
: summaryCards.length === 2
|
||||
? "grid gap-3 sm:grid-cols-2"
|
||||
: "grid gap-3";
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [overviewResponse, activityResponse, notificationsResponse, sessionsResponse] =
|
||||
await Promise.all([
|
||||
canReadOverview ? getSuperAdminOverview() : Promise.resolve(null),
|
||||
canReadAnalytics
|
||||
? getSuperAdminRecentActivity({ limit: 5 })
|
||||
: Promise.resolve(EMPTY_ACTIVITY),
|
||||
canReadNotifications
|
||||
? listPlatformNotifications({ limit: 5, sortOrder: "desc" })
|
||||
: Promise.resolve(EMPTY_NOTIFICATIONS),
|
||||
canManageSessions ? listSuperAdminSessions() : Promise.resolve(EMPTY_SESSIONS),
|
||||
]);
|
||||
setOverview(overviewResponse);
|
||||
setRecentActivity(activityResponse.items ?? []);
|
||||
setNotifications(notificationsResponse.items ?? notificationsResponse.data ?? []);
|
||||
setSessions(sessionsResponse.items ?? []);
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to load command center", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canManageSessions, canReadAnalytics, canReadNotifications, canReadOverview, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-enter mb-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/80 p-3 backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="warning">SuperAdmin</Badge>
|
||||
<p className="text-sm text-muted-foreground">{todayLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => void loadOverview()} disabled={loading}>
|
||||
<RefreshCcw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
toggle();
|
||||
toast({
|
||||
title: "Theme updated",
|
||||
description: theme === "dark" ? "Switched to light mode." : "Switched to dark mode.",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{theme === "dark" ? <SunMedium className="h-4 w-4" /> : <MoonStar className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDrawerOpen(true)}>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
title="Operations Center"
|
||||
description="A quick view of the latest notifications, activity, and active sessions."
|
||||
side="right"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{summaryCards.length ? (
|
||||
<div className={summaryGridClass}>
|
||||
{summaryCards.map((card) => (
|
||||
<div key={card.key} className="rounded-xl border border-border bg-secondary/40 p-4">
|
||||
<div className="text-xs text-muted-foreground">{card.label}</div>
|
||||
<div className="mt-2 text-2xl font-bold text-foreground">{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canReadNotifications ? (
|
||||
<div className="rounded-xl border border-border bg-secondary/40 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Latest notifications</p>
|
||||
<Link href="/notifications" className="text-xs font-semibold text-primary">
|
||||
Open page
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{notifications.length ? (
|
||||
notifications.map((item) => (
|
||||
<div key={item._id} className="rounded-lg border border-border/60 bg-background/40 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-foreground">{item.title ?? item.type}</p>
|
||||
<Badge variant={item.read ? "muted" : "warning"}>
|
||||
{item.read ? "Read" : "New"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{item.previewText ?? item.deepLink ?? "-"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No recent notifications.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canReadAnalytics ? (
|
||||
<div className="rounded-xl border border-border bg-secondary/40 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Recent activity</p>
|
||||
<Link href="/dashboard" className="text-xs font-semibold text-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{recentActivity.length ? (
|
||||
recentActivity.map((item, index) => (
|
||||
<div
|
||||
key={`${item.type}-${item.createdAt ?? index}`}
|
||||
className="rounded-lg border border-border/60 bg-background/40 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
<Badge
|
||||
variant={
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "hidden"
|
||||
? "danger"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.subtitle}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatDateTime(item.createdAt)}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No recent activity.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManageSessions ? (
|
||||
<div className="rounded-xl border border-border bg-secondary/40 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-semibold text-foreground">Active sessions</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessions.length ? (
|
||||
sessions.map((item) => (
|
||||
<div
|
||||
key={item.id ?? item.jti}
|
||||
className="rounded-lg border border-border/60 bg-background/40 p-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{item.id ?? item.jti ?? "session"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatDateTime(item.createdAt)} - {formatDateTime(item.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No active sessions to display.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
oudelaa_dashboard/components/theme/theme-provider.tsx
Normal file
49
oudelaa_dashboard/components/theme/theme-provider.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "oudelaa.theme";
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("theme-dark", "theme-light");
|
||||
root.classList.add(`theme-${theme}`);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
|
||||
const initial = stored ?? "dark";
|
||||
setTheme(initial);
|
||||
applyTheme(initial);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
setTheme((prev) => {
|
||||
const next = prev === "dark" ? "light" : "dark";
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
applyTheme(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
30
oudelaa_dashboard/components/ui/badge.tsx
Normal file
30
oudelaa_dashboard/components/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary/20 text-primary",
|
||||
success: "bg-emerald-500/15 text-emerald-300",
|
||||
warning: "bg-amber-500/15 text-amber-200",
|
||||
danger: "bg-rose-500/15 text-rose-200",
|
||||
muted: "bg-secondary text-muted-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
49
oudelaa_dashboard/components/ui/button.tsx
Normal file
49
oudelaa_dashboard/components/ui/button.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "bg-transparent text-foreground hover:bg-secondary/60",
|
||||
outline: "border border-border bg-transparent text-foreground hover:bg-secondary/60",
|
||||
danger: "bg-red-900/70 text-red-100 hover:bg-red-800/80",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2 text-sm",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-11 px-5 text-sm",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, type, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type={type ?? "button"}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
30
oudelaa_dashboard/components/ui/card.tsx
Normal file
30
oudelaa_dashboard/components/ui/card.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("frame-panel gold-edge", className)} {...props} />
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("font-heading text-lg font-semibold tracking-tight", className)} {...props} />
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => <p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />,
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export { Card, CardContent, CardDescription, CardHeader, CardTitle };
|
||||
62
oudelaa_dashboard/components/ui/dialog.tsx
Normal file
62
oudelaa_dashboard/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/70 backdrop-blur-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 grid w-[95vw] max-w-xl -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-border bg-card p-6 shadow-glow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose className="absolute left-4 top-4 rounded-md p-1 text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("space-y-2 text-right", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h2 className={cn("font-heading text-xl font-bold", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger };
|
||||
58
oudelaa_dashboard/components/ui/drawer.tsx
Normal file
58
oudelaa_dashboard/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type DrawerProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
side?: "left" | "right";
|
||||
widthClassName?: string;
|
||||
};
|
||||
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
side = "left",
|
||||
widthClassName,
|
||||
}: DrawerProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-black/50 backdrop-blur-sm transition",
|
||||
open ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed bottom-0 top-0 z-50 w-[96vw] max-w-md border border-border bg-card p-5 shadow-glow transition",
|
||||
widthClassName,
|
||||
side === "left" ? "left-0" : "right-0",
|
||||
open ? "translate-x-0" : side === "left" ? "-translate-x-full" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="mb-5 flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-heading text-lg font-bold">{title}</h3>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-72px)] overflow-y-auto">{children}</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
oudelaa_dashboard/components/ui/empty-state.tsx
Normal file
21
oudelaa_dashboard/components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Music2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type EmptyStateProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function EmptyState({ title, description, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn("ornament-grid shimmer-empty rounded-xl border border-dashed border-border p-8 text-center", className)}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-secondary text-primary">
|
||||
<Music2 className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-heading text-lg font-semibold">{title}</h3>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
oudelaa_dashboard/components/ui/input.tsx
Normal file
20
oudelaa_dashboard/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
71
oudelaa_dashboard/components/ui/select.tsx
Normal file
71
oudelaa_dashboard/components/ui/select.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-lg border border-input bg-background/60 px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = "SelectTrigger";
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn("z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = "SelectContent";
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-md py-2 pl-8 pr-3 text-sm outline-none hover:bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = "SelectItem";
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue };
|
||||
9
oudelaa_dashboard/components/ui/separator.tsx
Normal file
9
oudelaa_dashboard/components/ui/separator.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div role="separator" className={cn("h-px w-full bg-border", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
32
oudelaa_dashboard/components/ui/switch.tsx
Normal file
32
oudelaa_dashboard/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SwitchProps = {
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Switch({ checked, onCheckedChange, className }: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
"relative h-6 w-11 rounded-full border border-border transition",
|
||||
checked ? "bg-primary" : "bg-secondary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0.5 h-[18px] w-[18px] rounded-full bg-background transition",
|
||||
checked ? "right-0.5" : "right-[22px]",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
37
oudelaa_dashboard/components/ui/table.tsx
Normal file
37
oudelaa_dashboard/components/ui/table.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b [&_tr]:border-border", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(({ className, ...props }, ref) => (
|
||||
<tr ref={ref} className={cn("border-b border-border/70 transition hover:bg-secondary/40", className)} {...props} />
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(({ className, ...props }, ref) => (
|
||||
<th ref={ref} className={cn("h-11 px-4 text-right align-middle font-medium text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-4 align-middle", className)} {...props} />
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
|
||||
22
oudelaa_dashboard/components/ui/textarea.tsx
Normal file
22
oudelaa_dashboard/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-[90px] w-full rounded-lg border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
80
oudelaa_dashboard/components/ui/toast.tsx
Normal file
80
oudelaa_dashboard/components/ui/toast.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToastVariant = "default" | "success" | "warning" | "danger";
|
||||
|
||||
type ToastItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: ToastVariant;
|
||||
};
|
||||
|
||||
type ToastContextValue = {
|
||||
toast: (item: Omit<ToastItem, "id">) => void;
|
||||
};
|
||||
|
||||
const ToastContext = React.createContext<ToastContextValue | null>(null);
|
||||
|
||||
function getToastId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `toast_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ToastVariant, string> = {
|
||||
default: "border-border bg-card text-foreground",
|
||||
success: "border-emerald-500/40 bg-emerald-500/10 text-emerald-100",
|
||||
warning: "border-amber-500/40 bg-amber-500/10 text-amber-100",
|
||||
danger: "border-rose-500/40 bg-rose-500/10 text-rose-100",
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<ToastItem[]>([]);
|
||||
|
||||
const toast = React.useCallback((item: Omit<ToastItem, "id">) => {
|
||||
const id = getToastId();
|
||||
setToasts((prev) => [...prev, { id, ...item }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toastItem) => toastItem.id !== id));
|
||||
}, 3200);
|
||||
}, []);
|
||||
|
||||
const remove = React.useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((toastItem) => toastItem.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-6 left-6 z-50 flex w-[90vw] max-w-sm flex-col gap-3">
|
||||
{toasts.map((item) => (
|
||||
<div key={item.id} className={cn("frame-panel border px-4 py-3 shadow-glow", variantStyles[item.variant ?? "default"])}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{item.title}</p>
|
||||
{item.description ? <p className="mt-1 text-xs text-muted-foreground">{item.description}</p> : null}
|
||||
</div>
|
||||
<button className="rounded-md p-1 text-muted-foreground hover:text-foreground" onClick={() => remove(item.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
97
oudelaa_dashboard/lib/api/admin-users.ts
Normal file
97
oudelaa_dashboard/lib/api/admin-users.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type {
|
||||
AdminCreatePayload,
|
||||
AdminUpdatePayload,
|
||||
ApiRole,
|
||||
ApiUser,
|
||||
PaginatedResponse,
|
||||
ProfileOverviewResponse,
|
||||
SuccessMessage,
|
||||
} from "@/types/api";
|
||||
|
||||
function normalizeUser(user: Partial<ApiUser> & { id?: string }) {
|
||||
return {
|
||||
...user,
|
||||
_id: user._id ?? user.id ?? "",
|
||||
} as ApiUser;
|
||||
}
|
||||
|
||||
function normalizeUsersResponse(response: PaginatedResponse<ApiUser>) {
|
||||
return {
|
||||
...response,
|
||||
items: Array.isArray(response.items) ? response.items.map((item) => normalizeUser(item)) : response.items,
|
||||
data: Array.isArray(response.data) ? response.data.map((item) => normalizeUser(item)) : response.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPlatformAdmins(page = 1, limit = 20) {
|
||||
const response = await fetchWithAuth<PaginatedResponse<ApiUser>>(
|
||||
apiEndpoints.users.admins({ page, limit }),
|
||||
);
|
||||
return normalizeUsersResponse(response);
|
||||
}
|
||||
|
||||
export async function getAdminUserById(userId: string) {
|
||||
const response = await fetchWithAuth<ApiUser>(apiEndpoints.users.byId(userId));
|
||||
return normalizeUser(response);
|
||||
}
|
||||
|
||||
export async function updateAdminUser(userId: string, payload: AdminUpdatePayload) {
|
||||
const response = await fetchWithAuth<ApiUser>(apiEndpoints.users.update(userId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return normalizeUser(response);
|
||||
}
|
||||
|
||||
export async function updatePlatformAdmin(userId: string, payload: AdminUpdatePayload) {
|
||||
const response = await fetchWithAuth<ApiUser>(apiEndpoints.users.updateAdmin(userId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return normalizeUser(response);
|
||||
}
|
||||
|
||||
export async function setAdminUserRole(userId: string, role: ApiRole) {
|
||||
const response = await fetchWithAuth<ApiUser>(apiEndpoints.users.setRole(userId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
return normalizeUser(response);
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(userId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.users.remove(userId), { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function deletePlatformAdmin(userId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.users.removeAdmin(userId), { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: AdminCreatePayload) {
|
||||
const response = await fetchWithAuth<ApiUser>(apiEndpoints.users.createAdmin, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return normalizeUser(response);
|
||||
}
|
||||
|
||||
export async function searchAdminUsers(params: Record<string, string | number | boolean | null | undefined>) {
|
||||
const response = await fetchWithAuth<PaginatedResponse<ApiUser>>(apiEndpoints.users.all(params));
|
||||
return normalizeUsersResponse(response);
|
||||
}
|
||||
|
||||
export async function discoverAdminUsers(params: Record<string, string | number | boolean | null | undefined>) {
|
||||
const response = await fetchWithAuth<PaginatedResponse<ApiUser>>(apiEndpoints.users.discover(params));
|
||||
return normalizeUsersResponse(response);
|
||||
}
|
||||
|
||||
export async function searchPlatformAdmins(params: Record<string, string | number | boolean | null | undefined>) {
|
||||
const response = await fetchWithAuth<PaginatedResponse<ApiUser>>(apiEndpoints.users.admins(params));
|
||||
return normalizeUsersResponse(response);
|
||||
}
|
||||
|
||||
export async function getProfileOverviewForSuperAdmin(userId: string) {
|
||||
return fetchWithAuth<ProfileOverviewResponse>(apiEndpoints.users.profileOverview(userId));
|
||||
}
|
||||
7
oudelaa_dashboard/lib/api/audit.ts
Normal file
7
oudelaa_dashboard/lib/api/audit.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type { AuditLogsResponse } from "@/types/api";
|
||||
|
||||
export async function listAuditLogs(params: Record<string, string | number | boolean | null | undefined> = {}) {
|
||||
return fetchWithAuth<AuditLogsResponse>(apiEndpoints.audit.logs(params));
|
||||
}
|
||||
34
oudelaa_dashboard/lib/api/auth.ts
Normal file
34
oudelaa_dashboard/lib/api/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type { LoginResponse, SessionsResponse, SuccessMessage } from "@/types/api";
|
||||
|
||||
export async function loginDashboardUser(email: string, password: string) {
|
||||
return fetchWithAuth<LoginResponse>(apiEndpoints.auth.login, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshDashboardUser(refreshToken: string) {
|
||||
return fetchWithAuth<LoginResponse>(apiEndpoints.auth.refresh, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function logoutDashboardUser(refreshToken: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.auth.logout, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSuperAdminSessions() {
|
||||
return fetchWithAuth<SessionsResponse>(apiEndpoints.auth.superAdminSessions);
|
||||
}
|
||||
|
||||
export async function revokeSuperAdminSession(sessionId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.auth.revokeSuperAdminSession(sessionId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
15
oudelaa_dashboard/lib/api/comments.ts
Normal file
15
oudelaa_dashboard/lib/api/comments.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type { CommentsResponse, SuccessMessage } from "@/types/api";
|
||||
|
||||
export async function listModerationComments(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<CommentsResponse>(apiEndpoints.comments.moderation(params));
|
||||
}
|
||||
|
||||
export async function deleteAdminComment(commentId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.comments.adminDelete(commentId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
36
oudelaa_dashboard/lib/api/core.ts
Normal file
36
oudelaa_dashboard/lib/api/core.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ApiIdentifier, PaginatedResponse } from "@/types/api";
|
||||
|
||||
export function getEntityId(entity?: ApiIdentifier | null) {
|
||||
if (!entity) return "";
|
||||
return entity._id ?? entity.id ?? "";
|
||||
}
|
||||
|
||||
export function getItems<T>(payload: PaginatedResponse<T> | T[] | undefined | null) {
|
||||
if (!payload) return [] as T[];
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (Array.isArray(payload.items)) return payload.items;
|
||||
if (Array.isArray(payload.data)) return payload.data;
|
||||
return [] as T[];
|
||||
}
|
||||
|
||||
export function getTotal<T>(payload: PaginatedResponse<T> | undefined | null) {
|
||||
if (!payload) return 0;
|
||||
if (typeof payload.pagination?.total === "number") return payload.pagination.total;
|
||||
if (typeof payload.total === "number") return payload.total;
|
||||
return getItems(payload).length;
|
||||
}
|
||||
|
||||
export function getPagination<T>(payload: PaginatedResponse<T> | undefined | null) {
|
||||
return payload?.pagination ?? null;
|
||||
}
|
||||
|
||||
export function toQueryString(params: Record<string, string | number | boolean | null | undefined>) {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
search.set(key, String(value));
|
||||
});
|
||||
|
||||
return search.toString();
|
||||
}
|
||||
118
oudelaa_dashboard/lib/api/endpoints.ts
Normal file
118
oudelaa_dashboard/lib/api/endpoints.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { toQueryString } from "@/lib/api/core";
|
||||
|
||||
export const apiEndpoints = {
|
||||
health: "/",
|
||||
auth: {
|
||||
login: "/auth/login",
|
||||
refresh: "/auth/refresh",
|
||||
logout: "/auth/logout",
|
||||
superAdminLogin: "/auth/superadmin/login",
|
||||
superAdminRefresh: "/auth/superadmin/refresh",
|
||||
superAdminLogout: "/auth/superadmin/logout",
|
||||
superAdminSessions: "/auth/superadmin/sessions",
|
||||
revokeSuperAdminSession: (sessionId: string) => `/auth/superadmin/sessions/${sessionId}/revoke`,
|
||||
},
|
||||
users: {
|
||||
all: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/users/admin?${toQueryString(params)}`,
|
||||
byId: (userId: string) => `/users/admin/${userId}`,
|
||||
update: (userId: string) => `/users/admin/${userId}`,
|
||||
disable: (userId: string) => `/users/admin/${userId}/disable`,
|
||||
enable: (userId: string) => `/users/admin/${userId}/enable`,
|
||||
remove: (userId: string) => `/users/admin/${userId}`,
|
||||
setRole: (userId: string) => `/users/admin/${userId}/role`,
|
||||
admins: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/users/admin/admins?${toQueryString(params)}`,
|
||||
updateAdmin: (userId: string) => `/users/admin/admins/${userId}`,
|
||||
removeAdmin: (userId: string) => `/users/admin/admins/${userId}`,
|
||||
createAdmin: "/users/admin/create-admin",
|
||||
discover: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/users/admin/discover?${toQueryString(params)}`,
|
||||
profileOverview: (userId: string) => `/users/admin/${userId}/profile-overview`,
|
||||
},
|
||||
posts: {
|
||||
moderation: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/posts/admin/moderation?${toQueryString(params)}`,
|
||||
adminDelete: (postId: string) => `/posts/admin/${postId}`,
|
||||
},
|
||||
comments: {
|
||||
moderation: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/comments/admin?${toQueryString(params)}`,
|
||||
adminDelete: (commentId: string) => `/comments/admin/${commentId}`,
|
||||
},
|
||||
notifications: {
|
||||
superAdmin: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/notifications/superadmin?${toQueryString(params)}`,
|
||||
},
|
||||
reports: {
|
||||
superAdmin: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/reports/superadmin?${toQueryString(params)}`,
|
||||
updateStatus: (reportId: string) => `/reports/superadmin/${reportId}/status`,
|
||||
},
|
||||
marketplace: {
|
||||
home: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/marketplace/home?${toQueryString(params)}`,
|
||||
shopByAdminId: (adminId: string) => `/marketplace/shops/${adminId}`,
|
||||
adminShopProfile: "/marketplace/admin/shop-profile",
|
||||
adminMyShopProfile: "/marketplace/admin/shop-profile/me",
|
||||
adminCreateRepairShop: "/marketplace/admin/repair-shops",
|
||||
adminUpdateRepairShop: (repairShopId: string) => `/marketplace/admin/repair-shops/${repairShopId}`,
|
||||
adminDeleteRepairShop: (repairShopId: string) => `/marketplace/admin/repair-shops/${repairShopId}`,
|
||||
adminMyRepairShops: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/marketplace/admin/repair-shops/me?${toQueryString(params)}`,
|
||||
adminCreateInstrument: "/marketplace/admin/instruments",
|
||||
adminUpdateInstrument: (instrumentId: string) => `/marketplace/admin/instruments/${instrumentId}`,
|
||||
adminDeleteInstrument: (instrumentId: string) => `/marketplace/admin/instruments/${instrumentId}`,
|
||||
adminMyInstruments: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/marketplace/admin/instruments/me?${toQueryString(params)}`,
|
||||
adminCreateListing: "/marketplace/admin/listings",
|
||||
adminUpdateListing: (listingId: string) => `/marketplace/admin/listings/${listingId}`,
|
||||
adminDeleteListing: (listingId: string) => `/marketplace/admin/listings/${listingId}`,
|
||||
adminMyListings: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/marketplace/admin/listings/me?${toQueryString(params)}`,
|
||||
moderationListings: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/marketplace/superadmin/listings?${toQueryString(params)}`,
|
||||
superAdminCreateListing: (adminId: string) => `/marketplace/superadmin/admins/${adminId}/listings`,
|
||||
superAdminCreateInstrument: (adminId: string) => `/marketplace/superadmin/admins/${adminId}/instruments`,
|
||||
superAdminCreateRepairShop: (adminId: string) => `/marketplace/superadmin/admins/${adminId}/repair-shops`,
|
||||
superAdminUpdateShopProfile: (adminId: string) => `/marketplace/superadmin/admins/${adminId}/shop-profile`,
|
||||
moderationUpdateListingStatus: (listingId: string) => `/marketplace/superadmin/listings/${listingId}/status`,
|
||||
moderationDeleteListing: (listingId: string) => `/marketplace/superadmin/listings/${listingId}`,
|
||||
moderationRepairShops: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/marketplace/superadmin/repair-shops?${toQueryString(params)}`,
|
||||
moderationUpdateRepairShopStatus: (repairShopId: string) =>
|
||||
`/marketplace/superadmin/repair-shops/${repairShopId}/status`,
|
||||
moderationDeleteRepairShop: (repairShopId: string) => `/marketplace/superadmin/repair-shops/${repairShopId}`,
|
||||
},
|
||||
audit: {
|
||||
logs: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/audit/superadmin/logs?${toQueryString(params)}`,
|
||||
},
|
||||
superadmin: {
|
||||
session: "/superadmin/session",
|
||||
overview: "/superadmin/overview",
|
||||
charts: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/superadmin/charts?${toQueryString(params)}`,
|
||||
recentActivity: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/superadmin/recent-activity?${toQueryString(params)}`,
|
||||
reports: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/superadmin/reports?${toQueryString(params)}`,
|
||||
ops: "/superadmin/ops",
|
||||
cases: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/superadmin/cases?${toQueryString(params)}`,
|
||||
createCase: "/superadmin/cases",
|
||||
caseById: (caseId: string) => `/superadmin/cases/${caseId}`,
|
||||
bulkActions: "/superadmin/bulk-actions",
|
||||
settings: "/superadmin/settings",
|
||||
settingsHistory: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/superadmin/settings/history?${toQueryString(params)}`,
|
||||
restoreSettingsHistory: (historyId: string) => `/superadmin/settings/history/${historyId}/restore`,
|
||||
updatePostStatus: (postId: string) => `/superadmin/posts/${postId}/status`,
|
||||
deletePost: (postId: string) => `/superadmin/posts/${postId}`,
|
||||
restorePost: (postId: string) => `/superadmin/posts/${postId}/restore`,
|
||||
updateCommentStatus: (commentId: string) => `/superadmin/comments/${commentId}/status`,
|
||||
deleteComment: (commentId: string) => `/superadmin/comments/${commentId}`,
|
||||
restoreComment: (commentId: string) => `/superadmin/comments/${commentId}/restore`,
|
||||
updateUserStatus: (userId: string) => `/superadmin/users/${userId}/status`,
|
||||
},
|
||||
} as const;
|
||||
6
oudelaa_dashboard/lib/api/health.ts
Normal file
6
oudelaa_dashboard/lib/api/health.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
|
||||
export async function getHealth() {
|
||||
return fetchWithAuth<string | Record<string, unknown>>(apiEndpoints.health);
|
||||
}
|
||||
236
oudelaa_dashboard/lib/api/marketplace.ts
Normal file
236
oudelaa_dashboard/lib/api/marketplace.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type {
|
||||
MarketplaceHomeResponse,
|
||||
MarketplaceListing,
|
||||
MarketplaceRepairShop,
|
||||
MarketplaceRepairShopResponse,
|
||||
MarketplaceResponse,
|
||||
MarketplaceShopProfile,
|
||||
SuccessMessage,
|
||||
} from "@/types/api";
|
||||
|
||||
type MarketplaceFormValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| File
|
||||
| null
|
||||
| undefined
|
||||
| Array<string | number | boolean | File>;
|
||||
|
||||
type MarketplaceFormPayload = Record<string, MarketplaceFormValue>;
|
||||
|
||||
function appendFormValue(formData: FormData, key: string, value: MarketplaceFormValue) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => appendFormValue(formData, key, entry));
|
||||
return;
|
||||
}
|
||||
|
||||
if (value instanceof File) {
|
||||
formData.append(key, value);
|
||||
return;
|
||||
}
|
||||
|
||||
formData.append(key, String(value));
|
||||
}
|
||||
|
||||
function toMarketplaceFormData(payload: MarketplaceFormPayload) {
|
||||
const formData = new FormData();
|
||||
Object.entries(payload).forEach(([key, value]) => appendFormValue(formData, key, value));
|
||||
return formData;
|
||||
}
|
||||
|
||||
export async function getMarketplaceHome(params: Record<string, string | number | boolean | null | undefined> = {}) {
|
||||
return fetchWithAuth<MarketplaceHomeResponse>(apiEndpoints.marketplace.home(params));
|
||||
}
|
||||
|
||||
export async function getMarketplaceShopByAdminId(adminId: string) {
|
||||
return fetchWithAuth<MarketplaceShopProfile>(apiEndpoints.marketplace.shopByAdminId(adminId));
|
||||
}
|
||||
|
||||
export async function getAdminShopProfile() {
|
||||
return fetchWithAuth<MarketplaceShopProfile>(apiEndpoints.marketplace.adminMyShopProfile);
|
||||
}
|
||||
|
||||
export async function updateAdminShopProfile(payload: MarketplaceFormPayload) {
|
||||
return fetchWithAuth<MarketplaceShopProfile>(apiEndpoints.marketplace.adminShopProfile, {
|
||||
method: "PATCH",
|
||||
body: toMarketplaceFormData(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAdminRepairShop(payload: MarketplaceFormPayload) {
|
||||
return fetchWithAuth<MarketplaceRepairShop>(apiEndpoints.marketplace.adminCreateRepairShop, {
|
||||
method: "POST",
|
||||
body: toMarketplaceFormData(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAdminRepairShop(repairShopId: string, payload: MarketplaceFormPayload) {
|
||||
return fetchWithAuth<MarketplaceRepairShop>(apiEndpoints.marketplace.adminUpdateRepairShop(repairShopId), {
|
||||
method: "PATCH",
|
||||
body: toMarketplaceFormData(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminRepairShop(repairShopId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.marketplace.adminDeleteRepairShop(repairShopId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminRepairShops(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceRepairShopResponse>(apiEndpoints.marketplace.adminMyRepairShops(params));
|
||||
}
|
||||
|
||||
export async function createAdminInstrument(payload: MarketplaceFormPayload) {
|
||||
return fetchWithAuth<MarketplaceListing>(apiEndpoints.marketplace.adminCreateInstrument, {
|
||||
method: "POST",
|
||||
body: toMarketplaceFormData(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAdminInstrument(instrumentId: string, payload: MarketplaceFormPayload) {
|
||||
return fetchWithAuth<MarketplaceListing>(apiEndpoints.marketplace.adminUpdateInstrument(instrumentId), {
|
||||
method: "PATCH",
|
||||
body: toMarketplaceFormData(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminInstrument(instrumentId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.marketplace.adminDeleteInstrument(instrumentId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminInstruments(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceResponse>(apiEndpoints.marketplace.adminMyInstruments(params));
|
||||
}
|
||||
|
||||
export async function createAdminListing(payload: MarketplaceFormPayload) {
|
||||
return fetchWithAuth<MarketplaceListing>(apiEndpoints.marketplace.adminCreateListing, {
|
||||
method: "POST",
|
||||
body: toMarketplaceFormData(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAdminListing(listingId: string, payload: MarketplaceFormPayload) {
|
||||
return fetchWithAuth<MarketplaceListing>(apiEndpoints.marketplace.adminUpdateListing(listingId), {
|
||||
method: "PATCH",
|
||||
body: toMarketplaceFormData(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAdminListing(listingId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.marketplace.adminDeleteListing(listingId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminListings(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceResponse>(apiEndpoints.marketplace.adminMyListings(params));
|
||||
}
|
||||
|
||||
export async function listModerationListings(params: Record<string, string | number | boolean | null | undefined> = {}) {
|
||||
return fetchWithAuth<MarketplaceResponse>(apiEndpoints.marketplace.moderationListings(params));
|
||||
}
|
||||
|
||||
export async function updateModerationListingStatus(
|
||||
listingId: string,
|
||||
isActive: boolean,
|
||||
reason?: string,
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceListing>(apiEndpoints.marketplace.moderationUpdateListingStatus(listingId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ isActive, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteModerationListing(listingId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.marketplace.moderationDeleteListing(listingId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listModerationRepairShops(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceRepairShopResponse>(apiEndpoints.marketplace.moderationRepairShops(params));
|
||||
}
|
||||
|
||||
export async function updateModerationRepairShopStatus(
|
||||
repairShopId: string,
|
||||
isActive: boolean,
|
||||
reason?: string,
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceRepairShop>(
|
||||
apiEndpoints.marketplace.moderationUpdateRepairShopStatus(repairShopId),
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ isActive, reason }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteModerationRepairShop(repairShopId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.marketplace.moderationDeleteRepairShop(repairShopId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMarketplaceListingForSuperAdmin(
|
||||
adminId: string,
|
||||
payload: MarketplaceFormPayload,
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceListing>(apiEndpoints.marketplace.superAdminCreateListing(adminId), {
|
||||
method: "POST",
|
||||
body: toMarketplaceFormData(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMarketplaceInstrumentForSuperAdmin(
|
||||
adminId: string,
|
||||
payload: MarketplaceFormPayload,
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceListing>(apiEndpoints.marketplace.superAdminCreateInstrument(adminId), {
|
||||
method: "POST",
|
||||
body: toMarketplaceFormData(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMarketplaceRepairShopForSuperAdmin(
|
||||
adminId: string,
|
||||
payload: MarketplaceFormPayload,
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceRepairShop>(
|
||||
apiEndpoints.marketplace.superAdminCreateRepairShop(adminId),
|
||||
{
|
||||
method: "POST",
|
||||
body: toMarketplaceFormData(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateMarketplaceShopProfileForSuperAdmin(
|
||||
adminId: string,
|
||||
payload: MarketplaceFormPayload,
|
||||
) {
|
||||
return fetchWithAuth<MarketplaceShopProfile>(
|
||||
apiEndpoints.marketplace.superAdminUpdateShopProfile(adminId),
|
||||
{
|
||||
method: "PATCH",
|
||||
body: toMarketplaceFormData(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
9
oudelaa_dashboard/lib/api/notifications.ts
Normal file
9
oudelaa_dashboard/lib/api/notifications.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type { NotificationsResponse } from "@/types/api";
|
||||
|
||||
export async function listPlatformNotifications(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<NotificationsResponse>(apiEndpoints.notifications.superAdmin(params));
|
||||
}
|
||||
13
oudelaa_dashboard/lib/api/posts.ts
Normal file
13
oudelaa_dashboard/lib/api/posts.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type { PostsResponse, SuccessMessage } from "@/types/api";
|
||||
|
||||
export async function listModerationPosts(params: Record<string, string | number | boolean | null | undefined> = {}) {
|
||||
return fetchWithAuth<PostsResponse>(apiEndpoints.posts.moderation(params));
|
||||
}
|
||||
|
||||
export async function deleteAdminPost(postId: string) {
|
||||
return fetchWithAuth<SuccessMessage>(apiEndpoints.posts.adminDelete(postId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
20
oudelaa_dashboard/lib/api/reports.ts
Normal file
20
oudelaa_dashboard/lib/api/reports.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type { PlatformReport, ReportsResponse, ReportStatus } from "@/types/api";
|
||||
|
||||
export async function listPlatformReports(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<ReportsResponse>(apiEndpoints.reports.superAdmin(params));
|
||||
}
|
||||
|
||||
export async function updatePlatformReportStatus(
|
||||
reportId: string,
|
||||
status: ReportStatus,
|
||||
resolutionNote?: string,
|
||||
) {
|
||||
return fetchWithAuth<PlatformReport>(apiEndpoints.reports.updateStatus(reportId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, resolutionNote }),
|
||||
});
|
||||
}
|
||||
182
oudelaa_dashboard/lib/api/superadmin.ts
Normal file
182
oudelaa_dashboard/lib/api/superadmin.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type {
|
||||
ApiComment,
|
||||
ApiPost,
|
||||
ApiUser,
|
||||
CreateSuperAdminCasePayload,
|
||||
ModerationStatus,
|
||||
SuperAdminCase,
|
||||
SuperAdminCasesResponse,
|
||||
SuperAdminChartsResponse,
|
||||
SuperAdminOpsResponse,
|
||||
SuperAdminOverviewResponse,
|
||||
SuperAdminRecentActivityResponse,
|
||||
SuperAdminReportsResponse,
|
||||
SuperAdminSessionResponse,
|
||||
SuperAdminSettings,
|
||||
SuperAdminSettingsHistoryResponse,
|
||||
SuperAdminSettingsResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
export async function getSuperAdminSession() {
|
||||
return fetchWithAuth<SuperAdminSessionResponse>(apiEndpoints.superadmin.session);
|
||||
}
|
||||
|
||||
export async function getSuperAdminOverview() {
|
||||
return fetchWithAuth<SuperAdminOverviewResponse>(apiEndpoints.superadmin.overview);
|
||||
}
|
||||
|
||||
export async function getSuperAdminCharts(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<SuperAdminChartsResponse>(apiEndpoints.superadmin.charts(params));
|
||||
}
|
||||
|
||||
export async function getSuperAdminRecentActivity(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<SuperAdminRecentActivityResponse>(apiEndpoints.superadmin.recentActivity(params));
|
||||
}
|
||||
|
||||
export async function getSuperAdminReports(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<SuperAdminReportsResponse>(apiEndpoints.superadmin.reports(params));
|
||||
}
|
||||
|
||||
export async function getSuperAdminOps() {
|
||||
return fetchWithAuth<SuperAdminOpsResponse>(apiEndpoints.superadmin.ops);
|
||||
}
|
||||
|
||||
export async function getSuperAdminCases(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<SuperAdminCasesResponse>(apiEndpoints.superadmin.cases(params));
|
||||
}
|
||||
|
||||
export async function createSuperAdminCase(payload: CreateSuperAdminCasePayload) {
|
||||
return fetchWithAuth<SuperAdminCase>(apiEndpoints.superadmin.createCase, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSuperAdminCase(
|
||||
caseId: string,
|
||||
payload: Partial<SuperAdminCase> & { note?: string },
|
||||
) {
|
||||
return fetchWithAuth<SuperAdminCase>(apiEndpoints.superadmin.caseById(caseId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function performSuperAdminBulkAction(payload: {
|
||||
resourceType: string;
|
||||
targetIds: string[];
|
||||
action: string;
|
||||
reason?: string;
|
||||
priority?: string;
|
||||
assignToMe?: boolean;
|
||||
}) {
|
||||
return fetchWithAuth<Record<string, unknown>>(apiEndpoints.superadmin.bulkActions, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSuperAdminSettings() {
|
||||
return fetchWithAuth<SuperAdminSettingsResponse>(apiEndpoints.superadmin.settings);
|
||||
}
|
||||
|
||||
export async function getSuperAdminSettingsHistory(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<SuperAdminSettingsHistoryResponse>(
|
||||
apiEndpoints.superadmin.settingsHistory(params),
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSuperAdminSettings(payload: Partial<SuperAdminSettings>) {
|
||||
return fetchWithAuth<SuperAdminSettingsResponse>(apiEndpoints.superadmin.settings, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreSuperAdminSettingsHistory(historyId: string) {
|
||||
return fetchWithAuth<SuperAdminSettingsResponse>(
|
||||
apiEndpoints.superadmin.restoreSettingsHistory(historyId),
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSuperAdminPostStatus(postId: string, status: ModerationStatus, reason?: string) {
|
||||
return fetchWithAuth<ApiPost>(apiEndpoints.superadmin.updatePostStatus(postId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSuperAdminPost(postId: string) {
|
||||
return fetchWithAuth<Record<string, unknown>>(apiEndpoints.superadmin.deletePost(postId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreSuperAdminPost(postId: string) {
|
||||
return fetchWithAuth<ApiPost>(apiEndpoints.superadmin.restorePost(postId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSuperAdminCommentStatus(
|
||||
commentId: string,
|
||||
status: ModerationStatus,
|
||||
reason?: string,
|
||||
) {
|
||||
return fetchWithAuth<ApiComment>(apiEndpoints.superadmin.updateCommentStatus(commentId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSuperAdminComment(commentId: string) {
|
||||
return fetchWithAuth<Record<string, unknown>>(apiEndpoints.superadmin.deleteComment(commentId), {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreSuperAdminComment(commentId: string) {
|
||||
return fetchWithAuth<ApiComment>(apiEndpoints.superadmin.restoreComment(commentId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSuperAdminUserStatus(userId: string, isDisabled: boolean, reason?: string) {
|
||||
try {
|
||||
return await fetchWithAuth<ApiUser>(apiEndpoints.superadmin.updateUserStatus(userId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ isDisabled, reason }),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = String(error);
|
||||
if (!message.includes("404")) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (isDisabled) {
|
||||
return fetchWithAuth<ApiUser>(apiEndpoints.users.disable(userId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ reason: reason ?? "Disabled by SuperAdmin dashboard" }),
|
||||
});
|
||||
}
|
||||
|
||||
return fetchWithAuth<ApiUser>(apiEndpoints.users.enable(userId), {
|
||||
method: "PATCH",
|
||||
});
|
||||
}
|
||||
}
|
||||
108
oudelaa_dashboard/lib/auth/client.ts
Normal file
108
oudelaa_dashboard/lib/auth/client.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import type { LoginResponse } from "@/types/api";
|
||||
|
||||
const API_PREFIX = "/api/proxy";
|
||||
|
||||
function isHardAuthFailure(error: unknown) {
|
||||
const message = String(error);
|
||||
return message.includes("400") || message.includes("401");
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = payload as { message?: unknown; error?: unknown };
|
||||
if (Array.isArray(candidate.message)) {
|
||||
return candidate.message.map((item) => String(item)).join(", ");
|
||||
}
|
||||
if (typeof candidate.message === "string" && candidate.message.trim()) {
|
||||
return candidate.message.trim();
|
||||
}
|
||||
if (typeof candidate.error === "string" && candidate.error.trim()) {
|
||||
return candidate.error.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const isFormDataRequest =
|
||||
typeof FormData !== "undefined" && init?.body instanceof FormData;
|
||||
|
||||
const res = await fetch(`${API_PREFIX}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
...(isFormDataRequest ? {} : { "Content-Type": "application/json" }),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
let detail = "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const payload = (await res.json()) as unknown;
|
||||
const message = extractErrorMessage(payload);
|
||||
detail = message ? ` - ${message}` : ` - ${JSON.stringify(payload)}`;
|
||||
} else {
|
||||
const text = await res.text();
|
||||
detail = text ? ` - ${text}` : "";
|
||||
}
|
||||
|
||||
throw new Error(`Request failed: ${res.status}${detail}`);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function loginSuperAdmin(email: string, password: string): Promise<void> {
|
||||
await request<LoginResponse>(apiEndpoints.auth.superAdminLogin, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshSuperAdmin(): Promise<boolean> {
|
||||
try {
|
||||
await request<LoginResponse>(apiEndpoints.auth.superAdminRefresh, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isHardAuthFailure(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutSuperAdmin(): Promise<void> {
|
||||
await request<void>(apiEndpoints.auth.superAdminLogout, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchWithAuth<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const makeRequest = async () => request<T>(path, init);
|
||||
|
||||
try {
|
||||
return await makeRequest();
|
||||
} catch (error) {
|
||||
if (String(error).includes("401")) {
|
||||
const refreshed = await refreshSuperAdmin();
|
||||
if (refreshed) {
|
||||
return makeRequest();
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
23
oudelaa_dashboard/lib/auth/jwt.ts
Normal file
23
oudelaa_dashboard/lib/auth/jwt.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function parseJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
try {
|
||||
const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const decoded =
|
||||
typeof window !== "undefined"
|
||||
? window.atob(normalized)
|
||||
: Buffer.from(normalized, "base64").toString("utf8");
|
||||
return JSON.parse(decoded) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isJwtExpired(token: string, skewSeconds = 15): boolean {
|
||||
const payload = parseJwtPayload(token);
|
||||
const exp = typeof payload?.exp === "number" ? payload.exp : null;
|
||||
if (!exp) return false;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return exp <= now + skewSeconds;
|
||||
}
|
||||
14
oudelaa_dashboard/lib/auth/storage.ts
Normal file
14
oudelaa_dashboard/lib/auth/storage.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { AuthTokens } from "@/types/api";
|
||||
|
||||
export function loadTokens(): AuthTokens | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function saveTokens(tokens: AuthTokens) {
|
||||
void tokens;
|
||||
return;
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
return;
|
||||
}
|
||||
28
oudelaa_dashboard/lib/format.ts
Normal file
28
oudelaa_dashboard/lib/format.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) return "-";
|
||||
try {
|
||||
return new Intl.DateTimeFormat("ar-SA", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCurrency(value?: number | null, currency = "SAR") {
|
||||
if (typeof value !== "number") return "-";
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `${value} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCount(value?: number | null) {
|
||||
return typeof value === "number" ? new Intl.NumberFormat("en-US").format(value) : "0";
|
||||
}
|
||||
37
oudelaa_dashboard/lib/media-url.ts
Normal file
37
oudelaa_dashboard/lib/media-url.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? process.env.API_BASE_URL ?? "";
|
||||
|
||||
function getApiOrigin() {
|
||||
try {
|
||||
return new URL(API_BASE_URL).origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMediaUrl(value?: string | null) {
|
||||
const url = value?.trim() ?? "";
|
||||
|
||||
if (!url) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (
|
||||
/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ||
|
||||
url.startsWith("//") ||
|
||||
url.startsWith("data:") ||
|
||||
url.startsWith("blob:")
|
||||
) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const origin = getApiOrigin();
|
||||
if (!origin) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (url.startsWith("/")) {
|
||||
return `${origin}${url}`;
|
||||
}
|
||||
|
||||
return `${origin}/${url.replace(/^\/+/, "")}`;
|
||||
}
|
||||
34
oudelaa_dashboard/lib/mock/analytics.ts
Normal file
34
oudelaa_dashboard/lib/mock/analytics.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Insight, SettingsNotifications, SettingsProfile } from "@/types";
|
||||
|
||||
export const retentionCurve: Insight[] = [
|
||||
{ label: "الأسبوع 1", value: 100 },
|
||||
{ label: "الأسبوع 2", value: 84 },
|
||||
{ label: "الأسبوع 3", value: 73 },
|
||||
{ label: "الأسبوع 4", value: 64 },
|
||||
{ label: "الأسبوع 5", value: 58 },
|
||||
{ label: "الأسبوع 6", value: 53 },
|
||||
];
|
||||
|
||||
export const cityPerformance: Insight[] = [
|
||||
{ label: "الرياض", value: 34 },
|
||||
{ label: "جدة", value: 22 },
|
||||
{ label: "الدمام", value: 15 },
|
||||
{ label: "المدينة", value: 11 },
|
||||
{ label: "مكة", value: 9 },
|
||||
{ label: "أخرى", value: 9 },
|
||||
];
|
||||
|
||||
export const profileDefaults: SettingsProfile = {
|
||||
businessName: "Oudelaa Music Experience",
|
||||
supportEmail: "support@oudelaa.com",
|
||||
supportPhone: "+966 50 111 2233",
|
||||
city: "Riyadh",
|
||||
currency: "SAR",
|
||||
};
|
||||
|
||||
export const notificationDefaults: SettingsNotifications = {
|
||||
orders: true,
|
||||
newUsers: true,
|
||||
payouts: false,
|
||||
weeklyDigest: true,
|
||||
};
|
||||
9
oudelaa_dashboard/lib/mock/content.ts
Normal file
9
oudelaa_dashboard/lib/mock/content.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ContentItem } from "@/types";
|
||||
|
||||
export const contentLibrary: ContentItem[] = [
|
||||
{ id: "CNT-201", title: "موال السهرة", artist: "فرقة وتر الحجاز", genre: "طرب", duration: "06:12", status: "published", createdAt: "2026-03-10", listens: 12890 },
|
||||
{ id: "CNT-202", title: "ليالي العود", artist: "نواف الدوسري", genre: "Instrumental", duration: "04:48", status: "review", createdAt: "2026-03-26", listens: 7420 },
|
||||
{ id: "CNT-203", title: "نبض المرسى", artist: "سدن", genre: "Fusion", duration: "03:55", status: "draft", createdAt: "2026-04-01", listens: 0 },
|
||||
{ id: "CNT-204", title: "رحلة مقام", artist: "أمجاد", genre: "Classical", duration: "05:24", status: "published", createdAt: "2026-02-15", listens: 15430 },
|
||||
{ id: "CNT-205", title: "نخل الجنوب", artist: "علي الشهري", genre: "Folk", duration: "04:02", status: "published", createdAt: "2026-03-29", listens: 5120 },
|
||||
];
|
||||
36
oudelaa_dashboard/lib/mock/dashboard.ts
Normal file
36
oudelaa_dashboard/lib/mock/dashboard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ChannelPoint, RevenuePoint, StatMetric } from "@/types";
|
||||
|
||||
export const dashboardStats: StatMetric[] = [
|
||||
{ id: "gmv", label: "إجمالي الإيراد", value: "1,284,920 ر.س", delta: "+18.6%", trend: "up", note: "مقارنة بـ مارس" },
|
||||
{ id: "orders", label: "الطلبات المكتملة", value: "4,912", delta: "+11.2%", trend: "up", note: "آخر 30 يوم" },
|
||||
{ id: "aov", label: "متوسط قيمة الطلب", value: "261 ر.س", delta: "-2.4%", trend: "down", note: "يحتاج تحسين سلة الشراء" },
|
||||
{ id: "retention", label: "الاحتفاظ بالعملاء", value: "82.1%", delta: "+3.1%", trend: "up", note: "العملاء العائدون" },
|
||||
];
|
||||
|
||||
export const revenueSeries: RevenuePoint[] = [
|
||||
{ month: "يناير", revenue: 780000, orders: 2900 },
|
||||
{ month: "فبراير", revenue: 842000, orders: 3180 },
|
||||
{ month: "مارس", revenue: 906000, orders: 3375 },
|
||||
{ month: "أبريل", revenue: 1002000, orders: 3710 },
|
||||
{ month: "مايو", revenue: 1093000, orders: 3980 },
|
||||
{ month: "يونيو", revenue: 1160000, orders: 4250 },
|
||||
{ month: "يوليو", revenue: 1284920, orders: 4912 },
|
||||
];
|
||||
|
||||
export const channelShare: ChannelPoint[] = [
|
||||
{ name: "التطبيق", value: 46 },
|
||||
{ name: "الموقع", value: 33 },
|
||||
{ name: "واتساب", value: 14 },
|
||||
{ name: "شركاء", value: 7 },
|
||||
];
|
||||
|
||||
export const heatmapHours = [
|
||||
{ slot: "09:00", score: 20 },
|
||||
{ slot: "11:00", score: 42 },
|
||||
{ slot: "13:00", score: 54 },
|
||||
{ slot: "15:00", score: 79 },
|
||||
{ slot: "17:00", score: 92 },
|
||||
{ slot: "19:00", score: 68 },
|
||||
{ slot: "21:00", score: 49 },
|
||||
{ slot: "23:00", score: 30 },
|
||||
];
|
||||
6
oudelaa_dashboard/lib/mock/index.ts
Normal file
6
oudelaa_dashboard/lib/mock/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./dashboard";
|
||||
export * from "./users";
|
||||
export * from "./content";
|
||||
export * from "./orders";
|
||||
export * from "./messages";
|
||||
export * from "./analytics";
|
||||
8
oudelaa_dashboard/lib/mock/messages.ts
Normal file
8
oudelaa_dashboard/lib/mock/messages.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { MessageThread } from "@/types";
|
||||
|
||||
export const messageThreads: MessageThread[] = [
|
||||
{ id: "MSG-881", customer: "رؤى السالم", subject: "تحديث الاشتراك", state: "unread", lastMessageAt: "2026-04-06 11:03", channel: "WhatsApp", preview: "أحتاج ترقية من Silver إلى Gold مع حفظ المكتبة الحالية.", priority: "high" },
|
||||
{ id: "MSG-882", customer: "حسين العمري", subject: "مشكلة في الدفع", state: "open", lastMessageAt: "2026-04-06 09:54", channel: "Email", preview: "تم خصم المبلغ ولم يظهر الطلب في لوحة الطلبات.", priority: "high" },
|
||||
{ id: "MSG-883", customer: "سارة البقمي", subject: "طلب تعاون فني", state: "open", lastMessageAt: "2026-04-05 23:15", channel: "In-App", preview: "هل يمكن إدراج أعمالي ضمن قسم المواهب الجديدة؟", priority: "normal" },
|
||||
{ id: "MSG-884", customer: "عبدالاله الدوسري", subject: "استفسار باقة Maestro", state: "closed", lastMessageAt: "2026-04-04 20:01", channel: "Email", preview: "تم الرد وإغلاق التذكرة مع مشاركة العرض السعري.", priority: "low" },
|
||||
];
|
||||
10
oudelaa_dashboard/lib/mock/orders.ts
Normal file
10
oudelaa_dashboard/lib/mock/orders.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Order } from "@/types";
|
||||
|
||||
export const orders: Order[] = [
|
||||
{ id: "ORD-7781", customer: "مشاعل الكندري", product: "باقة Oudelaa Gold", amount: 420, paymentMethod: "Card", status: "paid", createdAt: "2026-04-06 10:42", city: "الرياض" },
|
||||
{ id: "ORD-7780", customer: "فهد العلي", product: "جلسة Maestro Live", amount: 960, paymentMethod: "Apple Pay", status: "processing", createdAt: "2026-04-06 09:27", city: "جدة" },
|
||||
{ id: "ORD-7779", customer: "أروى الحازمي", product: "باقة Silver", amount: 190, paymentMethod: "Wallet", status: "paid", createdAt: "2026-04-06 08:15", city: "الخبر" },
|
||||
{ id: "ORD-7778", customer: "محمد اليامي", product: "إهداء مقطوعة", amount: 300, paymentMethod: "Card", status: "refunded", createdAt: "2026-04-05 22:50", city: "أبها" },
|
||||
{ id: "ORD-7777", customer: "غلا السبيعي", product: "اشتراك سنوي", amount: 1880, paymentMethod: "Card", status: "failed", createdAt: "2026-04-05 20:33", city: "الدمام" },
|
||||
{ id: "ORD-7776", customer: "بدر الجهني", product: "باقة Gold", amount: 520, paymentMethod: "Apple Pay", status: "processing", createdAt: "2026-04-05 18:02", city: "الرياض" },
|
||||
];
|
||||
10
oudelaa_dashboard/lib/mock/users.ts
Normal file
10
oudelaa_dashboard/lib/mock/users.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { User } from "@/types";
|
||||
|
||||
export const users: User[] = [
|
||||
{ id: "USR-1048", fullName: "ريم الحربي", email: "reem@oudelaa.sa", plan: "Maestro", status: "active", joinedAt: "2026-02-13", totalSpent: 12680, city: "الرياض" },
|
||||
{ id: "USR-1049", fullName: "سلمان العتيبي", email: "salman@oudelaa.sa", plan: "Gold", status: "active", joinedAt: "2026-01-29", totalSpent: 7480, city: "جدة" },
|
||||
{ id: "USR-1050", fullName: "نور البلوشي", email: "nour@oudelaa.sa", plan: "Silver", status: "pending", joinedAt: "2026-03-03", totalSpent: 980, city: "الدمام" },
|
||||
{ id: "USR-1051", fullName: "عبدالرحمن الزهراني", email: "rahman@oudelaa.sa", plan: "Gold", status: "suspended", joinedAt: "2025-11-17", totalSpent: 5320, city: "الخبر" },
|
||||
{ id: "USR-1052", fullName: "لولوة المطيري", email: "lolwah@oudelaa.sa", plan: "Maestro", status: "active", joinedAt: "2026-03-19", totalSpent: 15400, city: "المدينة" },
|
||||
{ id: "USR-1053", fullName: "زياد القحطاني", email: "ziad@oudelaa.sa", plan: "Silver", status: "active", joinedAt: "2026-02-22", totalSpent: 2280, city: "الطائف" },
|
||||
];
|
||||
107
oudelaa_dashboard/lib/navigation.ts
Normal file
107
oudelaa_dashboard/lib/navigation.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Bell,
|
||||
ChartColumn,
|
||||
Flag,
|
||||
LayoutDashboard,
|
||||
MessageSquareMore,
|
||||
PackageSearch,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShoppingBag,
|
||||
SquareKanban,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { PermissionMatchMode } from "@/lib/permissions";
|
||||
import { SUPERADMIN_PERMISSIONS } from "@/lib/permissions";
|
||||
|
||||
export type DashboardNavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
permissionMode?: PermissionMatchMode;
|
||||
requiredPermissions: readonly string[];
|
||||
};
|
||||
|
||||
export const dashboardNav = [
|
||||
{
|
||||
href: "/dashboard",
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.OVERVIEW_READ],
|
||||
},
|
||||
{
|
||||
href: "/users",
|
||||
label: "Users",
|
||||
icon: Users,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.USERS_READ],
|
||||
},
|
||||
{
|
||||
href: "/analytics",
|
||||
label: "Analytics",
|
||||
icon: ChartColumn,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.ANALYTICS_READ],
|
||||
},
|
||||
{
|
||||
href: "/content",
|
||||
label: "Content",
|
||||
icon: SquareKanban,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.CONTENT_MODERATE],
|
||||
},
|
||||
{
|
||||
href: "/reports",
|
||||
label: "Reports",
|
||||
icon: Flag,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.CONTENT_MODERATE],
|
||||
},
|
||||
{
|
||||
href: "/marketplace",
|
||||
label: "Marketplace",
|
||||
icon: ShoppingBag,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.MARKETPLACE_MANAGE],
|
||||
},
|
||||
{
|
||||
href: "/notifications",
|
||||
label: "Notifications",
|
||||
icon: Bell,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ],
|
||||
},
|
||||
{
|
||||
href: "/messages",
|
||||
label: "Engagement",
|
||||
icon: MessageSquareMore,
|
||||
permissionMode: "any",
|
||||
requiredPermissions: [
|
||||
SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ,
|
||||
SUPERADMIN_PERMISSIONS.CONTENT_MODERATE,
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/orders",
|
||||
label: "Operations",
|
||||
icon: PackageSearch,
|
||||
permissionMode: "any",
|
||||
requiredPermissions: [
|
||||
SUPERADMIN_PERMISSIONS.CASES_MANAGE,
|
||||
SUPERADMIN_PERMISSIONS.OPS_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/security",
|
||||
label: "Security",
|
||||
icon: ShieldCheck,
|
||||
permissionMode: "any",
|
||||
requiredPermissions: [
|
||||
SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE,
|
||||
SUPERADMIN_PERMISSIONS.AUDIT_READ,
|
||||
SUPERADMIN_PERMISSIONS.OPS_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/settings",
|
||||
label: "Settings",
|
||||
icon: Settings,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.SETTINGS_READ],
|
||||
},
|
||||
] satisfies readonly DashboardNavItem[];
|
||||
37
oudelaa_dashboard/lib/permissions.ts
Normal file
37
oudelaa_dashboard/lib/permissions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export const SUPERADMIN_PERMISSIONS = {
|
||||
OVERVIEW_READ: "overview.read",
|
||||
ANALYTICS_READ: "analytics.read",
|
||||
USERS_READ: "users.read",
|
||||
USERS_MANAGE: "users.manage",
|
||||
CONTENT_MODERATE: "content.moderate",
|
||||
MARKETPLACE_MANAGE: "marketplace.manage",
|
||||
NOTIFICATIONS_READ: "notifications.read",
|
||||
AUDIT_READ: "audit.read",
|
||||
SETTINGS_READ: "settings.read",
|
||||
SETTINGS_WRITE: "settings.write",
|
||||
SESSIONS_MANAGE: "sessions.manage",
|
||||
OPS_READ: "ops.read",
|
||||
CASES_MANAGE: "cases.manage",
|
||||
} as const;
|
||||
|
||||
export type PermissionMatchMode = "all" | "any";
|
||||
|
||||
export function hasPermission(permissions: string[] | undefined, permission: string) {
|
||||
return (permissions ?? []).includes(permission);
|
||||
}
|
||||
|
||||
export function matchesPermissions(
|
||||
permissions: string[] | undefined,
|
||||
requiredPermissions: readonly string[],
|
||||
mode: PermissionMatchMode = "all",
|
||||
) {
|
||||
if (!requiredPermissions.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (mode === "any") {
|
||||
return requiredPermissions.some((permission) => hasPermission(permissions, permission));
|
||||
}
|
||||
|
||||
return requiredPermissions.every((permission) => hasPermission(permissions, permission));
|
||||
}
|
||||
73
oudelaa_dashboard/lib/post-utils.ts
Normal file
73
oudelaa_dashboard/lib/post-utils.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { resolveMediaUrl } from "@/lib/media-url";
|
||||
import type { ApiComment, ApiPost, ApiUser } from "@/types/api";
|
||||
|
||||
function asUser(value: ApiUser | string | undefined | null) {
|
||||
if (!value || typeof value === "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getPostAuthor(post: ApiPost) {
|
||||
return asUser(post.author ?? post.authorId);
|
||||
}
|
||||
|
||||
export function getCommentAuthor(comment: ApiComment) {
|
||||
return asUser(comment.author ?? comment.authorId);
|
||||
}
|
||||
|
||||
export function getUserLabel(user: ApiUser | null) {
|
||||
if (!user) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return user.name ?? user.stageName ?? user.username ?? user.email ?? "-";
|
||||
}
|
||||
|
||||
export function getPostPreviewMedia(post: ApiPost) {
|
||||
const imageCount = post.imageUrls?.length ?? 0;
|
||||
const imageUrl = resolveMediaUrl(post.imageUrls?.[0]);
|
||||
const thumbnailUrl = resolveMediaUrl(post.thumbnailUrl);
|
||||
const videoUrl = resolveMediaUrl(post.videoUrl);
|
||||
const audioUrl = resolveMediaUrl(post.audioUrl);
|
||||
|
||||
if (imageCount > 0 && imageUrl) {
|
||||
return {
|
||||
kind: "image" as const,
|
||||
url: imageUrl,
|
||||
sourceUrl: "",
|
||||
count: imageCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (thumbnailUrl) {
|
||||
return {
|
||||
kind: post.postType === "audio" ? ("audio" as const) : ("video" as const),
|
||||
url: thumbnailUrl,
|
||||
sourceUrl: post.postType === "video" ? videoUrl : audioUrl,
|
||||
count: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (post.postType === "video" && videoUrl) {
|
||||
return {
|
||||
kind: "video" as const,
|
||||
url: "",
|
||||
sourceUrl: videoUrl,
|
||||
count: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind:
|
||||
post.postType === "audio"
|
||||
? ("audio" as const)
|
||||
: post.postType === "video"
|
||||
? ("video" as const)
|
||||
: ("text" as const),
|
||||
url: "",
|
||||
sourceUrl: post.postType === "video" ? videoUrl : post.postType === "audio" ? audioUrl : "",
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
6
oudelaa_dashboard/lib/utils.ts
Normal file
6
oudelaa_dashboard/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
5
oudelaa_dashboard/next-env.d.ts
مباع
Normal file
5
oudelaa_dashboard/next-env.d.ts
مباع
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
11
oudelaa_dashboard/next.config.mjs
Normal file
11
oudelaa_dashboard/next.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
allowedDevOrigins: [
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:3000",
|
||||
"http://192.168.1.12:3000",
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7351
oudelaa_dashboard/package-lock.json
مولّد
Normal file
7351
oudelaa_dashboard/package-lock.json
مولّد
Normal file
تم حذف اختلاف الملف لأن الملف كبير جداً
تحميل الاختلاف
34
oudelaa_dashboard/package.json
Normal file
34
oudelaa_dashboard/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "oudelaa-admin-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"recharts": "^2.15.1",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
ثنائية
oudelaa_dashboard/photo/logo.png
Normal file
ثنائية
oudelaa_dashboard/photo/logo.png
Normal file
ملف ثنائي غير معروض.
|
بعد العرض: | الارتفاع: | الحجم: 214 KiB |
6
oudelaa_dashboard/postcss.config.mjs
Normal file
6
oudelaa_dashboard/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
81
oudelaa_dashboard/postman/oudelaa-route-map.json
Normal file
81
oudelaa_dashboard/postman/oudelaa-route-map.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"baseUrl": "{{baseUrl}}",
|
||||
"dashboardType": "SuperAdmin",
|
||||
"notes": [
|
||||
"This dashboard must authenticate with /auth/superadmin/* routes only.",
|
||||
"User-only routes are not a valid contract for this dashboard unless they have a dedicated /admin or /superadmin variant.",
|
||||
"The dashboard proxy should target the Nest backend API base URL defined in .env.local."
|
||||
],
|
||||
"modules": {
|
||||
"auth": [
|
||||
"POST /auth/superadmin/login",
|
||||
"POST /auth/superadmin/refresh",
|
||||
"POST /auth/superadmin/logout",
|
||||
"GET /auth/superadmin/sessions",
|
||||
"POST /auth/superadmin/sessions/:sessionId/revoke"
|
||||
],
|
||||
"users": [
|
||||
"GET /users/admin",
|
||||
"GET /users/admin/admins",
|
||||
"GET /users/admin/:userId",
|
||||
"PATCH /users/admin/:userId",
|
||||
"PATCH /users/admin/:userId/disable",
|
||||
"PATCH /users/admin/:userId/enable",
|
||||
"PATCH /users/admin/:userId/role",
|
||||
"DELETE /users/admin/:userId",
|
||||
"PATCH /users/admin/admins/:userId",
|
||||
"DELETE /users/admin/admins/:userId",
|
||||
"POST /users/admin/create-admin",
|
||||
"GET /users/admin/discover",
|
||||
"GET /users/admin/:userId/profile-overview",
|
||||
"PATCH /superadmin/users/:userId/status"
|
||||
],
|
||||
"posts": [
|
||||
"GET /posts/admin/moderation",
|
||||
"DELETE /posts/admin/:postId",
|
||||
"PATCH /superadmin/posts/:postId/status"
|
||||
],
|
||||
"comments": [
|
||||
"GET /comments/admin",
|
||||
"DELETE /comments/admin/:commentId",
|
||||
"PATCH /superadmin/comments/:commentId/status"
|
||||
],
|
||||
"notifications": [
|
||||
"GET /notifications/superadmin"
|
||||
],
|
||||
"marketplace": [
|
||||
"GET /marketplace/home",
|
||||
"GET /marketplace/listings",
|
||||
"GET /marketplace/repair-shops",
|
||||
"GET /marketplace/superadmin/listings",
|
||||
"PATCH /marketplace/superadmin/listings/:listingId/status",
|
||||
"DELETE /marketplace/superadmin/listings/:listingId",
|
||||
"GET /marketplace/superadmin/repair-shops",
|
||||
"PATCH /marketplace/superadmin/repair-shops/:repairShopId/status",
|
||||
"DELETE /marketplace/superadmin/repair-shops/:repairShopId"
|
||||
],
|
||||
"audit": [
|
||||
"GET /audit/superadmin/logs"
|
||||
],
|
||||
"superadmin": [
|
||||
"GET /superadmin/overview",
|
||||
"GET /superadmin/charts",
|
||||
"GET /superadmin/recent-activity",
|
||||
"GET /superadmin/reports",
|
||||
"GET /superadmin/settings",
|
||||
"PATCH /superadmin/settings"
|
||||
]
|
||||
},
|
||||
"dashboardPages": [
|
||||
"/dashboard",
|
||||
"/users",
|
||||
"/analytics",
|
||||
"/content",
|
||||
"/marketplace",
|
||||
"/notifications",
|
||||
"/messages",
|
||||
"/security",
|
||||
"/settings",
|
||||
"/orders"
|
||||
]
|
||||
}
|
||||
ثنائية
oudelaa_dashboard/public/logo.png
Normal file
ثنائية
oudelaa_dashboard/public/logo.png
Normal file
ملف ثنائي غير معروض.
|
بعد العرض: | الارتفاع: | الحجم: 214 KiB |
55
oudelaa_dashboard/tailwind.config.ts
Normal file
55
oudelaa_dashboard/tailwind.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./lib/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "rgb(var(--background))",
|
||||
foreground: "rgb(var(--foreground))",
|
||||
card: "rgb(var(--card))",
|
||||
"card-foreground": "rgb(var(--card-foreground))",
|
||||
popover: "rgb(var(--popover))",
|
||||
"popover-foreground": "rgb(var(--popover-foreground))",
|
||||
primary: "rgb(var(--primary))",
|
||||
"primary-foreground": "rgb(var(--primary-foreground))",
|
||||
secondary: "rgb(var(--secondary))",
|
||||
"secondary-foreground": "rgb(var(--secondary-foreground))",
|
||||
muted: "rgb(var(--muted))",
|
||||
"muted-foreground": "rgb(var(--muted-foreground))",
|
||||
accent: "rgb(var(--accent))",
|
||||
"accent-foreground": "rgb(var(--accent-foreground))",
|
||||
border: "rgb(var(--border))",
|
||||
input: "rgb(var(--input))",
|
||||
ring: "rgb(var(--ring))",
|
||||
chart: {
|
||||
1: "rgb(var(--chart-1))",
|
||||
2: "rgb(var(--chart-2))",
|
||||
3: "rgb(var(--chart-3))",
|
||||
4: "rgb(var(--chart-4))",
|
||||
5: "rgb(var(--chart-5))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
xl: "1.1rem",
|
||||
lg: "0.85rem",
|
||||
md: "0.65rem",
|
||||
sm: "0.45rem",
|
||||
},
|
||||
boxShadow: {
|
||||
glow: "0 0 0 1px rgba(212, 171, 102, 0.22), 0 10px 35px rgba(7, 5, 3, 0.45)",
|
||||
},
|
||||
backgroundImage: {
|
||||
"oudelaa-noise": "radial-gradient(circle at 1px 1px, rgba(255, 240, 211, 0.06) 1px, transparent 0)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
40
oudelaa_dashboard/tsconfig.json
Normal file
40
oudelaa_dashboard/tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es2022"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
688
oudelaa_dashboard/types/api.ts
Normal file
688
oudelaa_dashboard/types/api.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
export type AuthTokens = {
|
||||
superAdminAccessToken: string;
|
||||
superAdminRefreshToken: string;
|
||||
};
|
||||
|
||||
export type ApiRole = "superadmin" | "admin" | "user";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
export type ApiIdentifier = {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type PaginationMeta = {
|
||||
mode?: "offset" | "cursor";
|
||||
page: number;
|
||||
limit: number;
|
||||
count: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage?: boolean;
|
||||
hasPreviousPage?: boolean;
|
||||
nextPage?: number | null;
|
||||
previousPage?: number | null;
|
||||
currentCursor?: string | null;
|
||||
nextCursor?: string | null;
|
||||
};
|
||||
|
||||
export type PaginatedResponse<T> = {
|
||||
data?: T[];
|
||||
items?: T[];
|
||||
count?: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
totalPages?: number;
|
||||
nextCursor?: string | null;
|
||||
unreadCount?: number;
|
||||
pagination?: PaginationMeta;
|
||||
};
|
||||
|
||||
export type ApiUser = {
|
||||
_id: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
email: string;
|
||||
role?: ApiRole;
|
||||
stageName?: string;
|
||||
bio?: string;
|
||||
location?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
avatar?: string;
|
||||
coverImage?: string;
|
||||
musicRoles?: string[];
|
||||
musicGenres?: string[];
|
||||
favoriteInstruments?: string[];
|
||||
favoriteMaqamat?: string[];
|
||||
experienceLevel?: string;
|
||||
isPrivate?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isVerified?: boolean;
|
||||
disabledReason?: string;
|
||||
followersCount?: number;
|
||||
followingCount?: number;
|
||||
postsCount?: number;
|
||||
shopName?: string;
|
||||
shopDescription?: string;
|
||||
shopImageUrls?: string[];
|
||||
shopLocation?: string;
|
||||
shopLatitude?: number | null;
|
||||
shopLongitude?: number | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type UsersResponse = PaginatedResponse<ApiUser>;
|
||||
|
||||
export type AdminCreatePayload = {
|
||||
name?: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
||||
|
||||
export type AdminUpdatePayload = Partial<
|
||||
Pick<
|
||||
ApiUser,
|
||||
"name" | "username" | "email" | "stageName" | "bio" | "location" | "isPrivate" | "isVerified"
|
||||
>
|
||||
>;
|
||||
|
||||
export type RolePayload = {
|
||||
role: ApiRole;
|
||||
};
|
||||
|
||||
export type DisableUserPayload = {
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user?: ApiUser;
|
||||
superAdmin?: { email: string };
|
||||
};
|
||||
|
||||
export type SessionItem = {
|
||||
id?: string;
|
||||
jti?: string;
|
||||
createdAt?: string;
|
||||
expiresAt?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
};
|
||||
|
||||
export type SessionsResponse = {
|
||||
items: SessionItem[];
|
||||
};
|
||||
|
||||
export type SuperAdminSessionResponse = {
|
||||
superAdmin: {
|
||||
email?: string;
|
||||
};
|
||||
permissions: string[];
|
||||
sessionStrategy?: string;
|
||||
};
|
||||
|
||||
export type ApiPostType = "text" | "image" | "video" | "audio";
|
||||
export type ApiPostVisibility = "public" | "followers" | "private";
|
||||
export type ModerationStatus = "active" | "hidden" | "flagged";
|
||||
|
||||
export type ApiPost = {
|
||||
_id: string;
|
||||
content: string;
|
||||
visibility?: ApiPostVisibility;
|
||||
postType?: ApiPostType;
|
||||
imageUrls?: string[];
|
||||
videoUrl?: string;
|
||||
audioUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
durationSeconds?: number | null;
|
||||
style?: string;
|
||||
maqam?: string;
|
||||
rhythmSignature?: string;
|
||||
waveformPeaks?: number[];
|
||||
taggedUserIds?: ApiUser[];
|
||||
mentionUsernames?: string[];
|
||||
hashtags?: string[];
|
||||
moderationStatus?: ModerationStatus;
|
||||
moderationReason?: string;
|
||||
location?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
likesCount?: number;
|
||||
commentsCount?: number;
|
||||
savesCount?: number;
|
||||
shareCount?: number;
|
||||
viewCount?: number;
|
||||
playCount?: number;
|
||||
likedByMe?: boolean;
|
||||
savedByMe?: boolean;
|
||||
followingAuthor?: boolean;
|
||||
isOwnPost?: boolean;
|
||||
canComment?: boolean;
|
||||
canMessage?: boolean;
|
||||
author?: ApiUser;
|
||||
authorId?: ApiUser | string;
|
||||
engagement?: {
|
||||
likesCount?: number;
|
||||
commentsCount?: number;
|
||||
savesCount?: number;
|
||||
shareCount?: number;
|
||||
viewCount?: number;
|
||||
playCount?: number;
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type PostsResponse = PaginatedResponse<ApiPost>;
|
||||
|
||||
export type ApiComment = {
|
||||
_id: string;
|
||||
postId: string;
|
||||
parentCommentId?: string;
|
||||
content: string;
|
||||
mentionUsernames?: string[];
|
||||
moderationStatus?: ModerationStatus;
|
||||
moderationReason?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
author?: ApiUser;
|
||||
authorId?: ApiUser | string;
|
||||
};
|
||||
|
||||
export type CommentsResponse = PaginatedResponse<ApiComment>;
|
||||
|
||||
export type LikeStatusResponse = {
|
||||
liked?: boolean;
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
export type SaveStatusResponse = {
|
||||
saved?: boolean;
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
export type FollowStatusResponse = {
|
||||
following?: boolean;
|
||||
isFollowing?: boolean;
|
||||
};
|
||||
|
||||
export type NotificationType =
|
||||
| "like"
|
||||
| "comment"
|
||||
| "follow"
|
||||
| "message"
|
||||
| "save"
|
||||
| "share"
|
||||
| "mention";
|
||||
|
||||
export type NotificationItem = {
|
||||
_id: string;
|
||||
type: NotificationType;
|
||||
title?: string;
|
||||
previewText?: string;
|
||||
body?: string;
|
||||
read?: boolean;
|
||||
resourceType?: string;
|
||||
deepLink?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
referenceId?: string;
|
||||
actorId?: ApiUser | string;
|
||||
recipientId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type NotificationsResponse = PaginatedResponse<NotificationItem>;
|
||||
|
||||
export type ReportTargetType = "user" | "post" | "comment" | "listing" | "repair_shop";
|
||||
export type ReportStatus = "open" | "in_review" | "resolved" | "rejected";
|
||||
export type ReportReason =
|
||||
| "spam"
|
||||
| "harassment"
|
||||
| "hate_speech"
|
||||
| "nudity"
|
||||
| "violence"
|
||||
| "scam"
|
||||
| "intellectual_property"
|
||||
| "self_harm"
|
||||
| "other";
|
||||
|
||||
export type PlatformReport = {
|
||||
_id: string;
|
||||
reporterId?: ApiUser | string;
|
||||
targetType: ReportTargetType;
|
||||
targetId: string;
|
||||
reason: ReportReason;
|
||||
details?: string;
|
||||
status: ReportStatus;
|
||||
resolutionNote?: string;
|
||||
resolvedBy?: string;
|
||||
resolvedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type ReportsResponse = PaginatedResponse<PlatformReport>;
|
||||
|
||||
export type Conversation = {
|
||||
_id: string;
|
||||
isGroup?: boolean;
|
||||
participantIds?: string[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
_id: string;
|
||||
conversationId: string;
|
||||
messageType: string;
|
||||
content: string;
|
||||
mediaUrl?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type ConversationsResponse = PaginatedResponse<Conversation>;
|
||||
export type MessagesResponse = PaginatedResponse<Message>;
|
||||
|
||||
export type ChatBlockStatus = {
|
||||
iBlocked: boolean;
|
||||
blockedMe: boolean;
|
||||
};
|
||||
|
||||
export type FeedItemType = "post" | "suggested_users" | "featured_marketplace";
|
||||
|
||||
export type FeedItem = {
|
||||
_id: string;
|
||||
feedItemType?: FeedItemType;
|
||||
post?: ApiPost;
|
||||
user?: ApiUser;
|
||||
score?: number;
|
||||
items?: unknown[];
|
||||
};
|
||||
|
||||
export type FeedResponse = PaginatedResponse<FeedItem>;
|
||||
|
||||
export type MarketplaceListingCategory =
|
||||
| "musical_instrument"
|
||||
| "accessory"
|
||||
| "audio_gear"
|
||||
| "sheet_music"
|
||||
| "other";
|
||||
|
||||
export type MarketplaceListingCondition = "new" | "used" | "like_new" | "refurbished";
|
||||
|
||||
export type MarketplaceShopSummary = {
|
||||
adminId?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type MarketplaceListing = {
|
||||
_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
currency?: string;
|
||||
quantity?: number;
|
||||
imageUrls?: string[];
|
||||
isActive?: boolean;
|
||||
listingCategory?: MarketplaceListingCategory;
|
||||
condition?: MarketplaceListingCondition;
|
||||
instrumentType?: string;
|
||||
ownerAdminId?: ApiUser | string;
|
||||
shop?: MarketplaceShopSummary;
|
||||
storeName?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type MarketplaceRepairShop = {
|
||||
_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
services?: string[];
|
||||
phone?: string;
|
||||
whatsapp?: string;
|
||||
imageUrls?: string[];
|
||||
location?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
isActive?: boolean;
|
||||
ownerAdminId?: ApiUser | string;
|
||||
shop?: MarketplaceShopSummary;
|
||||
storeName?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type MarketplaceShopProfile = {
|
||||
adminId: string;
|
||||
adminName?: string;
|
||||
adminUsername?: string;
|
||||
adminEmail?: string;
|
||||
shopName?: string;
|
||||
shopDescription?: string;
|
||||
shopImageUrls?: string[];
|
||||
shopLocation?: string;
|
||||
shopLatitude?: number | null;
|
||||
shopLongitude?: number | null;
|
||||
isDisabled?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type MarketplaceResponse = PaginatedResponse<MarketplaceListing>;
|
||||
export type MarketplaceRepairShopResponse = PaginatedResponse<MarketplaceRepairShop>;
|
||||
|
||||
export type MarketplaceHomeResponse = {
|
||||
categories: Array<{ key: string; title: string; endpoint: string }>;
|
||||
summary: {
|
||||
activeListings: number;
|
||||
activeMusicalInstruments: number;
|
||||
activeRepairShops: number;
|
||||
};
|
||||
filters: {
|
||||
listingCategories: Array<{ key: string; count: number }>;
|
||||
};
|
||||
featuredShops: MarketplaceShopProfile[];
|
||||
sections: {
|
||||
listings: MarketplaceResponse & { title: string; endpoint: string };
|
||||
musicalInstruments: MarketplaceResponse & { title: string; endpoint: string };
|
||||
repairShops: MarketplaceRepairShopResponse & { title: string; endpoint: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type TalentRoleBucket = {
|
||||
role: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type TalentDiscoverResponse = UsersResponse & {
|
||||
roleBuckets?: TalentRoleBucket[];
|
||||
activeRole?: string | null;
|
||||
};
|
||||
|
||||
export type ProfileOverviewResponse = {
|
||||
user: ApiUser;
|
||||
stats: {
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
postsCount: number;
|
||||
collaborationsCount: number;
|
||||
};
|
||||
contentCounts: {
|
||||
reels: number;
|
||||
audio: number;
|
||||
image: number;
|
||||
text: number;
|
||||
other: number;
|
||||
};
|
||||
tabs: Array<{ key: string; postType?: ApiPostType; count: number }>;
|
||||
viewerState: {
|
||||
isOwnProfile: boolean;
|
||||
following: boolean;
|
||||
canMessage: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type AuditLogItem = {
|
||||
_id: string;
|
||||
actorType: "user" | "superadmin" | "system";
|
||||
actorUserId?: string;
|
||||
actorIdentifier?: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type AuditLogsResponse = PaginatedResponse<AuditLogItem>;
|
||||
|
||||
export type CounterResponse = {
|
||||
success?: boolean;
|
||||
postId?: string;
|
||||
viewCount?: number;
|
||||
playCount?: number;
|
||||
shareCount?: number;
|
||||
};
|
||||
|
||||
export type SuccessMessage = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
updatedCount?: number;
|
||||
unreadCount?: number;
|
||||
};
|
||||
|
||||
export type SuperAdminOverviewResponse = {
|
||||
metrics: {
|
||||
usersCount: number;
|
||||
adminsCount: number;
|
||||
disabledUsersCount: number;
|
||||
postsCount: number;
|
||||
hiddenPostsCount: number;
|
||||
flaggedPostsCount: number;
|
||||
commentsCount: number;
|
||||
hiddenCommentsCount: number;
|
||||
flaggedCommentsCount: number;
|
||||
marketplaceListingsCount: number;
|
||||
musicalInstrumentsCount: number;
|
||||
generalMarketplaceListingsCount: number;
|
||||
inactiveListingsCount: number;
|
||||
repairShopsCount: number;
|
||||
inactiveRepairShopsCount: number;
|
||||
unreadNotificationsCount: number;
|
||||
openCasesCount: number;
|
||||
inReviewCasesCount: number;
|
||||
failedOutboxEventsCount: number;
|
||||
pendingOutboxEventsCount: number;
|
||||
activeSuperAdminSessionsCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SuperAdminChartPoint = {
|
||||
date: string;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type SuperAdminBreakdownItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type SuperAdminChartsResponse = {
|
||||
range: "7d" | "30d" | "90d";
|
||||
days: number;
|
||||
series: {
|
||||
users: SuperAdminChartPoint[];
|
||||
posts: SuperAdminChartPoint[];
|
||||
comments: SuperAdminChartPoint[];
|
||||
listings: SuperAdminChartPoint[];
|
||||
repairShops: SuperAdminChartPoint[];
|
||||
notifications: SuperAdminChartPoint[];
|
||||
};
|
||||
breakdowns: {
|
||||
userRoles: SuperAdminBreakdownItem[];
|
||||
postTypes: SuperAdminBreakdownItem[];
|
||||
listingCategories: SuperAdminBreakdownItem[];
|
||||
moderation: SuperAdminBreakdownItem[];
|
||||
};
|
||||
kpis?: {
|
||||
activeSuperAdminSessionsCount: number;
|
||||
moderationQueueCount: number;
|
||||
failedOutboxEventsCount: number;
|
||||
pendingOutboxEventsCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SuperAdminRecentActivityItem = {
|
||||
type: string;
|
||||
action: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: string;
|
||||
deepLink: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type SuperAdminRecentActivityResponse = {
|
||||
items: SuperAdminRecentActivityItem[];
|
||||
};
|
||||
|
||||
export type SuperAdminReportsResponse = {
|
||||
summary: {
|
||||
flaggedPostsCount: number;
|
||||
flaggedCommentsCount: number;
|
||||
disabledUsersCount: number;
|
||||
inactiveListingsCount: number;
|
||||
inactiveRepairShopsCount: number;
|
||||
openCasesCount: number;
|
||||
failedOutboxEventsCount: number;
|
||||
pendingOutboxEventsCount: number;
|
||||
};
|
||||
flaggedPosts: ApiPost[];
|
||||
flaggedComments: ApiComment[];
|
||||
disabledUsers: ApiUser[];
|
||||
inactiveListings: MarketplaceListing[];
|
||||
inactiveRepairShops: MarketplaceRepairShop[];
|
||||
};
|
||||
|
||||
export type SuperAdminSettings = {
|
||||
scope?: string;
|
||||
siteName: string;
|
||||
publicBaseUrl: string;
|
||||
dashboardApiBaseUrl: string;
|
||||
corsOrigins: string[];
|
||||
maintenanceMode: boolean;
|
||||
emailEnabled: boolean;
|
||||
marketplaceAutoApprove: boolean;
|
||||
contentAutoHideFlagged: boolean;
|
||||
notes: string;
|
||||
updatedBy?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type SuperAdminSettingsResponse = {
|
||||
settings: SuperAdminSettings;
|
||||
historySummary?: {
|
||||
lastUpdatedBy?: string;
|
||||
lastChangedFields?: string[];
|
||||
lastUpdatedAt?: string;
|
||||
} | null;
|
||||
runtime: {
|
||||
nodeEnv?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
globalPrefix?: string;
|
||||
publicBaseUrl?: string;
|
||||
responseEnvelopeEnabled?: boolean;
|
||||
emailEnabled?: boolean;
|
||||
corsOrigins?: string[];
|
||||
swaggerPath?: string;
|
||||
storageProvider?: string;
|
||||
storageBasePath?: string;
|
||||
queueEnabled?: boolean;
|
||||
redisEnabled?: boolean;
|
||||
sessionStrategy?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type SuperAdminSettingsHistoryEntry = {
|
||||
_id: string;
|
||||
scope: string;
|
||||
updatedBy: string;
|
||||
changedFields: string[];
|
||||
previousSettings: Record<string, unknown>;
|
||||
nextSettings: Record<string, unknown>;
|
||||
note?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export type SuperAdminSettingsHistoryResponse = PaginatedResponse<SuperAdminSettingsHistoryEntry>;
|
||||
|
||||
export type SuperAdminCaseStatus = "open" | "in_review" | "resolved";
|
||||
export type SuperAdminCasePriority = "low" | "normal" | "high" | "critical";
|
||||
|
||||
export type CreateSuperAdminCasePayload = {
|
||||
title: string;
|
||||
description?: string;
|
||||
caseType?: string;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
priority?: SuperAdminCasePriority;
|
||||
assignedTo?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type SuperAdminCase = {
|
||||
_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
caseType: string;
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
status: SuperAdminCaseStatus;
|
||||
priority: SuperAdminCasePriority;
|
||||
assignedTo?: string;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
tags?: string[];
|
||||
resolution?: string;
|
||||
events?: Array<{
|
||||
action: string;
|
||||
actor: string;
|
||||
note?: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type SuperAdminCasesResponse = PaginatedResponse<SuperAdminCase>;
|
||||
|
||||
export type SuperAdminOpsResponse = {
|
||||
services: {
|
||||
mongodb: {
|
||||
status: string;
|
||||
database?: string;
|
||||
};
|
||||
redis: {
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
};
|
||||
queue: {
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
};
|
||||
storage: {
|
||||
provider?: string;
|
||||
basePath?: string;
|
||||
};
|
||||
email: {
|
||||
enabled: boolean;
|
||||
};
|
||||
websocket: {
|
||||
redisAdapterEnabled: boolean;
|
||||
};
|
||||
};
|
||||
queues: {
|
||||
outbox: {
|
||||
pending: number;
|
||||
failed: number;
|
||||
};
|
||||
};
|
||||
workload: {
|
||||
openCasesCount: number;
|
||||
activeSuperAdminSessionsCount: number;
|
||||
};
|
||||
};
|
||||
93
oudelaa_dashboard/types/index.ts
Normal file
93
oudelaa_dashboard/types/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type TrendDirection = "up" | "down" | "neutral";
|
||||
|
||||
export type StatMetric = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
delta: string;
|
||||
trend: TrendDirection;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type RevenuePoint = {
|
||||
month: string;
|
||||
revenue: number;
|
||||
orders: number;
|
||||
};
|
||||
|
||||
export type ChannelPoint = {
|
||||
name: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type UserStatus = "active" | "pending" | "suspended";
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
plan: "Silver" | "Gold" | "Maestro";
|
||||
status: UserStatus;
|
||||
joinedAt: string;
|
||||
totalSpent: number;
|
||||
city: string;
|
||||
};
|
||||
|
||||
export type ContentStatus = "published" | "draft" | "review";
|
||||
|
||||
export type ContentItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
genre: string;
|
||||
duration: string;
|
||||
status: ContentStatus;
|
||||
createdAt: string;
|
||||
listens: number;
|
||||
};
|
||||
|
||||
export type OrderStatus = "paid" | "processing" | "refunded" | "failed";
|
||||
|
||||
export type Order = {
|
||||
id: string;
|
||||
customer: string;
|
||||
product: string;
|
||||
amount: number;
|
||||
paymentMethod: "Card" | "Apple Pay" | "Wallet";
|
||||
status: OrderStatus;
|
||||
createdAt: string;
|
||||
city: string;
|
||||
};
|
||||
|
||||
export type MessageState = "unread" | "open" | "closed";
|
||||
|
||||
export type MessageThread = {
|
||||
id: string;
|
||||
customer: string;
|
||||
subject: string;
|
||||
state: MessageState;
|
||||
lastMessageAt: string;
|
||||
channel: "Email" | "WhatsApp" | "In-App";
|
||||
preview: string;
|
||||
priority: "low" | "normal" | "high";
|
||||
};
|
||||
|
||||
export type Insight = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type SettingsProfile = {
|
||||
businessName: string;
|
||||
supportEmail: string;
|
||||
supportPhone: string;
|
||||
city: string;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type SettingsNotifications = {
|
||||
orders: boolean;
|
||||
newUsers: boolean;
|
||||
payouts: boolean;
|
||||
weeklyDigest: boolean;
|
||||
};
|
||||
المرجع في مشكلة جديدة
حظر مستخدم