Files
bedolaga-cabinet/src/pages/AdminTickets.tsx
c0mrade cf469a1a9f chore(types): replace deprecated FormEvent with SyntheticEvent
React 19's @types/react flags FormEvent as deprecated — quote: 'FormEvent
doesn't actually exist. You probably meant to use ChangeEvent, InputEvent,
SubmitEvent, or just SyntheticEvent'. All 17 call sites in this repo
typed form onSubmit handlers and only called e.preventDefault(), so
SyntheticEvent is the correct general replacement.

No behavior change — pure type-level cleanup that clears the chronic
deprecation hint that has been showing up after every edit to any
form-bearing page.
2026-05-26 15:24:01 +03:00

692 lines
29 KiB
TypeScript

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<string, string> = {
'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 = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
export default function AdminTickets() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
const [selectedTicketId, setSelectedTicketId] = useState<number | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('');
const [replyText, setReplyText] = useState('');
const [isReplying, setIsReplying] = useState(false);
const [replyError, setReplyError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadIdRef = useRef(0);
// Track all created blob URLs for cleanup on unmount
const blobUrlsRef = useRef<Set<string>>(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<HTMLInputElement>) => {
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.SyntheticEvent) => {
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Show back button only on web, not in Telegram Mini App */}
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{t('admin.tickets.title')}
</h1>
</div>
<button
onClick={() => navigate('/admin/tickets/settings')}
className="btn-secondary flex items-center gap-2"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{t('admin.tickets.settings')}
</button>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="card text-center">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">{t('admin.tickets.total')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-accent-400">{stats.open}</div>
<div className="stat-label">{t('admin.tickets.statusOpen')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-warning-400">{stats.pending}</div>
<div className="stat-label">{t('admin.tickets.statusPending')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-success-400">{stats.answered}</div>
<div className="stat-label">{t('admin.tickets.statusAnswered')}</div>
</div>
<div className="card col-span-2 text-center sm:col-span-1">
<div className="stat-value text-dark-400">{stats.closed}</div>
<div className="stat-label">{t('admin.tickets.statusClosed')}</div>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Ticket List */}
<div className="card lg:col-span-1">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-dark-100">{t('admin.tickets.list')}</h2>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="input w-auto px-3 py-1.5 text-sm"
>
<option value="">{t('admin.tickets.allStatuses')}</option>
<option value="open">{t('admin.tickets.statusOpen')}</option>
<option value="pending">{t('admin.tickets.statusPending')}</option>
<option value="answered">{t('admin.tickets.statusAnswered')}</option>
<option value="closed">{t('admin.tickets.statusClosed')}</option>
</select>
</div>
{ticketsLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : ticketsData?.items.length === 0 ? (
<div className="py-12 text-center text-dark-500">{t('admin.tickets.noTickets')}</div>
) : (
<div className="scrollbar-hide max-h-[500px] space-y-2 overflow-y-auto">
{ticketsData?.items.map((ticket) => (
<button
key={ticket.id}
onClick={() => {
setSelectedTicketId(ticket.id);
setReplyText('');
clearAttachments();
}}
className={`w-full rounded-xl border p-4 text-left transition-all ${
selectedTicketId === ticket.id
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600'
}`}
>
<div className="mb-2 flex items-start justify-between gap-2">
<span className="truncate font-medium text-dark-100">
#{ticket.id} {ticket.title}
</span>
<span className={getStatusBadge(ticket.status)}>
{t(
`admin.tickets.status${ticket.status.charAt(0).toUpperCase() + ticket.status.slice(1)}`,
)}
</span>
</div>
<div className="text-xs text-dark-500">
{formatUser(ticket)}
{ticket.user?.telegram_id && (
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(String(ticket.user!.telegram_id));
}}
className="ml-1 text-dark-600 transition-colors hover:text-accent-400"
title={t('admin.tickets.copyTelegramId')}
>
(TG: {ticket.user!.telegram_id})
</button>
)}{' '}
| {new Date(ticket.updated_at).toLocaleDateString()}
</div>
{ticket.last_message && (
<div className="mt-1 truncate text-xs text-dark-600">
{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}]`
: '...'}
</div>
)}
</button>
))}
</div>
)}
{ticketsData && ticketsData.pages > 1 && (
<div className="mt-4 flex items-center justify-center gap-3 border-t border-dark-800/50 pt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="btn-secondary px-3 py-1.5 text-sm disabled:opacity-50"
>
{t('common.back')}
</button>
<span className="text-sm text-dark-400">
{page} / {ticketsData.pages}
</span>
<button
onClick={() => setPage((p) => Math.min(ticketsData.pages, p + 1))}
disabled={page === ticketsData.pages}
className="btn-secondary px-3 py-1.5 text-sm disabled:opacity-50"
>
{t('common.next')}
</button>
</div>
)}
</div>
{/* Ticket Detail */}
<div className="card lg:col-span-2">
{!selectedTicketId ? (
<div className="flex h-64 flex-col items-center justify-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg
className="h-8 w-8 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z"
/>
</svg>
</div>
<div className="text-dark-400">{t('admin.tickets.selectTicket')}</div>
</div>
) : ticketLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : selectedTicket ? (
<div className="flex h-full flex-col">
{/* Header */}
<div className="mb-4 border-b border-dark-800/50 pb-4">
<div className="mb-3 flex items-start justify-between">
<h3 className="text-lg font-semibold text-dark-100">
#{selectedTicket.id} {selectedTicket.title}
</h3>
<div className="flex gap-2">
<span className={getStatusBadge(selectedTicket.status)}>
{t(
`admin.tickets.status${selectedTicket.status.charAt(0).toUpperCase() + selectedTicket.status.slice(1)}`,
)}
</span>
<span className={getPriorityBadge(selectedTicket.priority)}>
{selectedTicket.priority}
</span>
</div>
</div>
<div className="mb-4 flex items-center gap-2 text-sm text-dark-500">
<span>
{t('admin.tickets.from')}: {formatUser(selectedTicket)}
{selectedTicket.user?.telegram_id && (
<button
onClick={() => copyToClipboard(String(selectedTicket.user!.telegram_id))}
className="ml-1 rounded bg-dark-700 px-2 py-0.5 text-xs transition-colors hover:bg-dark-600"
title={t('admin.tickets.copyTelegramId')}
>
TG: {selectedTicket.user!.telegram_id}
</button>
)}{' '}
| {t('admin.tickets.created')}:{' '}
{new Date(selectedTicket.created_at).toLocaleString()}
</span>
{selectedTicket.user && (
<button
onClick={() => navigate(`/admin/users/${selectedTicket.user!.id}`)}
className="shrink-0 rounded-lg border border-accent-500/30 bg-accent-500/10 px-2 py-0.5 text-xs text-accent-400 transition-colors hover:bg-accent-500/20"
>
{t('admin.tickets.viewUser')}
</button>
)}
</div>
<div className="flex flex-wrap gap-2">
{['open', 'pending', 'answered', 'closed'].map((s) => (
<button
key={s}
onClick={() =>
statusMutation.mutate({ ticketId: selectedTicket.id, status: s })
}
disabled={selectedTicket.status === s || statusMutation.isPending}
className={`rounded-lg border px-3 py-1.5 text-xs transition-all ${
selectedTicket.status === s
? 'border-accent-500/50 bg-accent-500/20 text-accent-400'
: 'border-dark-700/50 text-dark-400 hover:border-dark-600 hover:text-dark-200'
} disabled:opacity-50`}
>
{t(`admin.tickets.status${s.charAt(0).toUpperCase() + s.slice(1)}`)}
</button>
))}
</div>
</div>
{/* Messages */}
<div className="scrollbar-hide mb-4 max-h-[400px] flex-1 space-y-4 overflow-y-auto">
{selectedTicket.messages.map((msg) => (
<div
key={msg.id}
className={`rounded-xl p-4 ${
msg.is_from_admin
? 'ml-4 border border-accent-500/20 bg-accent-500/10'
: 'mr-4 border border-dark-700/30 bg-dark-800/50'
}`}
>
<div className="mb-2 flex items-center justify-between">
<span
className={`text-xs font-medium ${msg.is_from_admin ? 'text-accent-400' : 'text-dark-400'}`}
>
{msg.is_from_admin
? t('admin.tickets.adminLabel')
: t('admin.tickets.userLabel')}
</span>
<span className="text-xs text-dark-500">
{new Date(msg.created_at).toLocaleString()}
</span>
</div>
{msg.message_text && (
<p
className="whitespace-pre-wrap text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
/>
)}
<MessageMediaGrid message={msg} translateError={t('support.imageLoadFailed')} />
</div>
))}
</div>
{/* Reply form */}
{selectedTicket.status !== 'closed' && (
<form onSubmit={handleReply} className="border-t border-dark-800/50 pt-4">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t('admin.tickets.replyPlaceholder')}
rows={3}
className="input resize-none"
/>
{/* Attachments preview */}
{attachments.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((att, idx) => (
<div key={att.id} className="relative">
{att.mediaType === 'photo' && att.preview ? (
<img
src={att.preview}
alt="Preview"
className="h-16 w-16 rounded-lg object-cover"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-dark-700 text-xs text-dark-400">
{att.file.name.slice(-6)}
</div>
)}
{att.uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/50">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{att.error && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-error-500/30">
<span className="text-xs text-error-300">!</span>
</div>
)}
<button
type="button"
onClick={() => removeAttachment(idx)}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-dark-600 text-dark-300 hover:bg-error-500 hover:text-white"
>
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_STRING}
multiple
onChange={handleFileSelect}
className="hidden"
/>
{replyError && (
<div className="mt-2 rounded-lg border border-error-500/30 bg-error-500/10 px-3 py-2 text-sm text-error-300">
{replyError}
</div>
)}
<div className="mt-3 flex items-center justify-between">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={attachments.length >= 10 || attachments.some((a) => a.uploading)}
className="flex items-center gap-2 rounded-lg border border-dark-700/50 px-3 py-2 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200 disabled:opacity-50"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
/>
</svg>
{t('admin.tickets.attachMedia')}{' '}
{attachments.length > 0 && `(${attachments.length}/10)`}
</button>
<button
type="submit"
disabled={
(!replyText.trim() && attachments.filter((a) => a.fileId).length === 0) ||
isReplying ||
attachments.some((a) => a.uploading || a.error)
}
className="btn-primary"
>
{isReplying ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
t('admin.tickets.sendReply')
)}
</button>
</div>
</form>
)}
</div>
) : null}
</div>
</div>
</div>
);
}