Files
bedolaga-cabinet/src/pages/AdminTickets.tsx
c0mrade 3b48abbb7b refactor(cabinet): unify all statistics onto the shared StatCard
Bring every stat strip across the admin panel and user cabinet onto the
canonical StatCard (icon chip + label-on-top + tone-coloured value),
matching the sales/Remnawave style the rest of the app already used.

- Convert ~20 hand-rolled stat strips across 21 pages/components
- Remove 4 divergent local StatCard components (Dashboard, Users,
  BanSystem, Payments) and migrate Remnawave's local StatCard (33 cards)
- Extend StatCard with optional subValue, loading skeleton, trailing slot,
  and icon-size normalisation (all backward-compatible)
- StatsGrid nav tiles now use StatCard while keeping link/chevron/loading
- Preserve all data, i18n keys, conditionals and interactive filters;
  fix the dead 'info' colour by mapping it to accent
2026-06-06 21:27:57 +03:00

662 lines
26 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';
import {
BackIcon,
CheckCircleIcon,
ClockIcon,
InboxIcon,
SettingsIcon,
TicketIcon,
XCircleIcon,
XIcon,
} from '@/components/icons';
import { StatCard } from '@/components/stats';
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
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"
>
<SettingsIcon className="h-5 w-5" />
{t('admin.tickets.settings')}
</button>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard
label={t('admin.tickets.total')}
value={stats.total}
icon={<TicketIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.tickets.statusOpen')}
value={stats.open}
icon={<InboxIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.tickets.statusPending')}
value={stats.pending}
icon={<ClockIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.tickets.statusAnswered')}
value={stats.answered}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="success"
/>
<div className="col-span-2 sm:col-span-1">
<StatCard
label={t('admin.tickets.statusClosed')}
value={stats.closed}
icon={<XCircleIcon className="h-5 w-5" />}
tone="neutral"
/>
</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">
<TicketIcon className="h-8 w-8 text-dark-500" />
</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 flex-wrap 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"
loading="lazy"
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-dark-950/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"
>
<XIcon className="h-3 w-3" />
</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>
);
}