feat(schema-workflow): Phase 1c — CreateModal + /reviews extension + RBAC stub
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { useAuth } from 'react-oidc-context'
|
||||
|
||||
/**
|
||||
* Permission helpers для schema workflow. Phase 1c — stub: проверяем
|
||||
* только что пользователь authenticated (любой logged-in юзер может
|
||||
* approve/publish). Backend всё равно отрежет:
|
||||
* <ul>
|
||||
* <li>{@code 403 self_approve_forbidden} — если maker == reviewer.</li>
|
||||
* <li>{@code 403 forbidden_role} — когда Keycloak roles прорастут и
|
||||
* backend начнёт проверять realm-роли (placeholder).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Phase 1d: подключим decode JWT → realm_access.roles, и эти hook'и
|
||||
* начнут возвращать false для users без \`ordinis-schema-reviewer\` /
|
||||
* \`ordinis-schema-publisher\` ролей. UI hide'ит соответствующие кнопки.
|
||||
*
|
||||
* <p>До тех пор все logged-in юзеры могут жать любые workflow buttons —
|
||||
* backend гейт остаётся primary defence.
|
||||
*/
|
||||
|
||||
/** Может ли актор approve / request_changes / reject schema draft. */
|
||||
export function useCanReviewSchema(): boolean {
|
||||
const auth = useAuth()
|
||||
// TODO Phase 1d: const roles = (auth.user?.profile as { realm_access?: { roles?: string[] } })?.realm_access?.roles ?? []
|
||||
// return roles.includes('ordinis-schema-reviewer')
|
||||
return auth.isAuthenticated
|
||||
}
|
||||
|
||||
/** Может ли актор publish approved schema draft (bump live version). */
|
||||
export function useCanPublishSchema(): boolean {
|
||||
const auth = useAuth()
|
||||
// TODO Phase 1d: roles.includes('ordinis-schema-publisher')
|
||||
return auth.isAuthenticated
|
||||
}
|
||||
|
||||
/** Может ли актор create schema draft (maker side). */
|
||||
export function useCanCreateSchemaDraft(): boolean {
|
||||
const auth = useAuth()
|
||||
// TODO Phase 1d: roles.includes('ordinis-schema-maker') OR any authenticated
|
||||
return auth.isAuthenticated
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Modal,
|
||||
TextArea,
|
||||
toast,
|
||||
} from '@/ui'
|
||||
import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react'
|
||||
import type { DictionaryDetail } from '@/api/client'
|
||||
import { useCreateSchemaDraft } from '@/api/mutations'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
detail: DictionaryDetail
|
||||
/** Optional callback после успешного create — parent может закрыть modal +
|
||||
* открыть SchemaDraftDrawer чтоб maker сразу submit'нул. */
|
||||
onCreated?: (draftId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание schema draft'а с editor'ом proposed schema. Минимальная версия:
|
||||
* JSON textarea с {@code Format} и {@code Validate} buttons. Phase 1d
|
||||
* может upgrade'ить до Monaco / visual schema editor.
|
||||
*
|
||||
* <p>Initial value seeded из текущего {@code detail.schemaJson}. User editing
|
||||
* правит proposedSchema, добавляет {@code reason}, submit'ит.
|
||||
*
|
||||
* <p>Backend errors surface'ятся как toast:
|
||||
* <ul>
|
||||
* <li>409 {@code version_mismatch} — кто-то опубликовал свой draft пока
|
||||
* этот пользователь готовился. Refresh page + retry.</li>
|
||||
* <li>409 {@code active_draft_exists} — уже есть non-terminal draft.
|
||||
* Пользователь должен open existing.</li>
|
||||
* <li>400 {@code invalid_schema} — proposedSchema не валидна. Backend
|
||||
* вернёт detail в message.</li>
|
||||
* </ul>
|
||||
*/
|
||||
export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const initialJson = useMemo(
|
||||
() => JSON.stringify(detail.schemaJson, null, 2),
|
||||
[detail.schemaJson],
|
||||
)
|
||||
const [json, setJson] = useState(initialJson)
|
||||
const [reason, setReason] = useState('')
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
|
||||
// Reset state при re-open чтобы не показывать stale данные с прошлого dict'а.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setJson(initialJson)
|
||||
setReason('')
|
||||
setParseError(null)
|
||||
}
|
||||
}, [open, initialJson])
|
||||
|
||||
const createMut = useCreateSchemaDraft(detail.name)
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
setJson(JSON.stringify(parsed, null, 2))
|
||||
setParseError(null)
|
||||
} catch (e) {
|
||||
setParseError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidate = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
setParseError(
|
||||
t('createDraft.notObject', {
|
||||
defaultValue: 'Schema должна быть JSON object',
|
||||
}),
|
||||
)
|
||||
return false
|
||||
}
|
||||
setParseError(null)
|
||||
toast.success(
|
||||
t('createDraft.validationPassed', { defaultValue: 'JSON валиден' }),
|
||||
)
|
||||
return true
|
||||
} catch (e) {
|
||||
setParseError((e as Error).message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(json)
|
||||
} catch (e) {
|
||||
setParseError((e as Error).message)
|
||||
return
|
||||
}
|
||||
createMut.mutate(
|
||||
{
|
||||
fromVersion: detail.schemaVersion,
|
||||
reason: reason || undefined,
|
||||
proposedSchema: parsed,
|
||||
},
|
||||
{
|
||||
onSuccess: (draft) => {
|
||||
toast.success(
|
||||
t('createDraft.toast.created', { defaultValue: 'Черновик создан' }),
|
||||
)
|
||||
onCreated?.(draft.draftId)
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
let msg = t('createDraft.toast.failed', {
|
||||
defaultValue: 'Не удалось создать черновик',
|
||||
})
|
||||
if (axios.isAxiosError(err)) {
|
||||
const body = err.response?.data as
|
||||
| { code?: string; message?: string }
|
||||
| undefined
|
||||
msg = body?.message ?? body?.code ?? msg
|
||||
}
|
||||
toast.error(msg)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={t('createDraft.title', { defaultValue: 'Создать черновик схемы' })}
|
||||
description={`${detail.name} · v${detail.schemaVersion}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert variant="info">
|
||||
{t('createDraft.intro', {
|
||||
defaultValue:
|
||||
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
|
||||
})}
|
||||
</Alert>
|
||||
|
||||
{/* Reason */}
|
||||
<div>
|
||||
<label className="block text-cap text-mute font-display uppercase tracking-wider mb-1">
|
||||
{t('createDraft.reason', { defaultValue: 'Причина изменений' })}
|
||||
</label>
|
||||
<TextArea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder={t('createDraft.reasonPlaceholder', {
|
||||
defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)',
|
||||
})}
|
||||
rows={2}
|
||||
disabled={createMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* JSON Schema editor */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-cap text-mute font-display uppercase tracking-wider">
|
||||
{t('createDraft.schema', { defaultValue: 'Схема (JSON)' })}
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleFormat}
|
||||
disabled={createMut.isPending}
|
||||
leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('createDraft.format', { defaultValue: 'Format' })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleValidate}
|
||||
disabled={createMut.isPending}
|
||||
leftIcon={<CheckSquareIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('createDraft.validate', { defaultValue: 'Validate' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Custom monospace textarea — для full Monaco editor'а Phase 1d.
|
||||
Высота min-h-[400px] чтобы средняя schema ~30-50 полей помещалась
|
||||
без скролла. */}
|
||||
<textarea
|
||||
value={json}
|
||||
onChange={(e) => {
|
||||
setJson(e.target.value)
|
||||
if (parseError) setParseError(null)
|
||||
}}
|
||||
spellCheck={false}
|
||||
disabled={createMut.isPending}
|
||||
className="w-full min-h-[400px] max-h-[60vh] text-mono text-cell rounded-md border border-line bg-[#0c0a23] text-[#f0eaff] p-3 focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
{parseError && (
|
||||
<Alert variant="error" className="mt-2">
|
||||
<span className="font-mono text-cell">{parseError}</span>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-line">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={createMut.isPending}
|
||||
>
|
||||
{t('common.cancel', { defaultValue: 'Отмена' })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={createMut.isPending || Boolean(parseError)}
|
||||
>
|
||||
{createMut.isPending
|
||||
? t('createDraft.creating', { defaultValue: 'Создание…' })
|
||||
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useSubmitSchemaDraftForReview,
|
||||
useWithdrawSchemaDraft,
|
||||
} from '@/api/mutations'
|
||||
import { useCanPublishSchema, useCanReviewSchema } from '@/auth/usePermissions'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
@@ -391,6 +392,11 @@ function DraftActions({
|
||||
hasCurrentVersion: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
// Phase 1c RBAC stub: для review_pending reviewer-only действия скрываем
|
||||
// если нет approve role. Publish — отдельная role. Backend всё равно
|
||||
// защищает. Phase 1d intergrate'нет Keycloak realm_access.roles.
|
||||
const canReview = useCanReviewSchema()
|
||||
const canPublish = useCanPublishSchema()
|
||||
|
||||
if (!isActiveSchemaDraft(status)) {
|
||||
return (
|
||||
@@ -421,7 +427,7 @@ function DraftActions({
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{status === 'review_pending' && (
|
||||
{status === 'review_pending' && canReview && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -454,7 +460,7 @@ function DraftActions({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === 'approved' && (
|
||||
{status === 'approved' && canPublish && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
|
||||
@@ -476,6 +476,33 @@ i18n
|
||||
'workflow.schemaDraft.toast.decideFailed': 'Не удалось зафиксировать решение',
|
||||
'workflow.schemaDraft.toast.publishFailed': 'Не удалось опубликовать',
|
||||
'workflow.schemaDraft.toast.withdrawFailed': 'Не удалось отозвать',
|
||||
// === CreateSchemaDraftModal ===
|
||||
'createDraft.title': 'Создать черновик схемы',
|
||||
'createDraft.intro': 'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
|
||||
'createDraft.openButton': 'Создать черновик схемы',
|
||||
'createDraft.reason': 'Причина изменений',
|
||||
'createDraft.reasonPlaceholder': 'Зачем эти изменения нужны (видно ревьюеру)',
|
||||
'createDraft.schema': 'Схема (JSON)',
|
||||
'createDraft.format': 'Format',
|
||||
'createDraft.validate': 'Validate',
|
||||
'createDraft.validationPassed': 'JSON валиден',
|
||||
'createDraft.notObject': 'Schema должна быть JSON object',
|
||||
'createDraft.submit': 'Создать черновик',
|
||||
'createDraft.creating': 'Создание…',
|
||||
'createDraft.toast.created': 'Черновик создан',
|
||||
'createDraft.toast.failed': 'Не удалось создать черновик',
|
||||
// === Schema review queue panel ===
|
||||
'reviews.schema.title': 'Изменения схем',
|
||||
'reviews.schema.label': 'Schema workflow',
|
||||
'reviews.schema.queueTotal_one': '{{count}} draft ждёт ревью',
|
||||
'reviews.schema.queueTotal_few': '{{count}} draft\'а ждут ревью',
|
||||
'reviews.schema.queueTotal_many': '{{count}} draft\'ов ждут ревью',
|
||||
'reviews.schema.dictionary': 'Словарь',
|
||||
'reviews.schema.fromVersion': 'От версии',
|
||||
'reviews.schema.maker': 'Автор',
|
||||
'reviews.schema.submittedAt': 'Отправлен',
|
||||
'reviews.schema.action': 'Действие',
|
||||
'reviews.schema.open': 'Открыть',
|
||||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||
'lineage.usedBy.title': 'На этот словарь ссылаются',
|
||||
'lineage.usedBy.count_one': '{{count}} связь',
|
||||
@@ -1068,6 +1095,32 @@ i18n
|
||||
'workflow.schemaDraft.toast.decideFailed': 'Failed to record decision',
|
||||
'workflow.schemaDraft.toast.publishFailed': 'Failed to publish',
|
||||
'workflow.schemaDraft.toast.withdrawFailed': 'Failed to withdraw',
|
||||
// === CreateSchemaDraftModal ===
|
||||
'createDraft.title': 'Create schema draft',
|
||||
'createDraft.intro': 'Changes are saved as a draft branched from the current version. Live schema is not touched until publish-after-review.',
|
||||
'createDraft.openButton': 'Create schema draft',
|
||||
'createDraft.reason': 'Reason for changes',
|
||||
'createDraft.reasonPlaceholder': 'Why these changes are needed (visible to the reviewer)',
|
||||
'createDraft.schema': 'Schema (JSON)',
|
||||
'createDraft.format': 'Format',
|
||||
'createDraft.validate': 'Validate',
|
||||
'createDraft.validationPassed': 'JSON is valid',
|
||||
'createDraft.notObject': 'Schema must be a JSON object',
|
||||
'createDraft.submit': 'Create draft',
|
||||
'createDraft.creating': 'Creating…',
|
||||
'createDraft.toast.created': 'Draft created',
|
||||
'createDraft.toast.failed': 'Failed to create draft',
|
||||
// === Schema review queue panel ===
|
||||
'reviews.schema.title': 'Schema changes',
|
||||
'reviews.schema.label': 'Schema workflow',
|
||||
'reviews.schema.queueTotal_one': '{{count}} draft awaiting review',
|
||||
'reviews.schema.queueTotal_other': '{{count}} drafts awaiting review',
|
||||
'reviews.schema.dictionary': 'Dictionary',
|
||||
'reviews.schema.fromVersion': 'From version',
|
||||
'reviews.schema.maker': 'Author',
|
||||
'reviews.schema.submittedAt': 'Submitted',
|
||||
'reviews.schema.action': 'Action',
|
||||
'reviews.schema.open': 'Open',
|
||||
// === Lineage (dict-relationships-v2 Phase 1) ===
|
||||
'lineage.usedBy.title': 'Used by',
|
||||
'lineage.usedBy.count_one': '{{count}} reference',
|
||||
|
||||
@@ -25,10 +25,11 @@ import {
|
||||
TableRow,
|
||||
TextInput,
|
||||
} from '@/ui'
|
||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon } from '@phosphor-icons/react'
|
||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon, GitBranchIcon } from '@phosphor-icons/react'
|
||||
import {
|
||||
useAudit,
|
||||
useChangelog,
|
||||
useDictActiveSchemaDraft,
|
||||
useDictionaryDetail,
|
||||
useDictPendingDrafts,
|
||||
useRecordRaw,
|
||||
@@ -53,6 +54,7 @@ import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialo
|
||||
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
|
||||
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
|
||||
import { kindBadgeVariant, kindIcon, kindLabel, isVersioningKind } from '@/components/changelog/kind-meta'
|
||||
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
||||
import { nowIsoLocal } from '@/lib/dates'
|
||||
import { recordDisplayName } from '@/lib/locales'
|
||||
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
|
||||
@@ -132,6 +134,14 @@ function DictionaryDetail() {
|
||||
const { t } = useTranslation()
|
||||
const detailQuery = useDictionaryDetail(name)
|
||||
const canMutate = useCanMutate()
|
||||
// Phase 1c: show «Create draft» button only когда approvalRequired=true
|
||||
// и нет active draft'а (иначе banner предложит open existing). Live (без
|
||||
// approval flag) словари редактируются напрямую через EditorDialog —
|
||||
// создавать draft не нужно.
|
||||
const activeSchemaDraftQuery = useDictActiveSchemaDraft(name)
|
||||
const shouldOfferCreateDraft =
|
||||
detailQuery.data?.approvalRequired === true &&
|
||||
!activeSchemaDraftQuery.data
|
||||
|
||||
// AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
|
||||
// только в local state (не сериализуем в URL — слишком длинно).
|
||||
@@ -194,6 +204,7 @@ function DictionaryDetail() {
|
||||
total: 0,
|
||||
})
|
||||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||||
const [createDraftOpen, setCreateDraftOpen] = useState(false)
|
||||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||
const [aoiOpen, setAoiOpen] = useState(false)
|
||||
const [timeTravelOpen, setTimeTravelOpen] = useState(false)
|
||||
@@ -703,6 +714,21 @@ function DictionaryDetail() {
|
||||
actions={
|
||||
canMutate ? (
|
||||
<>
|
||||
{/* approval-required dict без active draft: предлагаем
|
||||
создать черновик. Direct edit через GearIcon оставляем
|
||||
для админов, но primary path — через workflow. */}
|
||||
{shouldOfferCreateDraft && (
|
||||
<IconButton
|
||||
type="button"
|
||||
label={t('createDraft.openButton', {
|
||||
defaultValue: 'Создать черновик схемы',
|
||||
})}
|
||||
variant="default"
|
||||
icon={<GitBranchIcon weight="regular" />}
|
||||
disabled={!detailQuery.data}
|
||||
onClick={() => setCreateDraftOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
type="button"
|
||||
label={t('schema.action.editSchema')}
|
||||
@@ -1214,6 +1240,14 @@ function DictionaryDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailQuery.data && (
|
||||
<CreateSchemaDraftModal
|
||||
open={createDraftOpen}
|
||||
onClose={() => setCreateDraftOpen(false)}
|
||||
detail={detailQuery.data}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RecordHistoryDrawer
|
||||
open={Boolean(historyKey)}
|
||||
onClose={() => setHistoryKey(undefined)}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
TextInput,
|
||||
} from '@/ui'
|
||||
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||
import { useDraft, useLiveRecord, useReviewQueue } from '@/api/queries'
|
||||
import { useDraft, useLiveRecord, useReviewQueue, useSchemaReviewQueue } from '@/api/queries'
|
||||
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
|
||||
import type { DraftOperation, DraftResponse } from '@/api/client'
|
||||
|
||||
@@ -142,6 +142,8 @@ function ReviewsPage() {
|
||||
description={t('reviews.description')}
|
||||
/>
|
||||
|
||||
<SchemaReviewQueuePanel />
|
||||
|
||||
{queue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||
|
||||
{queue.error && (
|
||||
@@ -507,3 +509,80 @@ function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema review pool queue — global view всех \`review_pending\` schema
|
||||
* draft'ов через {@code GET /admin/schema-reviews/pending}. Reviewer
|
||||
* жмёт «Открыть» → переходит на dict detail page, banner+drawer
|
||||
* автоматически подхватятся через {@code useDictActiveSchemaDraft}.
|
||||
*
|
||||
* <p>Скрывается если pool пуст — экономит вертикальное место на странице
|
||||
* для основного record-review queue.
|
||||
*/
|
||||
function SchemaReviewQueuePanel() {
|
||||
const { t } = useTranslation()
|
||||
const queue = useSchemaReviewQueue(0, 20)
|
||||
const items = queue.data?.items ?? []
|
||||
if (queue.isLoading || items.length === 0) return null
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-title-md font-semibold">
|
||||
{t('reviews.schema.title', { defaultValue: 'Изменения схем' })}
|
||||
</h2>
|
||||
<p className="text-cell text-mute mt-0.5">
|
||||
{t('reviews.schema.queueTotal', {
|
||||
count: queue.data?.total ?? items.length,
|
||||
defaultValue: `${queue.data?.total ?? items.length} draft(s) ждут ревью`,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="info">
|
||||
{t('reviews.schema.label', { defaultValue: 'Schema workflow' })}
|
||||
</Badge>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>{t('reviews.schema.dictionary', { defaultValue: 'Словарь' })}</TableHeaderCell>
|
||||
<TableHeaderCell>{t('reviews.schema.fromVersion', { defaultValue: 'От версии' })}</TableHeaderCell>
|
||||
<TableHeaderCell>{t('reviews.schema.maker', { defaultValue: 'Автор' })}</TableHeaderCell>
|
||||
<TableHeaderCell>{t('reviews.schema.submittedAt', { defaultValue: 'Отправлен' })}</TableHeaderCell>
|
||||
<TableHeaderCell align="right">{t('reviews.schema.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 className="font-mono">{d.makerId}</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('reviews.schema.open', { defaultValue: 'Открыть' })} →
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user