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 = { 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 = { 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 = { 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 = { 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([]); const [meta, setMeta] = useState({ page: 1, pageSize: 10, total: 0, totalPages: 1, hasMore: false }); const [stats, setStats] = useState({ 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 = { 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 (

{isEn ? 'Support Tickets' : 'Тикеты поддержки'}

{isEn ? 'Create tickets and track their processing in real time.' : 'Создавайте обращения и следите за их обработкой в режиме реального времени.'}

{statusCards.map((card) => (
{card.title}
{card.value}
))}
{isOperator && (
)}
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" />
{loading ? (

{isEn ? 'Loading tickets...' : 'Загрузка тикетов...'}

) : tickets.length === 0 ? (

{isEn ? 'No tickets yet' : 'Тикетов пока нет'}

{isEn ? 'Create a ticket so the support team can help. We are always here.' : 'Создайте тикет, чтобы команда поддержки могла помочь. Мы всегда рядом.'}

{isEn ? 'Create first ticket' : 'Создать первый тикет'}
) : ( <>
ID {isEn ? 'Subject' : 'Тема'} {isEn ? 'Status' : 'Статус'} {isEn ? 'Priority' : 'Приоритет'} {isEn ? 'Updated' : 'Обновлён'}
    {tickets.map((ticket) => { const statusMeta = STATUS_DICTIONARY[ticket.status] ?? STATUS_DICTIONARY.open; const priorityMeta = PRIORITY_DICTIONARY[ticket.priority] ?? PRIORITY_DICTIONARY.normal; return (
  • ); })}
Показано {(meta.page - 1) * meta.pageSize + 1}– {Math.min(meta.page * meta.pageSize, meta.total)} из {meta.total}
Стр. {meta.page} / {meta.totalPages}
)}
{error && (
{error}
)}
); }; export default TicketsPage;