Files
bedolaga-cabinet/src/components/admin/userDetail/TicketsTab.tsx
c0mrade 7f68e2cd5e fix(theme): readable text on any operator palette + admin consistency pass
Контраст-аудит (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
2026-06-11 15:49:17 +03:00

364 lines
14 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { adminApi, type AdminTicket, type AdminTicketDetail } from '../../../api/admin';
import { MessageMediaGrid } from '../../tickets/MessageMediaGrid';
import { linkifyText } from '../../../utils/linkify';
import { ChatIcon, BackIcon, SendIcon } from '@/components/icons';
// ──────────────────────────────────────────────────────────────────
// Tickets tab — list view + chat view (selected ticket replaces list).
// Owns its own query, selection state, reply form state, and the
// auto-scroll-to-bottom ref. Parent only knows the userId.
// ──────────────────────────────────────────────────────────────────
export interface TicketsTabProps {
userId: number;
formatDate: (date: string | null) => string;
}
const STATUS_VALUES = ['open', 'pending', 'answered', 'closed'] as const;
type TicketStatus = (typeof STATUS_VALUES)[number];
export function TicketsTab({ userId, formatDate }: TicketsTabProps) {
// List query
const ticketsQuery = useQuery({
queryKey: ['admin-user-tickets', userId] as const,
queryFn: () => adminApi.getTickets({ user_id: userId, per_page: 50 }),
enabled: !!userId,
});
const tickets = ticketsQuery.data?.items ?? [];
const ticketsTotal = ticketsQuery.data?.total ?? 0;
const ticketsLoading = ticketsQuery.isFetching;
// Selected ticket (chat view)
const [selectedTicketId, setSelectedTicketId] = useState<number | null>(null);
const [selectedTicket, setSelectedTicket] = useState<AdminTicketDetail | null>(null);
const [ticketDetailLoading, setTicketDetailLoading] = useState(false);
// Reply form
const [replyText, setReplyText] = useState('');
const [replySending, setReplySending] = useState(false);
// Status-change gate
const [actionLoading, setActionLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
// ─── Detail loader / refresh chain ──────────────────────────────
const loadTicketDetail = async (ticketId: number) => {
try {
setTicketDetailLoading(true);
const data = await adminApi.getTicket(ticketId);
setSelectedTicket(data);
} catch (error) {
console.error('Failed to load ticket detail:', error);
} finally {
setTicketDetailLoading(false);
}
};
useEffect(() => {
if (selectedTicketId) {
loadTicketDetail(selectedTicketId);
}
}, [selectedTicketId]);
// Auto-scroll messages list to the latest reply
useEffect(() => {
if (selectedTicket && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [selectedTicket]);
// ─── Mutations ──────────────────────────────────────────────────
const handleTicketReply = async () => {
if (!selectedTicketId || !replyText.trim()) return;
setReplySending(true);
try {
await adminApi.replyToTicket(selectedTicketId, replyText);
setReplyText('');
await loadTicketDetail(selectedTicketId);
await ticketsQuery.refetch();
} catch (error) {
console.error('Failed to reply:', error);
} finally {
setReplySending(false);
}
};
const handleTicketStatusChange = async (newStatus: TicketStatus) => {
if (!selectedTicketId) return;
setActionLoading(true);
try {
await adminApi.updateTicketStatus(selectedTicketId, newStatus);
await loadTicketDetail(selectedTicketId);
await ticketsQuery.refetch();
} catch (error) {
console.error('Failed to update ticket status:', error);
} finally {
setActionLoading(false);
}
};
// ─── Render ─────────────────────────────────────────────────────
return (
<div className="space-y-4">
{selectedTicketId ? (
/* Ticket Chat View */
ticketDetailLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : selectedTicket ? (
<ChatView
selectedTicket={selectedTicket}
actionLoading={actionLoading}
replyText={replyText}
replySending={replySending}
messagesEndRef={messagesEndRef}
onBack={() => {
setSelectedTicketId(null);
setSelectedTicket(null);
}}
onStatusChange={handleTicketStatusChange}
onReplyTextChange={setReplyText}
onReply={handleTicketReply}
formatDate={formatDate}
/>
) : null
) : ticketsLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : tickets.length === 0 ? (
<EmptyState />
) : (
<TicketsList
tickets={tickets}
ticketsTotal={ticketsTotal}
onOpenTicket={setSelectedTicketId}
formatDate={formatDate}
/>
)}
</div>
);
}
// ──────────────────────────────────────────────────────────────────
// Sub-views (kept private to this file for now)
// ──────────────────────────────────────────────────────────────────
function EmptyState() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center justify-center rounded-xl bg-dark-800/50 py-12">
<ChatIcon className="mb-3 h-12 w-12 text-dark-600" />
<p className="text-dark-400">{t('admin.users.detail.noTickets')}</p>
</div>
);
}
function TicketsList({
tickets,
ticketsTotal,
onOpenTicket,
formatDate,
}: {
tickets: AdminTicket[];
ticketsTotal: number;
onOpenTicket: (id: number) => void;
formatDate: (date: string | null) => string;
}) {
const { t } = useTranslation();
const statusStyles: Record<string, string> = {
open: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
pending: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
answered: 'bg-success-500/20 text-success-400 border-success-500/30',
closed: 'bg-dark-600 text-dark-400 border-dark-500',
};
return (
<>
<div className="text-sm text-dark-400">
{ticketsTotal} {t('admin.users.detail.ticketsCount')}
</div>
<div className="space-y-2">
{tickets.map((ticket) => (
<button
key={ticket.id}
onClick={() => onOpenTicket(ticket.id)}
className="w-full rounded-xl bg-dark-800/50 p-4 text-left transition-colors hover:bg-dark-700/50"
>
<div className="mb-2 flex items-center justify-between">
<span className="font-medium text-dark-100">
#{ticket.id} {ticket.title}
</span>
<span
className={`rounded-full border px-2 py-0.5 text-xs ${statusStyles[ticket.status] || statusStyles.closed}`}
>
{ticket.status}
</span>
</div>
<div className="flex items-center justify-between text-xs text-dark-500">
<span>{formatDate(ticket.created_at)}</span>
<span>
{ticket.messages_count} {t('admin.users.detail.messagesCount')}
</span>
</div>
{ticket.last_message && (
<div className="mt-2 truncate text-sm text-dark-400">
{ticket.last_message.is_from_admin ? '> ' : ''}
{ticket.last_message.message_text}
</div>
)}
</button>
))}
</div>
</>
);
}
function ChatView({
selectedTicket,
actionLoading,
replyText,
replySending,
messagesEndRef,
onBack,
onStatusChange,
onReplyTextChange,
onReply,
formatDate,
}: {
selectedTicket: AdminTicketDetail;
actionLoading: boolean;
replyText: string;
replySending: boolean;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
onBack: () => void;
onStatusChange: (s: TicketStatus) => void;
onReplyTextChange: (text: string) => void;
onReply: () => void;
formatDate: (date: string | null) => string;
}) {
const { t } = useTranslation();
return (
<div className="space-y-4">
{/* Chat header */}
<div className="flex items-center gap-3">
<button
onClick={onBack}
aria-label={t('common.back', 'Back')}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-dark-800 transition-colors hover:bg-dark-700 sm:h-8 sm:w-8"
>
<BackIcon className="h-4 w-4 text-dark-400" />
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-dark-100">
#{selectedTicket.id} {selectedTicket.title}
</div>
<div className="flex items-center gap-2 text-xs text-dark-500">
<span
className={`rounded-full border px-1.5 py-0.5 ${
{
open: 'border-accent-500/30 bg-accent-500/20 text-accent-400',
pending: 'border-warning-500/30 bg-warning-500/20 text-warning-400',
answered: 'border-success-500/30 bg-success-500/20 text-success-400',
closed: 'border-dark-500 bg-dark-600 text-dark-400',
}[selectedTicket.status] || 'border-dark-500 bg-dark-600 text-dark-400'
}`}
>
{selectedTicket.status}
</span>
<span>{formatDate(selectedTicket.created_at)}</span>
</div>
</div>
</div>
{/* Status buttons — 36px mobile / 26px desktop. Active state matters
and gets mis-tapped on mobile when too small. */}
<div className="flex flex-wrap gap-1.5">
{STATUS_VALUES.map((s) => (
<button
key={s}
onClick={() => onStatusChange(s)}
disabled={selectedTicket.status === s || actionLoading}
className={`min-h-[36px] rounded-lg border px-2.5 py-1.5 text-xs transition-all sm:min-h-0 sm:py-1 ${
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>
{/* Messages */}
<div className="scrollbar-hide max-h-[60vh] space-y-3 overflow-y-auto rounded-xl bg-dark-800/30 p-3">
{selectedTicket.messages.map((msg) => (
<div
key={msg.id}
className={`rounded-xl p-3 ${
msg.is_from_admin
? 'ml-6 border border-accent-500/20 bg-accent-500/10'
: 'mr-6 border border-dark-700/30 bg-dark-800/50'
}`}
>
<div className="mb-1 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">{formatDate(msg.created_at)}</span>
</div>
{msg.message_text && (
<p
className="whitespace-pre-wrap text-sm text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
/>
)}
<MessageMediaGrid message={msg} />
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Reply form */}
{selectedTicket.status !== 'closed' && (
<div className="flex gap-2">
<textarea
value={replyText}
onChange={(e) => onReplyTextChange(e.target.value)}
placeholder={t('admin.tickets.replyPlaceholder')}
rows={2}
className="input flex-1 resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onReply();
}
}}
/>
<button
onClick={onReply}
disabled={!replyText.trim() || replySending}
aria-label={t('admin.tickets.sendReply', 'Send reply')}
className="min-h-[44px] min-w-[44px] shrink-0 self-end rounded-lg bg-accent-500 px-4 py-2 text-sm text-on-accent transition-colors hover:bg-accent-600 disabled:opacity-50 sm:min-h-0 sm:min-w-0"
>
{replySending ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<SendIcon className="h-5 w-5" />
)}
</button>
</div>
)}
</div>
);
}