feat(approval): Approval Workflow v2 Phase 2 — admin UI reviewer queue + toggle
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.
This commit is contained in:
@@ -37,6 +37,9 @@ export type DictionaryDefinition = {
|
|||||||
defaultLocale: string
|
defaultLocale: string
|
||||||
/** CEO plan E2: per-dict Redis projection materialization opt-in. */
|
/** CEO plan E2: per-dict Redis projection materialization opt-in. */
|
||||||
redisProjectionEnabled: boolean
|
redisProjectionEnabled: boolean
|
||||||
|
/** Approval Workflow v2: per-dict opt-in. */
|
||||||
|
approvalRequired: boolean
|
||||||
|
approvalMinRole?: string | null
|
||||||
recordCount?: number
|
recordCount?: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@@ -107,6 +110,10 @@ export type CreateDictionaryRequest = {
|
|||||||
defaultLocale?: string
|
defaultLocale?: string
|
||||||
/** CEO plan E2: per-dict Redis projection opt-in. Default false. */
|
/** CEO plan E2: per-dict Redis projection opt-in. Default false. */
|
||||||
redisProjectionEnabled?: boolean
|
redisProjectionEnabled?: boolean
|
||||||
|
/** Approval Workflow v2: per-dict opt-in. Default false. */
|
||||||
|
approvalRequired?: boolean
|
||||||
|
/** Optional Keycloak role для reviewer'ов на этот dict. */
|
||||||
|
approvalMinRole?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateRecordRequest = {
|
export type CreateRecordRequest = {
|
||||||
@@ -246,6 +253,48 @@ export type WebhookDeliveryPage = {
|
|||||||
size: 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) ===
|
// === Smart search (CEO plan v1) ===
|
||||||
|
|
||||||
export type SearchItem = {
|
export type SearchItem = {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
type CreateRecordRequest,
|
type CreateRecordRequest,
|
||||||
type CreateWebhookSubscriptionRequest,
|
type CreateWebhookSubscriptionRequest,
|
||||||
type DictionaryDetail,
|
type DictionaryDetail,
|
||||||
|
type DraftResponse,
|
||||||
type RecordResponse,
|
type RecordResponse,
|
||||||
|
type SubmitDraftRequest,
|
||||||
type WebhookDelivery,
|
type WebhookDelivery,
|
||||||
type WebhookSubscription,
|
type WebhookSubscription,
|
||||||
type WebhookTestPingResult,
|
type WebhookTestPingResult,
|
||||||
@@ -293,6 +295,98 @@ export const useTestWebhook = () => {
|
|||||||
* Use case: оператор fixed receiver URL/cert, хочет replay'нуть failed
|
* Use case: оператор fixed receiver URL/cert, хочет replay'нуть failed
|
||||||
* delivery без ожидания TTL bypass'а.
|
* delivery без ожидания TTL bypass'а.
|
||||||
*/
|
*/
|
||||||
|
// === Approval Workflow v2 mutations ===
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maker submits a proposal. Backend creates draft с status=PENDING.
|
||||||
|
* 409 draft_already_pending если уже есть pending draft на этот business_key.
|
||||||
|
*/
|
||||||
|
export const useSubmitDraft = (dictionaryName: string) => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (req: SubmitDraftRequest): Promise<DraftResponse> => {
|
||||||
|
const { data } = await apiClient.post<DraftResponse>(
|
||||||
|
`/dictionaries/${encodeURIComponent(dictionaryName)}/records/drafts`,
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviewer approves pending draft. SERIALIZABLE tx → COPY draft → live +
|
||||||
|
* outbox event. 409 draft_not_pending / self_approve_forbidden если что-то
|
||||||
|
* не так.
|
||||||
|
*/
|
||||||
|
export const useApproveDraft = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (params: {
|
||||||
|
id: string
|
||||||
|
comment?: string
|
||||||
|
}): Promise<DraftResponse> => {
|
||||||
|
const { data } = await apiClient.post<DraftResponse>(
|
||||||
|
`/drafts/${encodeURIComponent(params.id)}/approve`,
|
||||||
|
null,
|
||||||
|
{ params: params.comment ? { comment: params.comment } : undefined },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['records'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['draft'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reviewer rejects pending draft. Reason required (compliance, D4=A
|
||||||
|
* soft-archive). 400 reject_reason_required если reason пустой.
|
||||||
|
*/
|
||||||
|
export const useRejectDraft = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (params: {
|
||||||
|
id: string
|
||||||
|
comment: string
|
||||||
|
}): Promise<DraftResponse> => {
|
||||||
|
const { data } = await apiClient.post<DraftResponse>(
|
||||||
|
`/drafts/${encodeURIComponent(params.id)}/reject`,
|
||||||
|
null,
|
||||||
|
{ params: { comment: params.comment } },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['draft'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maker отзывает свой pending draft. 403 not_draft_maker если не maker. */
|
||||||
|
export const useWithdrawDraft = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string): Promise<DraftResponse> => {
|
||||||
|
const { data } = await apiClient.delete<DraftResponse>(
|
||||||
|
`/drafts/${encodeURIComponent(id)}`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['draft'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const useRetryWebhookDelivery = () => {
|
export const useRetryWebhookDelivery = () => {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
type DictionaryDefinition,
|
type DictionaryDefinition,
|
||||||
type DictionaryDetail,
|
type DictionaryDetail,
|
||||||
type DlqPage,
|
type DlqPage,
|
||||||
|
type DraftResponse,
|
||||||
type FlattenedRecord,
|
type FlattenedRecord,
|
||||||
type OutboxStats,
|
type OutboxStats,
|
||||||
type RecordDependentsPage,
|
type RecordDependentsPage,
|
||||||
type RecordResponse,
|
type RecordResponse,
|
||||||
|
type ReviewQueuePage,
|
||||||
type SchemaDependent,
|
type SchemaDependent,
|
||||||
type SearchResponse,
|
type SearchResponse,
|
||||||
type WebhookDeliveryPage,
|
type WebhookDeliveryPage,
|
||||||
@@ -388,6 +390,66 @@ export const useSearch = (q: string | undefined, size = 100, perDict = 10) =>
|
|||||||
enabled: Boolean(q && q.length >= 3),
|
enabled: Boolean(q && q.length >= 3),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// === Approval Workflow v2 ===
|
||||||
|
|
||||||
|
/** Global reviewer queue — pool model (D3=A). All pending drafts FIFO. */
|
||||||
|
export const reviewQueueQuery = (page = 0, size = 50) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['review-queue', page, size] as const,
|
||||||
|
queryFn: async (): Promise<ReviewQueuePage> => {
|
||||||
|
const { data } = await apiClient.get<ReviewQueuePage>('/admin/reviews/pending', {
|
||||||
|
params: { page, size },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useReviewQueue = (page = 0, size = 50) =>
|
||||||
|
useQuery(reviewQueueQuery(page, size))
|
||||||
|
|
||||||
|
/** Single draft by id — for diff viewer drawer. */
|
||||||
|
export const draftQuery = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['draft', id] as const,
|
||||||
|
queryFn: async (): Promise<DraftResponse> => {
|
||||||
|
const { data } = await apiClient.get<DraftResponse>(
|
||||||
|
`/drafts/${encodeURIComponent(id)}`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useDraft = (id: string | undefined) =>
|
||||||
|
useQuery({
|
||||||
|
...draftQuery(id ?? ''),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Live record by businessKey — for diff comparison "current vs proposed". */
|
||||||
|
export const liveRecordQuery = (dict: string, businessKey: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['live-record', dict, businessKey] as const,
|
||||||
|
queryFn: async (): Promise<RecordResponse | null> => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get<RecordResponse>(
|
||||||
|
`/dictionaries/${encodeURIComponent(dict)}/records/${encodeURIComponent(businessKey)}`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const status = (e as { response?: { status?: number } })?.response?.status
|
||||||
|
if (status === 404) return null
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useLiveRecord = (dict: string | undefined, businessKey: string | undefined) =>
|
||||||
|
useQuery({
|
||||||
|
...liveRecordQuery(dict ?? '', businessKey ?? ''),
|
||||||
|
enabled: Boolean(dict && businessKey),
|
||||||
|
})
|
||||||
|
|
||||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||||
export const useRecords = (
|
export const useRecords = (
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
const [redisProjection, setRedisProjection] = useState(
|
const [redisProjection, setRedisProjection] = useState(
|
||||||
initial?.redisProjectionEnabled ?? false,
|
initial?.redisProjectionEnabled ?? false,
|
||||||
)
|
)
|
||||||
|
const [approvalRequired, setApprovalRequired] = useState(
|
||||||
|
(initial as { approvalRequired?: boolean } | null)?.approvalRequired ?? false,
|
||||||
|
)
|
||||||
|
const [approvalMinRole, setApprovalMinRole] = useState(
|
||||||
|
(initial as { approvalMinRole?: string } | null)?.approvalMinRole ?? '',
|
||||||
|
)
|
||||||
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
|
const [properties, setProperties] = useState<PropertyDef[]>(parsed.properties)
|
||||||
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
|
const [idSource, setIdSource] = useState<string>(parsed.idSource ?? '')
|
||||||
const [nameError, setNameError] = useState<string | null>(null)
|
const [nameError, setNameError] = useState<string | null>(null)
|
||||||
@@ -161,6 +167,8 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined,
|
supportedLocales: supportedLocales.length > 0 ? supportedLocales : undefined,
|
||||||
defaultLocale: defaultLocale || undefined,
|
defaultLocale: defaultLocale || undefined,
|
||||||
redisProjectionEnabled: redisProjection,
|
redisProjectionEnabled: redisProjection,
|
||||||
|
approvalRequired,
|
||||||
|
approvalMinRole: approvalMinRole.trim() || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -308,6 +316,46 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{/* Approval Workflow v2: per-dict opt-in. Default false. Включается
|
||||||
|
для критичных dicts (ground_station, spacecraft и т.д.) где
|
||||||
|
один admin не должен иметь power публиковать без review. */}
|
||||||
|
<label className="md:col-span-2 flex items-start gap-3 px-3 py-2 rounded-sm border border-orbit/40 bg-orbit/8 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={approvalRequired}
|
||||||
|
onChange={(e) => setApprovalRequired(e.target.checked)}
|
||||||
|
className="mt-0.5 size-4 accent-orbit"
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-primary text-carbon">
|
||||||
|
{t('schema.approvalRequired.label')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-2xs text-carbon/60 mt-0.5">
|
||||||
|
{t('schema.approvalRequired.hint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{approvalRequired && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label
|
||||||
|
htmlFor="approval-min-role"
|
||||||
|
className="text-2xs uppercase tracking-label text-carbon/70"
|
||||||
|
>
|
||||||
|
{t('schema.approvalMinRole.label')}
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
id="approval-min-role"
|
||||||
|
value={approvalMinRole}
|
||||||
|
onChange={(e) => setApprovalMinRole(e.target.value)}
|
||||||
|
placeholder="ordinis:internal"
|
||||||
|
/>
|
||||||
|
<p className="text-2xs text-carbon/60 mt-0.5">
|
||||||
|
{t('schema.approvalMinRole.hint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,38 @@ i18n
|
|||||||
'nav.outbox': 'Outbox',
|
'nav.outbox': 'Outbox',
|
||||||
'nav.webhooks': 'Webhooks',
|
'nav.webhooks': 'Webhooks',
|
||||||
'nav.search': 'Поиск',
|
'nav.search': 'Поиск',
|
||||||
|
'nav.reviews': 'Согласования',
|
||||||
|
'reviews.title': 'Очередь согласований',
|
||||||
|
'reviews.description': 'Pending drafts от makers, ждут approve/reject. Pool model: первый approver обрабатывает.',
|
||||||
|
'reviews.empty': 'Нет pending согласований. Все обработаны 👍',
|
||||||
|
'reviews.queueTotal_one': '{{count}} pending draft',
|
||||||
|
'reviews.queueTotal_few': '{{count}} pending draft',
|
||||||
|
'reviews.queueTotal_many': '{{count}} pending drafts',
|
||||||
|
'reviews.queueTotal_other': '{{count}} pending drafts',
|
||||||
|
'reviews.col.dict': 'Справочник',
|
||||||
|
'reviews.col.bk': 'Business key',
|
||||||
|
'reviews.col.op': 'Операция',
|
||||||
|
'reviews.col.maker': 'Автор',
|
||||||
|
'reviews.col.submitted': 'Отправлен',
|
||||||
|
'reviews.col.actions': 'Действия',
|
||||||
|
'reviews.action.review': 'Просмотр',
|
||||||
|
'reviews.action.approve': 'Approve',
|
||||||
|
'reviews.action.reject': 'Reject',
|
||||||
|
'reviews.action.cancel': 'Отмена',
|
||||||
|
'reviews.drawer.title': 'Согласование draft',
|
||||||
|
'reviews.drawer.maker': 'автор',
|
||||||
|
'reviews.drawer.submitted': 'отправлен',
|
||||||
|
'reviews.drawer.makerComment': 'Комментарий автора',
|
||||||
|
'reviews.drawer.live': 'Текущая (live)',
|
||||||
|
'reviews.drawer.proposed': 'Предложено',
|
||||||
|
'reviews.drawer.noLive': 'Нет live-записи (CREATE).',
|
||||||
|
'reviews.drawer.closeNote': 'CLOSE: запись будет закрыта (valid_to=now).',
|
||||||
|
'reviews.drawer.comment': 'Комментарий ревьюера',
|
||||||
|
'reviews.drawer.commentPlaceholder': 'Для approve опционально, для reject обязательно',
|
||||||
|
'schema.approvalRequired.label': 'Требовать approval (maker-checker)',
|
||||||
|
'schema.approvalRequired.hint': 'Approval Workflow v2. Все CRUD операции пойдут через draft → review → approve. Защита от operator error на критичных справочниках. Существующие маршруты (POST/PUT/DELETE) вернут 409 draft_required с pointer на /drafts API.',
|
||||||
|
'schema.approvalMinRole.label': 'Минимальная роль ревьюера (опционально)',
|
||||||
|
'schema.approvalMinRole.hint': 'Например ordinis:internal или ordinis:restricted. Пусто = любой пользователь с ролью ordinis:approver. (Phase 2 — enforcement TBD).',
|
||||||
'search.title': 'Smart search',
|
'search.title': 'Smart search',
|
||||||
'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).',
|
'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).',
|
||||||
'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…',
|
'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…',
|
||||||
@@ -390,6 +422,36 @@ i18n
|
|||||||
'nav.outbox': 'Outbox',
|
'nav.outbox': 'Outbox',
|
||||||
'nav.webhooks': 'Webhooks',
|
'nav.webhooks': 'Webhooks',
|
||||||
'nav.search': 'Search',
|
'nav.search': 'Search',
|
||||||
|
'nav.reviews': 'Reviews',
|
||||||
|
'reviews.title': 'Review queue',
|
||||||
|
'reviews.description': 'Pending drafts from makers awaiting approve/reject. Pool model: first approver handles.',
|
||||||
|
'reviews.empty': 'No pending reviews. All clear 👍',
|
||||||
|
'reviews.queueTotal_one': '{{count}} pending draft',
|
||||||
|
'reviews.queueTotal_other': '{{count}} pending drafts',
|
||||||
|
'reviews.col.dict': 'Dictionary',
|
||||||
|
'reviews.col.bk': 'Business key',
|
||||||
|
'reviews.col.op': 'Operation',
|
||||||
|
'reviews.col.maker': 'Maker',
|
||||||
|
'reviews.col.submitted': 'Submitted',
|
||||||
|
'reviews.col.actions': 'Actions',
|
||||||
|
'reviews.action.review': 'Review',
|
||||||
|
'reviews.action.approve': 'Approve',
|
||||||
|
'reviews.action.reject': 'Reject',
|
||||||
|
'reviews.action.cancel': 'Cancel',
|
||||||
|
'reviews.drawer.title': 'Review draft',
|
||||||
|
'reviews.drawer.maker': 'maker',
|
||||||
|
'reviews.drawer.submitted': 'submitted',
|
||||||
|
'reviews.drawer.makerComment': 'Maker comment',
|
||||||
|
'reviews.drawer.live': 'Current (live)',
|
||||||
|
'reviews.drawer.proposed': 'Proposed',
|
||||||
|
'reviews.drawer.noLive': 'No live record (CREATE).',
|
||||||
|
'reviews.drawer.closeNote': 'CLOSE: record will be closed (valid_to=now).',
|
||||||
|
'reviews.drawer.comment': 'Reviewer comment',
|
||||||
|
'reviews.drawer.commentPlaceholder': 'Optional for approve, required for reject',
|
||||||
|
'schema.approvalRequired.label': 'Require approval (maker-checker)',
|
||||||
|
'schema.approvalRequired.hint': 'Approval Workflow v2. All CRUD goes through draft → review → approve. Protects critical dictionaries from operator error. Existing endpoints (POST/PUT/DELETE) return 409 draft_required pointing to /drafts API.',
|
||||||
|
'schema.approvalMinRole.label': 'Minimum reviewer role (optional)',
|
||||||
|
'schema.approvalMinRole.hint': 'e.g. ordinis:internal or ordinis:restricted. Empty = any user with ordinis:approver. (Phase 2 — enforcement TBD).',
|
||||||
'search.title': 'Smart search',
|
'search.title': 'Smart search',
|
||||||
'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).',
|
'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).',
|
||||||
'search.placeholder': 'Type 3+ chars: code, name, identifier…',
|
'search.placeholder': 'Type 3+ chars: code, name, identifier…',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as WebhooksRouteImport } from './routes/webhooks'
|
import { Route as WebhooksRouteImport } from './routes/webhooks'
|
||||||
import { Route as SearchRouteImport } from './routes/search'
|
import { Route as SearchRouteImport } from './routes/search'
|
||||||
|
import { Route as ReviewsRouteImport } from './routes/reviews'
|
||||||
import { Route as OutboxRouteImport } from './routes/outbox'
|
import { Route as OutboxRouteImport } from './routes/outbox'
|
||||||
import { Route as DictionariesRouteImport } from './routes/dictionaries'
|
import { Route as DictionariesRouteImport } from './routes/dictionaries'
|
||||||
import { Route as AuditRouteImport } from './routes/audit'
|
import { Route as AuditRouteImport } from './routes/audit'
|
||||||
@@ -30,6 +31,11 @@ const SearchRoute = SearchRouteImport.update({
|
|||||||
path: '/search',
|
path: '/search',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ReviewsRoute = ReviewsRouteImport.update({
|
||||||
|
id: '/reviews',
|
||||||
|
path: '/reviews',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const OutboxRoute = OutboxRouteImport.update({
|
const OutboxRoute = OutboxRouteImport.update({
|
||||||
id: '/outbox',
|
id: '/outbox',
|
||||||
path: '/outbox',
|
path: '/outbox',
|
||||||
@@ -76,6 +82,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
|
'/reviews': typeof ReviewsRoute
|
||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/webhooks': typeof WebhooksRouteWithChildren
|
'/webhooks': typeof WebhooksRouteWithChildren
|
||||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||||
@@ -87,6 +94,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
|
'/reviews': typeof ReviewsRoute
|
||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||||
'/webhooks/$id': typeof WebhooksIdRoute
|
'/webhooks/$id': typeof WebhooksIdRoute
|
||||||
@@ -99,6 +107,7 @@ export interface FileRoutesById {
|
|||||||
'/audit': typeof AuditRoute
|
'/audit': typeof AuditRoute
|
||||||
'/dictionaries': typeof DictionariesRouteWithChildren
|
'/dictionaries': typeof DictionariesRouteWithChildren
|
||||||
'/outbox': typeof OutboxRoute
|
'/outbox': typeof OutboxRoute
|
||||||
|
'/reviews': typeof ReviewsRoute
|
||||||
'/search': typeof SearchRoute
|
'/search': typeof SearchRoute
|
||||||
'/webhooks': typeof WebhooksRouteWithChildren
|
'/webhooks': typeof WebhooksRouteWithChildren
|
||||||
'/dictionaries/$name': typeof DictionariesNameRoute
|
'/dictionaries/$name': typeof DictionariesNameRoute
|
||||||
@@ -113,6 +122,7 @@ export interface FileRouteTypes {
|
|||||||
| '/audit'
|
| '/audit'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
|
| '/reviews'
|
||||||
| '/search'
|
| '/search'
|
||||||
| '/webhooks'
|
| '/webhooks'
|
||||||
| '/dictionaries/$name'
|
| '/dictionaries/$name'
|
||||||
@@ -124,6 +134,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/audit'
|
| '/audit'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
|
| '/reviews'
|
||||||
| '/search'
|
| '/search'
|
||||||
| '/dictionaries/$name'
|
| '/dictionaries/$name'
|
||||||
| '/webhooks/$id'
|
| '/webhooks/$id'
|
||||||
@@ -135,6 +146,7 @@ export interface FileRouteTypes {
|
|||||||
| '/audit'
|
| '/audit'
|
||||||
| '/dictionaries'
|
| '/dictionaries'
|
||||||
| '/outbox'
|
| '/outbox'
|
||||||
|
| '/reviews'
|
||||||
| '/search'
|
| '/search'
|
||||||
| '/webhooks'
|
| '/webhooks'
|
||||||
| '/dictionaries/$name'
|
| '/dictionaries/$name'
|
||||||
@@ -148,6 +160,7 @@ export interface RootRouteChildren {
|
|||||||
AuditRoute: typeof AuditRoute
|
AuditRoute: typeof AuditRoute
|
||||||
DictionariesRoute: typeof DictionariesRouteWithChildren
|
DictionariesRoute: typeof DictionariesRouteWithChildren
|
||||||
OutboxRoute: typeof OutboxRoute
|
OutboxRoute: typeof OutboxRoute
|
||||||
|
ReviewsRoute: typeof ReviewsRoute
|
||||||
SearchRoute: typeof SearchRoute
|
SearchRoute: typeof SearchRoute
|
||||||
WebhooksRoute: typeof WebhooksRouteWithChildren
|
WebhooksRoute: typeof WebhooksRouteWithChildren
|
||||||
}
|
}
|
||||||
@@ -168,6 +181,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SearchRouteImport
|
preLoaderRoute: typeof SearchRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/reviews': {
|
||||||
|
id: '/reviews'
|
||||||
|
path: '/reviews'
|
||||||
|
fullPath: '/reviews'
|
||||||
|
preLoaderRoute: typeof ReviewsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/outbox': {
|
'/outbox': {
|
||||||
id: '/outbox'
|
id: '/outbox'
|
||||||
path: '/outbox'
|
path: '/outbox'
|
||||||
@@ -260,6 +280,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
AuditRoute: AuditRoute,
|
AuditRoute: AuditRoute,
|
||||||
DictionariesRoute: DictionariesRouteWithChildren,
|
DictionariesRoute: DictionariesRouteWithChildren,
|
||||||
OutboxRoute: OutboxRoute,
|
OutboxRoute: OutboxRoute,
|
||||||
|
ReviewsRoute: ReviewsRoute,
|
||||||
SearchRoute: SearchRoute,
|
SearchRoute: SearchRoute,
|
||||||
WebhooksRoute: WebhooksRouteWithChildren,
|
WebhooksRoute: WebhooksRouteWithChildren,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ function RootLayout() {
|
|||||||
>
|
>
|
||||||
{t('nav.search')}
|
{t('nav.search')}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/reviews"
|
||||||
|
className="text-carbon hover:text-ultramarain"
|
||||||
|
activeProps={{ className: 'text-ultramarain font-medium' }}
|
||||||
|
>
|
||||||
|
{t('nav.reviews')}
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<LanguageSwitch
|
<LanguageSwitch
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Drawer,
|
||||||
|
EmptyState,
|
||||||
|
LoadingBlock,
|
||||||
|
PageHeader,
|
||||||
|
Panel,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeaderCell,
|
||||||
|
TableRow,
|
||||||
|
TextInput,
|
||||||
|
} from '@nstart/ui'
|
||||||
|
import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||||
|
import { useDraft, useLiveRecord, useReviewQueue } from '@/api/queries'
|
||||||
|
import { useApproveDraft, useRejectDraft } from '@/api/mutations'
|
||||||
|
import type { DraftOperation } from '@/api/client'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/reviews')({
|
||||||
|
component: ReviewsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' => {
|
||||||
|
if (op === 'CREATE') return 'success'
|
||||||
|
if (op === 'CLOSE') return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval Workflow v2 — reviewer queue (D3=A pool model).
|
||||||
|
*
|
||||||
|
* <p>Все pending drafts в ordinis FIFO ordered by submitted_at. Click row →
|
||||||
|
* drawer открывается с side-by-side current vs proposed JSON + Approve /
|
||||||
|
* Reject actions. Reject reason required.
|
||||||
|
*/
|
||||||
|
function ReviewsPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queue = useReviewQueue(0, 50)
|
||||||
|
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title={t('reviews.title')}
|
||||||
|
description={t('reviews.description')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{queue.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
|
|
||||||
|
{queue.error && (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(queue.error)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{queue.data && queue.data.totalElements === 0 && (
|
||||||
|
<EmptyState title={t('reviews.empty')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{queue.data && queue.data.totalElements > 0 && (
|
||||||
|
<Panel>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-2xs text-carbon/60">
|
||||||
|
{t('reviews.queueTotal', { count: queue.data.totalElements })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeaderCell>{t('reviews.col.dict')}</TableHeaderCell>
|
||||||
|
<TableHeaderCell>{t('reviews.col.bk')}</TableHeaderCell>
|
||||||
|
<TableHeaderCell>{t('reviews.col.op')}</TableHeaderCell>
|
||||||
|
<TableHeaderCell>{t('reviews.col.maker')}</TableHeaderCell>
|
||||||
|
<TableHeaderCell>{t('reviews.col.submitted')}</TableHeaderCell>
|
||||||
|
<TableHeaderCell>{t('reviews.col.actions')}</TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{queue.data.items.map((d) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
to="/dictionaries/$name"
|
||||||
|
params={{ name: d.dictionaryId }}
|
||||||
|
className="text-ultramarain hover:underline font-mono text-2xs"
|
||||||
|
>
|
||||||
|
{d.dictionaryId.slice(0, 8)}…
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-2xs">{d.businessKey}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={operationVariant(d.operation)}>{d.operation}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-2xs">{d.makerId}</TableCell>
|
||||||
|
<TableCell className="text-2xs text-carbon/70">
|
||||||
|
{new Date(d.submittedAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedId(d.id)}
|
||||||
|
>
|
||||||
|
{t('reviews.action.review')}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReviewDrawer
|
||||||
|
draftId={selectedId}
|
||||||
|
onClose={() => setSelectedId(undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawerProps = {
|
||||||
|
draftId: string | undefined
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewDrawer({ draftId, onClose }: DrawerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const draftQ = useDraft(draftId)
|
||||||
|
const draft = draftQ.data
|
||||||
|
const liveQ = useLiveRecord(
|
||||||
|
draft ? draft.dictionaryId : undefined,
|
||||||
|
draft ? draft.businessKey : undefined,
|
||||||
|
)
|
||||||
|
const approveMut = useApproveDraft()
|
||||||
|
const rejectMut = useRejectDraft()
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
if (!draftId) return
|
||||||
|
approveMut.mutate(
|
||||||
|
{ id: draftId, comment: comment.trim() || undefined },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setComment('')
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
if (!draftId) return
|
||||||
|
if (!comment.trim()) return // backend will 400, but fail fast in UI
|
||||||
|
rejectMut.mutate(
|
||||||
|
{ id: draftId, comment: comment.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setComment('')
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
isOpen={Boolean(draftId)}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('reviews.drawer.title')}
|
||||||
|
side="right"
|
||||||
|
widthClassName="w-full max-w-3xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{draftQ.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
|
{draftQ.error && (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(draftQ.error)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draft && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-2xs">
|
||||||
|
<Badge variant={operationVariant(draft.operation)}>{draft.operation}</Badge>
|
||||||
|
<span className="font-mono">{draft.businessKey}</span>
|
||||||
|
<span className="text-carbon/60">
|
||||||
|
{t('reviews.drawer.maker')}: {draft.makerId}
|
||||||
|
</span>
|
||||||
|
<span className="text-carbon/60">
|
||||||
|
{t('reviews.drawer.submitted')}:{' '}
|
||||||
|
{new Date(draft.submittedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.makerComment && (
|
||||||
|
<div className="px-3 py-2 rounded-sm border border-regolith bg-regolith/30 text-2xs">
|
||||||
|
<span className="text-carbon/60 uppercase tracking-label font-secondary">
|
||||||
|
{t('reviews.drawer.makerComment')}:
|
||||||
|
</span>{' '}
|
||||||
|
{draft.makerComment}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-2xs uppercase tracking-label text-carbon/60 mb-1">
|
||||||
|
{t('reviews.drawer.live')}
|
||||||
|
</p>
|
||||||
|
{liveQ.isLoading && <LoadingBlock size="sm" label={t('loading')} />}
|
||||||
|
{liveQ.data === null && (
|
||||||
|
<p className="text-2xs text-carbon/60 italic">
|
||||||
|
{t('reviews.drawer.noLive')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{liveQ.data && (
|
||||||
|
<pre className="bg-regolith/30 rounded p-2 text-2xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(liveQ.data.data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xs uppercase tracking-label text-carbon/60 mb-1">
|
||||||
|
{t('reviews.drawer.proposed')}
|
||||||
|
</p>
|
||||||
|
{draft.operation === 'CLOSE' ? (
|
||||||
|
<p className="text-2xs text-aurora italic">
|
||||||
|
{t('reviews.drawer.closeNote')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<pre className="bg-ultramarain/4 rounded p-2 text-2xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{JSON.stringify(draft.data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(approveMut.error || rejectMut.error) && (
|
||||||
|
<Alert variant="error" title={t('error.failed')}>
|
||||||
|
{String(approveMut.error || rejectMut.error)}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-regolith pt-3 space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor="review-comment"
|
||||||
|
className="text-2xs uppercase tracking-label text-carbon/70"
|
||||||
|
>
|
||||||
|
{t('reviews.drawer.comment')}
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
id="review-comment"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder={t('reviews.drawer.commentPlaceholder')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={approveMut.isPending || rejectMut.isPending}
|
||||||
|
>
|
||||||
|
{t('reviews.action.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
leftIcon={<XCircleIcon weight="bold" size={14} />}
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={!comment.trim() || rejectMut.isPending || approveMut.isPending}
|
||||||
|
loading={rejectMut.isPending}
|
||||||
|
>
|
||||||
|
{t('reviews.action.reject')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<CheckCircleIcon weight="bold" size={14} />}
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={approveMut.isPending || rejectMut.isPending}
|
||||||
|
loading={approveMut.isPending}
|
||||||
|
>
|
||||||
|
{t('reviews.action.approve')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user