feat(reviews): "Мои" tab — maker self-tracking with status filter
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
classifyRecordStatus,
|
||||
classifySchemaStatus,
|
||||
} from './MyDraftsPanel'
|
||||
import type { DraftStatus, SchemaDraftStatus } from '@/api/client'
|
||||
|
||||
describe('classifyRecordStatus', () => {
|
||||
it('PENDING → pending bucket', () => {
|
||||
expect(classifyRecordStatus('PENDING')).toBe('pending')
|
||||
})
|
||||
|
||||
it.each<DraftStatus>(['APPROVED', 'REJECTED', 'WITHDRAWN'])(
|
||||
'terminal status %s → decided bucket',
|
||||
(s) => {
|
||||
expect(classifyRecordStatus(s)).toBe('decided')
|
||||
},
|
||||
)
|
||||
|
||||
it('covers all DraftStatus variants — exhaustiveness check', () => {
|
||||
// Sanity: every DraftStatus literal must land in exactly one bucket.
|
||||
// If this fails after adding a new status, update the classifier and
|
||||
// the bucket count tests in ReviewsPage's myActiveCount memo.
|
||||
const all: DraftStatus[] = ['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']
|
||||
const buckets = all.map(classifyRecordStatus)
|
||||
expect(new Set(buckets)).toEqual(new Set(['pending', 'decided']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifySchemaStatus', () => {
|
||||
it('review_pending → pending (reviewer action needed)', () => {
|
||||
expect(classifySchemaStatus('review_pending')).toBe('pending')
|
||||
})
|
||||
|
||||
it('approved → pending (maker still needs to publish)', () => {
|
||||
expect(classifySchemaStatus('approved')).toBe('pending')
|
||||
})
|
||||
|
||||
it('draft → wip (maker action needed)', () => {
|
||||
expect(classifySchemaStatus('draft')).toBe('wip')
|
||||
})
|
||||
|
||||
it('changes_requested → wip (reviewer returned to maker)', () => {
|
||||
expect(classifySchemaStatus('changes_requested')).toBe('wip')
|
||||
})
|
||||
|
||||
it.each<SchemaDraftStatus>(['rejected', 'published', 'withdrawn'])(
|
||||
'terminal status %s → decided',
|
||||
(s) => {
|
||||
expect(classifySchemaStatus(s)).toBe('decided')
|
||||
},
|
||||
)
|
||||
|
||||
it('covers all SchemaDraftStatus variants — exhaustiveness check', () => {
|
||||
const all: SchemaDraftStatus[] = [
|
||||
'draft',
|
||||
'review_pending',
|
||||
'approved',
|
||||
'changes_requested',
|
||||
'rejected',
|
||||
'published',
|
||||
'withdrawn',
|
||||
]
|
||||
const buckets = all.map(classifySchemaStatus)
|
||||
expect(new Set(buckets)).toEqual(new Set(['pending', 'wip', 'decided']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,432 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
EmptyState,
|
||||
LoadingBlock,
|
||||
Panel,
|
||||
QueryErrorState,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from '@/ui'
|
||||
import { useMyDrafts, useMySchemaDrafts } from '@/api/queries'
|
||||
import { UserCell } from '@/lib/useUserDisplay'
|
||||
import type {
|
||||
DraftOperation,
|
||||
DraftResponse,
|
||||
DraftStatus,
|
||||
SchemaDraft,
|
||||
SchemaDraftStatus,
|
||||
} from '@/api/client'
|
||||
|
||||
/**
|
||||
* "Мои" tab на /reviews — maker self-tracking. Mixed timeline записей и
|
||||
* схем (drafts) которые я создал, sorted submittedAt/createdAt DESC.
|
||||
*
|
||||
* <p>Filter chips: All / Pending / WIP / Decided. WIP применим только к
|
||||
* schemas — records у нас всегда submit'ятся через single-shot endpoint
|
||||
* (нет "в работе" состояния между created и submitted).
|
||||
*
|
||||
* <p>Status grouping:
|
||||
* <ul>
|
||||
* <li><b>Records</b> ({@link DraftStatus}): Pending=PENDING,
|
||||
* Decided=APPROVED+REJECTED+WITHDRAWN, no WIP.</li>
|
||||
* <li><b>Schemas</b> ({@link SchemaDraftStatus}):
|
||||
* Pending=review_pending+approved (awaiting publish),
|
||||
* WIP=draft+changes_requested (maker action needed),
|
||||
* Decided=rejected+published+withdrawn.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Click handler:
|
||||
* <ul>
|
||||
* <li>Record row → callback'ом наружу (parent открывает {@code ReviewDrawer}
|
||||
* с этим draftId — reuse того же drawer что used reviewer'ом).</li>
|
||||
* <li>Schema row → Link на {@code /dictionaries/$name} (там dict page
|
||||
* поднимает {@code SchemaDraftDrawer} через workflow banner; для
|
||||
* terminal статусов draft видно в audit history).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Both queries имеют {@code refetchInterval: 30_000} — maker видит
|
||||
* approve/reject reviewer'а без F5.
|
||||
*/
|
||||
type Props = {
|
||||
/** Called когда maker кликает на record row — открывает ReviewDrawer наружу. */
|
||||
onRecordClick: (draftId: string) => void
|
||||
}
|
||||
|
||||
export type StatusFilter = 'all' | 'pending' | 'wip' | 'decided'
|
||||
|
||||
type Bucket = 'pending' | 'wip' | 'decided'
|
||||
|
||||
type UnifiedRow =
|
||||
| { kind: 'record'; id: string; timestamp: string; bucket: Bucket; record: DraftResponse }
|
||||
| { kind: 'schema'; id: string; timestamp: string; bucket: Bucket; schema: SchemaDraft }
|
||||
|
||||
/**
|
||||
* Records pending bucket = PENDING only. Все остальные статусы
|
||||
* (APPROVED/REJECTED/WITHDRAWN) — это closure events, попадают в Decided.
|
||||
*
|
||||
* <p>Exported для unit-testing — buckets — load-bearing logic для filter
|
||||
* counts (eng-review pattern).
|
||||
*/
|
||||
export function classifyRecordStatus(s: DraftStatus): Bucket {
|
||||
return s === 'PENDING' ? 'pending' : 'decided'
|
||||
}
|
||||
|
||||
/**
|
||||
* Schemas: pending = "что-то ждёт reviewer/maker action", WIP = "maker должен
|
||||
* что-то сделать чтобы сдвинуть draft", decided = terminal.
|
||||
*
|
||||
* <p>{@code approved} попадает в pending потому что maker должен ещё publish'нуть
|
||||
* — для него это "почти готово, осталось одна кнопка". {@code changes_requested}
|
||||
* — в WIP потому что reviewer вернул maker'у на доработку.
|
||||
*
|
||||
* <p>Exported для unit-testing.
|
||||
*/
|
||||
export function classifySchemaStatus(s: SchemaDraftStatus): Bucket {
|
||||
if (s === 'review_pending' || s === 'approved') return 'pending'
|
||||
if (s === 'draft' || s === 'changes_requested') return 'wip'
|
||||
return 'decided'
|
||||
}
|
||||
|
||||
const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' => {
|
||||
if (op === 'CREATE') return 'success'
|
||||
if (op === 'CLOSE') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
export function MyDraftsPanel({ onRecordClick }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const recordsQ = useMyDrafts(0, 50)
|
||||
const schemasQ = useMySchemaDrafts(0, 50)
|
||||
const [filter, setFilter] = useState<StatusFilter>('all')
|
||||
|
||||
const rows: UnifiedRow[] = useMemo(() => {
|
||||
const records: UnifiedRow[] = (recordsQ.data?.items ?? []).map((r) => ({
|
||||
kind: 'record',
|
||||
id: r.id,
|
||||
timestamp: r.submittedAt,
|
||||
bucket: classifyRecordStatus(r.status),
|
||||
record: r,
|
||||
}))
|
||||
const schemas: UnifiedRow[] = (schemasQ.data?.items ?? []).map((s) => ({
|
||||
kind: 'schema',
|
||||
id: s.draftId,
|
||||
// SchemaDraft может быть pre-submit (draft без submittedAt) — берём
|
||||
// createdAt в fallback чтобы row не уехал в самый низ с пустым timestamp.
|
||||
timestamp: s.submittedAt ?? s.createdAt,
|
||||
bucket: classifySchemaStatus(s.status),
|
||||
schema: s,
|
||||
}))
|
||||
return [...records, ...schemas].sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
}, [recordsQ.data, schemasQ.data])
|
||||
|
||||
const counts: Record<StatusFilter, number> = useMemo(
|
||||
() => ({
|
||||
all: rows.length,
|
||||
pending: rows.filter((r) => r.bucket === 'pending').length,
|
||||
wip: rows.filter((r) => r.bucket === 'wip').length,
|
||||
decided: rows.filter((r) => r.bucket === 'decided').length,
|
||||
}),
|
||||
[rows],
|
||||
)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (filter === 'all' ? rows : rows.filter((r) => r.bucket === filter)),
|
||||
[rows, filter],
|
||||
)
|
||||
|
||||
if (recordsQ.isLoading || schemasQ.isLoading) {
|
||||
return <LoadingBlock size="md" label={t('loading')} />
|
||||
}
|
||||
if (recordsQ.error) {
|
||||
return <QueryErrorState error={recordsQ.error} onRetry={() => recordsQ.refetch()} />
|
||||
}
|
||||
if (schemasQ.error) {
|
||||
return <QueryErrorState error={schemasQ.error} onRetry={() => schemasQ.refetch()} />
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t('reviews.mine.empty', {
|
||||
defaultValue: 'У вас пока нет черновиков',
|
||||
})}
|
||||
description={t('reviews.mine.emptyDescription', {
|
||||
defaultValue:
|
||||
'Черновики появятся здесь, когда вы отправите изменения записи или схемы на ревью.',
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<FilterChips filter={filter} setFilter={setFilter} counts={counts} />
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
{t('reviews.mine.col.type', { defaultValue: 'Тип' })}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
{t('reviews.mine.col.target', { defaultValue: 'Объект' })}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
{t('reviews.mine.col.op', { defaultValue: 'Операция' })}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
{t('reviews.mine.col.status', { defaultValue: 'Статус' })}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
{t('reviews.mine.col.reviewer', { defaultValue: 'Ревьюер' })}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
{t('reviews.mine.col.submitted', { defaultValue: 'Создан' })}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell align="right">
|
||||
{t('reviews.mine.col.action', { defaultValue: 'Действие' })}
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map((row) =>
|
||||
row.kind === 'record' ? (
|
||||
<RecordRow
|
||||
key={`r-${row.id}`}
|
||||
draft={row.record}
|
||||
onClick={() => onRecordClick(row.id)}
|
||||
/>
|
||||
) : (
|
||||
<SchemaRow key={`s-${row.id}`} draft={row.schema} />
|
||||
),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{filtered.length === 0 && (
|
||||
<div className="py-8 text-center text-cell text-mute">
|
||||
{t('reviews.mine.emptyFilter', {
|
||||
defaultValue: 'В этой категории черновиков нет',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterChips({
|
||||
filter,
|
||||
setFilter,
|
||||
counts,
|
||||
}: {
|
||||
filter: StatusFilter
|
||||
setFilter: (f: StatusFilter) => void
|
||||
counts: Record<StatusFilter, number>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const chips: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'all', label: t('reviews.mine.filter.all', { defaultValue: 'Все' }) },
|
||||
{
|
||||
key: 'pending',
|
||||
label: t('reviews.mine.filter.pending', { defaultValue: 'На ревью' }),
|
||||
},
|
||||
{ key: 'wip', label: t('reviews.mine.filter.wip', { defaultValue: 'В работе' }) },
|
||||
{
|
||||
key: 'decided',
|
||||
label: t('reviews.mine.filter.decided', { defaultValue: 'Закрыты' }),
|
||||
},
|
||||
]
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
{chips.map((c) => {
|
||||
const active = filter === c.key
|
||||
return (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onClick={() => setFilter(c.key)}
|
||||
aria-pressed={active}
|
||||
className={
|
||||
'px-2.5 py-1 text-cell rounded-sm border transition-colors ' +
|
||||
(active
|
||||
? 'border-accent text-accent bg-accent/10'
|
||||
: 'border-line text-ink-2 hover:bg-line/30')
|
||||
}
|
||||
>
|
||||
{c.label}
|
||||
{counts[c.key] > 0 && (
|
||||
<span className="ml-1.5 text-mute tabular-nums">({counts[c.key]})</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecordRow({
|
||||
draft,
|
||||
onClick,
|
||||
}: {
|
||||
draft: DraftResponse
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Badge variant="info">
|
||||
{t('reviews.mine.type.record', { defaultValue: 'Запись' })}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to="/dictionaries/$name"
|
||||
params={{ name: draft.dictionaryId }}
|
||||
className="text-mono text-accent hover:underline"
|
||||
>
|
||||
<span className="text-mute">{draft.dictionaryId.slice(0, 8)}…</span>
|
||||
<span className="mx-1 text-mute">/</span>
|
||||
<span>{draft.businessKey}</span>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RecordStatusBadge status={draft.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{draft.reviewerId ? <UserCell uuid={draft.reviewerId} /> : <span className="text-mute">—</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-cell text-ink-2 tabular-nums">
|
||||
{new Date(draft.submittedAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button variant="secondary" size="sm" onClick={onClick}>
|
||||
{t('reviews.mine.action.open', { defaultValue: 'Открыть' })}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function SchemaRow({ draft }: { draft: SchemaDraft }) {
|
||||
const { t } = useTranslation()
|
||||
const timestamp = draft.submittedAt ?? draft.createdAt
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Badge variant="warning">
|
||||
{t('reviews.mine.type.schema', { defaultValue: 'Схема' })}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to="/dictionaries/$name"
|
||||
params={{ name: draft.dictionaryName }}
|
||||
className="text-mono text-accent hover:underline"
|
||||
>
|
||||
{draft.dictionaryName}
|
||||
</Link>
|
||||
<span className="ml-2 text-cell text-mute tabular-nums">
|
||||
{t('reviews.mine.fromVersion', { defaultValue: 'от v' })}
|
||||
{draft.branchedFromVersion}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-mute">—</TableCell>
|
||||
<TableCell>
|
||||
<SchemaStatusBadge status={draft.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{draft.decisionUserId ? (
|
||||
<UserCell uuid={draft.decisionUserId} />
|
||||
) : (
|
||||
<span className="text-mute">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-cell text-ink-2 tabular-nums">
|
||||
{new Date(timestamp).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Link
|
||||
to="/dictionaries/$name"
|
||||
params={{ name: draft.dictionaryName }}
|
||||
className="text-accent hover:underline text-cell"
|
||||
>
|
||||
{t('reviews.mine.action.openSchema', { defaultValue: 'Открыть' })} →
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function RecordStatusBadge({ 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>
|
||||
)
|
||||
}
|
||||
|
||||
function SchemaStatusBadge({ status }: { status: SchemaDraftStatus }) {
|
||||
const { t } = useTranslation()
|
||||
const variant: 'info' | 'success' | 'warning' | 'danger' | 'primary' | 'default' = (() => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
case 'changes_requested':
|
||||
return 'warning'
|
||||
case 'review_pending':
|
||||
return 'info'
|
||||
case 'approved':
|
||||
return 'success'
|
||||
case 'rejected':
|
||||
return 'danger'
|
||||
case 'withdrawn':
|
||||
return 'default'
|
||||
case 'published':
|
||||
return 'primary'
|
||||
}
|
||||
})()
|
||||
const fallback: Record<SchemaDraftStatus, string> = {
|
||||
draft: 'Черновик',
|
||||
review_pending: 'На ревью',
|
||||
approved: 'Одобрено',
|
||||
changes_requested: 'Запрошены правки',
|
||||
rejected: 'Отклонён',
|
||||
published: 'Опубликован',
|
||||
withdrawn: 'Отозван',
|
||||
}
|
||||
return (
|
||||
<Badge variant={variant}>
|
||||
{t(`workflow.schemaDraft.statusBadge.${status}`, {
|
||||
defaultValue: fallback[status],
|
||||
})}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user