Files
mdm-ordinis/ordinis-admin-ui/src/routes/reviews.tsx
T
Zimin A.N. e6294ce79c fix(ui): shared UserCell для display UUID actor'ов везде
Browser QA на staging показал что UUID actor'а render'ится raw во многих
местах кроме drawer (MR !158 — там был inline fix):
- /reviews Records tab — column AUTHOR
- /reviews Schemas tab — column AUTHOR
- /audit log — column USER

77ec2cc8-98e3-4d70-8037-94038fcbde67 — нечитаемо для оператора. Тот же
паттерн что я делал в MR !158, теперь shared helper:

src/lib/useUserDisplay.tsx:
- useUserDisplay(uuid) hook — returns { display, isMe, fullId }
- UserCell component — readonly cell с title=fullId tooltip
- Match current user (JWT sub claim) → 'Я • <preferred_username>'
- Other user → short UUID (8 chars) + full в title
- null/undefined → 'anonymous'

Применено:
- reviews.tsx:334,625 — records + schemas table AUTHOR cells
- audit.tsx:476 — USER cell
- SchemaDraftDrawer.tsx — refactor (был inline в MR !158 → теперь shared)

TODO Phase 1d: backend endpoint /admin/users/{sub}/display чтобы и для
не-current-user отображать username.
2026-05-13 11:43:04 +03:00

