From 0acba4c073507951b9dbdd191ff0a4ad0ed8252a Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 11 May 2026 15:58:12 +0300 Subject: [PATCH] feat(handoff): WorkflowBanner + table a11y + responsive + toast styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing handoff gaps выявленных при полном audit'е README.md vs current implementation. См. screen-by-screen mapping ниже. == Screen 2 — Editor: WorkflowBanner (NEW) == Required by handoff (line 238-242) но не существовал. Inline status row выше tab bar — live/review/info variants. Backend не имеет explicit dict-level workflow state (draft → review → live transition), используем proxy: - approvalRequired=false → 'Live' (green-bg + green left edge) - approvalRequired=true + 0 → 'Approval enabled' (accent-bg + accent edge) - approvalRequired=true + N → 'На ревью N' (warn-bg + warn edge), link к /reviews Backend extension future: добавить explicit workflowState к DictionaryDetail + buttons для transitions (request-review / approve / publish). Сейчас banner дает минимум — visual workflow signal без UI mutation buttons. == Table accessibility per handoff line 476 == TableHeaderCell: - scope='col' (a11y screen reader requirement) - aria-sort='ascending|descending|none' когда sortable prop true - New sortable + sortDirection props (API ready, не используется пока) == 560px responsive per handoff line 466 == TableHeaderCell + TableCell получили optional hideBelow='sm|md|lg' prop: hideBelow='sm' → hidden <640px (table-cell ≥sm) hideBelow='md' → hidden <768px hideBelow='lg' → hidden <1024px Callsites могут пометить secondary columns (created_by, updated_at, etc.) hideBelow='md' для clean mobile experience. Текущие routes ещё не используют prop — API ready для incremental adoption. == Toast styling per handoff line 302-305 == Sonner toast — раньше surface bg + ink text. Handoff: - Navy bg + on-accent text (warm cream-orange feeling) - 4s auto-dismiss - Variant accent через left border 4px: success → green / error → pink / info → accent / warning → warn - cap-style title (но используем text-body для нативности, не overkill) - 13px font-sans richColors disabled — наши tokens вместо sonner defaults. Files: - NEW src/components/editor/WorkflowBanner.tsx - src/routes/dictionaries.$name.tsx (wire banner above tab bar) - src/ui/components/table.tsx (a11y + hideBelow API) - src/ui/components/toaster.tsx (navy variant + variant borders) - src/ui/index.ts (TableCellProps export) Tests: 116 pass. TS strict clean. --- .../src/components/editor/WorkflowBanner.tsx | 135 ++++++++++++++++++ .../src/routes/dictionaries.$name.tsx | 6 + ordinis-admin-ui/src/ui/components/table.tsx | 63 ++++++-- .../src/ui/components/toaster.tsx | 41 +++--- ordinis-admin-ui/src/ui/index.ts | 1 + 5 files changed, 217 insertions(+), 29 deletions(-) create mode 100644 ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx 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'