feat(my-drafts): schema drafts тоже в "Мои черновики"
This commit is contained in:
@@ -290,6 +290,27 @@ export const schemaReviewQueueQuery = (page = 0, size = 50) =>
|
|||||||
export const useSchemaReviewQueue = (page = 0, size = 50) =>
|
export const useSchemaReviewQueue = (page = 0, size = 50) =>
|
||||||
useQuery(schemaReviewQueueQuery(page, size))
|
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) =>
|
export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['record-raw', dictionaryName, businessKey] as const,
|
queryKey: ['record-raw', dictionaryName, businessKey] as const,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
useDictionaries,
|
useDictionaries,
|
||||||
useMyDrafts,
|
useMyDrafts,
|
||||||
|
useMySchemaDrafts,
|
||||||
useOutboxStats,
|
useOutboxStats,
|
||||||
useReviewsBadgeCount,
|
useReviewsBadgeCount,
|
||||||
useWebhookSubscriptions,
|
useWebhookSubscriptions,
|
||||||
@@ -76,7 +77,10 @@ export function Sidebar() {
|
|||||||
const isReviewer = canReviewSchema || canReviewRecord
|
const isReviewer = canReviewSchema || canReviewRecord
|
||||||
const dictsQuery = useDictionaries()
|
const dictsQuery = useDictionaries()
|
||||||
// Loaded just for сидбар counter — light request, кэшируется.
|
// Loaded just for сидбар counter — light request, кэшируется.
|
||||||
|
// Badge "Мои черновики" суммирует record (/drafts/me) + schema
|
||||||
|
// (/admin/schema-drafts/me) — maker не должен помнить разные типы.
|
||||||
const myDrafts = useMyDrafts(0, 1)
|
const myDrafts = useMyDrafts(0, 1)
|
||||||
|
const mySchemaDrafts = useMySchemaDrafts(0, 1)
|
||||||
// Reviews badge — суммирует record-reviews + schema-reviews pending queues
|
// Reviews badge — суммирует record-reviews + schema-reviews pending queues
|
||||||
// через size=1 paged запросы. Gated на authenticated.
|
// через size=1 paged запросы. Gated на authenticated.
|
||||||
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
|
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
|
||||||
@@ -86,7 +90,8 @@ export function Sidebar() {
|
|||||||
const webhooks = useWebhookSubscriptions()
|
const webhooks = useWebhookSubscriptions()
|
||||||
|
|
||||||
const dictsCount = dictsQuery.data?.length ?? 0
|
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 reviewsCount = reviews.total
|
||||||
// Pending events ждут dispatch. dlq отдельно — alarming, но не блокирует
|
// Pending events ждут dispatch. dlq отдельно — alarming, но не блокирует
|
||||||
// нормальную работу, можно увидеть на /outbox. Badge показывает только
|
// нормальную работу, можно увидеть на /outbox. Badge показывает только
|
||||||
@@ -444,12 +449,14 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
const isReviewer = canReviewSchema || canReviewRecord
|
const isReviewer = canReviewSchema || canReviewRecord
|
||||||
const dictsQuery = useDictionaries()
|
const dictsQuery = useDictionaries()
|
||||||
const myDrafts = useMyDrafts(0, 1)
|
const myDrafts = useMyDrafts(0, 1)
|
||||||
|
const mySchemaDrafts = useMySchemaDrafts(0, 1)
|
||||||
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
|
const reviews = useReviewsBadgeCount(auth.isAuthenticated)
|
||||||
const outboxStats = useOutboxStats()
|
const outboxStats = useOutboxStats()
|
||||||
const webhooks = useWebhookSubscriptions()
|
const webhooks = useWebhookSubscriptions()
|
||||||
|
|
||||||
const dictsCount = dictsQuery.data?.length ?? 0
|
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 reviewsCount = reviews.total
|
||||||
const outboxPending = outboxStats.data?.pending ?? 0
|
const outboxPending = outboxStats.data?.pending ?? 0
|
||||||
const webhooksActive =
|
const webhooksActive =
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { Link, createFileRoute } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -16,9 +16,9 @@ import {
|
|||||||
TableHeaderCell,
|
TableHeaderCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/ui'
|
} from '@/ui'
|
||||||
import { useMyDrafts } from '@/api/queries'
|
import { useMyDrafts, useMySchemaDrafts } from '@/api/queries'
|
||||||
import { useWithdrawDraft } from '@/api/mutations'
|
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')({
|
export const Route = createFileRoute('/my-drafts')({
|
||||||
component: MyDraftsPage,
|
component: MyDraftsPage,
|
||||||
@@ -39,6 +39,17 @@ const statusVariant = (
|
|||||||
return 'neutral'
|
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 видит свои
|
* Approval Workflow v2 Phase 4: maker self-tracking. Maker видит свои
|
||||||
* submissions всех статусов (PENDING/APPROVED/REJECTED/WITHDRAWN), sorted
|
* submissions всех статусов (PENDING/APPROVED/REJECTED/WITHDRAWN), sorted
|
||||||
@@ -49,6 +60,7 @@ const statusVariant = (
|
|||||||
function MyDraftsPage() {
|
function MyDraftsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const drafts = useMyDrafts(0, 50)
|
const drafts = useMyDrafts(0, 50)
|
||||||
|
const schemaDrafts = useMySchemaDrafts(0, 50)
|
||||||
const withdrawMut = useWithdrawDraft()
|
const withdrawMut = useWithdrawDraft()
|
||||||
const [busyId, setBusyId] = useState<string | undefined>(undefined)
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -67,25 +87,39 @@ function MyDraftsPage() {
|
|||||||
description={t('myDrafts.description')}
|
description={t('myDrafts.description')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{drafts.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
{(drafts.isLoading || schemaDrafts.isLoading) && (
|
||||||
|
<LoadingBlock size="md" label={t('loading')} />
|
||||||
|
)}
|
||||||
|
|
||||||
{drafts.error && (
|
{drafts.error && (
|
||||||
<Alert variant="error" title={t('error.failed')}>
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
{String(drafts.error)}
|
{String(drafts.error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{schemaDrafts.error && (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(schemaDrafts.error)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{drafts.data && drafts.data.totalElements === 0 && (
|
{bothEmpty && <EmptyState title={t('myDrafts.empty')} />}
|
||||||
<EmptyState title={t('myDrafts.empty')} />
|
|
||||||
|
{schemaTotal > 0 && (
|
||||||
|
<MySchemaDraftsPanel items={(schemaDrafts.data?.items ?? []) as SchemaDraft[]} total={schemaTotal} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{drafts.data && drafts.data.totalElements > 0 && (
|
{drafts.data && drafts.data.totalElements > 0 && (
|
||||||
<Panel>
|
<Panel>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-cell text-mute">
|
<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 })}
|
{t('myDrafts.total', { count: drafts.data.totalElements })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -146,3 +180,96 @@ function MyDraftsPage() {
|
|||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+14
@@ -130,6 +130,20 @@ public class SchemaDraftService {
|
|||||||
return repository.findPendingReview(pageable);
|
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)
|
// Mutators (state machine transitions)
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+38
@@ -41,6 +41,7 @@ import java.util.UUID;
|
|||||||
* GET /api/v1/dictionaries/{name}/drafts/active
|
* GET /api/v1/dictionaries/{name}/drafts/active
|
||||||
* GET /api/v1/dictionaries/{name}/drafts/{draftId}
|
* GET /api/v1/dictionaries/{name}/drafts/{draftId}
|
||||||
* GET /api/v1/admin/schema-reviews/pending (pool queue)
|
* GET /api/v1/admin/schema-reviews/pending (pool queue)
|
||||||
|
* GET /api/v1/admin/schema-drafts/me (maker tracking)
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* <p>{@code {name}} в path передаётся для красивых URL'ов + кэширования
|
* <p>{@code {name}} в path передаётся для красивых URL'ов + кэширования
|
||||||
@@ -211,4 +212,41 @@ public class SchemaDraftController {
|
|||||||
"totalElements", pg.getTotalElements(),
|
"totalElements", pg.getTotalElements(),
|
||||||
"totalPages", pg.getTotalPages());
|
"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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user