feat(web): Empty states с CTA на list-страницах
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 5 Sprint 7 — заменил сухое «Нет данных» в DataTable на дружелюбный
центрированный блок: иконка + заголовок + объяснение + CTA «Создать первый
…». Показывается только когда нет поиска/фильтров (truly fresh org), иначе
обычный fallback DataTable.empty.

Компонент: src/components/EmptyState.tsx — Lucide-иконка, optional
actionLabel + onAction, optional secondaryLabel + onSecondary.

Применено (14 страниц):
- Catalog: Products (Package → /catalog/products/new), Counterparties
  (Users → открыть create-modal через setForm)
- Inventory: Enters (PackagePlus), Losses (Trash2),
  Transfers (ArrowLeftRight), Inventories (ClipboardList)
- Purchases: Supplies (PackagePlus), SupplierReturns (Undo2)
- Sales: Demands (Truck), RetailSales (Receipt)
- Reports: Sales (BarChart3), Stock (Warehouse), Profit (TrendingUp),
  Abc (BarChart3) — без CTA, текст «отчёт построится когда…»

Тексты — конкретные («Списания фиксируют выбытие — просрочка, брак»),
не «нажмите чтобы создать».

tsc clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-30 11:16:11 +05:00
parent cd83269d3a
commit 8d532927e2
15 changed files with 430 additions and 244 deletions

View file

@ -0,0 +1,56 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Button } from './Button'
/**
* Centered empty-state block для list-страниц, когда данных пока нет.
* Заменяет «Нет данных» в DataTable на дружественный CTA: иконка +
* заголовок + пояснение + кнопка «Создать первый ».
*
* Использование на list-pages: проверить (items.length===0 && !isLoading)
* и подменить таблицу на <EmptyState>. Можно использовать как `empty`
* prop у DataTable он отрендерит внутри пустой строки.
*/
interface EmptyStateProps {
icon: LucideIcon
title: string
description?: ReactNode
/** CTA-кнопка справа от описания: для создания первой записи. */
actionLabel?: string
onAction?: () => void
/** Если задано, рисуется secondary-link под основной кнопкой. */
secondaryLabel?: string
onSecondary?: () => void
}
export function EmptyState({
icon: Icon, title, description, actionLabel, onAction, secondaryLabel, onSecondary,
}: EmptyStateProps) {
return (
<div className="h-full min-h-[400px] flex flex-col items-center justify-center text-center px-6 py-12">
<div className="w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center mb-4">
<Icon className="w-7 h-7 text-slate-400 dark:text-slate-500" />
</div>
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
{description && (
<p className="mt-1.5 text-sm text-slate-500 dark:text-slate-400 max-w-md">{description}</p>
)}
{(actionLabel || secondaryLabel) && (
<div className="mt-5 flex flex-col items-center gap-2">
{actionLabel && onAction && (
<Button onClick={onAction}>{actionLabel}</Button>
)}
{secondaryLabel && onSecondary && (
<button
type="button"
onClick={onSecondary}
className="text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 underline-offset-4 hover:underline"
>
{secondaryLabel}
</button>
)}
</div>
)}
</div>
)
}

View file

