feat(handoff): WorkflowBanner + table a11y + responsive + toast styling
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.
This commit is contained in:
@@ -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).
|
||||
*
|
||||
* <p>Backend не имеет explicit dict-level workflow state (draft → review →
|
||||
* live transition). Используем существующие поля как proxy:
|
||||
* <ul>
|
||||
* <li><b>Live</b> (default) — approvalRequired=false → published immediately.
|
||||
* Green-bg, "Опубликовано · v{schemaVersion} · {updatedAt}".</li>
|
||||
* <li><b>Review pending</b> — approvalRequired=true И есть pending drafts
|
||||
* (pendingCount > 0). Warn-bg, link to /reviews.</li>
|
||||
* <li><b>Approval enabled empty</b> — approvalRequired=true но нет drafts.
|
||||
* Accent-bg, informational "changes require review".</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Когда 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 (
|
||||
<Banner
|
||||
variant="live"
|
||||
icon={<CheckCircle size={16} strokeWidth={2} className="shrink-0" />}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t('workflow.live.title', { defaultValue: 'Опубликовано' })}
|
||||
</span>
|
||||
<span className="text-mono opacity-80">v{detail.schemaVersion}</span>
|
||||
<span className="text-cell opacity-70">· {updatedAt}</span>
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
|
||||
// === Case 2: Review pending — approval required + drafts queued ===
|
||||
if (pendingCount > 0) {
|
||||
return (
|
||||
<Banner
|
||||
variant="review"
|
||||
icon={<ClipboardList size={16} strokeWidth={2} className="shrink-0" />}
|
||||
action={
|
||||
<Link
|
||||
to="/reviews"
|
||||
className="text-body font-medium text-accent hover:underline shrink-0"
|
||||
>
|
||||
{t('workflow.review.open', { defaultValue: 'Открыть /reviews' })} →
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t('workflow.review.title', {
|
||||
count: pendingCount,
|
||||
defaultValue: 'На ревью {{count}}',
|
||||
})}
|
||||
</span>
|
||||
<span className="text-cell opacity-70">
|
||||
{t('workflow.review.subtitle', {
|
||||
defaultValue: 'правки записей ждут approval',
|
||||
})}
|
||||
</span>
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
|
||||
// === Case 3: Approval enabled, no pending ===
|
||||
return (
|
||||
<Banner
|
||||
variant="info"
|
||||
icon={<FileText size={16} strokeWidth={2} className="shrink-0" />}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t('workflow.approval.title', { defaultValue: 'Approval workflow' })}
|
||||
</span>
|
||||
<span className="text-cell opacity-70">
|
||||
{t('workflow.approval.subtitle', {
|
||||
defaultValue: 'правки записей требуют ревью',
|
||||
})}
|
||||
</span>
|
||||
</Banner>
|
||||
)
|
||||
}
|
||||
|
||||
type BannerProps = {
|
||||
variant: 'live' | 'review' | 'info' | 'draft'
|
||||
icon: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const VARIANT_CLASS: Record<BannerProps['variant'], string> = {
|
||||
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 (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2.5 rounded-md border-l-4 text-body',
|
||||
VARIANT_CLASS[variant],
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
|
||||
{children}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user