frontend phase 15
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
import { useState } 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 type { StatusKind } from '@/components';
|
||||
import {
|
||||
AdminDataTable,
|
||||
AdminEmptyState,
|
||||
AdminErrorState,
|
||||
AdminPageHeader,
|
||||
AdminPager,
|
||||
type AdminTableColumn,
|
||||
} from '@/components/admin';
|
||||
import { adminTicketThreadPath } from '@/constants';
|
||||
import { useAdminTickets } from '@/services/tickets';
|
||||
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
|
||||
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
|
||||
|
||||
const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
|
||||
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
|
||||
/** Queue status → chip color: an open ticket is pending work, a closed one is neutral (phase §5). */
|
||||
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
|
||||
const EMPTY: AdminTicketFilters = {};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function AdminTicketsPage() {
|
||||
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 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 columns: AdminTableColumn<AdminTicketSummary>[] = [
|
||||
{
|
||||
key: 'ref',
|
||||
header: t('ticket_col_ref'),
|
||||
render: (row) => (
|
||||
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
|
||||
{row.referenceCode}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{ key: 'subject', header: t('ticket_col_subject'), render: (row) => row.subject ?? '—' },
|
||||
{
|
||||
key: 'category',
|
||||
header: t('ticket_col_category'),
|
||||
render: (row) => <Chip size="small" label={t(`tcat_${row.category}`)} />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('ticket_col_status'),
|
||||
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
|
||||
},
|
||||
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader title={t('ticket_title')} subtitle={t('ticket_subtitle')} />
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={t('ticket_col_status')}
|
||||
value={draft.status ?? ''}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, status: (e.target.value || undefined) as TicketStatus | undefined }))}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||
{STATUSES.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{t(`tstatus_${s}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={t('ticket_col_category')}
|
||||
value={draft.category ?? ''}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, category: (e.target.value || undefined) as TicketCategory | undefined }))}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||
{CATEGORIES.map((c) => (
|
||||
<MenuItem key={c} value={c}>
|
||||
{t(`tcat_${c}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
size="small"
|
||||
label={t('ticket_col_ref')}
|
||||
placeholder={t('ticket_search_ref_ph')}
|
||||
value={draft.referenceCode ?? ''}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, referenceCode: e.target.value || undefined }))}
|
||||
sx={{ minWidth: 220 }}
|
||||
/>
|
||||
<AppButton variant="contained" color="primary" onClick={apply} sx={{ m: 0 }}>
|
||||
{t('apply')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="inherit" onClick={clear} sx={{ m: 0 }}>
|
||||
{t('clear')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
{tickets.isLoading ? (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{[0, 1, 2, 3].map((k) => (
|
||||
<Skeleton key={k} variant="rounded" height={48} />
|
||||
))}
|
||||
</Stack>
|
||||
) : tickets.isError ? (
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => tickets.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<AdminEmptyState icon="support" title={t('ticket_empty')} />
|
||||
) : (
|
||||
<AdminDataTable
|
||||
columns={columns}
|
||||
rows={items}
|
||||
getRowKey={(row) => row.id}
|
||||
ariaLabel={t('ticket_title')}
|
||||
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user