@ -1,8 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Download } from 'lucide-react' import { Download, BarChart3 } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { EmptyState } from '@/components/EmptyState'
import { Field, Select } from '@/components/Field' import { Field, Select } from '@/components/Field'
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { useStores, useProductGroups } from '@/lib/useLookups' import { useStores, useProductGroups } from '@/lib/useLookups'
@ -141,7 +142,11 @@ export function AbcReportPage() {
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"> <section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю</div>} {rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю</div>}
{!rep.isLoading && (rep.data?.length ?? 0) === 0 && ( {!rep.isLoading && (rep.data?.length ?? 0) === 0 && (
<div className="text-sm text-slate-500 py-6 text-center">За период нет продаж.</div> <EmptyState
icon={BarChart3}
title="Данных для ABC-анализа нет"
description="Анализ запускается по периоду продаж — пока их недостаточно, классы не построятся."
/>
)} )}
{!rep.isLoading && rep.data && rep.data.length > 0 && ( {!rep.isLoading && rep.data && rep.data.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">

View file

@ -1,10 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { validateEmail, validatePhone } from '@/lib/validation' import { validateEmail, validatePhone } from '@/lib/validation'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2, Users } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -87,28 +88,38 @@ export function CounterpartiesPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={Users}
rowKey={(r) => r.id} title="Контрагентов пока нет"
sortKey={sortKey} description="Здесь будут поставщики и покупатели — добавьте первого."
sortOrder={sortOrder} actionLabel="Добавить контрагента"
onSortChange={setSort} onAction={() => { setForm(blankForm); setFieldErrors({}) }}
onRowClick={(r) => { setFieldErrors({}); setForm({ />
id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type, ) : (
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '', <DataTable
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '', rows={data?.items ?? []}
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '', isLoading={isLoading}
contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', rowKey={(r) => r.id}
}) }} sortKey={sortKey}
columns={[ sortOrder={sortOrder}
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, onSortChange={setSort}
{ header: 'Тип', width: '120px', sortKey: 'type', cell: (r) => typeLabel[r.type] }, onRowClick={(r) => { setFieldErrors({}); setForm({
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> }, id: r.id, name: r.name, legalName: r.legalName ?? '', type: r.type,
{ header: 'Телефон', width: '160px', sortKey: 'phone', cell: (r) => r.phone ?? '—' }, bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
{ header: 'Страна', width: '120px', sortKey: 'country', cell: (r) => r.countryName ?? '—' }, countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
]} bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
/> contactPerson: r.contactPerson ?? '', notes: r.notes ?? '',
}) }}
columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Тип', width: '120px', sortKey: 'type', cell: (r) => typeLabel[r.type] },
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
{ header: 'Телефон', width: '160px', sortKey: 'phone', cell: (r) => r.phone ?? '—' },
{ header: 'Страна', width: '120px', sortKey: 'country', cell: (r) => r.countryName ?? '—' },
]}
/>
)}
</ListPageShell> </ListPageShell>
<Modal <Modal

View file

