refactor(reviews): interaction state polish — 5 UX fixes
This commit is contained in:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user