84a4de4ceb
Phase 2 admin UI на скелете Phase 0/1 (e438c4c,2020af9). Делает workflow end-to-end usable: reviewer'ы могут approve/reject через UI; admins включают approval_required per-dict через checkbox. API surface (admin-ui): - types: DraftStatus, DraftOperation, DraftResponse, SubmitDraftRequest, ReviewQueuePage. + DictionaryDefinition: approvalRequired, approvalMinRole. - queries (react-query): * useReviewQueue (page, size) — global queue, refetch 30s. * useDraft(id) — single draft for diff drawer. * useLiveRecord(dict, bk) — current live record for side-by-side comparison. - mutations: * useSubmitDraft(dict) — POST /dictionaries/{n}/records/drafts * useApproveDraft() — POST /drafts/{id}/approve * useRejectDraft() — POST /drafts/{id}/reject (reason required) * useWithdrawDraft() — DELETE /drafts/{id} New /reviews route: - Table queue: dict, business_key, operation badge, maker, submitted_at. - Click "Просмотр" → side-by-side drawer: * left: current live (если есть; для CREATE — placeholder). * right: proposed (или CLOSE notice). * maker comment chip если есть. * Reviewer comment input + Approve / Reject buttons. * Reject disabled пока comment пустой (mirror's backend 400). - Auto-invalidates на success — queue refresh, list updates. - Nav link "Согласования" в header. DictionaryEditorDialog Metadata tab: - New checkbox "Требовать approval (maker-checker)" — orbit/8 styled (отделимо от Redis projection card visually). - При checked → shown approval_min_role TextInput (e.g. "ordinis:internal"). - payload sends approvalRequired + approvalMinRole. i18n RU/EN: - nav.reviews, reviews.{title,description,empty,queueTotal_*,col.*, action.*, drawer.*} - schema.approvalRequired.{label,hint}, schema.approvalMinRole.{label,hint} - Plurals для RU queueTotal (one/few/many/other). Verify: - pnpm tsc --noEmit: clean. - pnpm test (vitest): 89/89 PASS. - pnpm build: clean. - routeTree.gen.ts auto-regenerated с /reviews. Phase 3 (next session): - e2e tests: full flow maker submit → checker approve → record live + outbox event. - e2e: reject keeps draft, doesn't publish. - e2e: self-approve blocked (no_self_approve constraint). - e2e: two pending drafts на one business_key blocked (one_pending_per_key). - Per-dict opt-in soak: 1-2 dicts (например ground_station) с approval_required=true в staging.
408 lines
9.3 KiB
TypeScript
408 lines
9.3 KiB
TypeScript
import axios from 'axios'
|
|
|
|
export const apiClient = axios.create({
|
|
baseURL: import.meta.env.VITE_API_BASE ?? '/api/v1',
|
|
timeout: 10_000,
|
|
})
|
|
|
|
apiClient.interceptors.request.use((config) => {
|
|
const lang = (typeof navigator !== 'undefined' && navigator.language) || 'ru-RU'
|
|
config.headers['Accept-Language'] = `${lang},ru-RU;q=0.9,en-US;q=0.8`
|
|
const token = localStorage.getItem('ordinis.token')
|
|
if (token) config.headers['Authorization'] = `Bearer ${token}`
|
|
return config
|
|
})
|
|
|
|
apiClient.interceptors.response.use(
|
|
(resp) => resp,
|
|
(error) => {
|
|
if (error.response?.status === 401) {
|
|
localStorage.removeItem('ordinis.token')
|
|
}
|
|
return Promise.reject(error)
|
|
},
|
|
)
|
|
|
|
export type DataScope = 'PUBLIC' | 'INTERNAL' | 'RESTRICTED'
|
|
|
|
export type DictionaryDefinition = {
|
|
id: string
|
|
name: string
|
|
displayName?: string
|
|
description?: string
|
|
scope: DataScope
|
|
schemaVersion: string
|
|
bundle: string
|
|
supportedLocales: string[]
|
|
defaultLocale: string
|
|
/** CEO plan E2: per-dict Redis projection materialization opt-in. */
|
|
redisProjectionEnabled: boolean
|
|
/** Approval Workflow v2: per-dict opt-in. */
|
|
approvalRequired: boolean
|
|
approvalMinRole?: string | null
|
|
recordCount?: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
}
|
|
|
|
export type JsonSchema = {
|
|
$schema?: string
|
|
type?: string
|
|
title?: string
|
|
description?: string
|
|
properties?: Record<string, JsonSchema>
|
|
required?: string[]
|
|
enum?: unknown[]
|
|
format?: string
|
|
pattern?: string
|
|
minLength?: number
|
|
maxLength?: number
|
|
minimum?: number
|
|
maximum?: number
|
|
items?: JsonSchema
|
|
additionalProperties?: boolean | JsonSchema
|
|
['x-localized']?: boolean
|
|
['x-references']?: string
|
|
['x-id-source']?: string
|
|
['x-unique']?: boolean
|
|
default?: unknown
|
|
}
|
|
|
|
export type DictionaryDetail = DictionaryDefinition & {
|
|
schemaJson: JsonSchema
|
|
}
|
|
|
|
export type FlattenedRecord = {
|
|
id: string
|
|
businessKey: string
|
|
data: Record<string, unknown>
|
|
dataScope: DataScope
|
|
validFrom: string
|
|
validTo: string
|
|
_meta?: { locale?: string }
|
|
}
|
|
|
|
export type RecordResponse = {
|
|
id: string
|
|
dictionaryId: string
|
|
businessKey: string
|
|
data: Record<string, unknown>
|
|
geometryWkt?: string
|
|
dataScope: DataScope
|
|
validFrom: string
|
|
validTo: string
|
|
createdAt: string
|
|
updatedAt: string
|
|
createdBy?: string
|
|
updatedBy?: string
|
|
version: number
|
|
}
|
|
|
|
export type CreateDictionaryRequest = {
|
|
name: string
|
|
displayName?: string
|
|
description?: string
|
|
scope: DataScope
|
|
schemaJson: JsonSchema
|
|
schemaVersion?: string
|
|
bundle?: string
|
|
supportedLocales?: string[]
|
|
defaultLocale?: string
|
|
/** CEO plan E2: per-dict Redis projection opt-in. Default false. */
|
|
redisProjectionEnabled?: boolean
|
|
/** Approval Workflow v2: per-dict opt-in. Default false. */
|
|
approvalRequired?: boolean
|
|
/** Optional Keycloak role для reviewer'ов на этот dict. */
|
|
approvalMinRole?: string
|
|
}
|
|
|
|
export type CreateRecordRequest = {
|
|
businessKey: string
|
|
data: Record<string, unknown>
|
|
geometryWkt?: string
|
|
validFrom?: string
|
|
validTo?: string
|
|
}
|
|
|
|
export type BulkCloseRequest = {
|
|
businessKeys: string[]
|
|
at?: string
|
|
reason?: string
|
|
}
|
|
|
|
export type BulkCloseResponse = {
|
|
closed: string[]
|
|
skipped: { businessKey: string; reason: string }[]
|
|
errors: { businessKey: string; reason: string }[]
|
|
}
|
|
|
|
export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE'
|
|
|
|
export type AuditEntry = {
|
|
id: number
|
|
eventTime: string
|
|
eventType: string
|
|
action: AuditAction | string
|
|
userId?: string | null
|
|
userScope?: DataScope | null
|
|
dictionaryName?: string | null
|
|
dictionaryId?: string | null
|
|
recordId?: string | null
|
|
businessKey?: string | null
|
|
payloadBefore?: Record<string, unknown> | null
|
|
payloadAfter?: Record<string, unknown> | null
|
|
traceId?: string | null
|
|
requestId?: string | null
|
|
ipAddress?: string | null
|
|
userAgent?: string | null
|
|
}
|
|
|
|
export type AuditPage = {
|
|
content: AuditEntry[]
|
|
totalElements: number
|
|
totalPages: number
|
|
number: number
|
|
size: number
|
|
first: boolean
|
|
last: boolean
|
|
}
|
|
|
|
export type AuditFilters = {
|
|
dictionaryName?: string
|
|
userId?: string
|
|
action?: AuditAction | ''
|
|
businessKey?: string
|
|
from?: string
|
|
to?: string
|
|
page?: number
|
|
size?: number
|
|
}
|
|
|
|
export type OutboxStats = { pending: number; dlq: number }
|
|
|
|
export type DlqEntry = {
|
|
id: number
|
|
eventType: string
|
|
dictionaryName?: string | null
|
|
aggregateId: string
|
|
kafkaTopic: string
|
|
attemptCount: number
|
|
lastError?: string | null
|
|
createdAt: string
|
|
dlqAt: string
|
|
}
|
|
|
|
export type DlqPage = {
|
|
content: DlqEntry[]
|
|
totalElements: number
|
|
totalPages: number
|
|
number: number
|
|
size: number
|
|
}
|
|
|
|
// === Webhooks (v2) ===
|
|
|
|
export type WebhookSubscription = {
|
|
id: string
|
|
name: string
|
|
url: string
|
|
scopeFilter?: DataScope[] | null
|
|
dictionaryFilter?: string[] | null
|
|
eventTypeFilter?: string[] | null
|
|
/** Masked `sk_****<last4>` или plaintext только в response создания/rotate. */
|
|
hmacSecret: string
|
|
active: boolean
|
|
createdAt: string
|
|
createdBy: string
|
|
updatedAt: string
|
|
description?: string | null
|
|
}
|
|
|
|
export type CreateWebhookSubscriptionRequest = {
|
|
name: string
|
|
url: string
|
|
scopeFilter?: DataScope[]
|
|
dictionaryFilter?: string[]
|
|
eventTypeFilter?: string[]
|
|
active?: boolean
|
|
description?: string
|
|
}
|
|
|
|
export type WebhookDeliveryStatus = 'pending' | 'retrying' | 'delivered' | 'dlq'
|
|
|
|
export type WebhookDelivery = {
|
|
id: number
|
|
subscriptionId: string
|
|
outboxEventId: number
|
|
status: WebhookDeliveryStatus
|
|
attemptCount: number
|
|
createdAt: string
|
|
nextAttemptAt: string
|
|
lastAttemptAt?: string | null
|
|
deliveredAt?: string | null
|
|
lastError?: string | null
|
|
lastStatusCode?: number | null
|
|
dlqAt?: string | null
|
|
}
|
|
|
|
export type WebhookDeliveryPage = {
|
|
content: WebhookDelivery[]
|
|
totalElements: number
|
|
totalPages: number
|
|
number: number
|
|
size: number
|
|
}
|
|
|
|
// === Approval Workflow v2 ===
|
|
|
|
export type DraftStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN'
|
|
export type DraftOperation = 'CREATE' | 'UPDATE' | 'CLOSE'
|
|
|
|
export type DraftResponse = {
|
|
id: string
|
|
dictionaryId: string
|
|
businessKey: string
|
|
parentRecordId?: string | null
|
|
operation: DraftOperation
|
|
data?: Record<string, unknown> | null
|
|
geometryWkt?: string | null
|
|
proposedValidFrom?: string | null
|
|
proposedValidTo?: string | null
|
|
status: DraftStatus
|
|
makerId: string
|
|
makerComment?: string | null
|
|
submittedAt: string
|
|
reviewerId?: string | null
|
|
reviewedAt?: string | null
|
|
reviewComment?: string | null
|
|
}
|
|
|
|
export type SubmitDraftRequest = {
|
|
businessKey: string
|
|
operation: DraftOperation
|
|
data?: Record<string, unknown> | null
|
|
geometryWkt?: string | null
|
|
validFrom?: string | null
|
|
validTo?: string | null
|
|
comment?: string | null
|
|
}
|
|
|
|
export type ReviewQueuePage = {
|
|
items: DraftResponse[]
|
|
page: number
|
|
size: number
|
|
totalElements: number
|
|
totalPages: number
|
|
}
|
|
|
|
// === Smart search (CEO plan v1) ===
|
|
|
|
export type SearchItem = {
|
|
businessKey: string
|
|
dataScope: string
|
|
createdAt: string
|
|
}
|
|
|
|
export type SearchDictGroup = {
|
|
dictName: string
|
|
dictDisplayName?: string | null
|
|
count: number
|
|
items: SearchItem[]
|
|
}
|
|
|
|
export type SearchResponse = {
|
|
query: string
|
|
total: number
|
|
groups: SearchDictGroup[]
|
|
}
|
|
|
|
// === Dependents (Phase 1 dict-relationships-v2) ===
|
|
|
|
export type OnCloseAction = 'BLOCK' | 'WARN' | 'CASCADE'
|
|
|
|
export type SchemaDependent = {
|
|
sourceDict: string
|
|
sourceDisplayName?: string | null
|
|
sourceField: string
|
|
targetField: string
|
|
onClose: OnCloseAction
|
|
/** Total active records in source dict (для UI badge "X записей всего"). */
|
|
activeRecordsInSourceDict: number
|
|
}
|
|
|
|
export type RecordDependent = {
|
|
sourceDict: string
|
|
sourceDisplayName?: string | null
|
|
sourceField: string
|
|
onClose: OnCloseAction
|
|
id: string
|
|
businessKey: string
|
|
validFrom: string
|
|
validTo: string
|
|
createdAt: string
|
|
}
|
|
|
|
export type PerSourceSummary = {
|
|
sourceDict: string
|
|
sourceDisplayName?: string | null
|
|
sourceField: string
|
|
onClose: OnCloseAction
|
|
count: number
|
|
}
|
|
|
|
export type LineageMeta = {
|
|
/** True если backend использует materialized view; false — JSONB direct (fresh). */
|
|
mvEnabled: boolean
|
|
/** ISO timestamp последнего успешного MV refresh; null если MV выключена / never. */
|
|
lastRefreshedAt: string | null
|
|
/** "ok" | "error" | "never" — null если mvEnabled=false. */
|
|
lastStatus: string | null
|
|
lastDurationMs: number | null
|
|
}
|
|
|
|
export type CascadeEntry = {
|
|
sourceDict: string
|
|
sourceDisplayName?: string | null
|
|
businessKey: string
|
|
sourceField: string
|
|
onClose: OnCloseAction
|
|
recordId: string
|
|
}
|
|
|
|
export type CascadePlan = {
|
|
targetDict: string
|
|
targetKey: string
|
|
blockers: CascadeEntry[]
|
|
warnings: CascadeEntry[]
|
|
cascade: CascadeEntry[]
|
|
totalDependents: number
|
|
}
|
|
|
|
export type CascadeCloseResult = {
|
|
targetDict: string
|
|
targetKey: string
|
|
cascadedRecords: CascadeEntry[]
|
|
warnings: CascadeEntry[]
|
|
totalClosed: number
|
|
}
|
|
|
|
export type RecordDependentsPage = {
|
|
targetDict: string
|
|
targetBusinessKey: string
|
|
items: RecordDependent[]
|
|
perSource: PerSourceSummary[]
|
|
total: number
|
|
page: number
|
|
size: number
|
|
/** Phase 2 dict-relationships-v2: backing data source metadata + staleness. */
|
|
lineageMeta?: LineageMeta | null
|
|
}
|
|
|
|
export type WebhookTestPingResult = {
|
|
/** success | failure | rejected */
|
|
status: 'success' | 'failure' | 'rejected'
|
|
httpStatus?: number | null
|
|
latencyMs?: number | null
|
|
message?: string | null
|
|
}
|