refactor(reviews): interaction state polish — 5 UX fixes

This commit is contained in:
Александр Зимин
2026-05-14 14:13:57 +00:00
parent 513b57b6b5
commit fb64ca4350
6 changed files with 188 additions and 49 deletions
+77 -10
View File
@@ -38,6 +38,7 @@ import { useApproveDraft, useRejectDraft, useWithdrawDraft } from '@/api/mutatio
import type { DraftOperation, DraftResponse, DraftStatus } from '@/api/client'
import { UserCell } from '@/lib/useUserDisplay'
import { shallowDiffSummary, isEmptyDiffSummary } from '@/lib/diff'
import { humanizeBulkReason } from '@/lib/bulkReason'
import {
MyDraftsPanel,
classifyRecordStatus,
@@ -368,7 +369,7 @@ function ReviewsPage() {
<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}
<span className="font-mono">{f.bk}</span>: {humanizeBulkReason(f.reason)}
</li>
))}
</ul>
@@ -379,7 +380,19 @@ function ReviewsPage() {
)}
{activeTab === 'records' && queue.data && queue.data.totalElements === 0 && (
<EmptyState title={t('reviews.empty')} />
<EmptyState
icon={<CheckCircleIcon weight="duotone" size={32} />}
title={t('reviews.empty')}
description={t('reviews.emptyDescription', {
defaultValue:
'Очередь чистая. Прошлые решения можно посмотреть в журнале аудита.',
})}
action={
<Link to="/audit" className="text-accent hover:underline text-cell">
{t('reviews.emptyAuditLink', { defaultValue: 'Открыть /audit' })}
</Link>
}
/>
)}
{activeTab === 'records' && queue.data && queue.data.totalElements > 0 && (
@@ -609,6 +622,14 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
const rejectMut = useRejectDraft()
const withdrawMut = useWithdrawDraft()
const [comment, setComment] = useState('')
/** Inline error для reject reason — show when reviewer кликнул Reject с
* пустым comment'ом. Реально cleared автоматически когда user печатает. */
const [rejectError, setRejectError] = useState<string | null>(null)
/** Brief success affordance после approve/reject mutation — drawer
* остаётся 600ms с зелёной/красной полосой, чтобы reviewer почувствовал
* что click registered. Без этого drawer закрывается мгновенно, click
* feedback теряется. */
const [decisionFlash, setDecisionFlash] = useState<'approve' | 'reject' | null>(null)
// Identity gates — определяют что показать в footer'е drawer'а.
//
@@ -627,8 +648,14 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
{ id: draftId, comment: comment.trim() || undefined },
{
onSuccess: () => {
setComment('')
onClose()
setDecisionFlash('approve')
// Hold drawer 600ms так чтобы reviewer увидел зелёную полосу;
// потом reset state и close. Без флэша approve было instant.
setTimeout(() => {
setComment('')
setDecisionFlash(null)
onClose()
}, 600)
},
},
)
@@ -636,13 +663,29 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
const handleReject = () => {
if (!draftId) return
if (!comment.trim()) return // backend will 400, but fail fast in UI
if (!comment.trim()) {
// Раньше return был silent — reviewer кликал Reject и ничего не
// происходило. Backend всё равно вернёт 400 missing_reject_reason,
// но user-friendly inline error лучше.
setRejectError(
t('reviews.drawer.rejectReasonRequired', {
defaultValue:
'Укажите причину отклонения — comment обязателен для reject (compliance).',
}),
)
return
}
setRejectError(null)
rejectMut.mutate(
{ id: draftId, comment: comment.trim() },
{
onSuccess: () => {
setComment('')
onClose()
setDecisionFlash('reject')
setTimeout(() => {
setComment('')
setDecisionFlash(null)
onClose()
}, 600)
},
},
)
@@ -828,7 +871,16 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
)}
{isPending && !isMineAndPending && (
<div className="border-t border-line pt-3 space-y-2">
<div
className={
'border-t pt-3 space-y-2 transition-colors duration-300 -mx-4 px-4 ' +
(decisionFlash === 'approve'
? 'border-green bg-green-bg/30'
: decisionFlash === 'reject'
? 'border-pink bg-pink-bg/30'
: 'border-line')
}
>
<label
htmlFor="review-comment"
className="text-cap text-ink-2"
@@ -838,9 +890,24 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
<TextInput
id="review-comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
onChange={(e) => {
setComment(e.target.value)
// Stale validation error gone как только user печатает.
if (rejectError && e.target.value.trim()) setRejectError(null)
}}
placeholder={t('reviews.drawer.commentPlaceholder')}
aria-invalid={Boolean(rejectError)}
aria-describedby={rejectError ? 'review-comment-error' : undefined}
/>
{rejectError && (
<p
id="review-comment-error"
role="alert"
className="text-cell text-pink"
>
{rejectError}
</p>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="secondary"
@@ -853,7 +920,7 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
variant="danger"
leftIcon={<XCircleIcon weight="bold" size={14} />}
onClick={handleReject}
disabled={!comment.trim() || rejectMut.isPending || approveMut.isPending}
disabled={rejectMut.isPending || approveMut.isPending}
loading={rejectMut.isPending}
>
{t('reviews.action.reject')}