admin/features/events/detail/admin-event-detail/AdminEventBookingsTab.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

155 lines
5.8 KiB
TypeScript
Raw Permalink 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 type { BookingUser } from './types'
import { useRef } from 'react'
import type { PaginationListColumnType } from '@/types'
import type { PaginatedListHandle } from '@/components/PaginatedList'
import Button from '@/components/formElements/Button'
import FileCheckIcon from '@/components/icons/FileCheckIcon'
import PaginatedList from '@/components/PaginatedList'
import AdminTableActions from '@/components/ui/AdminTableActions'
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
import StatusChip from '@/components/ui/StatusChip'
import { APP_ROUTES } from '@/constants/routes'
import { BOOKING_STATUS_FILTER_ITEMS, getBookingStatus } from '@/constants/status'
import { formatPersonName, coerceToString } from '@/helpers'
import { checkInBookingAsAdmin } from '@/services/eventManagement'
import { API_ROUTES } from '@/services/config'
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
import useAdminAction from '@/hooks/useAdminAction'
// Bookings tab columns — copied from app/(dashboard)/bookings/page.tsx,
// minus the `eventId` column (this list is already scoped to one event via
// urlParams.filters). Cancel/refund stay on their existing flows — an
// organizer cannot cancel a single booking either (business-rules.md), only
// check-in is exposed here, mirroring the host's own attendance action.
// `search` is a filter-only synthetic column (hideInTable) — its value
// round-trips as filters.search, matched server-side (adminList in
// bookings.service.ts) against booking code and guest first/last name, the
// same lookup a host's attendance search covers.
const bookingsColumns: PaginationListColumnType[] = [
{ field: 'search', label: 'جستجو (کد رزرو یا نام مهمان)', filterable: true, sortable: false, type: 'text', hideInTable: true },
{ field: 'bookingCode', label: 'کد رزرو', filterable: false, sortable: false, type: 'text' },
{ field: 'userId', label: 'مهمان', filterable: false, sortable: false, type: 'text' },
{ field: 'status', label: 'وضعیت', filterable: true, sortable: true, type: 'select', filterItems: BOOKING_STATUS_FILTER_ITEMS },
{ field: 'checkedInAt', label: 'چک‌این', filterable: false, sortable: false, type: 'date' },
{ field: 'createdAt', label: 'تاریخ ثبت', filterable: false, sortable: true, type: 'date' },
{ field: 'actions', label: 'عملیات' },
]
interface AdminEventBookingsTabProps {
eventId: string
eventStatus: string
onCheckedIn?: () => void
}
const AdminEventBookingsTab = ({ eventId, eventStatus, onCheckedIn }: AdminEventBookingsTabProps) => {
const bookingsListRef = useRef<PaginatedListHandle>(null)
const { pendingId, runAction } = useAdminAction()
const handleCheckIn = (bookingId: string) =>
runAction(
bookingId,
async () => {
await checkInBookingAsAdmin(bookingId)
bookingsListRef.current?.refresh()
onCheckedIn?.()
},
'حضور مهمان ثبت شد'
)
return (
<div className="flex flex-col gap-2">
<div className="flex justify-end">
<Button
size="sm"
to={`${APP_ROUTES.BOOKINGS}?filters[eventId]=${eventId}`}
variant="flat"
>
مشاهده در لیست کلی
</Button>
</div>
<PaginatedList
ref={bookingsListRef}
columns={bookingsColumns}
tableWrapperClass="max-h-[360px] p-0"
url={API_ROUTES.BOOKINGS.ADMIN_LIST}
urlParams={{
page: 1,
pageSize: 10,
sort: '',
filters: { eventId },
}}
>
{{
userId: (row) => {
const user = row.user as BookingUser | undefined
if (!user) return '—'
const name = formatPersonName(user.firstName ?? undefined, user.lastName ?? undefined)
return (
<div className="flex flex-col gap-1">
<span>{name}</span>
<span
className="text-xs text-tertiary-300"
dir="ltr"
>
{formatIranianMobile(user.mobile)}
</span>
</div>
)
},
status: (_row, cellValue) => {
const { label, chipColor } = getBookingStatus(coerceToString(cellValue))
return (
<StatusChip
chipColor={chipColor}
label={label}
/>
)
},
checkedInAt: (_row, cellValue) => formatPersianDate(cellValue),
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
actions: (row) => {
const user = row.user as BookingUser | undefined
const bookingId = row.id as string
const canCheckIn = row.status === 'confirmed' && !row.checkedInAt && ['published', 'full'].includes(eventStatus)
return (
<AdminTableActions>
{user ? (
<AdminTableViewButton
label="مشاهده کاربر"
mode="navigate"
to={APP_ROUTES.USER_DETAIL(user.id)}
/>
) : null}
{canCheckIn ? (
<Button
iconOnly
aria-label="ثبت حضور"
color="success"
isLoading={pendingId === bookingId}
size="sm"
variant="flat"
onClick={() => void handleCheckIn(bookingId)}
>
<FileCheckIcon className="size-4" />
</Button>
) : null}
</AdminTableActions>
)
},
}}
</PaginatedList>
</div>
)
}
export default AdminEventBookingsTab