227 lines
9.0 KiB
TypeScript
227 lines
9.0 KiB
TypeScript
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;
|