From be58b6191383e0cecd9c2a06ef99b1835ecd3dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Thu, 14 May 2026 14:06:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(reviews):=20"=D0=9C=D0=BE=D0=B8"=20tab=20?= =?UTF-8?q?=E2=80=94=20maker=20self-tracking=20with=20status=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODOS.md | 30 +- .../components/reviews/MyDraftsPanel.test.ts | 67 +++ .../src/components/reviews/MyDraftsPanel.tsx | 432 ++++++++++++++++++ ordinis-admin-ui/src/i18n.ts | 72 +++ ordinis-admin-ui/src/routes/my-drafts.tsx | 9 +- ordinis-admin-ui/src/routes/reviews.tsx | 266 +++++++++-- 6 files changed, 805 insertions(+), 71 deletions(-) create mode 100644 ordinis-admin-ui/src/components/reviews/MyDraftsPanel.test.ts create mode 100644 ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx diff --git a/TODOS.md b/TODOS.md index d74e5b4..4bf32ed 100644 --- a/TODOS.md +++ b/TODOS.md @@ -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: -Pending (drafts awaiting review), Decided (drafts that received a verdict), -WIP (drafts the maker started but did not yet submit). - -**Why.** Today the maker has no surface to see "where are my drafts?". MR !170 -added the backend (`listByMaker` + analogous schema endpoint), but the UI never -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). +Closed via MR !188 (2026-05-14). Final shape: flat mixed timeline (records ++ schemas together sorted DESC), filter chips All/Pending/WIP/Decided with +per-bucket counts, ReviewDrawer status-aware (read-only decision banner for +terminal states, Withdraw for own-pending). 13 unit tests on classifiers + 32 +i18n keys. Schema rows link to /dictionaries/$name (existing drawer there); +record rows reuse ReviewDrawer with status-gated footer. --- diff --git a/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.test.ts b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.test.ts new file mode 100644 index 0000000..844b397 --- /dev/null +++ b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.test.ts @@ -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(['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(['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'])) + }) +}) diff --git a/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx new file mode 100644 index 0000000..3708a22 --- /dev/null +++ b/ordinis-admin-ui/src/components/reviews/MyDraftsPanel.tsx @@ -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. + * + *

Filter chips: All / Pending / WIP / Decided. WIP применим только к + * schemas — records у нас всегда submit'ятся через single-shot endpoint + * (нет "в работе" состояния между created и submitted). + * + *

Status grouping: + *

    + *
  • Records ({@link DraftStatus}): Pending=PENDING, + * Decided=APPROVED+REJECTED+WITHDRAWN, no WIP.
  • + *
  • Schemas ({@link SchemaDraftStatus}): + * Pending=review_pending+approved (awaiting publish), + * WIP=draft+changes_requested (maker action needed), + * Decided=rejected+published+withdrawn.
  • + *
+ * + *

Click handler: + *

    + *
  • Record row → callback'ом наружу (parent открывает {@code ReviewDrawer} + * с этим draftId — reuse того же drawer что used reviewer'ом).
  • + *
  • Schema row → Link на {@code /dictionaries/$name} (там dict page + * поднимает {@code SchemaDraftDrawer} через workflow banner; для + * terminal статусов draft видно в audit history).
  • + *
+ * + *

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. + * + *

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. + * + *

{@code approved} попадает в pending потому что maker должен ещё publish'нуть + * — для него это "почти готово, осталось одна кнопка". {@code changes_requested} + * — в WIP потому что reviewer вернул maker'у на доработку. + * + *

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('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 = 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 + } + if (recordsQ.error) { + return recordsQ.refetch()} /> + } + if (schemasQ.error) { + return schemasQ.refetch()} /> + } + + if (rows.length === 0) { + return ( + + ) + } + + return ( + + + + + + + {t('reviews.mine.col.type', { defaultValue: 'Тип' })} + + + {t('reviews.mine.col.target', { defaultValue: 'Объект' })} + + + {t('reviews.mine.col.op', { defaultValue: 'Операция' })} + + + {t('reviews.mine.col.status', { defaultValue: 'Статус' })} + + + {t('reviews.mine.col.reviewer', { defaultValue: 'Ревьюер' })} + + + {t('reviews.mine.col.submitted', { defaultValue: 'Создан' })} + + + {t('reviews.mine.col.action', { defaultValue: 'Действие' })} + + + + + {filtered.map((row) => + row.kind === 'record' ? ( + onRecordClick(row.id)} + /> + ) : ( + + ), + )} + +
+ {filtered.length === 0 && ( +

+ {t('reviews.mine.emptyFilter', { + defaultValue: 'В этой категории черновиков нет', + })} +
+ )} + + ) +} + +function FilterChips({ + filter, + setFilter, + counts, +}: { + filter: StatusFilter + setFilter: (f: StatusFilter) => void + counts: Record +}) { + 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 ( +
+ {chips.map((c) => { + const active = filter === c.key + return ( + + ) + })} +
+ ) +} + +function RecordRow({ + draft, + onClick, +}: { + draft: DraftResponse + onClick: () => void +}) { + const { t } = useTranslation() + return ( + + + + {t('reviews.mine.type.record', { defaultValue: 'Запись' })} + + + + + {draft.dictionaryId.slice(0, 8)}… + / + {draft.businessKey} + + + + {draft.operation} + + + + + + {draft.reviewerId ? : } + + + {new Date(draft.submittedAt).toLocaleString()} + + + + + + ) +} + +function SchemaRow({ draft }: { draft: SchemaDraft }) { + const { t } = useTranslation() + const timestamp = draft.submittedAt ?? draft.createdAt + return ( + + + + {t('reviews.mine.type.schema', { defaultValue: 'Схема' })} + + + + + {draft.dictionaryName} + + + {t('reviews.mine.fromVersion', { defaultValue: 'от v' })} + {draft.branchedFromVersion} + + + + + + + + {draft.decisionUserId ? ( + + ) : ( + + )} + + + {new Date(timestamp).toLocaleString()} + + + + {t('reviews.mine.action.openSchema', { defaultValue: 'Открыть' })} → + + + + ) +} + +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 = { + PENDING: 'На ревью', + APPROVED: 'Одобрено', + REJECTED: 'Отклонён', + WITHDRAWN: 'Отозван', + } + return ( + + {t(`reviews.mine.recordStatus.${status}`, { defaultValue: fallback[status] })} + + ) +} + +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 = { + draft: 'Черновик', + review_pending: 'На ревью', + approved: 'Одобрено', + changes_requested: 'Запрошены правки', + rejected: 'Отклонён', + published: 'Опубликован', + withdrawn: 'Отозван', + } + return ( + + {t(`workflow.schemaDraft.statusBadge.${status}`, { + defaultValue: fallback[status], + })} + + ) +} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 6e70d0f..2f8acb2 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -580,8 +580,44 @@ i18n // === Schema review queue panel === 'reviews.tab.records': 'Записи', 'reviews.tab.schemas': 'Схемы', + 'reviews.tab.mine': 'Мои', 'reviews.schema.empty': 'Очередь пуста', '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.label': 'Schema workflow', 'reviews.schema.queueTotal_one': '{{count}} draft ждёт ревью', @@ -1278,8 +1314,44 @@ i18n // === Schema review queue panel === 'reviews.tab.records': 'Records', 'reviews.tab.schemas': 'Schemas', + 'reviews.tab.mine': 'Mine', 'reviews.schema.empty': 'Queue is empty', '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.label': 'Schema workflow', 'reviews.schema.queueTotal_one': '{{count}} draft awaiting review', diff --git a/ordinis-admin-ui/src/routes/my-drafts.tsx b/ordinis-admin-ui/src/routes/my-drafts.tsx index f4617cd..c9c818c 100644 --- a/ordinis-admin-ui/src/routes/my-drafts.tsx +++ b/ordinis-admin-ui/src/routes/my-drafts.tsx @@ -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() : '—'} - - {d.reviewerId ?? '—'} + + {d.reviewerId ? ( + + ) : ( + + )} {d.reviewComment ?? d.makerComment ?? '—'} diff --git a/ordinis-admin-ui/src/routes/reviews.tsx b/ordinis-admin-ui/src/routes/reviews.tsx index bf2d2e8..e2d52aa 100644 --- a/ordinis-admin-ui/src/routes/reviews.tsx +++ b/ordinis-admin-ui/src/routes/reviews.tsx @@ -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. + * + *

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 = { + PENDING: 'На ревью', + APPROVED: 'Одобрено', + REJECTED: 'Отклонён', + WITHDRAWN: 'Отозван', + } + return ( + + {t(`reviews.mine.recordStatus.${status}`, { defaultValue: fallback[status] })} + + ) +} + /** * 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() { {schemasCount} ) : undefined, }, + { + id: 'mine', + label: t('reviews.tab.mine', { defaultValue: 'Мои' }), + count: + myActiveCount > 0 ? ( + {myActiveCount} + ) : undefined, + }, ]} /> + {activeTab === 'mine' && ( + setSelectedId(id)} /> + )} + {activeTab === 'schemas' && ( <> {schemaQueue.isLoading && } @@ -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 ( {draft.operation} {draft.businessKey} +

@@ -597,6 +699,36 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
+ {/* Read-only decision banner — для всех terminal статусов. Показывает + reviewer'а + decision time + reviewer comment чтобы maker'у не + пришлось гадать "что произошло". */} + {!isPending && ( +
+
+ {draft.reviewerId && ( + + {t('reviews.drawer.reviewer', { defaultValue: 'Ревьюер' })}:{' '} + + + )} + {draft.reviewedAt && ( + + {t('reviews.drawer.reviewedAt', { defaultValue: 'Решение' })}:{' '} + {new Date(draft.reviewedAt).toLocaleString()} + + )} +
+ {draft.reviewComment && ( +
+ + {t('reviews.drawer.reviewComment', { defaultValue: 'Комментарий' })}: + {' '} + {draft.reviewComment} +
+ )} +
+ )} + {draft.makerComment && (
@@ -645,8 +777,8 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
- {(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 ( @@ -655,47 +787,89 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) { ) })()} -
- - setComment(e.target.value)} - placeholder={t('reviews.drawer.commentPlaceholder')} - /> -
- - -
-
+ )} + + {isMineAndPending && ( +
+

+ {t('reviews.drawer.ownPendingHint', { + defaultValue: 'Это ваш черновик на ревью. Approve/reject запрещены — можно отозвать.', + })} +

+
+ + +
+
+ )} + + {isPending && !isMineAndPending && ( +
+ + setComment(e.target.value)} + placeholder={t('reviews.drawer.commentPlaceholder')} + /> +
+ + + +
+
+ )} )}