/** * Component: Admin Requests Management Table * Documentation: documentation/admin-dashboard.md */ 'use client'; import { useState, useEffect, useCallback } from 'react'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { formatDistanceToNow } from 'date-fns'; import useSWR from 'swr'; import { ConfirmDialog } from './ConfirmDialog'; import { RequestActionsDropdown } from './RequestActionsDropdown'; import { mutate } from 'swr'; import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api'; import { useToast } from '@/components/ui/Toast'; interface RecentRequest { requestId: string; title: string; author: string; status: string; type?: 'audiobook' | 'ebook'; userId: string; user: string; createdAt: Date; completedAt: Date | null; errorMessage: string | null; torrentUrl?: string | null; } interface User { id: string; plexUsername: string; } interface RequestsResponse { requests: RecentRequest[]; total: number; page: number; pageSize: number; totalPages: number; } interface RecentRequestsTableProps { ebookSidecarEnabled?: boolean; } const STATUS_OPTIONS = [ { value: 'all', label: 'All Statuses' }, { value: 'pending', label: 'Pending' }, { value: 'awaiting_approval', label: 'Awaiting Approval' }, { value: 'awaiting_search', label: 'Awaiting Search' }, { value: 'searching', label: 'Searching' }, { value: 'downloading', label: 'Downloading' }, { value: 'processing', label: 'Processing' }, { value: 'downloaded', label: 'Downloaded' }, { value: 'awaiting_import', label: 'Awaiting Import' }, { value: 'available', label: 'Available' }, { value: 'completed', label: 'Completed' }, { value: 'failed', label: 'Failed' }, { value: 'warn', label: 'Warning' }, { value: 'cancelled', label: 'Cancelled' }, { value: 'denied', label: 'Denied' }, ]; const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; type SortField = 'createdAt' | 'completedAt' | 'title' | 'user' | 'status'; type SortOrder = 'asc' | 'desc'; function getStatusBadge(status: string) { const styles: Record = { pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', downloaded: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', processing: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', awaiting_import: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', available: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', warn: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', denied: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', }; const style = styles[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'; const labels: Record = { awaiting_search: 'Awaiting Search', awaiting_import: 'Awaiting Import', awaiting_approval: 'Awaiting Approval', }; const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1); return ( {label} ); } function SortIcon({ field, currentSort, currentOrder }: { field: SortField; currentSort: SortField; currentOrder: SortOrder }) { if (field !== currentSort) { return ( ); } return currentOrder === 'asc' ? ( ) : ( ); } export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentRequestsTableProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const toast = useToast(); // Get filter state from URL const page = parseInt(searchParams.get('page') || '1', 10); const pageSize = parseInt(searchParams.get('pageSize') || '25', 10); const search = searchParams.get('search') || ''; const status = searchParams.get('status') || 'all'; const userId = searchParams.get('userId') || ''; const sortBy = (searchParams.get('sortBy') || 'createdAt') as SortField; const sortOrder = (searchParams.get('sortOrder') || 'desc') as SortOrder; // Local search input state for debouncing const [searchInput, setSearchInput] = useState(search); // Dialog states const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [selectedRequest, setSelectedRequest] = useState<{ id: string; title: string; } | null>(null); const [isDeleting, setIsDeleting] = useState(false); const [isFetchingEbook, setIsFetchingEbook] = useState(false); // Build API URL with current filters const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(search)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`; // Fetch requests with SWR const { data, error, isLoading } = useSWR(apiUrl, authenticatedFetcher, { refreshInterval: 10000, }); // Fetch users for filter dropdown const { data: usersData } = useSWR<{ users: User[] }>('/api/admin/users', authenticatedFetcher); // Update URL with new params const updateParams = useCallback( (updates: Record) => { const params = new URLSearchParams(searchParams.toString()); Object.entries(updates).forEach(([key, value]) => { if (value === '' || value === 'all' || (key === 'page' && value === 1) || (key === 'pageSize' && value === 25)) { params.delete(key); } else { params.set(key, String(value)); } }); // Reset to page 1 when filters change (except when changing page itself) if (!('page' in updates)) { params.delete('page'); } const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; router.push(newUrl, { scroll: false }); }, [pathname, router, searchParams] ); // Debounce search input useEffect(() => { const timer = setTimeout(() => { if (searchInput !== search) { updateParams({ search: searchInput }); } }, 300); return () => clearTimeout(timer); }, [searchInput, search, updateParams]); // Sync search input with URL param on mount useEffect(() => { setSearchInput(search); }, [search]); const handleSort = (field: SortField) => { if (field === sortBy) { updateParams({ sortOrder: sortOrder === 'asc' ? 'desc' : 'asc' }); } else { updateParams({ sortBy: field, sortOrder: 'desc' }); } }; const clearFilters = () => { setSearchInput(''); router.push(pathname, { scroll: false }); }; const hasActiveFilters = search || status !== 'all' || userId; // Action handlers const handleDeleteClick = (requestId: string, title: string) => { setSelectedRequest({ id: requestId, title }); setShowDeleteConfirm(true); }; const handleDeleteConfirm = async () => { if (!selectedRequest) return; setIsDeleting(true); try { const response = await fetchWithAuth(`/api/admin/requests/${selectedRequest.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Failed to delete request'); } // Refresh the requests list await mutate(apiUrl); await mutate('/api/admin/metrics'); await mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks')); setShowDeleteConfirm(false); setSelectedRequest(null); toast.success('Request deleted successfully'); } catch (error) { console.error('[Admin] Failed to delete request:', error); toast.error(`Failed to delete request: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsDeleting(false); } }; const handleDeleteCancel = () => { setShowDeleteConfirm(false); setSelectedRequest(null); }; const handleManualSearch = async (requestId: string) => { try { const response = await fetchWithAuth(`/api/requests/${requestId}/manual-search`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Failed to trigger manual search'); } toast.success('Manual search triggered'); await mutate(apiUrl); } catch (error) { console.error('[Admin] Failed to trigger manual search:', error); toast.error(`Failed to trigger manual search: ${error instanceof Error ? error.message : 'Unknown error'}`); } }; const handleCancel = async (requestId: string) => { try { const response = await fetchWithAuth(`/api/requests/${requestId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'cancel' }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Failed to cancel request'); } toast.success('Request cancelled'); await mutate(apiUrl); } catch (error) { console.error('[Admin] Failed to cancel request:', error); toast.error(`Failed to cancel request: ${error instanceof Error ? error.message : 'Unknown error'}`); } }; const handleFetchEbook = async (requestId: string) => { setIsFetchingEbook(true); try { const response = await fetchWithAuth(`/api/requests/${requestId}/fetch-ebook`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, }); const responseData = await response.json(); if (!response.ok) { throw new Error(responseData.error || responseData.message || 'Failed to fetch e-book'); } if (responseData.success) { toast.success(responseData.message || 'E-book fetched successfully'); } else { toast.warning(`E-book fetch failed: ${responseData.message}`); } } catch (error) { console.error('[Admin] Failed to fetch e-book:', error); toast.error(`Failed to fetch e-book: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsFetchingEbook(false); } }; // Render loading state if (isLoading && !data) { return (
); } // Render error state if (error) { return (
Failed to load requests. Please try again.
); } const requests = data?.requests || []; const total = data?.total || 0; const totalPages = data?.totalPages || 1; // Calculate display range const startIndex = (page - 1) * pageSize + 1; const endIndex = Math.min(page * pageSize, total); return (
{/* Filter Bar */}
{/* Search Input */}
setSearchInput(e.target.value)} className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" />
{/* Status Filter */} {/* User Filter */} {/* Clear Filters Button */} {hasActiveFilters && ( )}
{/* Table */} {requests.length === 0 ? (

{hasActiveFilters ? 'No Matching Requests' : 'No Requests'}

{hasActiveFilters ? 'Try adjusting your filters or search terms.' : 'No audiobook requests have been made yet.'}

) : ( <>
{requests.map((request) => ( ))}
handleSort('title')} >
Request
handleSort('user')} >
User
handleSort('status')} >
Status
handleSort('createdAt')} >
Requested
handleSort('completedAt')} >
Completed
Actions
{request.title} {request.type === 'ebook' && ( Ebook )}
{request.author}
{request.errorMessage && (request.status === 'failed' || request.status === 'warn') && (
{request.errorMessage}
)}
{request.user} {getStatusBadge(request.status)} {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })} {request.completedAt ? formatDistanceToNow(new Date(request.completedAt), { addSuffix: true, }) : '-'}
{/* Pagination */}
{/* Results info */}
Showing {startIndex} to{' '} {endIndex} of{' '} {total} requests
{/* Page size selector */}
{/* Page navigation */}
{/* Page numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => { let pageNum: number; if (totalPages <= 5) { pageNum = i + 1; } else if (page <= 3) { pageNum = i + 1; } else if (page >= totalPages - 2) { pageNum = totalPages - 4 + i; } else { pageNum = page - 2 + i; } return ( ); })}
)} {/* Confirm Dialog */}

This will delete the request for "{selectedRequest.title}" and:

  • Remove the request (allowing it to be re-requested)
  • Delete files from the media directory
  • Keep torrent seeding if time remaining

Are you sure?

) : ( '' ) } confirmLabel={isDeleting ? 'Deleting...' : 'Delete'} cancelLabel="Cancel" confirmVariant="danger" onConfirm={handleDeleteConfirm} onCancel={handleDeleteCancel} /> ); }