feat(admin): Control Center redesign, Explore dual-view и полный RU/EN i18n
Refs EventHub/EventHubFrontAdmin#32
This commit is contained in:
@@ -1,19 +1,53 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Card, Descriptions, Spin, Button, Tag, Space, Form, Select, Input } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTicket, useUpdateTicket } from '../../hooks/useTickets';
|
||||
import { useUser } from '../../hooks/useUsers';
|
||||
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import dayjs from 'dayjs';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useTicket, useUpdateTicket } from '@/hooks/useTickets';
|
||||
import { useUser } from '@/hooks/useUsers';
|
||||
import { useAdmin, useAdmins } from '@/hooks/useAdmins';
|
||||
import { formatDisplayValue } from '@/lib/utils';
|
||||
import { formatStatusLabel } from '@/utils/statusLabels';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
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 'success' as const;
|
||||
return 'secondary' as const;
|
||||
};
|
||||
|
||||
interface TicketFormValues {
|
||||
status: string;
|
||||
assigned_to?: string;
|
||||
resolution_note?: string;
|
||||
}
|
||||
|
||||
const UNASSIGNED = '__none__';
|
||||
|
||||
const TicketDetailPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: ticket, isLoading } = useTicket(id || '');
|
||||
const updateTicket = useUpdateTicket();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
|
||||
|
||||
const reporterId = ticket?.reporter_id;
|
||||
@@ -22,129 +56,202 @@ const TicketDetailPage: React.FC = () => {
|
||||
const assignedId = ticket?.assigned_to;
|
||||
const { data: assignedAdmin, isLoading: loadingAssigned } = useAdmin(assignedId || '');
|
||||
|
||||
const form = useForm<TicketFormValues>({
|
||||
defaultValues: { status: 'open', assigned_to: UNASSIGNED, resolution_note: '' },
|
||||
});
|
||||
|
||||
const emDash = t('common.emDash');
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (isBadValue(dateStr)) return emDash;
|
||||
const d = dayjs(dateStr);
|
||||
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ticket) {
|
||||
form.setFieldsValue({
|
||||
form.reset({
|
||||
status: ticket.status,
|
||||
assigned_to: !isBadValue(ticket.assigned_to) ? ticket.assigned_to : undefined,
|
||||
assigned_to: !isBadValue(ticket.assigned_to) ? ticket.assigned_to : UNASSIGNED,
|
||||
resolution_note: !isBadValue(ticket.resolution_note) ? ticket.resolution_note : '',
|
||||
});
|
||||
}
|
||||
}, [ticket, form]);
|
||||
|
||||
const handleSave = () => {
|
||||
form.validateFields().then((values) => {
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
|
||||
);
|
||||
updateTicket.mutate(
|
||||
{ id: id!, data: payload },
|
||||
{ onSuccess: () => navigate('/tickets') }
|
||||
);
|
||||
});
|
||||
};
|
||||
const onSubmit = (values: TicketFormValues) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (values.status) payload.status = values.status;
|
||||
if (values.assigned_to && values.assigned_to !== UNASSIGNED) payload.assigned_to = values.assigned_to;
|
||||
if (values.resolution_note?.trim()) payload.resolution_note = values.resolution_note.trim();
|
||||
|
||||
const isBadValue = (val: any) =>
|
||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (isBadValue(dateStr)) return '-';
|
||||
const d = dayjs(dateStr);
|
||||
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
|
||||
updateTicket.mutate(
|
||||
{ id: id!, data: payload },
|
||||
{ onSuccess: () => navigate('/tickets') }
|
||||
);
|
||||
};
|
||||
|
||||
const getUserLink = () => {
|
||||
if (loadingReporter) return <Spin size="small" />;
|
||||
if (!reporter) return reporterId || '-';
|
||||
if (loadingReporter) return <Skeleton className="h-4 w-24" />;
|
||||
if (!reporter) return reporterId || emDash;
|
||||
const name = reporter.nickname && !isBadValue(reporter.nickname)
|
||||
? reporter.nickname
|
||||
: reporter.email;
|
||||
return <Link to={`/users/${reporter.id}`}>{name || reporter.id}</Link>;
|
||||
return <Link to={`/users/${reporter.id}`} className="text-primary hover:underline">{name || reporter.id}</Link>;
|
||||
};
|
||||
|
||||
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||
if (!ticket) return <p>Тикет не найден</p>;
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!ticket) return <p className="text-muted-foreground">{t('tickets.notFound')}</p>;
|
||||
|
||||
return (
|
||||
<Card title={`Тикет ${ticket.id}`}>
|
||||
<Descriptions bordered column={1} size="small" style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="ID">{ticket.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="Отправитель">{getUserLink()}</Descriptions.Item>
|
||||
<Descriptions.Item label="Хеш ошибки">{ticket.error_hash}</Descriptions.Item>
|
||||
<Descriptions.Item label="Сообщение">{ticket.error_message}</Descriptions.Item>
|
||||
<Descriptions.Item label="Стектрейс">
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto' }}>
|
||||
{isBadValue(ticket.stacktrace) ? '-' : ticket.stacktrace}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Контекст">
|
||||
{isBadValue(ticket.context) ? '-' : ticket.context}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Количество">{ticket.count}</Descriptions.Item>
|
||||
<Descriptions.Item label="Первый раз">{formatDate(ticket.first_seen)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Последний раз">{formatDate(ticket.last_seen)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Статус">
|
||||
<Tag color={
|
||||
ticket.status === 'open' ? 'red' :
|
||||
ticket.status === 'in_progress' ? 'blue' :
|
||||
ticket.status === 'resolved' ? 'green' : 'default'
|
||||
}>
|
||||
{ticket.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Назначен">
|
||||
{loadingAssigned ? <Spin size="small" /> : (
|
||||
assignedAdmin ? (
|
||||
<Link to={`/admins/${assignedAdmin.id}`}>
|
||||
{!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
|
||||
</Link>
|
||||
) : (isBadValue(ticket.assigned_to) ? '-' : ticket.assigned_to)
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Решение">
|
||||
{isBadValue(ticket.resolution_note) ? '-' : ticket.resolution_note}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<Form.Item label="Статус" name="status">
|
||||
<Select>
|
||||
<Select.Option value="open">open</Select.Option>
|
||||
<Select.Option value="in_progress">in_progress</Select.Option>
|
||||
<Select.Option value="resolved">resolved</Select.Option>
|
||||
<Select.Option value="closed">closed</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="Назначить" name="assigned_to">
|
||||
<Select
|
||||
placeholder="Выберите администратора"
|
||||
loading={loadingAdmins}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
>
|
||||
{(admins?.data || []).map((admin) => {
|
||||
const label = `${admin.email}${admin.nickname && admin.nickname !== '-' ? ` – ${admin.nickname}` : ''}`;
|
||||
return (
|
||||
<Select.Option key={admin.id} value={admin.id} label={label}>
|
||||
{label}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="Комментарий решения" name="resolution_note">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={updateTicket.isPending}>
|
||||
Сохранить
|
||||
<>
|
||||
<PageHeader
|
||||
title={t('tickets.detailTitle', { id: ticket.id })}
|
||||
breadcrumbs={[
|
||||
{ label: t('tickets.title'), href: '/tickets' },
|
||||
{ label: ticket.id },
|
||||
]}
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => navigate('/tickets')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('common.backToList')}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/tickets')}>Назад к списку</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<CardContent className="space-y-6">
|
||||
<dl className="grid gap-3 text-sm">
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.id')}</dt>
|
||||
<dd>{ticket.id}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.sender')}</dt>
|
||||
<dd>{getUserLink()}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.errorHash')}</dt>
|
||||
<dd>{ticket.error_hash}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.errorMessage')}</dt>
|
||||
<dd>{ticket.error_message}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.stacktrace')}</dt>
|
||||
<dd>
|
||||
<pre className="max-h-[200px] overflow-auto rounded-md bg-muted p-2 text-xs">
|
||||
{isBadValue(ticket.stacktrace) ? emDash : ticket.stacktrace}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.context')}</dt>
|
||||
<dd>{formatDisplayValue(ticket.context) === '-' ? emDash : formatDisplayValue(ticket.context)}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.count')}</dt>
|
||||
<dd>{ticket.count}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.firstSeen')}</dt>
|
||||
<dd>{formatDate(ticket.first_seen)}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.lastSeen')}</dt>
|
||||
<dd>{formatDate(ticket.last_seen)}</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.status')}</dt>
|
||||
<dd>
|
||||
<Badge variant={ticketStatusVariant(ticket.status)}>{formatStatusLabel(ticket.status)}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.assigned')}</dt>
|
||||
<dd>
|
||||
{loadingAssigned ? (
|
||||
<Skeleton className="h-4 w-24" />
|
||||
) : assignedAdmin ? (
|
||||
<Link to={`/admins/${assignedAdmin.id}`} className="text-primary hover:underline">
|
||||
{!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
|
||||
</Link>
|
||||
) : (
|
||||
isBadValue(ticket.assigned_to) ? emDash : ticket.assigned_to
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2">
|
||||
<dt className="text-muted-foreground">{t('common.resolution')}</dt>
|
||||
<dd>{isBadValue(ticket.resolution_note) ? emDash : ticket.resolution_note}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<form className="space-y-4 border-t pt-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.status')}</Label>
|
||||
<Controller
|
||||
name="status"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">{formatStatusLabel('open')}</SelectItem>
|
||||
<SelectItem value="in_progress">{formatStatusLabel('in_progress')}</SelectItem>
|
||||
<SelectItem value="resolved">{formatStatusLabel('resolved')}</SelectItem>
|
||||
<SelectItem value="closed">{formatStatusLabel('closed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('common.assign')}</Label>
|
||||
<Controller
|
||||
name="assigned_to"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAdmins}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('common.selectAdmin')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGNED}>{t('common.unassigned')}</SelectItem>
|
||||
{(admins?.data || []).map((admin) => {
|
||||
const label = `${admin.email}${admin.nickname && admin.nickname !== '-' ? ` – ${admin.nickname}` : ''}`;
|
||||
return (
|
||||
<SelectItem key={admin.id} value={admin.id}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resolution_note">{t('common.resolutionNote')}</Label>
|
||||
<Textarea id="resolution_note" rows={3} {...form.register('resolution_note')} />
|
||||
</div>
|
||||
<Button type="submit" disabled={updateTicket.isPending}>
|
||||
{updateTicket.isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketDetailPage;
|
||||
export default TicketDetailPage;
|
||||
|
||||
@@ -1,165 +1,231 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Table, Button, Tag, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
|
||||
import { InfoCircleOutlined, DeleteOutlined, BugOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
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 { useAdmin } from '../../hooks/useAdmins';
|
||||
import { Ticket, TicketListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
|
||||
import { formatStatusLabel } from '../../utils/statusLabels';
|
||||
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 [params, setParams] = useState<TicketListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'last_seen', order: 'desc' });
|
||||
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 isBadValue = (val: any) =>
|
||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ open: boolean; ticketId: string | null }>({
|
||||
open: false,
|
||||
ticketId: null,
|
||||
});
|
||||
|
||||
// Резолвер администратора для колонки "Назначен"
|
||||
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 <Spin size="small" />;
|
||||
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 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 handleTableChange = (
|
||||
pagination: any,
|
||||
_filters: any,
|
||||
sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
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: ColumnsType<Ticket> = [
|
||||
{
|
||||
title: <InfoCircleOutlined />,
|
||||
key: 'detail',
|
||||
width: 48,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Tooltip title="Открыть страницу тикета">
|
||||
<Button
|
||||
icon={<InfoCircleOutlined />}
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => navigate(`/tickets/${record.id}`)}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Ошибка',
|
||||
dataIndex: 'error_message',
|
||||
key: 'error_message',
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
render: (text: string) => isBadValue(text) ? '-' : text,
|
||||
},
|
||||
{
|
||||
title: 'Статус',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
sorter: true,
|
||||
render: (status: string) => {
|
||||
const color =
|
||||
status === 'open' ? 'red' :
|
||||
status === 'in_progress' ? 'blue' :
|
||||
status === 'resolved' ? 'green' : 'default';
|
||||
return <Tag color={color}>{formatStatusLabel(status)}</Tag>;
|
||||
const columns = useMemo<ColumnDef<Ticket>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'error_message',
|
||||
header: t('common.error'),
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
const text = getValue<string>();
|
||||
return isBadValue(text) ? '-' : text;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Назначен',
|
||||
dataIndex: 'assigned_to',
|
||||
key: 'assigned_to',
|
||||
render: (assigned: string) => <AssignedCell adminId={assigned} />,
|
||||
},
|
||||
{ title: 'Повторов', dataIndex: 'count', key: 'count', width: 80, sorter: true },
|
||||
{ title: 'Первый раз', dataIndex: 'first_seen', key: 'first_seen', sorter: true },
|
||||
{ title: 'Последний', dataIndex: 'last_seen', key: 'last_seen', sorter: true },
|
||||
{
|
||||
title: 'Действия',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Popconfirm
|
||||
title="Удалить тикет?"
|
||||
onConfirm={() => deleteTicket.mutate(record.id)}
|
||||
>
|
||||
<Tooltip title="Удалить">
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
danger
|
||||
{
|
||||
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)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[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 (
|
||||
<div>
|
||||
<h2>Тикеты</h2>
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Всего тикетов" value={stats.total_tickets} prefix={<BugOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Открыто" value={stats.open} styles={{ content: { color: 'red' } }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="В работе" value={stats.in_progress} prefix={<ClockCircleOutlined />} styles={{ content: { color: 'blue' } }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Решено" value={stats.resolved} prefix={<CheckCircleOutlined />} styles={{ content: { color: 'green' } }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Закрыто" value={stats.closed} prefix={<CloseCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Всего ошибок" value={stats.total_errors} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.data}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<ExploreListShell
|
||||
title={t('tickets.title')}
|
||||
description={
|
||||
stats
|
||||
? t('tickets.statsHint', { closed: stats.closed, errors: stats.total_errors })
|
||||
: t('explore.descActions')
|
||||
}
|
||||
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;
|
||||
export default TicketListPage;
|
||||
|
||||
Reference in New Issue
Block a user