Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
435 lines
19 KiB
TypeScript
435 lines
19 KiB
TypeScript
'use client'
|
||
|
||
import type { ReactNode } from 'react'
|
||
|
||
import Link from 'next/link'
|
||
import { useEffect, useState } from 'react'
|
||
|
||
import { Card, CardBody, CardHeader } from '@/components/heroui/Card'
|
||
import PageNavbar from '@/components/layouts/PageNavbar'
|
||
import { DetailSkeleton, TableSkeleton } from '@/components/feedback/LoadingState'
|
||
import AdminState from '@/components/feedback/AdminState'
|
||
import { GET_ADMIN_REPORTS_OVERVIEW, type AdminReportsOverview } from '@/services/adminReports'
|
||
import { APP_ROUTES } from '@/constants/routes'
|
||
import { formatNumber as formatNumberBase } from '@/helpers'
|
||
import UserFillIcon from '@/components/icons/UserFillIcon'
|
||
import CalendarIcon from '@/components/icons/CalendarIcon'
|
||
import BookmarkIcon from '@/components/icons/BookmarkIcon'
|
||
import UserIdCardIcon from '@/components/icons/UserIdCardIcon'
|
||
import CoinDollarIcon from '@/components/icons/CoinDollarIcon'
|
||
import AngleLeftIcon from '@/components/icons/AngleLeftIcon'
|
||
import PlusIcon from '@/components/icons/PlusIcon'
|
||
|
||
interface StatItem {
|
||
label: string
|
||
value: number | string
|
||
href?: string
|
||
}
|
||
|
||
type KpiItem = Omit<StatItem, 'href'> & {
|
||
href: string
|
||
icon: ReactNode
|
||
}
|
||
|
||
const formatNumber = (value: number | string) => {
|
||
const num = typeof value === 'string' ? Number(value) : value
|
||
|
||
if (!Number.isFinite(num)) return String(value)
|
||
|
||
return formatNumberBase(num)
|
||
}
|
||
|
||
const StatCell = ({ item }: { item: StatItem }) => {
|
||
const content = (
|
||
<>
|
||
<span className="text-sm leading-5 text-secondary-30 md:text-xs">{item.label}</span>
|
||
<span className="flex items-center gap-1 text-xl font-bold text-secondary-10 transition-colors group-hover:text-primary-600 md:text-2xl">
|
||
{formatNumber(item.value)}
|
||
{item.href ? (
|
||
<AngleLeftIcon className="size-4 text-secondary-30 transition-transform group-hover:-translate-x-1 group-hover:text-primary-500" />
|
||
) : null}
|
||
</span>
|
||
</>
|
||
)
|
||
|
||
const className =
|
||
'group flex h-full min-h-24 flex-col justify-center gap-2 bg-white p-3 text-right transition-colors hover:bg-secondary-50/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary-500'
|
||
|
||
return item.href ? (
|
||
<Link
|
||
aria-label={`${item.label}: ${formatNumber(item.value)}`}
|
||
className={className}
|
||
href={item.href}
|
||
>
|
||
{content}
|
||
</Link>
|
||
) : (
|
||
<div className={className}>{content}</div>
|
||
)
|
||
}
|
||
|
||
const KpiCard = ({ item }: { item: KpiItem }) => (
|
||
<Link
|
||
className="group flex min-w-0 items-center gap-3 rounded-2xl border border-white/10 bg-white/10 p-3 text-white backdrop-blur-sm transition-colors hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||
href={item.href}
|
||
>
|
||
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-white/15 transition-transform group-hover:scale-105">
|
||
{item.icon}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<p className="truncate text-[10px] text-white/65 sm:text-xs">{item.label}</p>
|
||
<p className="mt-1 text-lg font-bold sm:text-xl">{formatNumber(item.value)}</p>
|
||
</div>
|
||
</Link>
|
||
)
|
||
|
||
const StatGroup = ({ title, description, icon, items }: { title: string; description: string; icon: ReactNode; items: StatItem[] }) => (
|
||
<Card className="admin-surface flex h-full flex-col gap-0 overflow-hidden p-0">
|
||
<CardHeader className="!flex-row flex w-full items-center justify-start gap-3 border-b border-secondary-40 px-5 py-4 text-right">
|
||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary-50 text-primary-600">{icon}</span>
|
||
<div className="min-w-0 text-right">
|
||
<h2 className="text-sm font-bold text-secondary-10 md:text-base">{title}</h2>
|
||
<p className="mt-1 text-sm text-secondary-30">{description}</p>
|
||
</div>
|
||
</CardHeader>
|
||
<CardBody className="flex flex-1 flex-col gap-0 bg-secondary-40 p-0">
|
||
<div
|
||
className={
|
||
items.length <= 2
|
||
? 'grid h-full auto-rows-fr grid-cols-1 gap-px sm:grid-cols-2'
|
||
: 'grid h-full auto-rows-fr grid-cols-2 gap-px lg:grid-cols-4'
|
||
}
|
||
>
|
||
{items.map((item) => (
|
||
<StatCell
|
||
key={item.label}
|
||
item={item}
|
||
/>
|
||
))}
|
||
</div>
|
||
</CardBody>
|
||
</Card>
|
||
)
|
||
|
||
const ActionQueue = ({ overview }: { overview: AdminReportsOverview }) => {
|
||
const items = [
|
||
{
|
||
label: 'احراز هویت نیازمند بررسی',
|
||
value: overview.identityVerifications.pending,
|
||
href: `${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`,
|
||
},
|
||
{
|
||
label: 'رویداد در انتظار بررسی',
|
||
value: overview.events.byStatus.pending_review,
|
||
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=pending_review`,
|
||
},
|
||
{
|
||
label: 'رزرو در انتظار پرداخت',
|
||
value: overview.bookings.byStatus.pending_payment,
|
||
href: `${APP_ROUTES.BOOKINGS}?filters[status]=pending_payment`,
|
||
},
|
||
]
|
||
|
||
const total = items.reduce((sum, item) => sum + item.value, 0)
|
||
|
||
return (
|
||
<Card className="admin-surface flex h-full flex-col gap-0 overflow-hidden p-0">
|
||
<CardHeader className="!flex-row flex w-full items-center justify-between gap-3 border-b border-separator px-5 py-4 text-right">
|
||
<div className="min-w-0 text-right">
|
||
<h2 className="text-sm font-bold text-foreground md:text-base">صف اقدامات امروز</h2>
|
||
<p className="mt-1 text-sm text-muted">مواردی که برای ادامه فرایند به توجه ادمین نیاز دارند</p>
|
||
</div>
|
||
<span
|
||
aria-label={`${formatNumber(total)} اقدام باز`}
|
||
className="shrink-0 rounded-full bg-fourth-100 px-3 py-1 text-xs font-bold text-fourth-900"
|
||
>
|
||
{formatNumber(total)} باز
|
||
</span>
|
||
</CardHeader>
|
||
<CardBody className="flex flex-1 flex-col p-3">
|
||
<div className="grid h-full flex-1 grid-cols-3 gap-2">
|
||
{items.map((item) => (
|
||
<Link
|
||
key={item.label}
|
||
className="group flex h-full min-w-0 items-center justify-between gap-2 rounded-2xl border border-border bg-surface-secondary p-3 transition-colors hover:border-accent hover:bg-accent-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus"
|
||
href={item.href}
|
||
>
|
||
<div className="min-w-0 text-right">
|
||
<p className="text-sm leading-4 text-muted sm:text-xs sm:leading-5">{item.label}</p>
|
||
<p className="mt-1 text-lg font-bold text-foreground sm:text-xl">{formatNumber(item.value)}</p>
|
||
</div>
|
||
<AngleLeftIcon className="size-4 shrink-0 text-muted transition-transform group-hover:-translate-x-1 group-hover:text-accent sm:size-5" />
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</CardBody>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const DashboardPage = () => {
|
||
const [overview, setOverview] = useState<AdminReportsOverview | null>(null)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [reloadKey, setReloadKey] = useState(0)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
const load = async () => {
|
||
setIsLoading(true)
|
||
setError(null)
|
||
const result = await GET_ADMIN_REPORTS_OVERVIEW()
|
||
|
||
if (cancelled) return
|
||
|
||
if (!result.ok) {
|
||
setOverview(null)
|
||
setError(result.error?.message ?? 'دریافت گزارشها ناموفق بود')
|
||
} else {
|
||
setOverview(result.data)
|
||
}
|
||
setIsLoading(false)
|
||
}
|
||
|
||
void load()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [reloadKey])
|
||
|
||
return (
|
||
<section className="h-full w-full text-right">
|
||
<PageNavbar pageTitle="پیشخوان" />
|
||
<div className="admin-page-container flex flex-col gap-5">
|
||
{isLoading && (
|
||
<div className="flex flex-col gap-5">
|
||
<DetailSkeleton />
|
||
<div className="admin-surface overflow-hidden">
|
||
<TableSkeleton rows={5} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!isLoading && error && (
|
||
<div className="admin-surface overflow-hidden">
|
||
<AdminState
|
||
actionLabel="تلاش دوباره"
|
||
description={error}
|
||
title="دریافت نمای کلی ناموفق بود"
|
||
variant="error"
|
||
onAction={() => {
|
||
setReloadKey((current) => current + 1)
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{!isLoading && overview && (
|
||
<>
|
||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-l from-[#082e70] via-primary-700 to-primary-600 px-5 py-6 text-white shadow-xl shadow-primary-900/10 sm:px-7 sm:py-8">
|
||
<div className="absolute -start-12 -top-20 size-52 rounded-full bg-white/10 blur-2xl" />
|
||
<div className="relative">
|
||
<p className="text-xs font-medium text-white/70">نمای کلی سامانه</p>
|
||
<h2 className="mt-2 text-xl font-bold sm:text-2xl">سلام، به پنل مدیریت قبیله خوش آمدید</h2>
|
||
<p className="mt-2 max-w-2xl text-xs leading-6 text-white/70">
|
||
مهمترین شاخصهای کاربران، رویدادها، رزروها و عملیات مالی را در یک نگاه بررسی کنید.
|
||
</p>
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<Link
|
||
className="inline-flex min-h-9 items-center gap-2 rounded-xl bg-white px-3 text-xs font-semibold text-primary-700 transition-colors hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||
href={APP_ROUTES.MANAGE_EVENT_NEW}
|
||
>
|
||
<PlusIcon className="size-4" />
|
||
ایجاد رویداد
|
||
</Link>
|
||
<Link
|
||
className="inline-flex min-h-9 items-center rounded-xl border border-white/20 bg-white/10 px-3 text-xs font-semibold text-white transition-colors hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||
href={`${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`}
|
||
>
|
||
بررسی احرازهای در انتظار
|
||
</Link>
|
||
</div>
|
||
<div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||
{(
|
||
[
|
||
{
|
||
label: 'کل کاربران',
|
||
value: overview.users.total,
|
||
icon: <UserFillIcon className="size-5" />,
|
||
href: APP_ROUTES.USERS,
|
||
},
|
||
{
|
||
label: 'رویدادهای فعال',
|
||
value: overview.events.active,
|
||
icon: <CalendarIcon className="size-5" />,
|
||
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=published`,
|
||
},
|
||
{
|
||
label: 'کل رزروها',
|
||
value: overview.bookings.total,
|
||
icon: <BookmarkIcon className="size-5" />,
|
||
href: APP_ROUTES.BOOKINGS,
|
||
},
|
||
{
|
||
label: 'در انتظار احراز',
|
||
value: overview.identityVerifications.pending,
|
||
icon: <UserIdCardIcon className="size-5" />,
|
||
href: `${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`,
|
||
},
|
||
] satisfies KpiItem[]
|
||
).map((item) => (
|
||
<KpiCard
|
||
key={item.label}
|
||
item={item}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 items-stretch gap-5 md:grid-cols-2">
|
||
<ActionQueue overview={overview} />
|
||
|
||
<StatGroup
|
||
description="شاخصهای کلیدی گردش مالی پلتفرم"
|
||
icon={<CoinDollarIcon className="size-5" />}
|
||
items={[
|
||
{
|
||
label: 'کل کمیسیون پلتفرم (تومان)',
|
||
value: overview.financial.totalCommissionCollected,
|
||
href: APP_ROUTES.SETTLEMENTS,
|
||
},
|
||
{
|
||
label: 'کل موجودی کیفپولها (تومان)',
|
||
value: overview.financial.totalWalletBalance,
|
||
href: APP_ROUTES.WITHDRAWAL_REQUESTS,
|
||
},
|
||
]}
|
||
title="مالی"
|
||
/>
|
||
|
||
<StatGroup
|
||
description="آمار انتشار و برگزاری رویدادها"
|
||
icon={<CalendarIcon className="size-5" />}
|
||
items={[
|
||
{ label: 'کل رویدادها', value: overview.events.total },
|
||
// «فعال» شامل هر دو وضعیت published و full است، اما فیلتر لیست
|
||
// رویدادها تکمقداری است (کاما در PaginatedList بهعنوان بازهی
|
||
// from/to تفسیر میشود، نه چند مقدار) — طبق تصمیم کاربر به published
|
||
// لینک میشود؛ رکوردهای full را میتوان جدا از ردیف «تکمیل ظرفیت» دید.
|
||
{
|
||
label: 'منتشر یا تکمیل ظرفیت',
|
||
value: overview.events.active,
|
||
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=published`,
|
||
},
|
||
{ label: 'کل برگذارشده', value: overview.events.totalHeld },
|
||
{
|
||
label: 'پیشنویس',
|
||
value: overview.events.byStatus.draft,
|
||
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=draft`,
|
||
},
|
||
{
|
||
label: 'در انتظار بررسی',
|
||
value: overview.events.byStatus.pending_review,
|
||
href: `${APP_ROUTES.MANAGE_EVENTS}?filters[status]=pending_review`,
|
||
},
|
||
{ label: 'منتشرشده', value: overview.events.byStatus.published },
|
||
{ label: 'تکمیل ظرفیت', value: overview.events.byStatus.full },
|
||
{ label: 'لغوشده', value: overview.events.byStatus.cancelled },
|
||
]}
|
||
title="رویدادها"
|
||
/>
|
||
|
||
<StatGroup
|
||
description="وضعیت رزروهای ثبتشده در سامانه"
|
||
icon={<BookmarkIcon className="size-5" />}
|
||
items={[
|
||
{ label: 'کل رزروها', value: overview.bookings.total },
|
||
// «فعال» = pending_payment + confirmed (رزروهای زندهای که ظرفیت را نگه میدارند).
|
||
// فیلتر لیست تکمقداری است؛ مثل رویدادهای فعال به confirmed لینک میشود.
|
||
{
|
||
label: 'فعال',
|
||
value: overview.bookings.byStatus.pending_payment + overview.bookings.byStatus.confirmed,
|
||
href: `${APP_ROUTES.BOOKINGS}?filters[status]=confirmed`,
|
||
},
|
||
{
|
||
label: 'در انتظار پرداخت',
|
||
value: overview.bookings.byStatus.pending_payment,
|
||
href: `${APP_ROUTES.BOOKINGS}?filters[status]=pending_payment`,
|
||
},
|
||
{
|
||
label: 'تأییدشده',
|
||
value: overview.bookings.byStatus.confirmed,
|
||
href: `${APP_ROUTES.BOOKINGS}?filters[status]=confirmed`,
|
||
},
|
||
{ label: 'لغوشده', value: overview.bookings.byStatus.cancelled },
|
||
{ label: 'منقضیشده', value: overview.bookings.byStatus.expired },
|
||
{ label: 'بازپرداختشده', value: overview.bookings.byStatus.refunded },
|
||
{ label: 'عدم حضور', value: overview.bookings.byStatus.no_show },
|
||
]}
|
||
title="رزروها"
|
||
/>
|
||
|
||
<StatGroup
|
||
description="وضعیت حسابها و فعالیت کاربران"
|
||
icon={<UserFillIcon className="size-5" />}
|
||
items={[
|
||
{ label: 'کل کاربران', value: overview.users.total },
|
||
{
|
||
label: 'فعال',
|
||
value: overview.users.active,
|
||
href: `${APP_ROUTES.USERS}?filters[status]=active`,
|
||
},
|
||
{
|
||
label: 'در انتظار تکمیل پروفایل',
|
||
value: overview.users.pending,
|
||
href: `${APP_ROUTES.USERS}?filters[status]=pending`,
|
||
},
|
||
{
|
||
label: 'معلق',
|
||
value: overview.users.suspended,
|
||
href: `${APP_ROUTES.USERS}?filters[status]=suspended`,
|
||
},
|
||
]}
|
||
title="کاربران"
|
||
/>
|
||
|
||
<StatGroup
|
||
description="فرآیند بررسی و تأیید هویت میزبانها"
|
||
icon={<UserIdCardIcon className="size-5" />}
|
||
items={[
|
||
{
|
||
label: 'بدون احراز',
|
||
value: overview.identityVerifications.none,
|
||
href: `${APP_ROUTES.USERS}?filters[identityStatus]=none`,
|
||
},
|
||
{
|
||
label: 'در انتظار',
|
||
value: overview.identityVerifications.pending,
|
||
href: `${APP_ROUTES.IDENTITY_VERIFICATIONS}?filters[status]=pending`,
|
||
},
|
||
{
|
||
label: 'تأییدشده',
|
||
value: overview.identityVerifications.verified,
|
||
href: `${APP_ROUTES.USERS}?filters[identityStatus]=verified`,
|
||
},
|
||
{
|
||
label: 'ردشده',
|
||
value: overview.identityVerifications.rejected,
|
||
href: `${APP_ROUTES.USERS}?filters[identityStatus]=rejected`,
|
||
},
|
||
]}
|
||
title="احراز هویت"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default DashboardPage
|