Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
248 lines
6.8 KiB
TypeScript
248 lines
6.8 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
|
||
import type { PaginationListColumnType } from '@/types'
|
||
import Button from '@/components/formElements/Button'
|
||
import PaginatedList from '@/components/PaginatedList'
|
||
import PageNavbar from '@/components/layouts/PageNavbar'
|
||
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
|
||
import StatusChip from '@/components/ui/StatusChip'
|
||
import axiosInstance from '@/config/axios'
|
||
import { APP_ROUTES } from '@/constants/routes'
|
||
import { EVENT_STATUS_FILTER_ITEMS, getBooleanStatus, getEventStatus } from '@/constants/status'
|
||
import { formatCurrency, formatPersonName, coerceToString } from '@/helpers'
|
||
import { formatPersianDate } from '@/lib/formatters'
|
||
import { API_ROUTES } from '@/services/config'
|
||
|
||
interface NamedEntity {
|
||
id: number | string
|
||
name: string
|
||
}
|
||
|
||
interface EventOrganizer {
|
||
id: string
|
||
firstName: string | null
|
||
lastName: string | null
|
||
}
|
||
|
||
interface EventRow {
|
||
id: string
|
||
title: string
|
||
status: string
|
||
isDiscoverable: boolean
|
||
isFree: boolean
|
||
price: number
|
||
category?: NamedEntity
|
||
province?: NamedEntity
|
||
city?: NamedEntity
|
||
organizer?: EventOrganizer
|
||
[key: string]: unknown
|
||
}
|
||
|
||
interface FilterOption {
|
||
code: string
|
||
name: string
|
||
}
|
||
|
||
const IS_FREE_FILTER_ITEMS: FilterOption[] = [
|
||
{ code: 'true', name: 'بله' },
|
||
{ code: 'false', name: 'خیر' },
|
||
]
|
||
|
||
const IS_DISCOVERABLE_FILTER_ITEMS: FilterOption[] = [
|
||
{ code: 'true', name: 'بله' },
|
||
{ code: 'false', name: 'خیر' },
|
||
]
|
||
|
||
const toSelectFilterItems = (items: NamedEntity[]): FilterOption[] => items.map((item) => ({ code: String(item.id), name: item.name }))
|
||
|
||
const extractApiList = <T,>(raw: unknown): T[] => {
|
||
if (Array.isArray(raw)) return raw as T[]
|
||
|
||
if (typeof raw === 'object' && raw !== null && 'data' in raw) {
|
||
const data = raw.data
|
||
|
||
if (Array.isArray(data)) return data as T[]
|
||
}
|
||
|
||
return []
|
||
}
|
||
|
||
const getOrganizer = (row: EventRow): EventOrganizer => {
|
||
if (row.organizer && typeof row.organizer === 'object') {
|
||
return row.organizer
|
||
}
|
||
|
||
return { id: '—', firstName: null, lastName: null }
|
||
}
|
||
|
||
const EventsPage = () => {
|
||
const [categoryFilterItems, setCategoryFilterItems] = useState<FilterOption[]>([])
|
||
const [cityFilterItems, setCityFilterItems] = useState<FilterOption[]>([])
|
||
|
||
useEffect(() => {
|
||
const loadFilterOptions = async () => {
|
||
try {
|
||
const [categoriesRes, citiesRes] = await Promise.all([
|
||
axiosInstance.get(API_ROUTES.EVENT_CATEGORIES.ADMIN_FLAT),
|
||
axiosInstance.get(API_ROUTES.GEOGRAPHY.ALL_CITIES),
|
||
])
|
||
|
||
setCategoryFilterItems(toSelectFilterItems(extractApiList<NamedEntity>(categoriesRes.data)))
|
||
setCityFilterItems(toSelectFilterItems(extractApiList<NamedEntity>(citiesRes.data)))
|
||
} catch {
|
||
setCategoryFilterItems([])
|
||
setCityFilterItems([])
|
||
}
|
||
}
|
||
|
||
void loadFilterOptions()
|
||
}, [])
|
||
|
||
const columns = useMemo<PaginationListColumnType[]>(
|
||
() => [
|
||
{
|
||
field: 'title',
|
||
label: 'عنوان',
|
||
filterable: true,
|
||
type: 'text',
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'status',
|
||
label: 'وضعیت',
|
||
filterable: true,
|
||
type: 'select',
|
||
sortable: false,
|
||
filterItems: EVENT_STATUS_FILTER_ITEMS,
|
||
},
|
||
{
|
||
field: 'isDiscoverable',
|
||
label: 'قابلجستجو',
|
||
filterable: true,
|
||
type: 'select',
|
||
sortable: false,
|
||
filterItems: IS_DISCOVERABLE_FILTER_ITEMS,
|
||
},
|
||
{
|
||
field: 'categoryId',
|
||
label: 'دستهبندی',
|
||
filterable: true,
|
||
type: 'select',
|
||
sortable: false,
|
||
filterItems: categoryFilterItems,
|
||
},
|
||
{
|
||
field: 'organizerId',
|
||
label: 'برگزارکننده',
|
||
filterable: true,
|
||
type: 'text',
|
||
sortable: false,
|
||
},
|
||
// Province-level filtering is disabled here too — see cities/page.tsx's
|
||
// note for why (city-only admin surface). Only the city filter below
|
||
// is active.
|
||
{
|
||
field: 'cityId',
|
||
label: 'شهر',
|
||
filterable: true,
|
||
type: 'select',
|
||
sortable: false,
|
||
filterItems: cityFilterItems,
|
||
},
|
||
{
|
||
field: 'startsAt',
|
||
label: 'تاریخ شروع',
|
||
filterable: true,
|
||
sortable: true,
|
||
type: 'dateFromTo',
|
||
},
|
||
{
|
||
field: 'isFree',
|
||
label: 'رایگان',
|
||
filterable: true,
|
||
type: 'select',
|
||
sortable: false,
|
||
filterItems: IS_FREE_FILTER_ITEMS,
|
||
},
|
||
{
|
||
field: 'price',
|
||
label: 'قیمت',
|
||
filterable: true,
|
||
type: 'inputFromTo',
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'actions',
|
||
label: 'عملیات',
|
||
},
|
||
],
|
||
[categoryFilterItems, cityFilterItems]
|
||
)
|
||
|
||
return (
|
||
<section className="h-full w-full text-right">
|
||
<PageNavbar
|
||
endSlot={
|
||
<Button
|
||
size="sm"
|
||
to={APP_ROUTES.MANAGE_EVENT_NEW}
|
||
>
|
||
افزودن رویداد
|
||
</Button>
|
||
}
|
||
pageTitle="رویدادها"
|
||
/>
|
||
<div className="admin-page-container">
|
||
<PaginatedList
|
||
columns={columns}
|
||
url={API_ROUTES.EVENTS.ADMIN_LIST}
|
||
>
|
||
{{
|
||
status: (_row, cellValue) => <StatusChip {...getEventStatus(coerceToString(cellValue))} />,
|
||
isDiscoverable: (_row, cellValue) => <StatusChip {...getBooleanStatus(typeof cellValue === 'boolean' ? cellValue : null)} />,
|
||
categoryId: (row) => {
|
||
const event = row as EventRow
|
||
|
||
return event.category?.name ?? '—'
|
||
},
|
||
organizerId: (row) => {
|
||
const organizer = getOrganizer(row as EventRow)
|
||
|
||
return formatPersonName(organizer.firstName, organizer.lastName)
|
||
},
|
||
cityId: (row) => {
|
||
const event = row as EventRow
|
||
|
||
return event.city?.name ?? '—'
|
||
},
|
||
startsAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||
isFree: (_row, cellValue) => <StatusChip {...getBooleanStatus(typeof cellValue === 'boolean' ? cellValue : null)} />,
|
||
price: (row, cellValue) => {
|
||
const event = row as EventRow
|
||
|
||
if (event.isFree) return '—'
|
||
|
||
const amount = typeof cellValue === 'number' ? cellValue : Number(cellValue)
|
||
|
||
if (!Number.isFinite(amount)) return '—'
|
||
|
||
return formatCurrency(amount)
|
||
},
|
||
actions: (row) => (
|
||
<AdminTableViewButton
|
||
label="مشاهده رویداد"
|
||
mode="navigate"
|
||
to={APP_ROUTES.MANAGE_EVENT_DETAIL(String((row as EventRow).id))}
|
||
/>
|
||
),
|
||
}}
|
||
</PaginatedList>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default EventsPage
|