admin/app/(dashboard)/dashboard/page.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

435 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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