admin/app/(dashboard)/manage-events/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

248 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { 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