mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 01:23:47 +00:00
Контраст-аудит (WCAG-обход всех текстовых узлов, 4 палитры × юзер+админ страницы) показал системные провалы читаемости: вторичный текст 1.5-2.5 на светлой/кастомных темах, статусные цвета 1.3-1.6, белый текст на светлых акцентах 1.9. Корневые фиксы: - useThemeColors: контраст-кламп производных серых токенов — dark-400/ champagne-600 (вторичный текст) держат >=5.0 к поверхности, dark-500/ champagne-500 (подсказки) >=3.8; палитры с достаточным контрастом рендерятся байт-в-байт как раньше - --color-on-accent/-success/-warning/-error: чёрный или белый текст по фактической яркости цвета оператора; text-white на accent-заливках заменён на text-on-accent (135 мест + variant примитива Button + .btn-primary) - .light: статусные текстовые оттенки *-300/400 ремапятся на тёмные *-700 (жёлтая «Админка» 1.26 -> 3.8+, зелёные суммы 1.4 -> 3.4+, бейджи состояний) Замер после: операторская тёмная палитра — 0 нарушений (всё >=4.5), худшие точки остальных подняты с 1.26-1.6 до 3.2+. Консистентность: - заголовки админ-страниц приведены к text-xl font-bold text-dark-100 (были 18/20/24/30px и 600/700 вперемешку) - «Создать FAQ»: warning-500/80+белый -> warning-500+text-on-warning - formatTraffic: единицы из i18n (убран микс «GB»/«ГБ» на одном экране) - categoryCanvas -> categoryCANVAS в локалях (битый ключ на настройках) - «Управление сквадами» -> «Управление локациями» (юзерский экран) - мин-макс сумм на выборе метода: dark-600 -> dark-400 - неактивные пункты нижнего нава: dark-500 -> dark-400
660 lines
26 KiB
TypeScript
660 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-xl font-bold text-dark-100">{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>
|
|
);
|
|
}
|