ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
181 lines
6.9 KiB
TypeScript
181 lines
6.9 KiB
TypeScript
import { ApiError } from '@pipu/core';
|
|
import type { components } from '@pipu/api-client';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { checkIcon, closeIcon, inboxIcon } from '../../shared/ui/icons';
|
|
import { useErrorText } from '../../shared/lib/errors';
|
|
import { QuotaMeter, type UploadFile, type UploadStatus } from './common';
|
|
import { BatchCard } from './QueueRows';
|
|
import { BatchEditDialog } from './BatchEditDialog';
|
|
import { EditDialog } from './EditDialog';
|
|
import { RejectDialog } from './RejectDialog';
|
|
import { useUploadQueue, useUploadsApi } from './queries';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button, EmptyState, FormError } from '../../shared/ui/ui';
|
|
import s from '../../pages/page.module.css';
|
|
import u from '../../pages/uploads/uploads.module.css';
|
|
const TABS = {
|
|
review: ['pending_review'],
|
|
failed: ['import_failed'],
|
|
progress: ['uploading', 'processing', 'approved', 'importing'],
|
|
imported: ['imported'],
|
|
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
|
|
} satisfies Record<string, UploadStatus[]>;
|
|
type Tab = keyof typeof TABS;
|
|
|
|
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
|
|
export function UploadQueue() {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const api = useUploadsApi();
|
|
const queryClient = useQueryClient();
|
|
const errorText = useErrorText();
|
|
const [tab, setTab] = useState<Tab>('review');
|
|
const [onlyPossible, setOnlyPossible] = useState(false);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const [editing, setEditing] = useState<UploadFile | null>(null);
|
|
const [batchEdit, setBatchEdit] = useState<string | null>(null);
|
|
const [rejecting, setRejecting] = useState(false);
|
|
|
|
const list = useUploadQueue(TABS[tab], tab, onlyPossible);
|
|
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
|
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
|
|
|
|
const refresh = async () => {
|
|
setSelected(new Set());
|
|
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
|
};
|
|
const review = useMutation({
|
|
mutationFn: (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) => api.review(v),
|
|
onSuccess: refresh,
|
|
});
|
|
const retry = useMutation({
|
|
mutationFn: api.retry,
|
|
onSuccess: refresh,
|
|
});
|
|
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
|
|
|
|
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
|
|
const batches = useMemo(() => {
|
|
const m = new Map<string, UploadFile[]>();
|
|
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
|
|
return [...m.values()];
|
|
}, [items]);
|
|
const toggle = (ids: string[], on: boolean) =>
|
|
setSelected((cur) => {
|
|
const next = new Set(cur);
|
|
for (const id of ids) {
|
|
if (on) next.add(id);
|
|
else next.delete(id);
|
|
}
|
|
return next;
|
|
});
|
|
const selectable = tab === 'review' || tab === 'failed';
|
|
const ids = [...selected];
|
|
|
|
return (
|
|
<div className={s.page}>
|
|
<div className={s.head}>
|
|
<div>
|
|
<h1>{t('uploads.queue_title')}</h1>
|
|
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
|
|
</div>
|
|
<QuotaMeter />
|
|
</div>
|
|
<div className={s.toolbar}>
|
|
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
|
|
{(Object.keys(TABS) as Tab[]).map((k) => (
|
|
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
|
|
{t(`uploads.tab_${k}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span style={{ flex: 1 }} />
|
|
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
|
|
{t('uploads.only_possible')}
|
|
</label>
|
|
</div>
|
|
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
|
|
{failed.length > 0 && (
|
|
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
|
)}
|
|
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
|
|
|
|
{batches.map((files) => (
|
|
<BatchCard
|
|
key={files[0]!.batch_id}
|
|
files={files}
|
|
selectable={selectable}
|
|
selected={selected}
|
|
onToggle={toggle}
|
|
onEdit={setEditing}
|
|
onEditBatch={setBatchEdit}
|
|
onRetry={(id) => retry.mutate(id)}
|
|
/>
|
|
))}
|
|
{list.hasNextPage && (
|
|
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
|
{t('uploads.load_more')}
|
|
</Button>
|
|
)}
|
|
|
|
{ids.length > 0 && (
|
|
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
|
|
<span>{t('uploads.selected', { count: ids.length })}</span>
|
|
<span style={{ flex: 1 }} />
|
|
<Button variant="ghost" onClick={() => setSelected(new Set())}>
|
|
{t('common:form.cancel')}
|
|
</Button>
|
|
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
|
|
{t('uploads.reject')}
|
|
</Button>
|
|
{tab === 'review' && (
|
|
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
|
|
{t('uploads.approve')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{batchEdit && (
|
|
<BatchEditDialog
|
|
batchId={batchEdit}
|
|
onClose={() => setBatchEdit(null)}
|
|
onSaved={() => {
|
|
setBatchEdit(null);
|
|
void refresh();
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{editing && (
|
|
<EditDialog
|
|
file={editing}
|
|
onClose={() => setEditing(null)}
|
|
onSaved={() => {
|
|
review.reset();
|
|
void refresh();
|
|
}}
|
|
/>
|
|
)}
|
|
{rejecting && (
|
|
<RejectDialog
|
|
count={ids.length}
|
|
busy={review.isPending}
|
|
onClose={() => setRejecting(false)}
|
|
onReject={(reason) => {
|
|
setRejecting(false);
|
|
review.mutate({ decision: 'reject', ids, reason });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Problem из ответа массового решения → объект, понятный useErrorText. */
|
|
function problemError(p: components['schemas']['Problem']) {
|
|
return new ApiError(p.status, p.code, p.params ?? {});
|
|
}
|