diff --git a/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx b/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx
new file mode 100644
index 0000000..2a1442b
--- /dev/null
+++ b/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx
@@ -0,0 +1,135 @@
+import { useTranslation } from 'react-i18next'
+import { Link } from '@tanstack/react-router'
+import { ClipboardList, CheckCircle, FileText } from 'lucide-react'
+import type { DictionaryDetail } from '@/api/client'
+import { cn } from '@/lib/utils'
+
+/**
+ * WorkflowBanner — status banner для editor (Screen 2 per handoff).
+ *
+ *
Backend не имеет explicit dict-level workflow state (draft → review →
+ * live transition). Используем существующие поля как proxy:
+ *
+ * Live (default) — approvalRequired=false → published immediately.
+ * Green-bg, "Опубликовано · v{schemaVersion} · {updatedAt}".
+ * Review pending — approvalRequired=true И есть pending drafts
+ * (pendingCount > 0). Warn-bg, link to /reviews.
+ * Approval enabled empty — approvalRequired=true но нет drafts.
+ * Accent-bg, informational "changes require review".
+ *
+ *
+ * Когда backend добавит explicit workflowState (draft/review/live transition
+ * на dict-level) — расширим этот компонент. Сейчас фокус UI на approval flow.
+ */
+
+export type WorkflowBannerProps = {
+ detail: DictionaryDetail
+ /** Pending drafts count для this dict (из useDictPendingDrafts). */
+ pendingCount?: number
+}
+
+export function WorkflowBanner({ detail, pendingCount = 0 }: WorkflowBannerProps) {
+ const { t } = useTranslation()
+ const updatedAt = new Date(detail.updatedAt).toLocaleString(undefined, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+
+ // === Case 1: Live — no approval required, immediate publish ===
+ if (!detail.approvalRequired) {
+ return (
+ }
+ >
+
+ {t('workflow.live.title', { defaultValue: 'Опубликовано' })}
+
+ v{detail.schemaVersion}
+ · {updatedAt}
+
+ )
+ }
+
+ // === Case 2: Review pending — approval required + drafts queued ===
+ if (pendingCount > 0) {
+ return (
+ }
+ action={
+
+ {t('workflow.review.open', { defaultValue: 'Открыть /reviews' })} →
+
+ }
+ >
+
+ {t('workflow.review.title', {
+ count: pendingCount,
+ defaultValue: 'На ревью {{count}}',
+ })}
+
+
+ {t('workflow.review.subtitle', {
+ defaultValue: 'правки записей ждут approval',
+ })}
+
+
+ )
+ }
+
+ // === Case 3: Approval enabled, no pending ===
+ return (
+ }
+ >
+
+ {t('workflow.approval.title', { defaultValue: 'Approval workflow' })}
+
+
+ {t('workflow.approval.subtitle', {
+ defaultValue: 'правки записей требуют ревью',
+ })}
+
+
+ )
+}
+
+type BannerProps = {
+ variant: 'live' | 'review' | 'info' | 'draft'
+ icon: React.ReactNode
+ action?: React.ReactNode
+ children: React.ReactNode
+}
+
+const VARIANT_CLASS: Record = {
+ live: 'bg-green-bg border-l-green text-ink',
+ review: 'bg-warn-bg border-l-warn text-ink',
+ info: 'bg-accent-bg border-l-accent text-ink',
+ draft: 'bg-warn-bg border-l-warn text-ink',
+}
+
+function Banner({ variant, icon, action, children }: BannerProps) {
+ return (
+
+ {icon}
+
+ {children}
+
+ {action}
+
+ )
+}
diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
index 1a1f1de..419554c 100644
--- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
+++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx
@@ -41,6 +41,7 @@ import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { RecordDrawer } from '@/components/record/RecordDrawer'
+import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
import { nowIsoLocal } from '@/lib/dates'
@@ -578,6 +579,11 @@ function DictionaryDetail() {
}
/>
+ {/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
+ {detailQuery.data && (
+
+ )}
+
{/* Editor tabs per handoff Stage 3.4:
Records | Relations | Fields | JSON | Events | History.
URL-synced через ?tab=. Default — records (records table + filters). */}
diff --git a/ordinis-admin-ui/src/ui/components/table.tsx b/ordinis-admin-ui/src/ui/components/table.tsx
index d597926..ad175a7 100644
--- a/ordinis-admin-ui/src/ui/components/table.tsx
+++ b/ordinis-admin-ui/src/ui/components/table.tsx
@@ -89,18 +89,46 @@ export const TableRow = React.forwardRef & {
align?: 'left' | 'center' | 'right'
+ /** Hide column на narrow viewports per handoff line 466:
+ * - 'sm' — hide <640px (table cells 6-7 columns secondary)
+ * - 'md' — hide <768px
+ * - 'lg' — hide <1024px
+ * Используется pair с same prop на соответствующих TableCell. */
+ hideBelow?: 'sm' | 'md' | 'lg'
+ sortable?: boolean
+ sortDirection?: 'asc' | 'desc' | 'none'
+}
+
+const HIDE_BELOW: Record, string> = {
+ sm: 'hidden sm:table-cell',
+ md: 'hidden md:table-cell',
+ lg: 'hidden lg:table-cell',
}
export const TableHeaderCell = React.forwardRef(
- function TableHeaderCell({ className, align = 'left', ...props }, ref) {
+ function TableHeaderCell(
+ { className, align = 'left', hideBelow, sortable, sortDirection, ...props },
+ ref,
+ ) {
+ const ariaSort =
+ sortDirection === 'asc'
+ ? 'ascending'
+ : sortDirection === 'desc'
+ ? 'descending'
+ : sortable
+ ? 'none'
+ : undefined
return (
->(function TableCell({ className, ...props }, ref) {
- return (
-
- )
-})
+export type TableCellProps = React.TdHTMLAttributes & {
+ /** Pair с TableHeaderCell.hideBelow — column скрывается на narrow viewport. */
+ hideBelow?: 'sm' | 'md' | 'lg'
+}
+
+export const TableCell = React.forwardRef(
+ function TableCell({ className, hideBelow, ...props }, ref) {
+ return (
+
+ )
+ },
+)
export const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
diff --git a/ordinis-admin-ui/src/ui/components/toaster.tsx b/ordinis-admin-ui/src/ui/components/toaster.tsx
index 1e0146a..352002c 100644
--- a/ordinis-admin-ui/src/ui/components/toaster.tsx
+++ b/ordinis-admin-ui/src/ui/components/toaster.tsx
@@ -2,20 +2,22 @@ import { Toaster as SonnerToaster, toast } from 'sonner'
import { useTheme } from '@/stores/ThemeProvider'
/**
- * Toast notification provider — wraps sonner с our design tokens.
+ * Toast notification provider — sonner с handoff Toast styling (Screen 4).
*
- * Sonner = headless, кастомизируем через CSS variables. Параметризируется
- * theme="light" | "dark" — берётся из ThemeProvider, авто-switch на смену темы.
+ * Handoff spec: navy bg (or Claude-orange в dark theme), "OK" cap header в
+ * toast-cap color (orange-cream), then message text, then × dismiss button.
+ * Auto-dismiss после 4s. Slide-up bottom-right.
+ *
+ *
Sonner = headless library. Кастомизируем через CSS overrides — наша
+ * navy color tokens применяются к bg/text/border.
*
* Использование:
*
- * import { toast } from '@/ui/components/toaster'
+ * import { toast } from '@/ui'
* toast.success('Запись сохранена')
* toast.error('Ошибка валидации', { description: '...' })
* toast.promise(mutateAsync(), {loading, success, error})
*
- *
- * Mount: один раз в main.tsx внутри ThemeProvider.
*/
export function Toaster() {
const { resolved } = useTheme()
@@ -23,30 +25,37 @@ export function Toaster() {
return (
)
}
-// Re-export для удобства: `import { toast } from '@/ui/components/toaster'`.
+// Re-export для удобства: `import { toast } from '@/ui'`.
export { toast }
diff --git a/ordinis-admin-ui/src/ui/index.ts b/ordinis-admin-ui/src/ui/index.ts
index 5d31dad..6818fe1 100644
--- a/ordinis-admin-ui/src/ui/index.ts
+++ b/ordinis-admin-ui/src/ui/index.ts
@@ -92,6 +92,7 @@ export {
TableCaption,
TableEmpty,
type TableHeaderCellProps,
+ type TableCellProps,
type TableEmptyProps,
} from './components/table'