@ -1,7 +1,8 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react' import { Plus, Truck } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -36,31 +37,41 @@ export function DemandsPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={Truck}
rowKey={(r) => r.id} title="Оптовых отгрузок пока нет"
sortKey={sortKey} description="Отгрузки оформляются для оптовых клиентов с оплатой постфактум или предоплатой."
sortOrder={sortOrder} actionLabel="Создать отгрузку"
onSortChange={setSort} onAction={() => navigate('/sales/demands/new')}
onRowClick={(r) => navigate(`/sales/demands/${r.id}`)} />
columns={[ ) : (
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, <DataTable
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, rows={data?.items ?? []}
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( isLoading={isLoading}
r.status === DemandStatus.Posted rowKey={(r) => r.id}
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span> sortKey={sortKey}
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span> sortOrder={sortOrder}
)}, onSortChange={setSort}
{ header: 'Контрагент', sortKey: 'customer', cell: (r) => r.customerName }, onRowClick={(r) => navigate(`/sales/demands/${r.id}`)}
{ header: 'Склад', width: '160px', cell: (r) => r.storeName }, columns={[
{ header: 'Оплата', width: '110px', cell: (r) => demandPaymentLabel[r.payment] ?? r.payment }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Сумма', width: '140px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` }, { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
{ header: 'Оплачено', width: '140px', className: 'text-right font-mono text-slate-500', cell: (r) => r.paidAmount.toLocaleString('ru', moneyFmt) }, r.status === DemandStatus.Posted
]} ? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
empty="Отгрузок пока нет. Создай первую — товар спишется со склада после проведения." : <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
/> )},
{ header: 'Контрагент', sortKey: 'customer', cell: (r) => r.customerName },
{ header: 'Склад', width: '160px', cell: (r) => r.storeName },
{ header: 'Оплата', width: '110px', cell: (r) => demandPaymentLabel[r.payment] ?? r.payment },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '140px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
{ header: 'Оплачено', width: '140px', className: 'text-right font-mono text-slate-500', cell: (r) => r.paidAmount.toLocaleString('ru', moneyFmt) },
]}
empty="Отгрузок пока нет. Создай первую — товар спишется со склада после проведения."
/>
)}
</ListPageShell> </ListPageShell>
) )
} }

View file

@ -1,7 +1,8 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react' import { Plus, PackagePlus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -36,28 +37,38 @@ export function EntersPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={PackagePlus}
rowKey={(r) => r.id} title="Оприходований пока нет"
sortKey={sortKey} description="Оприходование используется для первичной партии товаров — например при запуске магазина без приёмки."
sortOrder={sortOrder} actionLabel="Создать оприходование"
onSortChange={setSort} onAction={() => navigate('/inventory/enters/new')}
onRowClick={(r) => navigate(`/inventory/enters/${r.id}`)} />
columns={[ ) : (
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, <DataTable
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, rows={data?.items ?? []}
{ header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => ( isLoading={isLoading}
r.status === EnterStatus.Posted rowKey={(r) => r.id}
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span> sortKey={sortKey}
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span> sortOrder={sortOrder}
)}, onSortChange={setSort}
{ header: 'Склад', sortKey: 'store', cell: (r) => r.storeName }, onRowClick={(r) => navigate(`/inventory/enters/${r.id}`)}
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, columns={[
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
]} { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
empty="Оприходований пока нет. Создай первое — товар попадёт на склад после проведения." { header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => (
/> r.status === EnterStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Склад', sortKey: 'store', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
]}
empty="Оприходований пока нет. Создай первое — товар попадёт на склад после проведения."
/>
)}
</ListPageShell> </ListPageShell>
) )
} }

View file

@ -1,7 +1,8 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react' import { Plus, ClipboardList } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -36,29 +37,39 @@ export function InventoriesPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={ClipboardList}
rowKey={(r) => r.id} title="Инвентаризаций пока нет"
sortKey={sortKey} description="Инвентаризация сверяет фактические остатки с книжными и формирует корректировки."
sortOrder={sortOrder} actionLabel="Создать инвентаризацию"
onSortChange={setSort} onAction={() => navigate('/inventory/inventories/new')}
onRowClick={(r) => navigate(`/inventory/inventories/${r.id}`)} />
columns={[ ) : (
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, <DataTable
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, rows={data?.items ?? []}
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( isLoading={isLoading}
r.status === InventoryStatus.Posted rowKey={(r) => r.id}
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span> sortKey={sortKey}
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span> sortOrder={sortOrder}
)}, onSortChange={setSort}
{ header: 'Склад', cell: (r) => r.storeName }, onRowClick={(r) => navigate(`/inventory/inventories/${r.id}`)}
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, columns={[
{ header: 'Излишек', width: '140px', className: 'text-right font-mono text-green-700', cell: (r) => `+${r.surplusValue.toLocaleString('ru', moneyFmt)}` }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Недостача', width: '140px', className: 'text-right font-mono text-red-700', cell: (r) => `${r.shortageValue.toLocaleString('ru', moneyFmt)}` }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
]} { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
empty="Инвентаризаций пока нет." r.status === InventoryStatus.Posted
/> ? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Склад', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Излишек', width: '140px', className: 'text-right font-mono text-green-700', cell: (r) => `+${r.surplusValue.toLocaleString('ru', moneyFmt)}` },
{ header: 'Недостача', width: '140px', className: 'text-right font-mono text-red-700', cell: (r) => `${r.shortageValue.toLocaleString('ru', moneyFmt)}` },
]}
empty="Инвентаризаций пока нет."
/>
)}
</ListPageShell> </ListPageShell>
) )
} }

View file

@ -1,7 +1,8 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -36,29 +37,39 @@ export function LossesPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={Trash2}
rowKey={(r) => r.id} title="Списаний пока нет"
sortKey={sortKey} description="Списания фиксируют выбытие товаров (просрочка, брак, потери)."
sortOrder={sortOrder} actionLabel="Создать списание"
onSortChange={setSort} onAction={() => navigate('/inventory/losses/new')}
onRowClick={(r) => navigate(`/inventory/losses/${r.id}`)} />
columns={[ ) : (
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, <DataTable
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, rows={data?.items ?? []}
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( isLoading={isLoading}
r.status === LossStatus.Posted rowKey={(r) => r.id}
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span> sortKey={sortKey}
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span> sortOrder={sortOrder}
)}, onSortChange={setSort}
{ header: 'Причина', width: '140px', sortKey: 'reason', cell: (r) => lossReasonLabel[r.reason] ?? r.reason }, onRowClick={(r) => navigate(`/inventory/losses/${r.id}`)}
{ header: 'Склад', sortKey: 'store', cell: (r) => r.storeName }, columns={[
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
]} { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
empty="Списаний пока нет." r.status === LossStatus.Posted
/> ? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Причина', width: '140px', sortKey: 'reason', cell: (r) => lossReasonLabel[r.reason] ?? r.reason },
{ header: 'Склад', sortKey: 'store', cell: (r) => r.storeName },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
]}
empty="Списаний пока нет."
/>
)}
</ListPageShell> </ListPageShell>
) )
} }

View file

@ -1,10 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Plus, Filter, X, FolderTree } from 'lucide-react' import { Plus, Filter, X, FolderTree, Package } from 'lucide-react'
import { useCatalogList } from '@/lib/useCatalog' import { useCatalogList } from '@/lib/useCatalog'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { usePriceTypes } from '@/lib/useLookups' import { usePriceTypes } from '@/lib/useLookups'
@ -282,17 +283,27 @@ export function ProductsPage() {
{/* Table */} {/* Table */}
<div className="flex-1 min-h-0 overflow-auto"> <div className="flex-1 min-h-0 overflow-auto">
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search && activeCount === 0 ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={Package}
rowKey={(r) => r.id} title="Здесь пока пусто"
sortKey={sortKey} description="Товары появятся когда вы создадите первый или сделаете приёмку."
sortOrder={sortOrder} actionLabel="Создать первый товар"
onSortChange={setSort} onAction={() => navigate('/catalog/products/new')}
onRowClick={(r) => navigate(`/catalog/products/${r.id}`)} />
columns={baseColumns} ) : (
empty="Товаров ещё нет. Они появятся после приёмки или через API." <DataTable
/> rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => navigate(`/catalog/products/${r.id}`)}
columns={baseColumns}
empty="Товаров ещё нет. Они появятся после приёмки или через API."
/>
)}
</div> </div>
{data && data.total > 0 && ( {data && data.total > 0 && (

View file

@ -1,8 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Download } from 'lucide-react' import { Download, TrendingUp } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { EmptyState } from '@/components/EmptyState'
import { Field, Select } from '@/components/Field' import { Field, Select } from '@/components/Field'
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { useStores, useProductGroups } from '@/lib/useLookups' import { useStores, useProductGroups } from '@/lib/useLookups'
@ -139,7 +140,11 @@ export function ProfitReportPage() {
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"> <section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю</div>} {rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю</div>}
{!rep.isLoading && (rep.data?.length ?? 0) === 0 && ( {!rep.isLoading && (rep.data?.length ?? 0) === 0 && (
<div className="text-sm text-slate-500 py-6 text-center">За период нет данных.</div> <EmptyState
icon={TrendingUp}
title="Данных для отчёта прибыли нет"
description="Отчёт строится по разнице цена-себестоимость по проведённым продажам."
/>
)} )}
{!rep.isLoading && rep.data && rep.data.length > 0 && ( {!rep.isLoading && rep.data && rep.data.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">

View file

@ -1,7 +1,8 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react' import { Plus, Receipt } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -44,31 +45,41 @@ export function RetailSalesPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={Receipt}
rowKey={(r) => r.id} title="Чеков пока нет"
sortKey={sortKey} description="Чеки приходят с касс POS либо создаются вручную."
sortOrder={sortOrder} actionLabel="Создать чек"
onSortChange={setSort} onAction={() => navigate('/sales/retail/new')}
onRowClick={(r) => navigate(`/sales/retail/${r.id}`)} />
columns={[ ) : (
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, <DataTable
{ header: 'Дата/время', width: '160px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleString('ru') }, rows={data?.items ?? []}
{ header: 'Статус', width: '120px', sortKey: 'status', cell: (r) => ( isLoading={isLoading}
r.status === RetailSaleStatus.Posted rowKey={(r) => r.id}
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span> sortKey={sortKey}
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span> sortOrder={sortOrder}
)}, onSortChange={setSort}
{ header: 'Магазин', sortKey: 'store', cell: (r) => r.storeName }, onRowClick={(r) => navigate(`/sales/retail/${r.id}`)}
{ header: 'Касса', width: '160px', cell: (r) => r.retailPointName ?? '—' }, columns={[
{ header: 'Покупатель', width: '180px', cell: (r) => r.customerName ?? <span className="text-slate-400">аноним</span> }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Оплата', width: '120px', cell: (r) => paymentLabel[r.payment] ?? '?' }, { header: 'Дата/время', width: '160px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleString('ru') },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount }, { header: 'Статус', width: '120px', sortKey: 'status', cell: (r) => (
{ header: 'Сумма', width: '160px', className: 'text-right font-mono font-semibold', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` }, r.status === RetailSaleStatus.Posted
]} ? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
empty="Чеков пока нет. На Phase 5 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста." : <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
/> )},
{ header: 'Магазин', sortKey: 'store', cell: (r) => r.storeName },
{ header: 'Касса', width: '160px', cell: (r) => r.retailPointName ?? '—' },
{ header: 'Покупатель', width: '180px', cell: (r) => r.customerName ?? <span className="text-slate-400">аноним</span> },
{ header: 'Оплата', width: '120px', cell: (r) => paymentLabel[r.payment] ?? '?' },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono font-semibold', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
]}
empty="Чеков пока нет. На Phase 5 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
/>
)}
</ListPageShell> </ListPageShell>
) )
} }

