feat(web): Breadcrumbs на edit-страницах (Каталог / Товары / Молоко 3.2%)
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Item 6 Sprint 7 — реюзабельный <Breadcrumbs items={...}> над h1 на 9 edit-pages.

Компонент: src/components/Breadcrumbs.tsx — Lucide ChevronRight как
разделитель, последний item — текущий (semibold темнее, без линка), не-
последние — кликабельные Link'и react-router (если задан to). Truncate с
title-tooltip для длинных названий.

Применено:
- ProductEditPage: Каталог / Товары / <name|Новый товар>
- SupplyEditPage: Закупки / Приёмки / <number|Новая приёмка>
- EnterEditPage / LossEditPage / TransferEditPage / InventoryEditPage:
  Остатки / <тип> / <number>
- SupplierReturnEditPage: Закупки / Возвраты поставщикам / <number>
- DemandEditPage: Продажи / Оптовые отгрузки / <number>
- RetailSaleEditPage: Продажи / Чеки / <number>

Side-effect: на doc-edit pages убрана дублирующая subtitle «Черновик —
товар не списан, пока не проведёшь» (breadcrumbs дают контекст). Зелёная
плашка «Проведён <дата>» сохранилась — она несёт реальную инфу.

tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-30 11:25:32 +05:00
parent 6fc74f8db6
commit 821bc4ed8d
10 changed files with 137 additions and 43 deletions

View file

