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: + *

+ * + *

Когда 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'