From fab2a504d3d219bcef9ff0ebc56f11e5ef9db789 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 26 May 2026 18:00:02 +0300 Subject: [PATCH] refactor(admin-user-detail): extract Tickets tab (list + chat view) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls the tickets tab into components/admin/userDetail/TicketsTab.tsx: - ticketsQuery (list) - selectedTicketId / selectedTicket state + detail loader - reply form state (replyText, replySending) - status-change action handler + actionLoading - auto-scroll messagesEndRef - JSX is split into ChatView + TicketsList + EmptyState helpers inside the new file (file stays under 400 lines despite the split-view UI) Parent now passes only userId + formatDate. Imports for adminApi, MessageMediaGrid, linkifyText, AdminTicket / AdminTicketDetail types all drop from AdminUserDetail.tsx — tickets are the only consumers. Net to AdminUserDetail.tsx: down to 2395 lines (-295). Same DOM, same event handlers, same queryKey ['admin-user-tickets', userId]. --- .../admin/userDetail/TicketsTab.tsx | 391 ++++++++++++++++++ src/pages/AdminUserDetail.tsx | 313 +------------- 2 files changed, 400 insertions(+), 304 deletions(-) create mode 100644 src/components/admin/userDetail/TicketsTab.tsx diff --git a/src/components/admin/userDetail/TicketsTab.tsx b/src/components/admin/userDetail/TicketsTab.tsx new file mode 100644 index 0000000..d251424 --- /dev/null +++ b/src/components/admin/userDetail/TicketsTab.tsx @@ -0,0 +1,391 @@ +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'; + +// ────────────────────────────────────────────────────────────────── +// 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(null); + const [selectedTicket, setSelectedTicket] = useState(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(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 ( +
+ {selectedTicketId ? ( + /* Ticket Chat View */ + ticketDetailLoading ? ( +
+
+
+ ) : selectedTicket ? ( + { + setSelectedTicketId(null); + setSelectedTicket(null); + }} + onStatusChange={handleTicketStatusChange} + onReplyTextChange={setReplyText} + onReply={handleTicketReply} + formatDate={formatDate} + /> + ) : null + ) : ticketsLoading ? ( +
+
+
+ ) : tickets.length === 0 ? ( + + ) : ( + + )} +
+ ); +} + +// ────────────────────────────────────────────────────────────────── +// Sub-views (kept private to this file for now) +// ────────────────────────────────────────────────────────────────── + +function EmptyState() { + const { t } = useTranslation(); + return ( +
+ + + +

{t('admin.users.detail.noTickets')}

+
+ ); +} + +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 = { + 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 ( + <> +
+ {ticketsTotal} {t('admin.users.detail.ticketsCount')} +
+
+ {tickets.map((ticket) => ( + + ))} +
+ + ); +} + +function ChatView({ + selectedTicket, + actionLoading, + replyText, + replySending, + messagesEndRef, + onBack, + onStatusChange, + onReplyTextChange, + onReply, + formatDate, +}: { + selectedTicket: AdminTicketDetail; + actionLoading: boolean; + replyText: string; + replySending: boolean; + messagesEndRef: React.RefObject; + onBack: () => void; + onStatusChange: (s: TicketStatus) => void; + onReplyTextChange: (text: string) => void; + onReply: () => void; + formatDate: (date: string | null) => string; +}) { + const { t } = useTranslation(); + return ( +
+ {/* Chat header */} +
+ +
+
+ #{selectedTicket.id} {selectedTicket.title} +
+
+ + {selectedTicket.status} + + {formatDate(selectedTicket.created_at)} +
+
+
+ + {/* Status buttons */} +
+ {STATUS_VALUES.map((s) => ( + + ))} +
+ + {/* Messages */} +
+ {selectedTicket.messages.map((msg) => ( +
+
+ + {msg.is_from_admin ? t('admin.tickets.adminLabel') : t('admin.tickets.userLabel')} + + {formatDate(msg.created_at)} +
+ {msg.message_text && ( +

+ )} + +

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