Files
EventHubFrontAdmin/src/pages/tickets/TicketListPage.tsx
T
aleksey b7d572ea8e
CI / test (push) Successful in 1m52s
CI / push-image (push) Successful in 6m57s
CI / ci-done (push) Successful in 0s
fix(admin): UX Control Center — даты Inbox, bulk, фильтры событий, палитра по ID
Refs EventHub/EventHubFrontAdmin#32

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 20:53:58 +03:00

227 lines
9.0 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.
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ColumnDef } from '@tanstack/react-table';
import { Info, Trash2 } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { useTickets, useDeleteTicket, useTicketStats } from '@/hooks/useTickets';
import { useExploreView } from '@/hooks/useExploreView';
import { useAdmin } from '@/hooks/useAdmins';
import { Ticket, TicketListParams } from '@/types/api';
import { getSavedPageSize } from '@/utils/tablePagination';
import { formatStatusLabel } from '@/utils/statusLabels';
import { ExploreListShell } from '@/components/explore/ExploreListShell';
import { ExploreDataView } from '@/components/explore/ExploreDataView';
import { RowActionsMenu, type RowAction } from '@/components/explore/RowActionsMenu';
import type { DenseRowModel } from '@/components/explore/DenseRowList';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
const TABLE_KEY = 'tickets';
const isBadValue = (val: unknown) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const ticketStatusVariant = (status: string) => {
if (status === 'open') return 'destructive' as const;
if (status === 'in_progress') return 'default' as const;
if (status === 'resolved') return 'secondary' as const;
return 'outline' as const;
};
function ticketActions(
handlers: { onOpen: () => void; onDelete: () => void },
t: TFunction
): RowAction[] {
return [
{ label: t('common.open'), icon: <Info className="h-4 w-4" />, onClick: handlers.onOpen },
{
label: t('common.delete'),
icon: <Trash2 className="h-4 w-4" />,
onClick: handlers.onDelete,
destructive: true,
separatorBefore: true,
},
];
}
const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loading) return <Skeleton className="h-4 w-20" />;
if (!admin) return <span>{adminId}</span>;
const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>;
};
const TicketListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { viewMode, setViewMode } = useExploreView();
const [params, setParams] = useState<TicketListParams>({
limit: getSavedPageSize(TABLE_KEY),
offset: 0,
sort: 'last_seen',
order: 'desc',
});
const { data, isLoading } = useTickets(params);
const deleteTicket = useDeleteTicket();
const { data: stats } = useTicketStats();
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; ticketId: string | null }>({
open: false,
ticketId: null,
});
const handleDelete = (id: string) => setDeleteConfirm({ open: true, ticketId: id });
const confirmDelete = () => {
if (!deleteConfirm.ticketId) return;
deleteTicket.mutate(deleteConfirm.ticketId, {
onSuccess: () => setDeleteConfirm({ open: false, ticketId: null }),
});
};
const exploreStats = useMemo(() => {
if (!stats) return [];
return [
{ label: t('common.total'), value: stats.total_tickets },
{ label: formatStatusLabel('open'), value: stats.open },
{ label: formatStatusLabel('in_progress'), value: stats.in_progress },
{ label: formatStatusLabel('resolved'), value: stats.resolved },
];
}, [stats, t]);
const columns = useMemo<ColumnDef<Ticket>[]>(
() => [
{
accessorKey: 'error_message',
header: t('common.error'),
enableSorting: true,
cell: ({ getValue }) => {
const text = getValue<string>();
return isBadValue(text) ? '-' : text;
},
},
{
accessorKey: 'status',
header: t('common.status'),
size: 100,
enableSorting: true,
cell: ({ getValue }) => {
const status = getValue<string>();
return <Badge variant={ticketStatusVariant(status)}>{formatStatusLabel(status)}</Badge>;
},
},
{
accessorKey: 'assigned_to',
header: t('common.assigned'),
enableSorting: false,
cell: ({ getValue }) => <AssignedCell adminId={getValue<string | null>()} />,
},
{ accessorKey: 'count', header: t('common.count'), size: 80, enableSorting: true },
{ accessorKey: 'first_seen', header: t('common.firstSeen'), enableSorting: true },
{ accessorKey: 'last_seen', header: t('common.lastSeen'), enableSorting: true },
{
id: 'actions',
header: '',
size: 48,
enableSorting: false,
cell: ({ row }) => {
const record = row.original;
return (
<RowActionsMenu
actions={ticketActions({
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
}, t)}
/>
);
},
},
],
[navigate, t]
);
const denseRows = useMemo<DenseRowModel[]>(() => {
return (data?.data ?? []).map((record) => {
const errorText = isBadValue(record.error_message) ? record.id : record.error_message;
const title =
typeof errorText === 'string' && errorText.length > 80
? `${errorText.slice(0, 80)}`
: errorText;
return {
id: record.id,
title,
subtitle: record.assigned_to && record.assigned_to !== '-' ? record.assigned_to : undefined,
badges: (
<Badge variant={ticketStatusVariant(record.status)}>
{formatStatusLabel(record.status)}
</Badge>
),
meta: `×${record.count} · ${record.last_seen}`,
onActivate: () => navigate(`/tickets/${record.id}`),
actions: ticketActions({
onOpen: () => navigate(`/tickets/${record.id}`),
onDelete: () => handleDelete(record.id),
}, t),
};
});
}, [data?.data, navigate, t]);
return (
<>
<ExploreListShell
title={t('tickets.title')}
stats={exploreStats}
viewMode={viewMode}
onViewModeChange={setViewMode}
banner={{
text: t('inbox.bannerTickets'),
to: '/inbox/tickets',
actionLabel: t('common.openInbox'),
}}
>
<ExploreDataView
viewMode={viewMode}
columns={columns}
data={data?.data ?? []}
denseRows={denseRows}
total={data?.total}
isLoading={isLoading}
tableKey={TABLE_KEY}
params={params}
onParamsChange={setParams}
/>
</ExploreListShell>
<Dialog open={deleteConfirm.open} onOpenChange={(open) => !open && setDeleteConfirm({ open: false, ticketId: null })}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('tickets.deleteTitle')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">{t('common.irreversible')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm({ open: false, ticketId: null })}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteTicket.isPending}>
{t('common.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default TicketListPage;