@ -0,0 +1,43 @@
import { Fragment, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
/**
* Хлебные крошки для edit-страниц: «Каталог / Товары / Молоко 3.2%».
*
* Каждый item с `to` кликабельная ссылка (sm grey). Последний item
* текущий, не кликабельный, semibold темнее. Разделитель ChevronRight.
*
* Используется через PageHeader/инлайн в шапке формы.
*/
export interface BreadcrumbItem {
label: ReactNode
to?: string
}
interface Props {
items: BreadcrumbItem[]
className?: string
}
export function Breadcrumbs({ items, className }: Props) {
if (items.length === 0) return null
return (
<nav aria-label="Хлебные крошки" className={'flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400 min-w-0 ' + (className ?? '')}>
{items.map((item, idx) => {
const isLast = idx === items.length - 1
const content = isLast
? <span className="text-slate-700 dark:text-slate-300 font-medium truncate" title={typeof item.label === 'string' ? item.label : undefined}>{item.label}</span>
: item.to
? <Link to={item.to} className="hover:text-slate-700 dark:hover:text-slate-300 hover:underline underline-offset-4 truncate">{item.label}</Link>
: <span className="truncate">{item.label}</span>
return (
<Fragment key={idx}>
{idx > 0 && <ChevronRight className="w-3 h-3 shrink-0 text-slate-300 dark:text-slate-600" />}
<span className="min-w-0">{content}</span>
</Fragment>
)
})}
</nav>
)
}

View file

@ -9,6 +9,7 @@ import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -221,14 +222,19 @@ export function DemandEditPage() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Продажи' },
{ label: 'Оптовые отгрузки', to: '/sales/demands' },
{ label: isNew ? 'Новая отгрузка' : (existing.data?.number || 'Отгрузка') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая отгрузка' : existing.data?.number ?? 'Отгрузка'}
</h1>
{isPosted && (
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не списан, пока не проведёшь'}
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
</p>
)}
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">

View file

@ -9,6 +9,7 @@ import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -201,14 +202,19 @@ export function EnterEditPage() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Остатки' },
{ label: 'Оприходования', to: '/inventory/enters' },
{ label: isNew ? 'Новое оприходование' : (existing.data?.number || 'Оприходование') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новое оприходование' : existing.data?.number ?? 'Оприходование'}
</h1>
{isPosted && (
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не попадает на склад, пока не проведёшь'}
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
</p>
)}
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">

View file

@ -8,6 +8,7 @@ import { Field, TextArea, Select, NumberInput, Checkbox } from '@/components/Fie
import { DateField } from '@/components/DateField'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useStores } from '@/lib/useLookups'
import { InventoryStatus, type InventoryDto } from '@/lib/types'
@ -217,14 +218,19 @@ export function InventoryEditPage() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Остатки' },
{ label: 'Инвентаризации', to: '/inventory/inventories' },
{ label: isNew ? 'Новая инвентаризация' : (existing.data?.number || 'Инвентаризация') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая инвентаризация' : existing.data?.number ?? 'Инвентаризация'}
</h1>
{isPosted && (
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — расхождения не отражаются на остатках, пока не проведёшь'}
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
</p>
)}
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">

View file

@ -9,6 +9,7 @@ import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -204,14 +205,19 @@ export function LossEditPage() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Остатки' },
{ label: 'Списания', to: '/inventory/losses' },
{ label: isNew ? 'Новое списание' : (existing.data?.number || 'Списание') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новое списание' : existing.data?.number ?? 'Списание'}
</h1>
{isPosted && (
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не списан, пока не проведёшь'}
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
</p>
)}
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">

View file

@ -13,6 +13,7 @@ import { BarcodeType, Packaging, type Product } from '@/lib/types'
import { ProductImageGallery } from '@/components/ProductImageGallery'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode'
@ -238,12 +239,14 @@ export function ProductEditPage() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Каталог' },
{ label: 'Товары', to: '/catalog/products' },
{ label: isNew ? 'Новый товар' : (form.name || 'Без названия') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новый товар' : form.name || 'Товар'}
</h1>
<p className="text-xs text-slate-500">
{isNew ? 'Создание новой позиции каталога' : 'Редактирование'}
</p>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">

View file

@ -8,6 +8,7 @@ import { Field, TextInput, TextArea, Select, AsyncSelect, MoneyInput, NumberInpu
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -220,14 +221,19 @@ export function RetailSaleEditPage() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Продажи' },
{ label: 'Чеки', to: '/sales/retail' },
{ label: isNew ? 'Новый чек' : (existing.data?.number || 'Чек') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новый чек' : existing.data?.number ?? 'Чек'}
</h1>
{isPosted && (
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не списывается со склада до проведения'}
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
</p>
)}
</div>
</div>
<div className="flex gap-2 flex-shrink-0">

View file

@ -9,6 +9,7 @@ import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -207,14 +208,19 @@ export function SupplierReturnEditPage() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Закупки' },
{ label: 'Возвраты поставщикам', to: '/purchases/supplier-returns' },
{ label: isNew ? 'Новый возврат' : (existing.data?.number || 'Возврат') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новый возврат поставщику' : existing.data?.number ?? 'Возврат поставщику'}
</h1>
{isPosted && (
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не списан, пока не проведёшь'}
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
</p>
)}
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">

View file

@ -10,6 +10,7 @@ import { ProductPicker } from '@/components/ProductPicker'
import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -281,14 +282,19 @@ export function SupplyEditPage() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Закупки' },
{ label: 'Приёмки', to: '/purchases/supplies' },
{ label: isNew ? 'Новая приёмка' : (existing.data?.number || 'Приёмка') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая приёмка' : existing.data?.number ?? 'Приёмка'}
</h1>
{isPosted && (
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не попадает на склад, пока не проведёшь'}
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
</p>
)}
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">

View file

@ -9,6 +9,7 @@ import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useStores } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -189,14 +190,19 @@ export function TransferEditPage() {
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<Breadcrumbs items={[
{ label: 'Остатки' },
{ label: 'Перемещения', to: '/inventory/transfers' },
{ label: isNew ? 'Новое перемещение' : (existing.data?.number || 'Перемещение') },
]} />
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новое перемещение' : existing.data?.number ?? 'Перемещение'}
</h1>
{isPosted && (
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не перемещён, пока не проведёшь'}
<span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
</p>
)}
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">