import { useState, useRef, useEffect } from 'react'; import logger from '../utils/logger'; import { linkifyText } from '../utils/linkify'; import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminApi, AdminTicket, AdminTicketDetail } from '../api/admin'; import { ticketsApi } from '../api/tickets'; import { copyToClipboard as copyText } from '../utils/clipboard'; import { usePlatform } from '../platform/hooks/usePlatform'; interface MediaAttachment { id: string; file: File; preview: string; uploading: boolean; fileId?: string; mediaType: string; error?: string; } const ALLOWED_FILE_TYPES: Record = { 'image/jpeg': 'photo', 'image/png': 'photo', 'image/gif': 'photo', 'image/webp': 'photo', 'video/mp4': 'video', 'video/webm': 'video', 'video/quicktime': 'video', 'application/pdf': 'document', 'application/msword': 'document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document', 'text/plain': 'document', 'application/zip': 'document', 'application/x-rar-compressed': 'document', }; const ACCEPT_STRING = Object.keys(ALLOWED_FILE_TYPES).join(','); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB // BackIcon const BackIcon = () => ( ); export default function AdminTickets() { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { capabilities } = usePlatform(); const [selectedTicketId, setSelectedTicketId] = useState(null); const [statusFilter, setStatusFilter] = useState(''); const [replyText, setReplyText] = useState(''); const [isReplying, setIsReplying] = useState(false); const [replyError, setReplyError] = useState(null); const [page, setPage] = useState(1); const [attachments, setAttachments] = useState([]); const fileInputRef = useRef(null); const uploadIdRef = useRef(0); // Track all created blob URLs for cleanup on unmount const blobUrlsRef = useRef>(new Set()); useEffect(() => { const uploadRef = uploadIdRef; const urls = blobUrlsRef; return () => { uploadRef.current++; urls.current.forEach((u) => URL.revokeObjectURL(u)); }; }, []); const { data: stats } = useQuery({ queryKey: ['admin-ticket-stats'], queryFn: adminApi.getTicketStats, }); const { data: ticketsData, isLoading: ticketsLoading } = useQuery({ queryKey: ['admin-tickets', page, statusFilter], queryFn: () => adminApi.getTickets({ page, per_page: 20, status: statusFilter || undefined, }), }); const { data: selectedTicket, isLoading: ticketLoading } = useQuery({ queryKey: ['admin-ticket', selectedTicketId], queryFn: () => adminApi.getTicket(selectedTicketId!), enabled: !!selectedTicketId, }); const statusMutation = useMutation({ mutationFn: ({ ticketId, status }: { ticketId: number; status: string }) => adminApi.updateTicketStatus(ticketId, status), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] }); queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }); queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] }); }, }); const clearAttachments = () => { uploadIdRef.current++; setAttachments((prev) => { prev.forEach((a) => { if (a.preview) { URL.revokeObjectURL(a.preview); blobUrlsRef.current.delete(a.preview); } }); return []; }); if (fileInputRef.current) fileInputRef.current.value = ''; }; const removeAttachment = (idx: number) => { setAttachments((prev) => { const removed = prev[idx]; if (removed?.preview) URL.revokeObjectURL(removed.preview); return prev.filter((_, i) => i !== idx); }); }; const handleFileSelect = async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (!files.length) return; if (fileInputRef.current) fileInputRef.current.value = ''; const remaining = 10 - attachments.length; const toAdd = files.slice(0, remaining); for (const file of toAdd) { const mediaType = ALLOWED_FILE_TYPES[file.type]; if (!mediaType) continue; if (file.size > MAX_FILE_SIZE) continue; const preview = mediaType === 'photo' ? URL.createObjectURL(file) : ''; if (preview) blobUrlsRef.current.add(preview); const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `att_${Date.now()}_${Math.random().toString(36).slice(2)}`; const entry: MediaAttachment = { id, file, preview, uploading: true, mediaType }; const uploadToken = uploadIdRef.current; setAttachments((prev) => [...prev, entry]); // Upload in background; ignore the result if user cleared/unmounted in the meantime. (async () => { try { const result = await ticketsApi.uploadMedia(file, mediaType); if (uploadIdRef.current !== uploadToken) return; setAttachments((prev) => prev.map((a) => (a.id === id ? { ...a, uploading: false, fileId: result.file_id } : a)), ); } catch { if (uploadIdRef.current !== uploadToken) return; setAttachments((prev) => prev.map((a) => a.id === id ? { ...a, uploading: false, error: t('admin.tickets.uploadFailed') } : a, ), ); } })(); } }; const handleReply = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedTicketId) return; if (attachments.some((a) => a.uploading || a.error)) return; const readyAttachments = attachments.filter((a) => a.fileId) as Array<{ fileId: string; mediaType: string; }>; const hasText = replyText.trim().length > 0; const hasMedia = readyAttachments.length > 0; if (!hasText && !hasMedia) return; const media = hasMedia ? { media_type: readyAttachments[0].mediaType, media_file_id: readyAttachments[0].fileId, media_items: readyAttachments.map((a) => ({ type: a.mediaType as 'photo' | 'video' | 'document', file_id: a.fileId, })), } : undefined; setIsReplying(true); setReplyError(null); try { await adminApi.replyToTicket(selectedTicketId, replyText, media); } catch (err) { logger.error('Ticket reply failed:', err); const msg = err instanceof Error ? err.message : t('admin.tickets.replyFailed', 'Failed to send reply'); setReplyError(msg); setIsReplying(false); return; } setReplyText(''); clearAttachments(); setIsReplying(false); queryClient.invalidateQueries({ queryKey: ['admin-ticket', selectedTicketId] }); queryClient.invalidateQueries({ queryKey: ['admin-tickets'] }); queryClient.invalidateQueries({ queryKey: ['admin-ticket-stats'] }); }; const getStatusBadge = (status: string) => { switch (status) { case 'open': return 'badge-info'; case 'pending': return 'badge-warning'; case 'answered': return 'badge-success'; case 'closed': return 'badge-neutral'; default: return 'badge-neutral'; } }; const getPriorityBadge = (priority: string) => { switch (priority) { case 'urgent': return 'badge-error'; case 'high': return 'badge-warning'; default: return 'badge-neutral'; } }; const formatUser = (ticket: AdminTicket | AdminTicketDetail) => { if (!ticket.user) return 'Unknown'; const { first_name, last_name, username } = ticket.user; if (first_name || last_name) return `${first_name || ''} ${last_name || ''}`.trim(); if (username) return `@${username}`; return 'User'; }; const copyToClipboard = (text: string) => { void copyText(text); }; return (
{/* Show back button only on web, not in Telegram Mini App */} {!capabilities.hasBackButton && ( )}