View file

@ -1,8 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Download } from 'lucide-react' import { Download, BarChart3 } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { EmptyState } from '@/components/EmptyState'
import { Field, Select } from '@/components/Field' import { Field, Select } from '@/components/Field'
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { useStores, useProductGroups } from '@/lib/useLookups' import { useStores, useProductGroups } from '@/lib/useLookups'
@ -137,7 +138,11 @@ export function SalesReportPage() {
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"> <section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю</div>} {rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю</div>}
{!rep.isLoading && (rep.data?.length ?? 0) === 0 && ( {!rep.isLoading && (rep.data?.length ?? 0) === 0 && (
<div className="text-sm text-slate-500 py-6 text-center">За период нет данных.</div> <EmptyState
icon={BarChart3}
title="Данных для отчёта нет"
description="Отчёт строится по проведённым продажам — пока их нет, графика и таблиц нет."
/>
)} )}
{!rep.isLoading && rep.data && rep.data.length > 0 && ( {!rep.isLoading && rep.data && rep.data.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">

View file

@ -1,8 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Download } from 'lucide-react' import { Download, Warehouse } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { EmptyState } from '@/components/EmptyState'
import { Field, Select, Checkbox } from '@/components/Field' import { Field, Select, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField' import { DateField } from '@/components/DateField'
import { useStores, useProductGroups } from '@/lib/useLookups' import { useStores, useProductGroups } from '@/lib/useLookups'
@ -114,7 +115,11 @@ export function StockReportPage() {
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4"> <section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю</div>} {rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю</div>}
{!rep.isLoading && (rep.data?.length ?? 0) === 0 && ( {!rep.isLoading && (rep.data?.length ?? 0) === 0 && (
<div className="text-sm text-slate-500 py-6 text-center">На эту дату нет остатков.</div> <EmptyState
icon={Warehouse}
title="Остатки пусты"
description="Остатки появятся после первой приёмки или оприходования."
/>
)} )}
{!rep.isLoading && rep.data && rep.data.length > 0 && ( {!rep.isLoading && rep.data && rep.data.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">

View file

@ -1,7 +1,8 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react' import { Plus, Undo2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -36,30 +37,40 @@ export function SupplierReturnsPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={Undo2}
rowKey={(r) => r.id} title="Возвратов поставщикам пока нет"
sortKey={sortKey} description="Возврат поставщику оформляется на брак или излишки приёмки."
sortOrder={sortOrder} actionLabel="Создать возврат"
onSortChange={setSort} onAction={() => navigate('/purchases/supplier-returns/new')}
onRowClick={(r) => navigate(`/purchases/supplier-returns/${r.id}`)} />
columns={[ ) : (
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, <DataTable
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, rows={data?.items ?? []}
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( isLoading={isLoading}
r.status === SupplierReturnStatus.Posted rowKey={(r) => r.id}
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span> sortKey={sortKey}
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span> sortOrder={sortOrder}
)}, onSortChange={setSort}
{ header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName }, onRowClick={(r) => navigate(`/purchases/supplier-returns/${r.id}`)}
{ header: 'По приёмке', width: '140px', cell: (r) => r.referenceSupplyNumber ? <span className="font-mono text-xs text-slate-500">{r.referenceSupplyNumber}</span> : '—' }, columns={[
{ header: 'Склад', width: '180px', cell: (r) => r.storeName }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` }, { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
]} r.status === SupplierReturnStatus.Posted
empty="Возвратов поставщикам нет." ? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
/> : <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName },
{ header: 'По приёмке', width: '140px', cell: (r) => r.referenceSupplyNumber ? <span className="font-mono text-xs text-slate-500">{r.referenceSupplyNumber}</span> : '—' },
{ header: 'Склад', width: '180px', cell: (r) => r.storeName },
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
]}
empty="Возвратов поставщикам нет."
/>
)}
</ListPageShell> </ListPageShell>
) )
} }

View file

@ -1,7 +1,8 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react' import { Plus, PackagePlus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -36,29 +37,39 @@ export function SuppliesPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={PackagePlus}
rowKey={(r) => r.id} title="Приёмок пока нет"
sortKey={sortKey} description="Приёмки пополняют склад и обновляют себестоимость скользящим средним."
sortOrder={sortOrder} actionLabel="Создать приёмку"
onSortChange={setSort} onAction={() => navigate('/purchases/supplies/new')}
onRowClick={(r) => navigate(`/purchases/supplies/${r.id}`)} />
columns={[ ) : (
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, <DataTable
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, rows={data?.items ?? []}
{ header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => ( isLoading={isLoading}
r.status === SupplyStatus.Posted rowKey={(r) => r.id}
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span> sortKey={sortKey}
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span> sortOrder={sortOrder}
)}, onSortChange={setSort}
{ header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName }, onRowClick={(r) => navigate(`/purchases/supplies/${r.id}`)}
{ header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName }, columns={[
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
]} { header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => (
empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения." r.status === SupplyStatus.Posted
/> ? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName },
{ header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
]}
empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
/>
)}
</ListPageShell> </ListPageShell>
) )
} }

View file

@ -1,7 +1,8 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Plus, ArrowRight } from 'lucide-react' import { Plus, ArrowRight, ArrowLeftRight } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
@ -36,34 +37,44 @@ export function TransfersPage() {
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} /> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)} )}
> >
<DataTable {!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
rows={data?.items ?? []} <EmptyState
isLoading={isLoading} icon={ArrowLeftRight}
rowKey={(r) => r.id} title="Перемещений пока нет"
sortKey={sortKey} description="Перемещения переносят товар между складами."
sortOrder={sortOrder} actionLabel="Создать перемещение"
onSortChange={setSort} onAction={() => navigate('/inventory/transfers/new')}
onRowClick={(r) => navigate(`/inventory/transfers/${r.id}`)} />
columns={[ ) : (
{ header: '№', width: '180px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, <DataTable
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, rows={data?.items ?? []}
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( isLoading={isLoading}
r.status === TransferStatus.Posted rowKey={(r) => r.id}
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span> sortKey={sortKey}
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span> sortOrder={sortOrder}
)}, onSortChange={setSort}
{ header: 'Откуда → Куда', cell: (r) => ( onRowClick={(r) => navigate(`/inventory/transfers/${r.id}`)}
<span className="inline-flex items-center gap-2 text-slate-700"> columns={[
<span>{r.fromStoreName}</span> { header: '№', width: '180px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
<ArrowRight className="w-3.5 h-3.5 text-slate-400" /> { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
<span>{r.toStoreName}</span> { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
</span> r.status === TransferStatus.Posted
)}, ? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount }, : <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)}` }, )},
]} { header: 'Откуда → Куда', cell: (r) => (
empty="Перемещений пока нет." <span className="inline-flex items-center gap-2 text-slate-700">
/> <span>{r.fromStoreName}</span>
<ArrowRight className="w-3.5 h-3.5 text-slate-400" />
<span>{r.toStoreName}</span>
</span>
)},
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)}` },
]}
empty="Перемещений пока нет."
/>
)}
</ListPageShell> </ListPageShell>
) )
} }