english version update
This commit is contained in:
@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import AuthContext from '../../../context/authcontext';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
|
||||
interface TicketAuthor {
|
||||
id: number;
|
||||
@@ -66,7 +67,7 @@ interface TicketStats {
|
||||
unassigned?: number;
|
||||
}
|
||||
|
||||
const STATUS_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
const STATUS_DICTIONARY_RU: Record<string, { label: string; badge: string }> = {
|
||||
open: { label: 'Открыт', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { label: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { label: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
@@ -74,21 +75,41 @@ const STATUS_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
closed: { label: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
const STATUS_DICTIONARY_EN: Record<string, { label: string; badge: string }> = {
|
||||
open: { label: 'Open', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { label: 'In Progress', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { label: 'Awaiting Reply', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
resolved: { label: 'Resolved', badge: 'bg-purple-100 text-purple-800' },
|
||||
closed: { label: 'Closed', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY_RU: Record<string, { label: string; badge: string }> = {
|
||||
urgent: { label: 'Срочно', badge: 'bg-red-50 text-red-700 border border-red-200' },
|
||||
high: { label: 'Высокий', badge: 'bg-orange-50 text-orange-700 border border-orange-200' },
|
||||
normal: { label: 'Обычный', badge: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
low: { label: 'Низкий', badge: 'bg-green-50 text-green-700 border border-green-200' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY_EN: Record<string, { label: string; badge: string }> = {
|
||||
urgent: { label: 'Urgent', badge: 'bg-red-50 text-red-700 border border-red-200' },
|
||||
high: { label: 'High', badge: 'bg-orange-50 text-orange-700 border border-orange-200' },
|
||||
normal: { label: 'Normal', badge: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
low: { label: 'Low', badge: 'bg-green-50 text-green-700 border border-green-200' },
|
||||
};
|
||||
|
||||
|
||||
|
||||
const TicketsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { userData } = useContext(AuthContext);
|
||||
const { addToast } = useToast();
|
||||
const { locale } = useTranslation();
|
||||
const isEn = locale === 'en';
|
||||
const isOperator = Boolean(userData?.user?.operator);
|
||||
|
||||
const STATUS_DICTIONARY = isEn ? STATUS_DICTIONARY_EN : STATUS_DICTIONARY_RU;
|
||||
const PRIORITY_DICTIONARY = isEn ? PRIORITY_DICTIONARY_EN : PRIORITY_DICTIONARY_RU;
|
||||
|
||||
const [tickets, setTickets] = useState<TicketItem[]>([]);
|
||||
const [meta, setMeta] = useState<TicketListMeta>({ page: 1, pageSize: 10, total: 0, totalPages: 1, hasMore: false });
|
||||
const [stats, setStats] = useState<TicketStats>({ open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
|
||||
@@ -182,29 +203,29 @@ const TicketsPage = () => {
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMinutes < 1) return 'только что';
|
||||
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
if (diffMinutes < 1) return isEn ? 'just now' : 'только что';
|
||||
if (diffMinutes < 60) return isEn ? `${diffMinutes} min ago` : `${diffMinutes} мин назад`;
|
||||
if (diffHours < 24) return isEn ? `${diffHours} h ago` : `${diffHours} ч назад`;
|
||||
if (diffDays < 7) return isEn ? `${diffDays} d ago` : `${diffDays} дн назад`;
|
||||
return date.toLocaleDateString(isEn ? 'en-US' : 'ru-RU');
|
||||
};
|
||||
|
||||
const statusCards = useMemo(() => {
|
||||
if (isOperator) {
|
||||
return [
|
||||
{ title: 'Открытые', value: stats.open, accent: 'bg-green-50 text-green-700 border border-green-100' },
|
||||
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: 'Назначены мне', value: stats.assignedToMe ?? 0, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: 'Без оператора', value: stats.unassigned ?? 0, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
{ title: isEn ? 'Open' : 'Открытые', value: stats.open, accent: 'bg-green-50 text-green-700 border border-green-100' },
|
||||
{ title: isEn ? 'Awaiting Reply' : 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: isEn ? 'Assigned to me' : 'Назначены мне', value: stats.assignedToMe ?? 0, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: isEn ? 'Unassigned' : 'Без оператора', value: stats.unassigned ?? 0, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ title: 'Активные', value: stats.open + stats.inProgress, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: 'Закрытые', value: stats.closed + stats.resolved, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
{ title: isEn ? 'Active' : 'Активные', value: stats.open + stats.inProgress, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: isEn ? 'Awaiting Reply' : 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: isEn ? 'Closed' : 'Закрытые', value: stats.closed + stats.resolved, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
];
|
||||
}, [isOperator, stats]);
|
||||
}, [isOperator, stats, isEn]);
|
||||
|
||||
const handleChangePage = (nextPage: number) => {
|
||||
setMeta((prev) => ({ ...prev, page: nextPage }));
|
||||
@@ -215,14 +236,14 @@ const TicketsPage = () => {
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Тикеты поддержки</h1>
|
||||
<p className="text-gray-600">Создавайте обращения и следите за их обработкой в режиме реального времени.</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{isEn ? 'Support Tickets' : 'Тикеты поддержки'}</h1>
|
||||
<p className="text-gray-600">{isEn ? 'Create tickets and track their processing in real time.' : 'Создавайте обращения и следите за их обработкой в режиме реального времени.'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
Новый тикет
|
||||
{isEn ? 'New Ticket' : 'Новый тикет'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -240,69 +261,69 @@ const TicketsPage = () => {
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-6">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Статус</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Status' : 'Статус'}</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, status: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="open">Открыт</option>
|
||||
<option value="in_progress">В работе</option>
|
||||
<option value="awaiting_reply">Ожидает ответа</option>
|
||||
<option value="resolved">Решён</option>
|
||||
<option value="closed">Закрыт</option>
|
||||
<option value="all">{isEn ? 'All statuses' : 'Все статусы'}</option>
|
||||
<option value="open">{isEn ? 'Open' : 'Открыт'}</option>
|
||||
<option value="in_progress">{isEn ? 'In Progress' : 'В работе'}</option>
|
||||
<option value="awaiting_reply">{isEn ? 'Awaiting Reply' : 'Ожидает ответа'}</option>
|
||||
<option value="resolved">{isEn ? 'Resolved' : 'Решён'}</option>
|
||||
<option value="closed">{isEn ? 'Closed' : 'Закрыт'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Категория</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Category' : 'Категория'}</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, category: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все категории</option>
|
||||
<option value="general">Общие вопросы</option>
|
||||
<option value="technical">Технические</option>
|
||||
<option value="billing">Биллинг</option>
|
||||
<option value="other">Другое</option>
|
||||
<option value="all">{isEn ? 'All categories' : 'Все категории'}</option>
|
||||
<option value="general">{isEn ? 'General' : 'Общие вопросы'}</option>
|
||||
<option value="technical">{isEn ? 'Technical' : 'Технические'}</option>
|
||||
<option value="billing">{isEn ? 'Billing' : 'Биллинг'}</option>
|
||||
<option value="other">{isEn ? 'Other' : 'Другое'}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Приоритет</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Priority' : 'Приоритет'}</label>
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, priority: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все приоритеты</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="normal">Обычный</option>
|
||||
<option value="low">Низкий</option>
|
||||
<option value="all">{isEn ? 'All priorities' : 'Все приоритеты'}</option>
|
||||
<option value="urgent">{isEn ? 'Urgent' : 'Срочно'}</option>
|
||||
<option value="high">{isEn ? 'High' : 'Высокий'}</option>
|
||||
<option value="normal">{isEn ? 'Normal' : 'Обычный'}</option>
|
||||
<option value="low">{isEn ? 'Low' : 'Низкий'}</option>
|
||||
</select>
|
||||
</div>
|
||||
{isOperator && (
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Назначение</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Assignment' : 'Назначение'}</label>
|
||||
<select
|
||||
value={filters.assigned}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, assigned: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
<option value="me">Мои тикеты</option>
|
||||
<option value="unassigned">Без оператора</option>
|
||||
<option value="others">Назначены другим</option>
|
||||
<option value="all">{isEn ? 'All' : 'Все'}</option>
|
||||
<option value="me">{isEn ? 'My tickets' : 'Мои тикеты'}</option>
|
||||
<option value="unassigned">{isEn ? 'Unassigned' : 'Без оператора'}</option>
|
||||
<option value="others">{isEn ? 'Assigned to others' : 'Назначены другим'}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className={isOperator ? 'lg:col-span-4' : 'lg:col-span-6'}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Поиск</label>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">{isEn ? 'Search' : 'Поиск'}</label>
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="Поиск по теме или описанию..."
|
||||
placeholder={isEn ? 'Search by subject or description...' : 'Поиск по теме или описанию...'}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
@@ -313,29 +334,29 @@ const TicketsPage = () => {
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Загрузка тикетов...</p>
|
||||
<p className="text-sm text-gray-500">{isEn ? 'Loading tickets...' : 'Загрузка тикетов...'}</p>
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Тикетов пока нет</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{isEn ? 'No tickets yet' : 'Тикетов пока нет'}</h3>
|
||||
<p className="max-w-md text-sm text-gray-500">
|
||||
Создайте тикет, чтобы команда поддержки могла помочь. Мы всегда рядом.
|
||||
{isEn ? 'Create a ticket so the support team can help. We are always here.' : 'Создайте тикет, чтобы команда поддержки могла помочь. Мы всегда рядом.'}
|
||||
</p>
|
||||
<Link
|
||||
to="/dashboard/tickets/new"
|
||||
className="rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
Создать первый тикет
|
||||
{isEn ? 'Create first ticket' : 'Создать первый тикет'}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden w-full grid-cols-[100px_1fr_160px_160px_160px] gap-4 border-b border-gray-100 px-6 py-3 text-xs font-semibold uppercase tracking-wide text-gray-500 lg:grid">
|
||||
<span>ID</span>
|
||||
<span>Тема</span>
|
||||
<span>Статус</span>
|
||||
<span>Приоритет</span>
|
||||
<span>Обновлён</span>
|
||||
<span>{isEn ? 'Subject' : 'Тема'}</span>
|
||||
<span>{isEn ? 'Status' : 'Статус'}</span>
|
||||
<span>{isEn ? 'Priority' : 'Приоритет'}</span>
|
||||
<span>{isEn ? 'Updated' : 'Обновлён'}</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{tickets.map((ticket) => {
|
||||
@@ -358,7 +379,7 @@ const TicketsPage = () => {
|
||||
<p className="mt-1 line-clamp-2 text-sm text-gray-500">{ticket.message}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
{ticket.assignedOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-1 text-blue-700">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-1 text-blue-700 truncate max-w-[120px]" title={ticket.assignedOperator.username}>
|
||||
{ticket.assignedOperator.username}
|
||||
</span>
|
||||
)}
|
||||
@@ -367,7 +388,7 @@ const TicketsPage = () => {
|
||||
{ticket.responseCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-500">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-500 truncate max-w-[120px]" title={ticket.user?.username ?? 'Неизвестно'}>
|
||||
{ticket.user?.username ?? 'Неизвестно'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user