feat(my-drafts): schema drafts тоже в "Мои черновики"

This commit is contained in:
Александр Зимин
2026-05-13 12:45:45 +00:00
parent 1b20db5059
commit f096b5746d
5 changed files with 218 additions and 11 deletions
+21
View File
@@ -290,6 +290,27 @@ export const schemaReviewQueueQuery = (page = 0, size = 50) =>
export const useSchemaReviewQueue = (page = 0, size = 50) =>
useQuery(schemaReviewQueueQuery(page, size))
/**
* Maker self-tracking — все мои schema drafts (любой статус). Зеркало
* `myDraftsQuery` для record drafts. Frontend объединяет обе очереди
* на /my-drafts, чтобы пользователь не запоминал какой тип где.
*/
export const mySchemaDraftsQuery = (page = 0, size = 50) =>
queryOptions({
queryKey: ['schema-drafts', 'me', page, size] as const,
queryFn: async (): Promise<SchemaReviewQueuePage> => {
const { data } = await apiClient.get<SchemaReviewQueuePage>(
'/admin/schema-drafts/me',
{ params: { page, size } },
)
return data
},
refetchInterval: 30_000,
})
export const useMySchemaDrafts = (page = 0, size = 50) =>
useQuery(mySchemaDraftsQuery(page, size))
export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
queryOptions({
queryKey: ['record-raw', dictionaryName, businessKey] as const,
@@ -21,6 +21,7 @@ import {
import {
useDictionaries,
useMyDrafts,
useMySchemaDrafts,
useOutboxStats,
useReviewsBadgeCount,
useWebhookSubscriptions,
@@ -76,7 +77,10 @@ export function Sidebar() {
const isReviewer = canReviewSchema || canReviewRecord
const dictsQuery = useDictionaries()
// Loaded just for сидбар counter — light request, кэшируется.
// Badge "Мои черновики" суммирует record (/drafts/me) + schema
// (/admin/schema-drafts/me) — maker не должен помнить разные типы.
const myDrafts = useMyDrafts(0, 1)
const mySchemaDrafts = useMySchemaDrafts(0, 1)
// Reviews badge — суммирует record-reviews + schema-reviews pending queues
// через size=1 paged запросы. Gated на authenticated.
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
@@ -86,7 +90,8 @@ export function Sidebar() {
const webhooks = useWebhookSubscriptions()
const dictsCount = dictsQuery.data?.length ?? 0
const myDraftsCount = myDrafts.data?.totalElements ?? 0
const myDraftsCount =
(myDrafts.data?.totalElements ?? 0) + (mySchemaDrafts.data?.totalElements ?? 0)
const reviewsCount = reviews.total
// Pending events ждут dispatch. dlq отдельно — alarming, но не блокирует
// нормальную работу, можно увидеть на /outbox. Badge показывает только
@@ -444,12 +449,14 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
const isReviewer = canReviewSchema || canReviewRecord
const dictsQuery = useDictionaries()
const myDrafts = useMyDrafts(0, 1)
const mySchemaDrafts = useMySchemaDrafts(0, 1)
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
const outboxStats = useOutboxStats()
const webhooks = useWebhookSubscriptions()
const dictsCount = dictsQuery.data?.length ?? 0
const myDraftsCount = myDrafts.data?.totalElements ?? 0
const myDraftsCount =
(myDrafts.data?.totalElements ?? 0) + (mySchemaDrafts.data?.totalElements ?? 0)
const reviewsCount = reviews.total
const outboxPending = outboxStats.data?.pending ?? 0
const webhooksActive =
+136 -9
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { Link, createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Alert,
@@ -16,9 +16,9 @@ import {
TableHeaderCell,
TableRow,
} from '@/ui'
import { useMyDrafts } from '@/api/queries'
import { useMyDrafts, useMySchemaDrafts } from '@/api/queries'
import { useWithdrawDraft } from '@/api/mutations'
import type { DraftOperation, DraftStatus } from '@/api/client'
import type { DraftOperation, DraftStatus, SchemaDraft } from '@/api/client'
export const Route = createFileRoute('/my-drafts')({
component: MyDraftsPage,
@@ -39,6 +39,17 @@ const statusVariant = (
return 'neutral'
}
/** Mapping schema draft статусов на UI badge variant. */
const schemaStatusVariant = (
s: string,
): 'warning' | 'success' | 'error' | 'info' | 'neutral' => {
if (s === 'review_pending') return 'warning'
if (s === 'approved' || s === 'published') return 'success'
if (s === 'rejected') return 'error'
if (s === 'changes_requested') return 'info'
return 'neutral'
}
/**
* Approval Workflow v2 Phase 4: maker self-tracking. Maker видит свои
* submissions всех статусов (PENDING/APPROVED/REJECTED/WITHDRAWN), sorted
@@ -49,6 +60,7 @@ const statusVariant = (
function MyDraftsPage() {
const { t } = useTranslation()
const drafts = useMyDrafts(0, 50)
const schemaDrafts = useMySchemaDrafts(0, 50)
const withdrawMut = useWithdrawDraft()
const [busyId, setBusyId] = useState<string | undefined>(undefined)
@@ -60,6 +72,14 @@ function MyDraftsPage() {
})
}
const recordTotal = drafts.data?.totalElements ?? 0
const schemaTotal = schemaDrafts.data?.totalElements ?? 0
const bothEmpty =
!drafts.isLoading &&
!schemaDrafts.isLoading &&
recordTotal === 0 &&
schemaTotal === 0
return (
<div className="space-y-4">
<PageHeader
@@ -67,24 +87,38 @@ function MyDraftsPage() {
description={t('myDrafts.description')}
/>
{drafts.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{(drafts.isLoading || schemaDrafts.isLoading) && (
<LoadingBlock size="md" label={t('loading')} />
)}
{drafts.error && (
<Alert variant="error" title={t('error.failed')}>
{String(drafts.error)}
</Alert>
)}
{schemaDrafts.error && (
<Alert variant="error" title={t('error.failed')}>
{String(schemaDrafts.error)}
</Alert>
)}
{drafts.data && drafts.data.totalElements === 0 && (
<EmptyState title={t('myDrafts.empty')} />
{bothEmpty && <EmptyState title={t('myDrafts.empty')} />}
{schemaTotal > 0 && (
<MySchemaDraftsPanel items={(schemaDrafts.data?.items ?? []) as SchemaDraft[]} total={schemaTotal} />
)}
{drafts.data && drafts.data.totalElements > 0 && (
<Panel>
<div className="flex items-center justify-between mb-3">
<p className="text-cell text-mute">
{t('myDrafts.total', { count: drafts.data.totalElements })}
</p>
<div>
<h2 className="text-title-md font-semibold">
{t('myDrafts.records.title', { defaultValue: 'Записи' })}
</h2>
<p className="text-cell text-mute mt-0.5">
{t('myDrafts.total', { count: drafts.data.totalElements })}
</p>
</div>
</div>
<Table>
<TableHead>
@@ -146,3 +180,96 @@ function MyDraftsPage() {
</div>
)
}
/**
* Schema drafts maker self-tracking panel. Параллельно с record drafts
* на той же странице — пользователь не должен помнить разные типы.
*
* <p>Status badge показывает schema state machine (draft / review_pending /
* approved / rejected / changes_requested / withdrawn / published).
* "Открыть" линкует на dictionary page где есть drawer с details + actions
* (withdraw / re-submit для maker).
*/
function MySchemaDraftsPanel({ items, total }: { items: SchemaDraft[]; total: number }) {
const { t } = useTranslation()
return (
<Panel>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-title-md font-semibold">
{t('myDrafts.schema.title', { defaultValue: 'Черновики схем' })}
</h2>
<p className="text-cell text-mute mt-0.5">
{t('myDrafts.schema.total', {
count: total,
defaultValue: `${total} schema draft(s)`,
})}
</p>
</div>
<Badge variant="info">
{t('myDrafts.schema.label', { defaultValue: 'Schema workflow' })}
</Badge>
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>
{t('myDrafts.schema.col.dictionary', { defaultValue: 'Словарь' })}
</TableHeaderCell>
<TableHeaderCell>
{t('myDrafts.schema.col.fromVersion', { defaultValue: 'От версии' })}
</TableHeaderCell>
<TableHeaderCell>
{t('myDrafts.schema.col.status', { defaultValue: 'Статус' })}
</TableHeaderCell>
<TableHeaderCell>
{t('myDrafts.schema.col.created', { defaultValue: 'Создан' })}
</TableHeaderCell>
<TableHeaderCell>
{t('myDrafts.schema.col.submitted', { defaultValue: 'Отправлен' })}
</TableHeaderCell>
<TableHeaderCell align="right">
{t('myDrafts.schema.col.action', { defaultValue: 'Действие' })}
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((d) => (
<TableRow key={d.draftId}>
<TableCell>
<Link
to="/dictionaries/$name"
params={{ name: d.dictionaryName ?? '' }}
className="text-accent hover:underline font-mono"
>
{d.dictionaryName ?? '—'}
</Link>
</TableCell>
<TableCell className="font-mono tabular-nums">
v{d.branchedFromVersion}
</TableCell>
<TableCell>
<Badge variant={schemaStatusVariant(d.status)}>{d.status}</Badge>
</TableCell>
<TableCell className="tabular-nums">
{new Date(d.createdAt).toLocaleString()}
</TableCell>
<TableCell className="tabular-nums">
{d.submittedAt ? new Date(d.submittedAt).toLocaleString() : '—'}
</TableCell>
<TableCell align="right">
<Link
to="/dictionaries/$name"
params={{ name: d.dictionaryName ?? '' }}
className="text-accent hover:underline"
>
{t('myDrafts.schema.open', { defaultValue: 'Открыть' })}
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Panel>
)
}
@@ -130,6 +130,20 @@ public class SchemaDraftService {
return repository.findPendingReview(pageable);
}
/**
* Maker self-tracking — все schema drafts которые создал actor, любой
* статус (DRAFT / REVIEW_PENDING / APPROVED / REJECTED / CHANGES_REQUESTED
* / WITHDRAWN / PUBLISHED), отсортированы createdAt DESC.
*
* <p>Зеркало record-side {@code DraftService.listByMaker} — frontend
* "Мои черновики" (route {@code /my-drafts}) показывает оба типа,
* чтобы maker не запоминал где какие drafts лежат.
*/
@Transactional(readOnly = true)
public Page<DictionarySchemaDraft> listByMaker(String makerId, Pageable pageable) {
return repository.findByMaker(makerId, pageable);
}
// ─────────────────────────────────────────────────────────────────────
// Mutators (state machine transitions)
// ─────────────────────────────────────────────────────────────────────
@@ -41,6 +41,7 @@ import java.util.UUID;
* GET /api/v1/dictionaries/{name}/drafts/active
* GET /api/v1/dictionaries/{name}/drafts/{draftId}
* GET /api/v1/admin/schema-reviews/pending (pool queue)
* GET /api/v1/admin/schema-drafts/me (maker tracking)
* </pre>
*
* <p>{@code {name}} в path передаётся для красивых URL'ов + кэширования
@@ -211,4 +212,41 @@ public class SchemaDraftController {
"totalElements", pg.getTotalElements(),
"totalPages", pg.getTotalPages());
}
/**
* Maker self-tracking все schema drafts актора, любой статус.
* Зеркало record-side {@code /api/v1/drafts/me}. Frontend "Мои черновики"
* объединяет обе очереди (record + schema), чтобы пользователь не
* запоминал какой тип где лежит.
*
* <p>Sorted createdAt DESC (newest first) самые свежие drafts наверху,
* legacy / approved уходят вниз. UI добавляет фильтр по status.
*/
@GetMapping("/api/v1/admin/schema-drafts/me")
public Map<String, Object> myDrafts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
String makerId = currentUserId();
var pg = draftService.listByMaker(makerId, PageRequest.of(page, Math.min(size, 200)));
List<SchemaDraftResponse> items = pg.getContent().stream()
.map(d -> {
DictionaryDefinition def = definitionService.findById(d.getDictionaryId());
return SchemaDraftResponse.from(d, def.getName());
})
.toList();
return Map.of(
"items", items,
"page", pg.getNumber(),
"size", pg.getSize(),
"totalElements", pg.getTotalElements(),
"totalPages", pg.getTotalPages());
}
/** Resolve current user from JWT subject. Fallback "anonymous" если auth off. */
private static String currentUserId() {
var auth = org.springframework.security.core.context.SecurityContextHolder
.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return "anonymous";
return auth.getName();
}
}