{t('admin.tickets.title')}

{/* Stats */} {stats && (
{stats.total}
{t('admin.tickets.total')}
{stats.open}
{t('admin.tickets.statusOpen')}
{stats.pending}
{t('admin.tickets.statusPending')}
{stats.answered}
{t('admin.tickets.statusAnswered')}
{stats.closed}
{t('admin.tickets.statusClosed')}
)}
{/* Ticket List */}

{t('admin.tickets.list')}

{ticketsLoading ? (
) : ticketsData?.items.length === 0 ? (
{t('admin.tickets.noTickets')}
) : (
{ticketsData?.items.map((ticket) => ( )}{' '} | {new Date(ticket.updated_at).toLocaleDateString()}
{ticket.last_message && (
{ticket.last_message.is_from_admin ? t('admin.tickets.you') : t('admin.tickets.user')} :{' '} {ticket.last_message.message_text ? `${ticket.last_message.message_text.substring(0, 50)}${ticket.last_message.message_text.length > 50 ? '...' : ''}` : ticket.last_message.has_media ? `[${ticket.last_message.media_type}]` : '...'}
)} ))}
)} {ticketsData && ticketsData.pages > 1 && (
{page} / {ticketsData.pages}
)}
{/* Ticket Detail */}
{!selectedTicketId ? (
{t('admin.tickets.selectTicket')}
) : ticketLoading ? (
) : selectedTicket ? (
{/* Header */}

#{selectedTicket.id} {selectedTicket.title}

{t( `admin.tickets.status${selectedTicket.status.charAt(0).toUpperCase() + selectedTicket.status.slice(1)}`, )} {selectedTicket.priority}
{t('admin.tickets.from')}: {formatUser(selectedTicket)} {selectedTicket.user?.telegram_id && ( )}{' '} | {t('admin.tickets.created')}:{' '} {new Date(selectedTicket.created_at).toLocaleString()} {selectedTicket.user && ( )}
{['open', 'pending', 'answered', 'closed'].map((s) => ( ))}
{/* Messages */}
{selectedTicket.messages.map((msg) => (
{msg.is_from_admin ? t('admin.tickets.adminLabel') : t('admin.tickets.userLabel')} {new Date(msg.created_at).toLocaleString()}
{msg.message_text && (

)}

))}
{/* Reply form */} {selectedTicket.status !== 'closed' && (