feat(web): Empty states с CTA на list-страницах
Some checks are pending
Some checks are pending
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:
parent
cd83269d3a
commit
8d532927e2
56
src/food-market.web/src/components/EmptyState.tsx
Normal file
56
src/food-market.web/src/components/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Download, BarChart3 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Button } from '@/components/Button'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Field, Select } from '@/components/Field'
|
||||
import { DateField } from '@/components/DateField'
|
||||
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">
|
||||
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю…</div>}
|
||||
{!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 && (
|
||||
<div className="overflow-x-auto">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -87,28 +88,38 @@ export function CounterpartiesPage() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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 ?? '',
|
||||
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 ?? '—' },
|
||||
]}
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="Контрагентов пока нет"
|
||||
description="Здесь будут поставщики и покупатели — добавьте первого."
|
||||
actionLabel="Добавить контрагента"
|
||||
onAction={() => { setForm(blankForm); setFieldErrors({}) }}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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 ?? '',
|
||||
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>
|
||||
|
||||
<Modal
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, Truck } from 'lucide-react'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -36,31 +37,41 @@ export function DemandsPage() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
r.status === DemandStatus.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: '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="Отгрузок пока нет. Создай первую — товар спишется со склада после проведения."
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
|
||||
<EmptyState
|
||||
icon={Truck}
|
||||
title="Оптовых отгрузок пока нет"
|
||||
description="Отгрузки оформляются для оптовых клиентов с оплатой постфактум или предоплатой."
|
||||
actionLabel="Создать отгрузку"
|
||||
onAction={() => navigate('/sales/demands/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
r.status === DemandStatus.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: '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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, PackagePlus } from 'lucide-react'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -36,28 +37,38 @@ export function EntersPage() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ 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="Оприходований пока нет. Создай первое — товар попадёт на склад после проведения."
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
|
||||
<EmptyState
|
||||
icon={PackagePlus}
|
||||
title="Оприходований пока нет"
|
||||
description="Оприходование используется для первичной партии товаров — например при запуске магазина без приёмки."
|
||||
actionLabel="Создать оприходование"
|
||||
onAction={() => navigate('/inventory/enters/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, ClipboardList } from 'lucide-react'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -36,29 +37,39 @@ export function InventoriesPage() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
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="Инвентаризаций пока нет."
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="Инвентаризаций пока нет"
|
||||
description="Инвентаризация сверяет фактические остатки с книжными и формирует корректировки."
|
||||
actionLabel="Создать инвентаризацию"
|
||||
onAction={() => navigate('/inventory/inventories/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -36,29 +37,39 @@ export function LossesPage() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
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="Списаний пока нет."
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
|
||||
<EmptyState
|
||||
icon={Trash2}
|
||||
title="Списаний пока нет"
|
||||
description="Списания фиксируют выбытие товаров (просрочка, брак, потери)."
|
||||
actionLabel="Создать списание"
|
||||
onAction={() => navigate('/inventory/losses/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
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 { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { usePriceTypes } from '@/lib/useLookups'
|
||||
|
|
@ -282,17 +283,27 @@ export function ProductsPage() {
|
|||
|
||||
{/* Table */}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<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."
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search && activeCount === 0 ? (
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="Здесь пока пусто"
|
||||
description="Товары появятся когда вы создадите первый или сделаете приёмку."
|
||||
actionLabel="Создать первый товар"
|
||||
onAction={() => navigate('/catalog/products/new')}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{data && data.total > 0 && (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Download, TrendingUp } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Button } from '@/components/Button'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Field, Select } from '@/components/Field'
|
||||
import { DateField } from '@/components/DateField'
|
||||
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">
|
||||
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю…</div>}
|
||||
{!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 && (
|
||||
<div className="overflow-x-auto">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, Receipt } from 'lucide-react'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -44,31 +45,41 @@ export function RetailSalesPage() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата/время', width: '160px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleString('ru') },
|
||||
{ header: 'Статус', width: '120px', sortKey: 'status', cell: (r) => (
|
||||
r.status === RetailSaleStatus.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: '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 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
|
||||
<EmptyState
|
||||
icon={Receipt}
|
||||
title="Чеков пока нет"
|
||||
description="Чеки приходят с касс POS либо создаются вручную."
|
||||
actionLabel="Создать чек"
|
||||
onAction={() => navigate('/sales/retail/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата/время', width: '160px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleString('ru') },
|
||||
{ header: 'Статус', width: '120px', sortKey: 'status', cell: (r) => (
|
||||
r.status === RetailSaleStatus.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: '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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Download, BarChart3 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Button } from '@/components/Button'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Field, Select } from '@/components/Field'
|
||||
import { DateField } from '@/components/DateField'
|
||||
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">
|
||||
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю…</div>}
|
||||
{!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 && (
|
||||
<div className="overflow-x-auto">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Download } from 'lucide-react'
|
||||
import { Download, Warehouse } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Button } from '@/components/Button'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Field, Select, Checkbox } from '@/components/Field'
|
||||
import { DateField } from '@/components/DateField'
|
||||
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">
|
||||
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю…</div>}
|
||||
{!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 && (
|
||||
<div className="overflow-x-auto">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, Undo2 } from 'lucide-react'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -36,30 +37,40 @@ export function SupplierReturnsPage() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
r.status === SupplierReturnStatus.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: '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="Возвратов поставщикам нет."
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
|
||||
<EmptyState
|
||||
icon={Undo2}
|
||||
title="Возвратов поставщикам пока нет"
|
||||
description="Возврат поставщику оформляется на брак или излишки приёмки."
|
||||
actionLabel="Создать возврат"
|
||||
onAction={() => navigate('/purchases/supplier-returns/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
r.status === SupplierReturnStatus.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: '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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, PackagePlus } from 'lucide-react'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -36,29 +37,39 @@ export function SuppliesPage() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => (
|
||||
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="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
|
||||
<EmptyState
|
||||
icon={PackagePlus}
|
||||
title="Приёмок пока нет"
|
||||
description="Приёмки пополняют склад и обновляют себестоимость скользящим средним."
|
||||
actionLabel="Создать приёмку"
|
||||
onAction={() => navigate('/purchases/supplies/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => (
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
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 { DataTable } from '@/components/DataTable'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
|
|
@ -36,34 +37,44 @@ export function TransfersPage() {
|
|||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
r.status === TransferStatus.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) => (
|
||||
<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="Перемещений пока нет."
|
||||
/>
|
||||
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
|
||||
<EmptyState
|
||||
icon={ArrowLeftRight}
|
||||
title="Перемещений пока нет"
|
||||
description="Перемещения переносят товар между складами."
|
||||
actionLabel="Создать перемещение"
|
||||
onAction={() => navigate('/inventory/transfers/new')}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={setSort}
|
||||
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> },
|
||||
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
|
||||
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
|
||||
r.status === TransferStatus.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) => (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue