feat(web): Breadcrumbs на edit-страницах (Каталог / Товары / Молоко 3.2%)
Some checks are pending
Some checks are pending
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:
parent
6fc74f8db6
commit
821bc4ed8d
43
src/food-market.web/src/components/Breadcrumbs.tsx
Normal file
43
src/food-market.web/src/components/Breadcrumbs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue