feat(reviews): "Мои" tab — maker self-tracking with status filter

This commit is contained in:
Александр Зимин
2026-05-14 14:06:30 +00:00
parent a6a07428ed
commit be58b61913
6 changed files with 805 additions and 71 deletions
+7 -2
View File
@@ -19,6 +19,7 @@ import {
import { useMyDrafts, useMySchemaDrafts } from '@/api/queries'
import { useWithdrawDraft } from '@/api/mutations'
import type { DraftOperation, DraftStatus, SchemaDraft } from '@/api/client'
import { UserCell } from '@/lib/useUserDisplay'
export const Route = createFileRoute('/my-drafts')({
component: MyDraftsPage,
@@ -151,8 +152,12 @@ function MyDraftsPage() {
? new Date(d.reviewedAt).toLocaleString()
: '—'}
</TableCell>
<TableCell className="text-mono">
{d.reviewerId ?? '—'}
<TableCell>
{d.reviewerId ? (
<UserCell uuid={d.reviewerId} />
) : (
<span className="text-mute"></span>
)}
</TableCell>
<TableCell className="text-cell text-ink max-w-md">
{d.reviewComment ?? d.makerComment ?? '—'}
+220 -46
View File
@@ -24,12 +24,25 @@ import {
QueryErrorState,
} from '@/ui'
import axios from 'axios'
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 { useAuth } from 'react-oidc-context'
import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
import {
useDraft,
useLiveRecord,
useMyDrafts,
useMySchemaDrafts,
useReviewQueue,
useSchemaReviewQueue,
} from '@/api/queries'
import { useApproveDraft, useRejectDraft, useWithdrawDraft } from '@/api/mutations'
import type { DraftOperation, DraftResponse, DraftStatus } from '@/api/client'
import { UserCell } from '@/lib/useUserDisplay'
import { shallowDiffSummary, isEmptyDiffSummary } from '@/lib/diff'
import {
MyDraftsPanel,
classifyRecordStatus,
classifySchemaStatus,
} from '@/components/reviews/MyDraftsPanel'
export const Route = createFileRoute('/reviews')({
component: ReviewsPage,
@@ -41,6 +54,40 @@ const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' =>
return 'info'
}
/**
* Record draft status badge — используется в drawer header'е, чтобы maker
* сразу видел "в каком состоянии мой draft" без скролла к decision banner.
*
* <p>Visual mapping повторяет {@link MyDraftsPanel} badge'и для consistency:
* PENDING=info, APPROVED=success, REJECTED=danger, WITHDRAWN=default.
*/
function DrawerStatusBadge({ status }: { status: DraftStatus }) {
const { t } = useTranslation()
const variant: 'info' | 'success' | 'danger' | 'default' = (() => {
switch (status) {
case 'PENDING':
return 'info'
case 'APPROVED':
return 'success'
case 'REJECTED':
return 'danger'
case 'WITHDRAWN':
return 'default'
}
})()
const fallback: Record<DraftStatus, string> = {
PENDING: 'На ревью',
APPROVED: 'Одобрено',
REJECTED: 'Отклонён',
WITHDRAWN: 'Отозван',
}
return (
<Badge variant={variant}>
{t(`reviews.mine.recordStatus.${status}`, { defaultValue: fallback[status] })}
</Badge>
)
}
/**
* Diff summary chips for the ReviewDrawer — "+N added (fields), M removed,
* ~K changed" above the side-by-side JSON panes. Reviewer sees the gist
@@ -108,14 +155,29 @@ type BulkResult = {
failed: { id: string; bk: string; reason: string }[]
}
type ReviewsTab = 'records' | 'schemas'
type ReviewsTab = 'records' | 'schemas' | 'mine'
function ReviewsPage() {
const { t } = useTranslation()
const queue = useReviewQueue(0, 50)
const schemaQueue = useSchemaReviewQueue(0, 50)
// Counts для "Мои" tab badge — sum of attention-needing drafts (pending +
// WIP). Recompute дешёвый (linear over ≤100 items), refetch interval 30s
// делает badge самозаживляющим без manual invalidation.
const myDrafts = useMyDrafts(0, 50)
const mySchemaDrafts = useMySchemaDrafts(0, 50)
const recordsCount = queue.data?.totalElements ?? 0
const schemasCount = schemaQueue.data?.totalElements ?? 0
const myActiveCount = useMemo(() => {
const records = (myDrafts.data?.items ?? []).filter(
(r) => classifyRecordStatus(r.status) === 'pending',
).length
const schemas = (mySchemaDrafts.data?.items ?? []).filter((s) => {
const b = classifySchemaStatus(s.status)
return b === 'pending' || b === 'wip'
}).length
return records + schemas
}, [myDrafts.data, mySchemaDrafts.data])
// Tab дефолтит на records (старее, чаще). Если schema queue не пустой
// а record queue пустой — переключаемся на schemas чтобы reviewer'у не
// пришлось кликать дважды.
@@ -237,9 +299,21 @@ function ReviewsPage() {
<Badge variant="info">{schemasCount}</Badge>
) : undefined,
},
{
id: 'mine',
label: t('reviews.tab.mine', { defaultValue: 'Мои' }),
count:
myActiveCount > 0 ? (
<Badge variant="info">{myActiveCount}</Badge>
) : undefined,
},
]}
/>
{activeTab === 'mine' && (
<MyDraftsPanel onRecordClick={(id) => setSelectedId(id)} />
)}
{activeTab === 'schemas' && (
<>
{schemaQueue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
@@ -523,6 +597,8 @@ function extractReviewError(err: unknown): { title: string; body: string } {
function ReviewDrawer({ draftId, onClose }: DrawerProps) {
const { t } = useTranslation()
const auth = useAuth()
const currentSub = auth.user?.profile?.sub
const draftQ = useDraft(draftId)
const draft = draftQ.data
const liveQ = useLiveRecord(
@@ -531,8 +607,20 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
)
const approveMut = useApproveDraft()
const rejectMut = useRejectDraft()
const withdrawMut = useWithdrawDraft()
const [comment, setComment] = useState('')
// Identity gates — определяют что показать в footer'е drawer'а.
//
// 1. `isPending` = false: terminal draft (APPROVED/REJECTED/WITHDRAWN).
// Никаких actions — read-only diff + decision banner.
// 2. `isMineAndPending`: maker открыл свой собственный pending draft из
// "Мои" таба. Approve self запрещён backend'ом (self_approve_forbidden),
// reject своего черновика бессмыслен — показываем Withdraw.
// 3. Иначе — reviewer flow: approve/reject как было.
const isPending = draft?.status === 'PENDING'
const isMineAndPending = isPending && Boolean(currentSub) && draft?.makerId === currentSub
const handleApprove = () => {
if (!draftId) return
approveMut.mutate(
@@ -560,6 +648,19 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
)
}
const handleWithdraw = () => {
if (!draftId) return
if (!confirm(t('reviews.action.withdrawConfirm', {
defaultValue: 'Отозвать свой черновик? После этого он попадёт в Decided.',
}))) return
withdrawMut.mutate(draftId, {
onSuccess: () => {
setComment('')
onClose()
},
})
}
return (
<Drawer
isOpen={Boolean(draftId)}
@@ -585,6 +686,7 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
<div className="flex flex-wrap items-center gap-2">
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
<span className="font-mono">{draft.businessKey}</span>
<DrawerStatusBadge status={draft.status} />
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
<span className="inline-flex items-center gap-1">
@@ -597,6 +699,36 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
</div>
</div>
{/* Read-only decision banner — для всех terminal статусов. Показывает
reviewer'а + decision time + reviewer comment чтобы maker'у не
пришлось гадать "что произошло". */}
{!isPending && (
<div className="px-3 py-2 rounded-sm border border-line bg-line/30 text-cell space-y-1">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
{draft.reviewerId && (
<span className="inline-flex items-center gap-1">
{t('reviews.drawer.reviewer', { defaultValue: 'Ревьюер' })}:{' '}
<UserCell uuid={draft.reviewerId} />
</span>
)}
{draft.reviewedAt && (
<span>
{t('reviews.drawer.reviewedAt', { defaultValue: 'Решение' })}:{' '}
{new Date(draft.reviewedAt).toLocaleString()}
</span>
)}
</div>
{draft.reviewComment && (
<div>
<span className="text-mute uppercase tracking-[0.10em] font-display">
{t('reviews.drawer.reviewComment', { defaultValue: 'Комментарий' })}:
</span>{' '}
{draft.reviewComment}
</div>
)}
</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">
@@ -645,8 +777,8 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
</div>
</div>
{(approveMut.error || rejectMut.error) && (() => {
const err = approveMut.error || rejectMut.error
{(approveMut.error || rejectMut.error || withdrawMut.error) && (() => {
const err = approveMut.error || rejectMut.error || withdrawMut.error
const info = extractReviewError(err)
return (
<Alert variant="error" title={info.title}>
@@ -655,47 +787,89 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
)
})()}
<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')}
{/* Action footer split на три режима:
- terminal (не pending) — только Close, нет input/actions
- own pending — Withdraw, без comment input (maker не reviewer'ит)
- peer pending — approve/reject (existing reviewer flow) */}
{!isPending && (
<div className="border-t border-line pt-3 flex items-center justify-end">
<Button variant="secondary" onClick={onClose}>
{t('reviews.action.close', { defaultValue: 'Закрыть' })}
</Button>
</div>
</div>
)}
{isMineAndPending && (
<div className="border-t border-line pt-3 space-y-2">
<p className="text-cell text-mute">
{t('reviews.drawer.ownPendingHint', {
defaultValue: 'Это ваш черновик на ревью. Approve/reject запрещены — можно отозвать.',
})}
</p>
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="secondary"
onClick={onClose}
disabled={withdrawMut.isPending}
>
{t('reviews.action.cancel')}
</Button>
<Button
variant="danger"
leftIcon={<ArrowCounterClockwiseIcon weight="bold" size={14} />}
onClick={handleWithdraw}
disabled={withdrawMut.isPending}
loading={withdrawMut.isPending}
>
{t('reviews.action.withdraw', { defaultValue: 'Отозвать' })}
</Button>
</div>
</div>
)}
{isPending && !isMineAndPending && (
<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>