ui phase 11
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import { AppButton, AppLoading, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminDataTable,
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
type AdminTableColumn,
|
||||
} from '@/components/admin';
|
||||
import { adminTicketThreadPath } from '@/constants';
|
||||
import { useAdminListState } from '@/hooks';
|
||||
import { useAdminTickets } from '@/services/tickets';
|
||||
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
|
||||
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
|
||||
const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
|
||||
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
|
||||
@@ -24,35 +26,55 @@ const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refun
|
||||
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
|
||||
const EMPTY: AdminTicketFilters = {};
|
||||
|
||||
function parseFilters(params: URLSearchParams): AdminTicketFilters {
|
||||
return {
|
||||
status: (params.get('status') as TicketStatus | null) ?? undefined,
|
||||
category: (params.get('category') as TicketCategory | null) ?? undefined,
|
||||
referenceCode: params.get('referenceCode') ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeFilters(filters: AdminTicketFilters): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
if (filters.status) record.status = filters.status;
|
||||
if (filters.category) record.category = filters.category;
|
||||
if (filters.referenceCode) record.referenceCode = filters.referenceCode;
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin global ticket queue (f15) — EVERY ticket across the platform (not one viewer's), the entry point
|
||||
* into a case. Filter by status/category/reference; a row opens the admin thread where internal notes and the
|
||||
* refund panel live. The filter **draft** commits to the query only on Apply, so typing a reference never
|
||||
* refetches; the applied filters + page are the cache key (`useAdminTickets`), so revisiting a filter/page
|
||||
* serves from cache. This surface is staff-only — the server enforces the scope; the UI just routes here.
|
||||
* refetches; the applied filters + page are mirrored into the URL (`useAdminListState`, ui-phase-11) so
|
||||
* browser back/refresh/a pasted link all reproduce the exact same queue view. This surface is staff-only —
|
||||
* the server enforces the scope; the UI just routes here.
|
||||
*/
|
||||
export default function AdminTicketsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminTicketsQueue />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTicketsQueue() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [draft, setDraft] = useState<AdminTicketFilters>(EMPTY);
|
||||
const [applied, setApplied] = useState<AdminTicketFilters>(EMPTY);
|
||||
const [page, setPage] = useState(1);
|
||||
const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<AdminTicketFilters>({
|
||||
parse: parseFilters,
|
||||
serialize: serializeFilters,
|
||||
empty: EMPTY,
|
||||
});
|
||||
|
||||
const tickets = useAdminTickets(applied, page);
|
||||
const items = tickets.data?.items ?? [];
|
||||
const pageCount = Math.max(1, Math.ceil((tickets.data?.total ?? 0) / TICKETS_PAGE_SIZE));
|
||||
|
||||
const apply = () => {
|
||||
setApplied(draft);
|
||||
setPage(1);
|
||||
};
|
||||
const clear = () => {
|
||||
setDraft(EMPTY);
|
||||
setApplied(EMPTY);
|
||||
setPage(1);
|
||||
};
|
||||
const total = tickets.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / TICKETS_PAGE_SIZE));
|
||||
const from = items.length === 0 ? 0 : (page - 1) * TICKETS_PAGE_SIZE + 1;
|
||||
const to = items.length === 0 ? 0 : from + items.length - 1;
|
||||
|
||||
const columns: AdminTableColumn<AdminTicketSummary>[] = [
|
||||
{
|
||||
@@ -76,6 +98,12 @@ export default function AdminTicketsPage() {
|
||||
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
|
||||
},
|
||||
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
|
||||
{
|
||||
key: 'activity',
|
||||
header: t('ticket_activity_col'),
|
||||
minWidth: 140,
|
||||
render: (row) => formatShamsiDate(row.createdAt, locale),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -149,17 +177,18 @@ export default function AdminTicketsPage() {
|
||||
getRowKey={(row) => row.id}
|
||||
ariaLabel={t('ticket_title')}
|
||||
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
|
||||
footer={t('showing_range', { from, to, total })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
onPrev={() => goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user