281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { useState } from 'react'
|
||
import { Link, createFileRoute } from '@tanstack/react-router'
|
||
import { useTranslation } from 'react-i18next'
|
||
import {
|
||
Alert,
|
||
Badge,
|
||
Button,
|
||
EmptyState,
|
||
LoadingBlock,
|
||
PageHeader,
|
||
Panel,
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeaderCell,
|
||
TableRow,
|
||
} from '@/ui'
|
||
import { useMyDrafts, useMySchemaDrafts } from '@/api/queries'
|
||
import { useWithdrawDraft } from '@/api/mutations'
|
||
import type { DraftOperation, DraftStatus, SchemaDraft } from '@/api/client'
|
||
import { UserCell } from '@/lib/useUserDisplay'
|
||
|
||
export const Route = createFileRoute('/my-drafts')({
|
||
component: MyDraftsPage,
|
||
})
|
||
|
||
const operationVariant = (op: DraftOperation): 'info' | 'success' | 'warning' => {
|
||
if (op === 'CREATE') return 'success'
|
||
if (op === 'CLOSE') return 'warning'
|
||
return 'info'
|
||
}
|
||
|
||
const statusVariant = (
|
||
s: DraftStatus,
|
||
): 'warning' | 'success' | 'error' | 'neutral' => {
|
||
if (s === 'PENDING') return 'warning'
|
||
if (s === 'APPROVED') return 'success'
|
||
if (s === 'REJECTED') return 'error'
|
||
return 'neutral'
|
||
}
|
||
|
||
/** Mapping schema draft статусов на UI badge variant. */
|
||
const schemaStatusVariant = (
|
||
s: string,
|
||
): 'warning' | 'success' | 'error' | 'info' | 'neutral' => {
|
||
if (s === 'review_pending') return 'warning'
|
||
if (s === 'approved' || s === 'published') return 'success'
|
||
if (s === 'rejected') return 'error'
|
||
if (s === 'changes_requested') return 'info'
|
||
return 'neutral'
|
||
}
|
||
|
||
/**
|
||
* Approval Workflow v2 Phase 4: maker self-tracking. Maker видит свои
|
||
* submissions всех статусов (PENDING/APPROVED/REJECTED/WITHDRAWN), sorted
|
||
* submitted_at DESC. Polled каждые 30s — maker увидит когда reviewer
|
||
* approve/reject. Withdraw кнопка для PENDING (только свои + maker-only
|
||
* gate в backend).
|
||
*/
|
||
function MyDraftsPage() {
|
||
const { t } = useTranslation()
|
||
const drafts = useMyDrafts(0, 50)
|
||
const schemaDrafts = useMySchemaDrafts(0, 50)
|
||
const withdrawMut = useWithdrawDraft()
|
||
const [busyId, setBusyId] = useState<string | undefined>(undefined)
|
||
|
||
const handleWithdraw = (id: string) => {
|
||
if (!confirm(t('myDrafts.confirmWithdraw'))) return
|
||
setBusyId(id)
|
||
withdrawMut.mutate(id, {
|
||
onSettled: () => setBusyId(undefined),
|
||
})
|
||
}
|
||
|
||
const recordTotal = drafts.data?.totalElements ?? 0
|
||
const schemaTotal = schemaDrafts.data?.totalElements ?? 0
|
||
const bothEmpty =
|
||
!drafts.isLoading &&
|
||
!schemaDrafts.isLoading &&
|
||
recordTotal === 0 &&
|
||
schemaTotal === 0
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<PageHeader
|
||
title={t('myDrafts.title')}
|
||
description={t('myDrafts.description')}
|
||
/>
|
||
|
||
{(drafts.isLoading || schemaDrafts.isLoading) && (
|
||
<LoadingBlock size="md" label={t('loading')} />
|
||
)}
|
||
|
||
{drafts.error && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(drafts.error)}
|
||
</Alert>
|
||
)}
|
||
{schemaDrafts.error && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{String(schemaDrafts.error)}
|
||
</Alert>
|
||
)}
|
||
|
||
{bothEmpty && <EmptyState title={t('myDrafts.empty')} />}
|
||
|
||
{schemaTotal > 0 && (
|
||
<MySchemaDraftsPanel items={(schemaDrafts.data?.items ?? []) as SchemaDraft[]} total={schemaTotal} />
|
||
)}
|
||
|
||
{drafts.data && drafts.data.totalElements > 0 && (
|
||
<Panel>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<h2 className="text-title-md font-semibold">
|
||
{t('myDrafts.records.title', { defaultValue: 'Записи' })}
|
||
</h2>
|
||
<p className="text-cell text-mute mt-0.5">
|
||
{t('myDrafts.total', { count: drafts.data.totalElements })}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Table>
|
||
<TableHead>
|
||
<TableRow>
|
||
<TableHeaderCell>{t('myDrafts.col.bk')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('myDrafts.col.op')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('myDrafts.col.status')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('myDrafts.col.submitted')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('myDrafts.col.reviewed')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('myDrafts.col.reviewer')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('myDrafts.col.comment')}</TableHeaderCell>
|
||
<TableHeaderCell>{t('myDrafts.col.actions')}</TableHeaderCell>
|
||
</TableRow>
|
||
</TableHead>
|
||
<TableBody>
|
||
{drafts.data.items.map((d) => (
|
||
<TableRow key={d.id}>
|
||
<TableCell className="text-mono">{d.businessKey}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={operationVariant(d.operation)}>{d.operation}</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={statusVariant(d.status)}>{d.status}</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-cell text-ink-2">
|
||
{new Date(d.submittedAt).toLocaleString()}
|
||
</TableCell>
|
||
<TableCell className="text-cell text-ink-2">
|
||
{d.reviewedAt
|
||
? new Date(d.reviewedAt).toLocaleString()
|
||
: '—'}
|
||
</TableCell>
|
||
<TableCell>
|
||
{d.reviewerId ? (
|
||
<UserCell uuid={d.reviewerId} />
|
||
) : (
|
||
<span className="text-mute">—</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-cell text-ink max-w-md">
|
||
{d.reviewComment ?? d.makerComment ?? '—'}
|
||
</TableCell>
|
||
<TableCell>
|
||
{d.status === 'PENDING' ? (
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
disabled={busyId === d.id}
|
||
onClick={() => handleWithdraw(d.id)}
|
||
>
|
||
{t('myDrafts.action.withdraw')}
|
||
</Button>
|
||
) : (
|
||
<span className="text-cell text-mute/70">—</span>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</Panel>
|
||
)}
|
||
</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>
|
||
)
|
||
}
|