Files
ospab.host/ospabhost/frontend/src/pages/dashboard/tickets/index.tsx
2025-12-31 19:59:43 +03:00

455 lines
22 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 { useContext, useEffect, useMemo, useState } from 'react';
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;
username: string;
operator: boolean;
email?: string | null;
}
interface TicketAttachment {
id: number;
filename: string;
fileUrl: string;
fileSize: number;
mimeType: string;
createdAt: string;
}
interface TicketResponse {
id: number;
message: string;
isInternal: boolean;
createdAt: string;
author: TicketAuthor | null;
attachments: TicketAttachment[];
}
interface TicketItem {
id: number;
title: string;
message: string;
status: string;
priority: string;
category: string;
user: TicketAuthor | null;
assignedTo: number | null;
assignedOperator: TicketAuthor | null;
createdAt: string;
updatedAt: string;
closedAt: string | null;
responseCount: number;
lastResponseAt: string | null;
attachments: TicketAttachment[];
responses: TicketResponse[];
}
interface TicketListMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasMore: boolean;
}
interface TicketStats {
open: number;
inProgress: number;
awaitingReply: number;
resolved: number;
closed: number;
assignedToMe?: number;
unassigned?: number;
}
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' },
resolved: { label: 'Решён', badge: 'bg-purple-100 text-purple-800' },
closed: { label: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
};
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 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filters, setFilters] = useState({
status: 'all',
category: 'all',
priority: 'all',
assigned: 'all',
});
const [searchInput, setSearchInput] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
// Debounce search input to avoid flooding the API while typing
const timer = window.setTimeout(() => {
setDebouncedSearch(searchInput.trim());
}, 350);
return () => window.clearTimeout(timer);
}, [searchInput]);
useEffect(() => {
setMeta((prev) => (prev.page === 1 ? prev : { ...prev, page: 1 }));
}, [filters.status, filters.category, filters.priority, filters.assigned, debouncedSearch]);
useEffect(() => {
let isMounted = true;
const fetchTickets = async () => {
setLoading(true);
setError('');
try {
const params: Record<string, string | number> = {
page: meta.page,
pageSize: meta.pageSize,
};
if (filters.status !== 'all') params.status = filters.status;
if (filters.category !== 'all') params.category = filters.category;
if (filters.priority !== 'all') params.priority = filters.priority;
if (debouncedSearch) params.search = debouncedSearch;
if (isOperator && filters.assigned !== 'all') params.assigned = filters.assigned;
const response = await apiClient.get('/api/ticket', { params });
if (!isMounted) return;
const payload = response.data ?? {};
setTickets(Array.isArray(payload.tickets) ? payload.tickets : []);
setMeta((prev) => ({
...prev,
...(payload.meta ?? {}),
}));
setStats(payload.stats ?? { open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
} catch (err) {
if (!isMounted) return;
console.error('Ошибка загрузки тикетов:', err);
setError('Не удалось загрузить тикеты');
addToast('Не удалось загрузить тикеты. Попробуйте позже.', 'error');
setTickets([]);
setMeta((prev) => ({ ...prev, page: 1, total: 0, totalPages: 1, hasMore: false }));
setStats({ open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchTickets();
return () => {
isMounted = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meta.page, meta.pageSize, filters.status, filters.category, filters.priority, filters.assigned, debouncedSearch, isOperator]);
const formatRelativeTime = (dateString: string | null) => {
if (!dateString) {
return '—';
}
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
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: 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: 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, isEn]);
const handleChangePage = (nextPage: number) => {
setMeta((prev) => ({ ...prev, page: nextPage }));
};
return (
<div className="min-h-screen bg-gray-50 py-8">
<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">{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>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{statusCards.map((card) => (
<div key={card.title} className={`rounded-xl p-4 shadow-sm ${card.accent}`}>
<div className="flex items-center justify-between">
<span className="text-lg font-semibold">{card.title}</span>
</div>
<div className="mt-2 text-3xl font-bold">{card.value}</div>
</div>
))}
</div>
<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">{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">{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">{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">{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">{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">{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">{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">{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">{isEn ? 'Search' : 'Поиск'}</label>
<input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
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>
</div>
</div>
<div className="rounded-2xl bg-white shadow-sm">
{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">{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">{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>{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) => {
const statusMeta = STATUS_DICTIONARY[ticket.status] ?? STATUS_DICTIONARY.open;
const priorityMeta = PRIORITY_DICTIONARY[ticket.priority] ?? PRIORITY_DICTIONARY.normal;
return (
<li key={ticket.id}>
<button
type="button"
onClick={() => navigate(`/dashboard/tickets/${ticket.id}`)}
className="w-full px-6 py-4 text-left transition hover:bg-gray-50"
>
<div className="flex flex-col gap-4 lg:grid lg:grid-cols-[100px_1fr_160px_160px_160px] lg:items-center lg:gap-4">
<span className="text-sm font-semibold text-gray-500">#{ticket.id}</span>
<div>
<div className="flex items-center gap-2 text-base font-semibold text-gray-900">
<span className="line-clamp-1">{ticket.title}</span>
</div>
<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 truncate max-w-[120px]" title={ticket.assignedOperator.username}>
{ticket.assignedOperator.username}
</span>
)}
{ticket.responseCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-600">
{ticket.responseCount}
</span>
)}
<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>
</div>
<span className={`inline-flex items-center justify-start rounded-full px-3 py-1 text-xs font-semibold ${statusMeta.badge}`}>
{statusMeta.label}
</span>
<span className={`inline-flex items-center justify-start rounded-full px-3 py-1 text-xs font-semibold ${priorityMeta.badge}`}>
{priorityMeta.label}
</span>
<div className="text-sm text-gray-500">
<div>{formatRelativeTime(ticket.updatedAt)}</div>
{ticket.lastResponseAt && (
<div className="text-xs text-gray-400">Ответ: {formatRelativeTime(ticket.lastResponseAt)}</div>
)}
</div>
</div>
</button>
</li>
);
})}
</ul>
<div className="flex flex-col items-center justify-between gap-3 border-t border-gray-100 px-6 py-4 text-sm text-gray-600 md:flex-row">
<span>
Показано {(meta.page - 1) * meta.pageSize + 1}
{Math.min(meta.page * meta.pageSize, meta.total)} из {meta.total}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleChangePage(Math.max(1, meta.page - 1))}
disabled={meta.page === 1}
className="rounded-lg border border-gray-200 px-3 py-1 font-medium text-gray-600 transition disabled:cursor-not-allowed disabled:opacity-40 hover:bg-gray-100"
>
Назад
</button>
<span className="px-2 text-sm">Стр. {meta.page} / {meta.totalPages}</span>
<button
type="button"
onClick={() => handleChangePage(meta.page + 1)}
disabled={!meta.hasMore}
className="rounded-lg border border-gray-200 px-3 py-1 font-medium text-gray-600 transition disabled:cursor-not-allowed disabled:opacity-40 hover:bg-gray-100"
>
Вперёд
</button>
</div>
</div>
</>
)}
</div>
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-600">
{error}
</div>
)}
</div>
</div>
);
};
export default TicketsPage;