646 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
Badge,
Button,
Checkbox,
Drawer,
EmptyState,
LoadingBlock,
Modal,
PageHeader,
Panel,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Tabs,
TextArea,
TextInput,
QueryErrorState,
} from '@/ui'
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
import type { DraftOperation, DraftResponse } from '@/api/client'
import { UserCell } from '@/lib/useUserDisplay'
export const Route = createFileRoute('/reviews')({
component: ReviewsPage,
})
const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' => {
if (op === 'CREATE') return 'success'
if (op === 'CLOSE') return 'warning'
return 'info'
}
/**
* Approval Workflow v2 — reviewer queue (D3=A pool model).
*
* <p>Все pending drafts в ordinis FIFO ordered by submitted_at. Click row →
* drawer открывается с side-by-side current vs proposed JSON + Approve /
* Reject actions. Reject reason required.
*/
type BulkResult = {
approved: { id: string; bk: string }[]
rejected: { id: string; bk: string }[]
failed: { id: string; bk: string; reason: string }[]
}
type ReviewsTab = 'records' | 'schemas'
function ReviewsPage() {
const { t } = useTranslation()
const queue = useReviewQueue(0, 50)
const schemaQueue = useSchemaReviewQueue(0, 50)
const recordsCount = queue.data?.totalElements ?? 0
const schemasCount = schemaQueue.data?.totalElements ?? 0
// Tab дефолтит на records (старее, чаще). Если schema queue не пустой
// а record queue пустой — переключаемся на schemas чтобы reviewer'у не
// пришлось кликать дважды.
const [activeTab, setActiveTab] = useState<ReviewsTab>('records')
// Auto-switch один раз когда queries loaded и records=0/schemas>0.
useMemo(() => {
if (recordsCount === 0 && schemasCount > 0 && activeTab === 'records') {
setActiveTab('schemas')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recordsCount, schemasCount])
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
/** Phase 4: bulk approve/reject. Set<draftId> — стабильное id'шник. */
const [selection, setSelection] = useState<Set<string>>(new Set())
const [bulkRejectOpen, setBulkRejectOpen] = useState(false)
const [bulkRejectReason, setBulkRejectReason] = useState('')
const [bulkRunning, setBulkRunning] = useState(false)
const [bulkResult, setBulkResult] = useState<BulkResult | undefined>(undefined)
const approveMut = useApproveDraft()
const rejectMut = useRejectDraft()
const items = queue.data?.items ?? []
const visibleIds = useMemo(() => items.map((d) => d.id), [items])
const allVisibleSelected =
visibleIds.length > 0 && visibleIds.every((id) => selection.has(id))
const anySelected = selection.size > 0
const toggleSelect = (id: string) => {
setSelection((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (allVisibleSelected) setSelection(new Set())
else setSelection(new Set(visibleIds))
}
/**
* Bulk-execute approve или reject параллельно. Каждый mutation independent
* — если один fail (e.g. 409 self_approve_forbidden, 409 draft_not_pending
* race), остальные продолжают. Result summary показывает кто прошёл и кто
* нет с reason'ом.
*/
const runBulk = async (
op: 'approve' | 'reject',
targets: DraftResponse[],
sharedReason: string,
) => {
setBulkRunning(true)
const result: BulkResult = { approved: [], rejected: [], failed: [] }
await Promise.all(
targets.map(async (d) => {
try {
if (op === 'approve') {
await approveMut.mutateAsync({ id: d.id, comment: undefined })
result.approved.push({ id: d.id, bk: d.businessKey })
} else {
await rejectMut.mutateAsync({ id: d.id, comment: sharedReason })
result.rejected.push({ id: d.id, bk: d.businessKey })
}
} catch (e: unknown) {
const msg =
(e as { response?: { data?: { code?: string; message?: string } } })
?.response?.data?.code ??
(e as Error)?.message ??
'unknown'
result.failed.push({ id: d.id, bk: d.businessKey, reason: msg })
}
}),
)
setBulkResult(result)
setBulkRunning(false)
setSelection(new Set())
setBulkRejectOpen(false)
setBulkRejectReason('')
}
const handleBulkApprove = () => {
const targets = items.filter((d) => selection.has(d.id))
if (targets.length === 0) return
if (!confirm(t('reviews.bulk.confirmApprove', { count: targets.length }))) return
runBulk('approve', targets, '')
}
const handleBulkReject = () => {
if (!bulkRejectReason.trim()) return
const targets = items.filter((d) => selection.has(d.id))
if (targets.length === 0) return
runBulk('reject', targets, bulkRejectReason.trim())
}
return (
<div className="space-y-4">
<PageHeader
title={t('reviews.title')}
description={t('reviews.description')}
/>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as ReviewsTab)}
items={[
{
id: 'records',
label: t('reviews.tab.records', { defaultValue: 'Записи' }),
count:
recordsCount > 0 ? (
<Badge variant="info">{recordsCount}</Badge>
) : undefined,
},
{
id: 'schemas',
label: t('reviews.tab.schemas', { defaultValue: 'Схемы' }),
count:
schemasCount > 0 ? (
<Badge variant="info">{schemasCount}</Badge>
) : undefined,
},
]}
/>
{activeTab === 'schemas' && (
<>
{schemaQueue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
<SchemaReviewQueuePanel />
{schemasCount === 0 && !schemaQueue.isLoading && (
<EmptyState
title={t('reviews.schema.empty', {
defaultValue: 'Нет схем на ревью',
})}
description={t('reviews.schema.emptyDescription', {
defaultValue: 'Когда maker отправит schema draft на ревью, он появится здесь.',
})}
/>
)}
</>
)}
{activeTab === 'records' && queue.isLoading && (
<LoadingBlock size="md" label={t('loading')} />
)}
{activeTab === 'records' && queue.error && (
<QueryErrorState error={queue.error} onRetry={() => queue.refetch()} />
)}
{activeTab === 'records' && bulkResult && (
<Alert
variant={bulkResult.failed.length === 0 ? 'success' : 'warning'}
title={t('reviews.bulk.resultTitle')}
action={
<Button variant="ghost" size="sm" onClick={() => setBulkResult(undefined)}>
{t('reviews.bulk.cancel')}
</Button>
}
>
<ul className="space-y-1 text-cell">
{bulkResult.approved.length > 0 && (
<li>
{t('reviews.bulk.approvedCount', { count: bulkResult.approved.length })}
: {bulkResult.approved.map((r) => r.bk).join(', ')}
</li>
)}
{bulkResult.rejected.length > 0 && (
<li>
{t('reviews.bulk.rejectedCount', { count: bulkResult.rejected.length })}
: {bulkResult.rejected.map((r) => r.bk).join(', ')}
</li>
)}
{bulkResult.failed.length > 0 && (
<li className="text-error">
{t('reviews.bulk.failedCount', { count: bulkResult.failed.length })}:
<ul className="ml-4 mt-1 list-disc">
{bulkResult.failed.map((f) => (
<li key={f.id}>
<span className="font-mono">{f.bk}</span>: {f.reason}
</li>
))}
</ul>
</li>
)}
</ul>
</Alert>
)}
{activeTab === 'records' && queue.data && queue.data.totalElements === 0 && (
<EmptyState title={t('reviews.empty')} />
)}
{activeTab === 'records' && queue.data && queue.data.totalElements > 0 && (
<Panel>
<div className="flex items-center justify-between mb-3 gap-3">
<p className="text-cell text-mute">
{t('reviews.queueTotal', { count: queue.data.totalElements })}
{anySelected ? (
<span className="ml-2 text-accent font-medium">
· {t('reviews.bulk.selectedCount', { count: selection.size })}
</span>
) : null}
</p>
{anySelected && (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
disabled={bulkRunning}
onClick={handleBulkApprove}
>
{t('reviews.bulk.approveSelected')}
</Button>
<Button
variant="secondary"
size="sm"
disabled={bulkRunning}
onClick={() => setBulkRejectOpen(true)}
>
{t('reviews.bulk.rejectSelected')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setSelection(new Set())}
>
{t('reviews.bulk.clear')}
</Button>
</div>
)}
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>
<Checkbox
aria-label={t('reviews.bulk.selectAll')}
checked={allVisibleSelected}
onChange={toggleSelectAll}
/>
</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.dict')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.bk')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.op')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.maker')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.submitted')}</TableHeaderCell>
<TableHeaderCell>{t('reviews.col.actions')}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((d) => (
<TableRow key={d.id} data-selected={selection.has(d.id)}>
<TableCell>
<Checkbox
aria-label={`${t('reviews.bulk.selectRow')} ${d.businessKey}`}
checked={selection.has(d.id)}
onChange={() => toggleSelect(d.id)}
/>
</TableCell>
<TableCell>
<Link
to="/dictionaries/$name"
params={{ name: d.dictionaryId }}
className="text-mono text-accent hover:underline"
>
{d.dictionaryId.slice(0, 8)}
</Link>
</TableCell>
<TableCell className="text-mono">{d.businessKey}</TableCell>
<TableCell>
<Badge variant={operationVariant(d.operation)}>{d.operation}</Badge>
</TableCell>
<TableCell><UserCell uuid={d.makerId} /></TableCell>
<TableCell className="text-cell text-ink-2">
{new Date(d.submittedAt).toLocaleString()}
</TableCell>
<TableCell>
<Button
variant="secondary"
size="sm"
onClick={() => setSelectedId(d.id)}
>
{t('reviews.action.review')}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
)}
<ReviewDrawer
draftId={selectedId}
onClose={() => setSelectedId(undefined)}
/>
<Modal
isOpen={bulkRejectOpen}
onClose={() => setBulkRejectOpen(false)}
title={t('reviews.bulk.rejectModalTitle', { count: selection.size })}
>
<div className="space-y-3">
<p className="text-cell text-ink-2">
{t('reviews.bulk.rejectModalDescription')}
</p>
<TextArea
label={t('reviews.bulk.rejectReasonLabel')}
value={bulkRejectReason}
onChange={(e) => setBulkRejectReason(e.target.value)}
rows={3}
placeholder={t('reviews.bulk.rejectReasonPlaceholder')}
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => {
setBulkRejectOpen(false)
setBulkRejectReason('')
}}
>
{t('reviews.bulk.cancel')}
</Button>
<Button
variant="primary"
disabled={!bulkRejectReason.trim() || bulkRunning}
onClick={handleBulkReject}
>
{t('reviews.bulk.confirmReject', { count: selection.size })}
</Button>
</div>
</div>
</Modal>
</div>
)
}
type DrawerProps = {
draftId: string | undefined
onClose: () => void
}
function ReviewDrawer({ draftId, onClose }: DrawerProps) {
const { t } = useTranslation()
const draftQ = useDraft(draftId)
const draft = draftQ.data
const liveQ = useLiveRecord(
draft ? draft.dictionaryId : undefined,
draft ? draft.businessKey : undefined,
)
const approveMut = useApproveDraft()
const rejectMut = useRejectDraft()
const [comment, setComment] = useState('')
const handleApprove = () => {
if (!draftId) return
approveMut.mutate(
{ id: draftId, comment: comment.trim() || undefined },
{
onSuccess: () => {
setComment('')
onClose()
},
},
)
}
const handleReject = () => {
if (!draftId) return
if (!comment.trim()) return // backend will 400, but fail fast in UI
rejectMut.mutate(
{ id: draftId, comment: comment.trim() },
{
onSuccess: () => {
setComment('')
onClose()
},
},
)
}
return (
<Drawer
isOpen={Boolean(draftId)}
onClose={onClose}
title={t('reviews.drawer.title')}
side="right"
widthClassName="w-full max-w-3xl"
>
<div className="space-y-4">
{draftQ.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{draftQ.error && (
<Alert variant="error" title={t('error.failed')}>
{String(draftQ.error)}
</Alert>
)}
{draft && (
<>
<div className="flex flex-wrap items-center gap-2 text-cell">
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
<span className="font-mono">{draft.businessKey}</span>
<span className="text-mute">
{t('reviews.drawer.maker')}: {draft.makerId}
</span>
<span className="text-mute">
{t('reviews.drawer.submitted')}:{' '}
{new Date(draft.submittedAt).toLocaleString()}
</span>
</div>
{draft.makerComment && (
<div className="px-3 py-2 rounded-sm border border-line bg-line/30 text-cell">
<span className="text-mute uppercase tracking-[0.10em] font-display">
{t('reviews.drawer.makerComment')}:
</span>{' '}
{draft.makerComment}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<p className="text-cap text-mute mb-1">
{t('reviews.drawer.live')}
</p>
{liveQ.isLoading && <LoadingBlock size="sm" label={t('loading')} />}
{liveQ.data === null && (
<p className="text-cell text-mute italic">
{t('reviews.drawer.noLive')}
</p>
)}
{liveQ.data && (
<pre className="text-mono bg-line/30 rounded p-2 overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(liveQ.data.data, null, 2)}
</pre>
)}
</div>
<div>
<p className="text-cap text-mute mb-1">
{t('reviews.drawer.proposed')}
</p>
{draft.operation === 'CLOSE' ? (
<p className="text-cell text-aurora italic">
{t('reviews.drawer.closeNote')}
</p>
) : (
<pre className="text-mono bg-accent/4 rounded p-2 overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(draft.data, null, 2)}
</pre>
)}
</div>
</div>
{(approveMut.error || rejectMut.error) && (
<Alert variant="error" title={t('error.failed')}>
{String(approveMut.error || rejectMut.error)}
</Alert>
)}
<div className="border-t border-line pt-3 space-y-2">
<label
htmlFor="review-comment"
className="text-cap text-ink-2"
>
{t('reviews.drawer.comment')}
</label>
<TextInput
id="review-comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder={t('reviews.drawer.commentPlaceholder')}
/>
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="secondary"
onClick={onClose}
disabled={approveMut.isPending || rejectMut.isPending}
>
{t('reviews.action.cancel')}
</Button>
<Button
variant="danger"
leftIcon={<XCircleIcon weight="bold" size={14} />}
onClick={handleReject}
disabled={!comment.trim() || rejectMut.isPending || approveMut.isPending}
loading={rejectMut.isPending}
>
{t('reviews.action.reject')}
</Button>
<Button
variant="primary"
leftIcon={<CheckCircleIcon weight="bold" size={14} />}
onClick={handleApprove}
disabled={approveMut.isPending || rejectMut.isPending}
loading={approveMut.isPending}
>
{t('reviews.action.approve')}
</Button>
</div>
</div>
</>
)}
</div>
</Drawer>
)
}
/**
* Schema review pool queue — global view всех \`review_pending\` schema
* draft'ов через {@code GET /admin/schema-reviews/pending}. Reviewer
* жмёт «Открыть» → переходит на dict detail page, banner+drawer
* автоматически подхватятся через {@code useDictActiveSchemaDraft}.
*
* <p>Скрывается если pool пуст — экономит вертикальное место на странице
* для основного record-review queue.
*/
function SchemaReviewQueuePanel() {
const { t } = useTranslation()
const queue = useSchemaReviewQueue(0, 20)
const items = queue.data?.items ?? []
if (queue.isLoading || items.length === 0) return null
return (
<Panel>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-title-md font-semibold">
{t('reviews.schema.title', { defaultValue: 'Изменения схем' })}
</h2>
<p className="text-cell text-mute mt-0.5">
{t('reviews.schema.queueTotal', {
count: queue.data?.totalElements ?? items.length,
defaultValue: `${queue.data?.totalElements ?? items.length} draft(s) ждут ревью`,
})}
</p>
</div>
<Badge variant="info">
{t('reviews.schema.label', { defaultValue: 'Schema workflow' })}
</Badge>
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>{t('reviews.schema.dictionary', { defaultValue: 'Словарь' })}</TableHeaderCell>
<TableHeaderCell>{t('reviews.schema.fromVersion', { defaultValue: 'От версии' })}</TableHeaderCell>
<TableHeaderCell>{t('reviews.schema.maker', { defaultValue: 'Автор' })}</TableHeaderCell>
<TableHeaderCell>{t('reviews.schema.submittedAt', { defaultValue: 'Отправлен' })}</TableHeaderCell>
<TableHeaderCell align="right">{t('reviews.schema.action', { defaultValue: 'Действие' })}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((d) => (
<TableRow key={d.draftId}>
<TableCell>
<Link
to="/dictionaries/$name"
params={{ name: d.dictionaryName }}
className="text-accent hover:underline font-mono"
>
{d.dictionaryName}
</Link>
</TableCell>
<TableCell className="font-mono tabular-nums">v{d.branchedFromVersion}</TableCell>
<TableCell><UserCell uuid={d.makerId} /></TableCell>
<TableCell className="tabular-nums">
{d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}
</TableCell>
<TableCell align="right">
<Link
to="/dictionaries/$name"
params={{ name: d.dictionaryName }}
className="text-accent hover:underline"
>
{t('reviews.schema.open', { defaultValue: 'Открыть' })}
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
)
}