Enhance user and media management with sorting and statistics: add sorting options to user and media listing endpoints, implement media statistics retrieval, and update frontend components for sorting and displaying media processing times. Refactor related query handlers and API types to support new features.
build / backend (push) Successful in 1m35s
build / frontend (push) Successful in 31s
tests / backend-tests (push) Successful in 1m48s

This commit is contained in:
Leonid Pershin
2026-07-26 00:28:22 +03:00
parent d6ad8e11a4
commit 64a2dade74
27 changed files with 1634 additions and 39 deletions
@@ -8,17 +8,20 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, listMedia } from './api'
import { deleteMedia, getMediaStats, listMedia } from './api'
import { UploadToShowDialog } from './UploadToShowDialog'
import { useUploadStore } from './upload-store'
const PAGE_SIZE = 20
type MediaFilter = 'active' | 'all' | 'Ready' | 'Failed'
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
active: ['Pending', 'Processing'],
Pending: ['Pending'],
Processing: ['Processing'],
all: [],
Ready: ['Ready'],
Failed: ['Failed'],
@@ -48,12 +51,25 @@ export function MediaPanel() {
const fileInputShow = useRef<HTMLInputElement>(null)
const [filter, setFilter] = useState<MediaFilter>('active')
const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('created', true)
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
const enqueue = useUploadStore((s) => s.enqueue)
const sortColumn = (key: string) => {
setPage(1)
toggle(key)
}
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', filter, page],
queryFn: () => listMedia({ page, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
queryKey: ['admin', 'media', filter, page, sort.key, sort.desc],
queryFn: () =>
listMedia({
page,
pageSize: PAGE_SIZE,
statuses: filterStatuses[filter],
sort: sort.key,
desc: sort.desc,
}),
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
refetchInterval: (query) =>
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
@@ -61,6 +77,14 @@ export function MediaPanel() {
: false,
})
const { data: stats } = useQuery({
queryKey: ['admin', 'media', 'stats'],
queryFn: getMediaStats,
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
refetchInterval: (query) =>
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
@@ -84,11 +108,32 @@ export function MediaPanel() {
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
<SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem>
<SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem>
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
</SelectContent>
</Select>
{stats && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title={t('admin.media.stats.queued')}>
{t('admin.media.stats.queuedShort')}:{' '}
<span className="text-foreground tabular-nums">{stats.queued}</span>
</span>
<span title={t('admin.media.stats.processing')}>
{t('admin.media.stats.processingShort')}:{' '}
<span className="text-foreground tabular-nums">{stats.processing}</span>
</span>
<span title={t('admin.media.stats.average')}>
{t('admin.media.stats.averageShort')}:{' '}
<span className="text-foreground tabular-nums">
{formatDuration(stats.averageProcessingSeconds)}
</span>
</span>
</div>
)}
</div>
<div className="flex items-center gap-3">
<input
@@ -134,17 +179,43 @@ export function MediaPanel() {
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.media.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.duration')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.resolution')}</th>
<SortHeader
label={t('admin.media.name')}
sortKey="name"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.status')}
sortKey="status"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.duration')}
sortKey="duration"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.resolution')}
sortKey="resolution"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.processingTime')}
sortKey="processing"
sort={sort}
onToggle={sortColumn}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('common.loading')}
</td>
</tr>
@@ -158,7 +229,7 @@ export function MediaPanel() {
))}
{data && data.items.length === 0 && !isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('admin.media.empty')}
</td>
</tr>
@@ -190,6 +261,9 @@ function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => v
<td className="px-4 py-2 text-muted-foreground">
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
</td>
<td className="px-4 py-2 text-muted-foreground tabular-nums">
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
</td>
<td className="px-4 py-2">
<Button size="sm" variant="destructive" onClick={onDelete}>
{t('common.delete')}