Merge branch 'feat/reviews-mine-tab' into 'main'
feat(reviews): "Мои" tab — maker self-tracking with status filter See merge request 2-6/2-6-4/terravault/ordinis!188
This commit is contained in:
@@ -6,30 +6,14 @@ the **Context** to understand the motivation without re-running the review.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. `/reviews` "Мои" tab — maker visibility
|
## 1. `/reviews` "Мои" tab — maker visibility ✅ SHIPPED MR !188
|
||||||
|
|
||||||
**What.** Add a third tab "Мои" on the `/reviews` page with three sub-sections:
|
Closed via MR !188 (2026-05-14). Final shape: flat mixed timeline (records
|
||||||
Pending (drafts awaiting review), Decided (drafts that received a verdict),
|
+ schemas together sorted DESC), filter chips All/Pending/WIP/Decided with
|
||||||
WIP (drafts the maker started but did not yet submit).
|
per-bucket counts, ReviewDrawer status-aware (read-only decision banner for
|
||||||
|
terminal states, Withdraw for own-pending). 13 unit tests on classifiers + 32
|
||||||
**Why.** Today the maker has no surface to see "where are my drafts?". MR !170
|
i18n keys. Schema rows link to /dictionaries/$name (existing drawer there);
|
||||||
added the backend (`listByMaker` + analogous schema endpoint), but the UI never
|
record rows reuse ReviewDrawer with status-gated footer.
|
||||||
got the entry point. Reviewer's queue mixes records and schemas — symmetric
|
|
||||||
"my drafts" view is missing.
|
|
||||||
|
|
||||||
**Pros.** Reuses `/reviews` page chrome. Backend endpoints already exist. Closes
|
|
||||||
the loop on the maker journey ("submit → ??? → ..." now has a destination).
|
|
||||||
|
|
||||||
**Cons.** Adds two more list queries to the page. Need to think about whether
|
|
||||||
the existing reviewer "Записи / Схемы" toggle nests inside "Мои" too (so 6 panels
|
|
||||||
total) or "Мои" is flat.
|
|
||||||
|
|
||||||
**Context.** Review pass 1 rated information architecture 6/10 specifically
|
|
||||||
because maker visibility was missing. User chose "third tab on /reviews"
|
|
||||||
over a separate `/my-drafts` route — keeps everything in one place.
|
|
||||||
|
|
||||||
**Depends on / blocked by.** Nothing — endpoints live (`/admin/dictionaries/*/drafts?makerId=...`
|
|
||||||
and analogous for schemas).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -580,8 +580,44 @@ i18n
|
|||||||
// === Schema review queue panel ===
|
// === Schema review queue panel ===
|
||||||
'reviews.tab.records': 'Записи',
|
'reviews.tab.records': 'Записи',
|
||||||
'reviews.tab.schemas': 'Схемы',
|
'reviews.tab.schemas': 'Схемы',
|
||||||
|
'reviews.tab.mine': 'Мои',
|
||||||
'reviews.schema.empty': 'Очередь пуста',
|
'reviews.schema.empty': 'Очередь пуста',
|
||||||
'reviews.schema.emptyDescription': 'Схемы на ревью появятся здесь.',
|
'reviews.schema.emptyDescription': 'Схемы на ревью появятся здесь.',
|
||||||
|
// === /reviews → "Мои" tab (maker self-tracking) ===
|
||||||
|
'reviews.mine.empty': 'У вас пока нет черновиков',
|
||||||
|
'reviews.mine.emptyDescription':
|
||||||
|
'Черновики появятся здесь, когда вы отправите изменения записи или схемы на ревью.',
|
||||||
|
'reviews.mine.emptyFilter': 'В этой категории черновиков нет',
|
||||||
|
'reviews.mine.filter.all': 'Все',
|
||||||
|
'reviews.mine.filter.pending': 'На ревью',
|
||||||
|
'reviews.mine.filter.wip': 'В работе',
|
||||||
|
'reviews.mine.filter.decided': 'Закрыты',
|
||||||
|
'reviews.mine.col.type': 'Тип',
|
||||||
|
'reviews.mine.col.target': 'Объект',
|
||||||
|
'reviews.mine.col.op': 'Операция',
|
||||||
|
'reviews.mine.col.status': 'Статус',
|
||||||
|
'reviews.mine.col.reviewer': 'Ревьюер',
|
||||||
|
'reviews.mine.col.submitted': 'Создан',
|
||||||
|
'reviews.mine.col.action': 'Действие',
|
||||||
|
'reviews.mine.type.record': 'Запись',
|
||||||
|
'reviews.mine.type.schema': 'Схема',
|
||||||
|
'reviews.mine.fromVersion': 'от v',
|
||||||
|
'reviews.mine.action.open': 'Открыть',
|
||||||
|
'reviews.mine.action.openSchema': 'Открыть',
|
||||||
|
'reviews.mine.recordStatus.PENDING': 'На ревью',
|
||||||
|
'reviews.mine.recordStatus.APPROVED': 'Одобрено',
|
||||||
|
'reviews.mine.recordStatus.REJECTED': 'Отклонён',
|
||||||
|
'reviews.mine.recordStatus.WITHDRAWN': 'Отозван',
|
||||||
|
// ReviewDrawer additions (read-only banner + own-pending withdraw flow)
|
||||||
|
'reviews.drawer.reviewer': 'Ревьюер',
|
||||||
|
'reviews.drawer.reviewedAt': 'Решение',
|
||||||
|
'reviews.drawer.reviewComment': 'Комментарий',
|
||||||
|
'reviews.drawer.ownPendingHint':
|
||||||
|
'Это ваш черновик на ревью. Approve/reject запрещены — можно отозвать.',
|
||||||
|
'reviews.action.close': 'Закрыть',
|
||||||
|
'reviews.action.withdraw': 'Отозвать',
|
||||||
|
'reviews.action.withdrawConfirm':
|
||||||
|
'Отозвать свой черновик? После этого он попадёт в Decided.',
|
||||||
'reviews.schema.title': 'Изменения схем',
|
'reviews.schema.title': 'Изменения схем',
|
||||||
'reviews.schema.label': 'Schema workflow',
|
'reviews.schema.label': 'Schema workflow',
|
||||||
'reviews.schema.queueTotal_one': '{{count}} draft ждёт ревью',
|
'reviews.schema.queueTotal_one': '{{count}} draft ждёт ревью',
|
||||||
@@ -1278,8 +1314,44 @@ i18n
|
|||||||
// === Schema review queue panel ===
|
// === Schema review queue panel ===
|
||||||
'reviews.tab.records': 'Records',
|
'reviews.tab.records': 'Records',
|
||||||
'reviews.tab.schemas': 'Schemas',
|
'reviews.tab.schemas': 'Schemas',
|
||||||
|
'reviews.tab.mine': 'Mine',
|
||||||
'reviews.schema.empty': 'Queue is empty',
|
'reviews.schema.empty': 'Queue is empty',
|
||||||
'reviews.schema.emptyDescription': 'Schemas under review will appear here.',
|
'reviews.schema.emptyDescription': 'Schemas under review will appear here.',
|
||||||
|
// === /reviews → "Mine" tab (maker self-tracking) ===
|
||||||
|
'reviews.mine.empty': 'You have no drafts yet',
|
||||||
|
'reviews.mine.emptyDescription':
|
||||||
|
'Drafts will appear here when you submit record or schema changes for review.',
|
||||||
|
'reviews.mine.emptyFilter': 'No drafts in this category',
|
||||||
|
'reviews.mine.filter.all': 'All',
|
||||||
|
'reviews.mine.filter.pending': 'Under review',
|
||||||
|
'reviews.mine.filter.wip': 'In progress',
|
||||||
|
'reviews.mine.filter.decided': 'Closed',
|
||||||
|
'reviews.mine.col.type': 'Type',
|
||||||
|
'reviews.mine.col.target': 'Target',
|
||||||
|
'reviews.mine.col.op': 'Op',
|
||||||
|
'reviews.mine.col.status': 'Status',
|
||||||
|
'reviews.mine.col.reviewer': 'Reviewer',
|
||||||
|
'reviews.mine.col.submitted': 'Created',
|
||||||
|
'reviews.mine.col.action': 'Action',
|
||||||
|
'reviews.mine.type.record': 'Record',
|
||||||
|
'reviews.mine.type.schema': 'Schema',
|
||||||
|
'reviews.mine.fromVersion': 'from v',
|
||||||
|
'reviews.mine.action.open': 'Open',
|
||||||
|
'reviews.mine.action.openSchema': 'Open',
|
||||||
|
'reviews.mine.recordStatus.PENDING': 'Under review',
|
||||||
|
'reviews.mine.recordStatus.APPROVED': 'Approved',
|
||||||
|
'reviews.mine.recordStatus.REJECTED': 'Rejected',
|
||||||
|
'reviews.mine.recordStatus.WITHDRAWN': 'Withdrawn',
|
||||||
|
// ReviewDrawer additions (read-only banner + own-pending withdraw flow)
|
||||||
|
'reviews.drawer.reviewer': 'Reviewer',
|
||||||
|
'reviews.drawer.reviewedAt': 'Decided at',
|
||||||
|
'reviews.drawer.reviewComment': 'Comment',
|
||||||
|
'reviews.drawer.ownPendingHint':
|
||||||
|
'This is your own draft under review. Approve/reject are disabled — you can withdraw it.',
|
||||||
|
'reviews.action.close': 'Close',
|
||||||
|
'reviews.action.withdraw': 'Withdraw',
|
||||||
|
'reviews.action.withdrawConfirm':
|
||||||
|
'Withdraw your draft? It will move to Decided.',
|
||||||
'reviews.schema.title': 'Schema changes',
|
'reviews.schema.title': 'Schema changes',
|
||||||
'reviews.schema.label': 'Schema workflow',
|
'reviews.schema.label': 'Schema workflow',
|
||||||
'reviews.schema.queueTotal_one': '{{count}} draft awaiting review',
|
'reviews.schema.queueTotal_one': '{{count}} draft awaiting review',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { useMyDrafts, useMySchemaDrafts } from '@/api/queries'
|
import { useMyDrafts, useMySchemaDrafts } from '@/api/queries'
|
||||||
import { useWithdrawDraft } from '@/api/mutations'
|
import { useWithdrawDraft } from '@/api/mutations'
|
||||||
import type { DraftOperation, DraftStatus, SchemaDraft } from '@/api/client'
|
import type { DraftOperation, DraftStatus, SchemaDraft } from '@/api/client'
|
||||||
|
import { UserCell } from '@/lib/useUserDisplay'
|
||||||
|
|
||||||
export const Route = createFileRoute('/my-drafts')({
|
export const Route = createFileRoute('/my-drafts')({
|
||||||
component: MyDraftsPage,
|
component: MyDraftsPage,
|
||||||
@@ -151,8 +152,12 @@ function MyDraftsPage() {
|
|||||||
? new Date(d.reviewedAt).toLocaleString()
|
? new Date(d.reviewedAt).toLocaleString()
|
||||||
: '—'}
|
: '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-mono">
|
<TableCell>
|
||||||
{d.reviewerId ?? '—'}
|
{d.reviewerId ? (
|
||||||
|
<UserCell uuid={d.reviewerId} />
|
||||||
|
) : (
|
||||||
|
<span className="text-mute">—</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-cell text-ink max-w-md">
|
<TableCell className="text-cell text-ink max-w-md">
|
||||||
{d.reviewComment ?? d.makerComment ?? '—'}
|
{d.reviewComment ?? d.makerComment ?? '—'}
|
||||||
|
|||||||
@@ -24,12 +24,25 @@ import {
|
|||||||
QueryErrorState,
|
QueryErrorState,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
import { useAuth } from 'react-oidc-context'
|
||||||
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
|
import { ArrowCounterClockwiseIcon, CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||||
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
|
import {
|
||||||
import type { DraftOperation, DraftResponse } from '@/api/client'
|
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 { UserCell } from '@/lib/useUserDisplay'
|
||||||
import { shallowDiffSummary, isEmptyDiffSummary } from '@/lib/diff'
|
import { shallowDiffSummary, isEmptyDiffSummary } from '@/lib/diff'
|
||||||
|
import {
|
||||||
|
MyDraftsPanel,
|
||||||
|
classifyRecordStatus,
|
||||||
|
classifySchemaStatus,
|
||||||
|
} from '@/components/reviews/MyDraftsPanel'
|
||||||
|
|
||||||
export const Route = createFileRoute('/reviews')({
|
export const Route = createFileRoute('/reviews')({
|
||||||
component: ReviewsPage,
|
component: ReviewsPage,
|
||||||
@@ -41,6 +54,40 @@ const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' =>
|
|||||||
return 'info'
|
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,
|
* Diff summary chips for the ReviewDrawer — "+N added (fields), −M removed,
|
||||||
* ~K changed" above the side-by-side JSON panes. Reviewer sees the gist
|
* ~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 }[]
|
failed: { id: string; bk: string; reason: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReviewsTab = 'records' | 'schemas'
|
type ReviewsTab = 'records' | 'schemas' | 'mine'
|
||||||
|
|
||||||
function ReviewsPage() {
|
function ReviewsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queue = useReviewQueue(0, 50)
|
const queue = useReviewQueue(0, 50)
|
||||||
const schemaQueue = useSchemaReviewQueue(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 recordsCount = queue.data?.totalElements ?? 0
|
||||||
const schemasCount = schemaQueue.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 не пустой
|
// Tab дефолтит на records (старее, чаще). Если schema queue не пустой
|
||||||
// а record queue пустой — переключаемся на schemas чтобы reviewer'у не
|
// а record queue пустой — переключаемся на schemas чтобы reviewer'у не
|
||||||
// пришлось кликать дважды.
|
// пришлось кликать дважды.
|
||||||
@@ -237,9 +299,21 @@ function ReviewsPage() {
|
|||||||
<Badge variant="info">{schemasCount}</Badge>
|
<Badge variant="info">{schemasCount}</Badge>
|
||||||
) : undefined,
|
) : 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' && (
|
{activeTab === 'schemas' && (
|
||||||
<>
|
<>
|
||||||
{schemaQueue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
{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) {
|
function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const auth = useAuth()
|
||||||
|
const currentSub = auth.user?.profile?.sub
|
||||||
const draftQ = useDraft(draftId)
|
const draftQ = useDraft(draftId)
|
||||||
const draft = draftQ.data
|
const draft = draftQ.data
|
||||||
const liveQ = useLiveRecord(
|
const liveQ = useLiveRecord(
|
||||||
@@ -531,8 +607,20 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
|||||||
)
|
)
|
||||||
const approveMut = useApproveDraft()
|
const approveMut = useApproveDraft()
|
||||||
const rejectMut = useRejectDraft()
|
const rejectMut = useRejectDraft()
|
||||||
|
const withdrawMut = useWithdrawDraft()
|
||||||
const [comment, setComment] = useState('')
|
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 = () => {
|
const handleApprove = () => {
|
||||||
if (!draftId) return
|
if (!draftId) return
|
||||||
approveMut.mutate(
|
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 (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
isOpen={Boolean(draftId)}
|
isOpen={Boolean(draftId)}
|
||||||
@@ -585,6 +686,7 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
||||||
<span className="font-mono">{draft.businessKey}</span>
|
<span className="font-mono">{draft.businessKey}</span>
|
||||||
|
<DrawerStatusBadge status={draft.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-mute">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
@@ -597,6 +699,36 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{draft.makerComment && (
|
||||||
<div className="px-3 py-2 rounded-sm border border-line bg-line/30 text-cell">
|
<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">
|
<span className="text-mute uppercase tracking-[0.10em] font-display">
|
||||||
@@ -645,8 +777,8 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(approveMut.error || rejectMut.error) && (() => {
|
{(approveMut.error || rejectMut.error || withdrawMut.error) && (() => {
|
||||||
const err = approveMut.error || rejectMut.error
|
const err = approveMut.error || rejectMut.error || withdrawMut.error
|
||||||
const info = extractReviewError(err)
|
const info = extractReviewError(err)
|
||||||
return (
|
return (
|
||||||
<Alert variant="error" title={info.title}>
|
<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">
|
{/* Action footer split на три режима:
|
||||||
<label
|
- terminal (не pending) — только Close, нет input/actions
|
||||||
htmlFor="review-comment"
|
- own pending — Withdraw, без comment input (maker не reviewer'ит)
|
||||||
className="text-cap text-ink-2"
|
- peer pending — approve/reject (existing reviewer flow) */}
|
||||||
>
|
{!isPending && (
|
||||||
{t('reviews.drawer.comment')}
|
<div className="border-t border-line pt-3 flex items-center justify-end">
|
||||||
</label>
|
<Button variant="secondary" onClick={onClose}>
|
||||||
<TextInput
|
{t('reviews.action.close', { defaultValue: 'Закрыть' })}
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user