ui: sticky sidebar + scroll only inside pages; cleaner product edit form

AppLayout is now h-screen with overflow-hidden; main area is flex-col so each
page controls its own scroll region. The sidebar and page header stay put no
matter how long the content.

New ListPageShell wraps every list page: sticky title/actions bar at top,
scrollable body (with sticky table thead via DataTable update), optional
sticky pagination footer. Converted 10 list pages (products, countries,
currencies, price-types, units, vat-rates, stores, retail-points, product-
groups, counterparties).

ProductEditPage rebuilt around the same pattern:
- Sticky top bar with back arrow, title, and Save/Delete buttons — no more
  hunting for the save button after scrolling a long form.
- Body is a max-w-5xl centered column with evenly spaced section cards.
- Sections get header strips (title + optional action on the right).
- Grid is a consistent 3-col (or 4 for stock/покупка) on md+, single column
  on mobile. Field sizes line up across sections.
- Flags collapse into a single wrap row under classification.
- Prices/Barcodes tables use a 12-col grid so columns align horizontally.

DataTable: thead is now position:sticky top-0, backdrop-blurred; rows use
border-bottom on cells for consistent separator in the scrolled body.

PageHeader gained a `variant="bar"` mode for shell usage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-22 00:28:27 +05:00
parent c47826e015
commit d3aa13dcbf
15 changed files with 529 additions and 428 deletions

View file

@ -52,8 +52,8 @@ export function AppLayout() {
}) })
return ( return (
<div className="min-h-screen flex bg-slate-50 dark:bg-slate-950"> <div className="h-screen flex bg-slate-50 dark:bg-slate-950 overflow-hidden">
<aside className="w-60 flex-shrink-0 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col"> <aside className="w-60 flex-shrink-0 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 flex flex-col h-full">
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800"> <div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
<Logo /> <Logo />
</div> </div>
@ -98,7 +98,7 @@ export function AppLayout() {
</div> </div>
</aside> </aside>
<main className="flex-1 overflow-x-hidden"> <main className="flex-1 min-w-0 flex flex-col overflow-hidden">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View file

@ -15,58 +15,83 @@ interface DataTableProps<T> {
onRowClick?: (row: T) => void onRowClick?: (row: T) => void
empty?: ReactNode empty?: ReactNode
isLoading?: boolean isLoading?: boolean
/** If true (default), the table wraps itself in a scrollable container with a sticky thead.
* If false, use when the caller provides its own scroll container. */
scrollable?: boolean
} }
export function DataTable<T>({ rows, columns, rowKey, onRowClick, empty, isLoading }: DataTableProps<T>) { export function DataTable<T>({
return ( rows, columns, rowKey, onRowClick, empty, isLoading, scrollable = true,
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden"> }: DataTableProps<T>) {
<table className="w-full text-sm"> const table = (
<thead className="bg-slate-50 dark:bg-slate-800/50 text-left"> <table className="w-full text-sm border-separate border-spacing-0">
<thead className="sticky top-0 z-10 bg-slate-50 dark:bg-slate-800/90 backdrop-blur text-left">
<tr>
{columns.map((c, i) => (
<th
key={i}
className={cn(
'px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200 dark:border-slate-700',
c.className,
)}
style={c.width ? { width: c.width } : undefined}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr> <tr>
{columns.map((c, i) => ( <td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400">
<th Загрузка
key={i} </td>
className={cn('px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500', c.className)}
style={c.width ? { width: c.width } : undefined}
>
{c.header}
</th>
))}
</tr> </tr>
</thead> ) : rows.length === 0 ? (
<tbody> <tr>
{isLoading ? ( <td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400">
<tr> {empty ?? 'Нет данных'}
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400"> </td>
Загрузка </tr>
</td> ) : (
rows.map((row) => (
<tr
key={rowKey(row)}
onClick={() => onRowClick?.(row)}
className={cn(
onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30',
)}
>
{columns.map((c, i) => (
<td
key={i}
className={cn(
'px-4 py-2.5 text-slate-700 dark:text-slate-200 border-b border-slate-100 dark:border-slate-800',
c.className,
)}
>
{c.cell(row)}
</td>
))}
</tr> </tr>
) : rows.length === 0 ? ( ))
<tr> )}
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400"> </tbody>
{empty ?? 'Нет данных'} </table>
</td> )
</tr>
) : ( if (!scrollable) {
rows.map((row) => ( return (
<tr <div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
key={rowKey(row)} {table}
onClick={() => onRowClick?.(row)} </div>
className={cn( )
'border-t border-slate-100 dark:border-slate-800', }
onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30'
)} return (
> <div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 h-full overflow-auto">
{columns.map((c, i) => ( {table}
<td key={i} className={cn('px-4 py-2.5 text-slate-700 dark:text-slate-200', c.className)}>
{c.cell(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div> </div>
) )
} }

View file

@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { PageHeader } from './PageHeader'
interface Props {
title: string
description?: string
actions?: ReactNode
children: ReactNode
footer?: ReactNode
}
/** Fullheight list-page layout: sticky top bar + scrollable content + optional sticky footer (pagination). */
export function ListPageShell({ title, description, actions, children, footer }: Props) {
return (
<div className="flex flex-col h-full">
<PageHeader variant="bar" title={title} description={description} actions={actions} />
<div className="flex-1 min-h-0 p-4">{children}</div>
{footer && (
<div className="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 px-4 py-2">
{footer}
</div>
)}
</div>
)
}

View file

@ -4,16 +4,30 @@ interface PageHeaderProps {
title: string title: string
description?: string description?: string
actions?: ReactNode actions?: ReactNode
/** Visual style — set 'bar' to render inside a sticky top bar (used by list/edit pages). */
variant?: 'plain' | 'bar'
} }
export function PageHeader({ title, description, actions }: PageHeaderProps) { export function PageHeader({ title, description, actions, variant = 'plain' }: PageHeaderProps) {
if (variant === 'bar') {
return (
<div className="flex items-center justify-between gap-4 px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="min-w-0">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100 truncate">{title}</h1>
{description && (
<p className="text-xs text-slate-500 mt-0.5 truncate">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2 flex-shrink-0">{actions}</div>}
</div>
)
}
return ( return (
<div className="flex items-start justify-between gap-4 mb-5"> <div className="flex items-start justify-between gap-4 mb-5">
<div> <div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1> <h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
{description && ( {description && <p className="text-sm text-slate-500 mt-0.5">{description}</p>}
<p className="text-sm text-slate-500 mt-0.5">{description}</p>
)}
</div> </div>
{actions && <div className="flex items-center gap-2">{actions}</div>} {actions && <div className="flex items-center gap-2">{actions}</div>}
</div> </div>

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -70,8 +70,8 @@ export function CounterpartiesPage() {
} }
return ( return (
<div className="p-6"> <>
<PageHeader <ListPageShell
title="Контрагенты" title="Контрагенты"
description="Поставщики и покупатели." description="Поставщики и покупатели."
actions={ actions={
@ -80,30 +80,31 @@ export function CounterpartiesPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button> <Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</> </>
} }
/> footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
<DataTable )}
rows={data?.items ?? []} >
isLoading={isLoading} <DataTable
rowKey={(r) => r.id} rows={data?.items ?? []}
onRowClick={(r) => setForm({ isLoading={isLoading}
id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type, rowKey={(r) => r.id}
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '', onRowClick={(r) => setForm({
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '', id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type,
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '', bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', isActive: r.isActive, countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
})} bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
columns={[ contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', isActive: r.isActive,
{ header: 'Название', cell: (r) => r.name }, })}
{ header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] }, columns={[
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> }, { header: 'Название', cell: (r) => r.name },
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' }, { header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] },
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' }, { header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
]} { header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
/> { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />} />
</ListPageShell>
<Modal <Modal
open={!!form} open={!!form}
@ -193,6 +194,6 @@ export function CounterpartiesPage() {
</div> </div>
)} )}
</Modal> </Modal>
</div> </>
) )
} }

View file

@ -1,4 +1,4 @@
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -9,11 +9,14 @@ export function CountriesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Country>('/api/catalog/countries') const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Country>('/api/catalog/countries')
return ( return (
<div className="p-6"> <ListPageShell
<PageHeader title="Страны" description="Глобальный справочник. По умолчанию Казахстан." actions={ title="Страны"
<SearchBar value={search} onChange={setSearch} /> description="Глобальный справочник. По умолчанию Казахстан."
} /> actions={<SearchBar value={search} onChange={setSearch} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -24,8 +27,6 @@ export function CountriesPage() {
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
]} ]}
/> />
</ListPageShell>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
</div>
) )
} }

View file

@ -1,4 +1,4 @@
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -9,11 +9,14 @@ export function CurrenciesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Currency>('/api/catalog/currencies') const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Currency>('/api/catalog/currencies')
return ( return (
<div className="p-6"> <ListPageShell
<PageHeader title="Валюты" description="Доступные валюты для операций. Основная — тенге (KZT)." actions={ title="Валюты"
<SearchBar value={search} onChange={setSearch} /> description="Доступные валюты для операций. Основная — тенге (KZT)."
} /> actions={<SearchBar value={search} onChange={setSearch} />}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -26,8 +29,6 @@ export function CurrenciesPage() {
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]} ]}
/> />
</ListPageShell>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
</div>
) )
} }

View file

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -29,8 +29,8 @@ export function PriceTypesPage() {
} }
return ( return (
<div className="p-6"> <>
<PageHeader <ListPageShell
title="Типы цен" title="Типы цен"
description="Розничная, оптовая и другие ценовые группы." description="Розничная, оптовая и другие ценовые группы."
actions={ actions={
@ -39,23 +39,24 @@ export function PriceTypesPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button> <Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</> </>
} }
/> footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
<DataTable )}
rows={data?.items ?? []} >
isLoading={isLoading} <DataTable
rowKey={(r) => r.id} rows={data?.items ?? []}
onRowClick={(r) => setForm({ ...r })} isLoading={isLoading}
columns={[ rowKey={(r) => r.id}
{ header: 'Название', cell: (r) => r.name }, onRowClick={(r) => setForm({ ...r })}
{ header: 'Розничная', width: '120px', cell: (r) => r.isRetail ? '✓' : '—' }, columns={[
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' }, { header: 'Название', cell: (r) => r.name },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, { header: 'Розничная', width: '120px', cell: (r) => r.isRetail ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
]} { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
/> { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />} />
</ListPageShell>
<Modal <Modal
open={!!form} open={!!form}
@ -95,6 +96,6 @@ export function PriceTypesPage() {
</div> </div>
)} )}
</Modal> </Modal>
</div> </>
) )
} }

View file

@ -1,4 +1,4 @@
import { useState, useEffect, type FormEvent } from 'react' import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom' import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react' import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
@ -93,7 +93,6 @@ export function ProductEditPage() {
}, [isNew, existing.data]) }, [isNew, existing.data])
useEffect(() => { useEffect(() => {
// Pre-fill defaults for new product
if (isNew && form.vatRateId === '' && vats.data?.length) { if (isNew && form.vatRateId === '' && vats.data?.length) {
setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' })) setForm((f) => ({ ...f, vatRateId: vats.data?.find(v => v.isDefault)?.id ?? vats.data?.[0]?.id ?? '' }))
} }
@ -169,21 +168,30 @@ export function ProductEditPage() {
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) => const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) }) setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId && !!form.vatRateId
return ( return (
<form onSubmit={onSubmit} className="p-6 max-w-5xl"> <form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 mb-5"> {/* Sticky top bar */}
<div className="flex items-center gap-3"> <div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<Link to="/catalog/products" className="text-slate-400 hover:text-slate-600"> <div className="flex items-center gap-3 min-w-0">
<Link
to="/catalog/products"
className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0"
title="Назад к списку"
>
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
</Link> </Link>
<div> <div className="min-w-0">
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100"> <h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новый товар' : form.name || 'Товар'} {isNew ? 'Новый товар' : form.name || 'Товар'}
</h1> </h1>
<p className="text-sm text-slate-500">Справочник товаров и услуг</p> <p className="text-xs text-slate-500">
{isNew ? 'Создание новой позиции каталога' : 'Редактирование'}
</p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 flex-shrink-0">
{!isNew && ( {!isNew && (
<Button <Button
type="button" type="button"
@ -194,177 +202,198 @@ export function ProductEditPage() {
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
</Button> </Button>
)} )}
<Button type="submit" disabled={!form.name || !form.unitOfMeasureId || !form.vatRateId}> <Button type="submit" disabled={!canSave || save.isPending}>
<Save className="w-4 h-4" /> Сохранить <Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button> </Button>
</div> </div>
</div> </div>
{error && ( {/* Scrollable body */}
<div className="mb-4 p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div> <div className="flex-1 overflow-auto">
)} <div className="max-w-5xl mx-auto p-6 space-y-5">
{error && (
<div className="space-y-5"> <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
<Section title="Основное">
<div className="grid grid-cols-3 gap-3">
<Field label="Название *" className="col-span-2">
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Артикул">
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field>
</div>
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
</Section>
<Section title="Классификация">
<div className="grid grid-cols-3 gap-3">
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value=""></option>
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} {u.name}</option>)}
</Select>
</Field>
<Field label="Ставка НДС *">
<Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
<option value=""></option>
{vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
</Select>
</Field>
<Field label="Группа">
<Select value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
<option value=""></option>
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
</Select>
</Field>
<Field label="Страна происхождения">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
<option value=""></option>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Основной поставщик">
<Select value={form.defaultSupplierId} onChange={(e) => setForm({ ...form, defaultSupplierId: e.target.value })}>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="URL изображения">
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-5 gap-3 pt-1">
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
</Section>
<Section title="Остатки и закупка">
<div className="grid grid-cols-4 gap-3">
<Field label="Мин. остаток">
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} />
</Field>
<Field label="Макс. остаток">
<TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} />
</Field>
<Field label="Закупочная цена">
<TextInput type="number" step="0.01" value={form.purchasePrice} onChange={(e) => setForm({ ...form, purchasePrice: e.target.value })} />
</Field>
<Field label="Валюта закупки">
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
</div>
</Section>
<Section title="Цены продажи"
action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.prices.length === 0 ? (
<div className="text-sm text-slate-400">Цен ещё нет. Добавь хотя бы розничную.</div>
) : (
<div className="space-y-2">
{form.prices.map((p, i) => (
<div key={i} className="grid grid-cols-[2fr_1fr_1fr_40px] gap-2 items-end">
<Field label={i === 0 ? 'Тип цены' : ''}>
<Select value={p.priceTypeId} onChange={(e) => updatePrice(i, { priceTypeId: e.target.value })}>
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
</Select>
</Field>
<Field label={i === 0 ? 'Сумма' : ''}>
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
</Field>
<Field label={i === 0 ? 'Валюта' : ''}>
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
<button type="button" onClick={() => removePrice(i)} className="text-slate-400 hover:text-red-600 pb-2">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)} )}
</Section>
<Section title="Штрихкоды" <Section title="Основное">
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>} <Grid cols={3}>
> <Field label="Название *" className="col-span-2">
{form.barcodes.length === 0 ? ( <TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<div className="text-sm text-slate-400">Штрихкодов нет.</div> </Field>
) : ( <Field label="Артикул">
<div className="space-y-2"> <TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
{form.barcodes.map((b, i) => ( </Field>
<div key={i} className="grid grid-cols-[2fr_1fr_auto_40px] gap-2 items-end"> <Field label="Описание" className="col-span-3">
<Field label={i === 0 ? 'Код' : ''}> <TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
<TextInput value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} /> </Field>
</Field> </Grid>
<Field label={i === 0 ? 'Тип' : ''}> </Section>
<Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}>
<option value={BarcodeType.Ean13}>EAN-13</option> <Section title="Классификация">
<option value={BarcodeType.Ean8}>EAN-8</option> <Grid cols={3}>
<option value={BarcodeType.Code128}>CODE 128</option> <Field label="Единица измерения *">
<option value={BarcodeType.Code39}>CODE 39</option> <Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value={BarcodeType.Upca}>UPC-A</option> <option value=""></option>
<option value={BarcodeType.Upce}>UPC-E</option> {units.data?.map((u) => <option key={u.id} value={u.id}>{u.symbol} {u.name}</option>)}
<option value={BarcodeType.Other}>Прочий</option> </Select>
</Select> </Field>
</Field> <Field label="Ставка НДС *">
<div className="pb-2"> <Select required value={form.vatRateId} onChange={(e) => setForm({ ...form, vatRateId: e.target.value })}>
<Checkbox label="Основной" checked={b.isPrimary} onChange={(v) => { <option value=""></option>
// Enforce single primary {vats.data?.map((v) => <option key={v.id} value={v.id}>{v.name} ({v.percent}%)</option>)}
setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) }) </Select>
}} /> </Field>
<Field label="Группа">
<Select value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
<option value=""></option>
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
</Select>
</Field>
<Field label="Страна происхождения">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
<option value=""></option>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Основной поставщик">
<Select value={form.defaultSupplierId} onChange={(e) => setForm({ ...form, defaultSupplierId: e.target.value })}>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="URL изображения">
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</Field>
</Grid>
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
<Checkbox label="Алкоголь" checked={form.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
</Section>
<Section title="Остатки и закупка">
<Grid cols={4}>
<Field label="Мин. остаток">
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} />
</Field>
<Field label="Макс. остаток">
<TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} />
</Field>
<Field label="Закупочная цена">
<TextInput type="number" step="0.01" value={form.purchasePrice} onChange={(e) => setForm({ ...form, purchasePrice: e.target.value })} />
</Field>
<Field label="Валюта закупки">
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
</Grid>
</Section>
<Section
title="Цены продажи"
action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.prices.length === 0 ? (
<div className="text-sm text-slate-400 py-2">Цен ещё нет. Добавь хотя бы розничную.</div>
) : (
<div className="space-y-2">
{form.prices.map((p, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<Select value={p.priceTypeId} onChange={(e) => updatePrice(i, { priceTypeId: e.target.value })}>
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
</Select>
</div>
<div className="col-span-3">
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
</div>
<div className="col-span-2">
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</div>
<button
type="button"
onClick={() => removePrice(i)}
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
<button type="button" onClick={() => removeBarcode(i)} className="text-slate-400 hover:text-red-600 pb-2"> ))}
<Trash2 className="w-4 h-4" /> </div>
</button> )}
</div> </Section>
))}
</div> <Section
)} title="Штрихкоды"
</Section> action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.barcodes.length === 0 ? (
<div className="text-sm text-slate-400 py-2">Штрихкодов нет.</div>
) : (
<div className="space-y-2">
{form.barcodes.map((b, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</div>
<div className="col-span-3">
<Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}>
<option value={BarcodeType.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option>
<option value={BarcodeType.Code128}>CODE 128</option>
<option value={BarcodeType.Code39}>CODE 39</option>
<option value={BarcodeType.Upca}>UPC-A</option>
<option value={BarcodeType.Upce}>UPC-E</option>
<option value={BarcodeType.Other}>Прочий</option>
</Select>
</div>
<div className="col-span-2">
<Checkbox
label="Основной"
checked={b.isPrimary}
onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })}
/>
</div>
<button
type="button"
onClick={() => removeBarcode(i)}
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</Section>
</div>
</div> </div>
</form> </form>
) )
} }
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
return ( return (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5"> <section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<div className="flex items-center justify-between mb-3"> <header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2> <h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{action} {action}
</div> </header>
<div className="space-y-3">{children}</div> <div className="p-5">{children}</div>
</section> </section>
) )
} }
function Grid({ cols, children }: { cols: 2 | 3 | 4; children: ReactNode }) {
const cls = cols === 2 ? 'grid-cols-1 md:grid-cols-2' : cols === 3 ? 'grid-cols-1 md:grid-cols-3' : 'grid-cols-2 md:grid-cols-4'
return <div className={`grid ${cls} gap-x-4 gap-y-3`}>{children}</div>
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -29,8 +29,8 @@ export function ProductGroupsPage() {
} }
return ( return (
<div className="p-6"> <>
<PageHeader <ListPageShell
title="Группы товаров" title="Группы товаров"
description="Иерархический справочник категорий." description="Иерархический справочник категорий."
actions={ actions={
@ -39,22 +39,23 @@ export function ProductGroupsPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button> <Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</> </>
} }
/> footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
<DataTable )}
rows={data?.items ?? []} >
isLoading={isLoading} <DataTable
rowKey={(r) => r.id} rows={data?.items ?? []}
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive })} isLoading={isLoading}
columns={[ rowKey={(r) => r.id}
{ header: 'Название', cell: (r) => r.name }, onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive })}
{ header: 'Путь', cell: (r) => <span className="text-slate-500">{r.path}</span> }, columns={[
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, { header: 'Название', cell: (r) => r.name },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'Путь', cell: (r) => <span className="text-slate-500">{r.path}</span> },
]} { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
/> { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />} />
</ListPageShell>
<Modal <Modal
open={!!form} open={!!form}
@ -103,6 +104,6 @@ export function ProductGroupsPage() {
</div> </div>
)} )}
</Modal> </Modal>
</div> </>
) )
} }

View file

@ -1,5 +1,5 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -15,22 +15,23 @@ export function ProductsPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL) const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
return ( return (
<div className="p-6"> <ListPageShell
<PageHeader title="Товары"
title="Товары" description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'}
description="Каталог товаров и услуг." actions={
actions={ <>
<> <SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" /> <Link to="/catalog/products/new">
<Link to="/catalog/products/new"> <Button>
<Button> <Plus className="w-4 h-4" /> Добавить
<Plus className="w-4 h-4" /> Добавить </Button>
</Button> </Link>
</Link> </>
</> }
} footer={data && data.total > 0 && (
/> <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -43,10 +44,10 @@ export function ProductsPage() {
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>} {r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div> </div>
)}, )},
{ header: 'Группа', width: '180px', cell: (r) => r.productGroupName ?? '—' }, { header: 'Группа', width: '200px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol }, { header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` }, { header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
{ header: 'Тип', width: '120px', cell: (r) => ( { header: 'Тип', width: '140px', cell: (r) => (
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
{r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>} {r.isService && <span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">Услуга</span>}
{r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>} {r.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
@ -59,8 +60,6 @@ export function ProductsPage() {
]} ]}
empty="Товаров ещё нет. Они появятся после приёмки или через API." empty="Товаров ещё нет. Они появятся после приёмки или через API."
/> />
</ListPageShell>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
</div>
) )
} }

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -53,8 +53,8 @@ export function RetailPointsPage() {
const firstStore = stores.data?.[0]?.id ?? '' const firstStore = stores.data?.[0]?.id ?? ''
return ( return (
<div className="p-6"> <>
<PageHeader <ListPageShell
title="Точки продаж" title="Точки продаж"
description="Кассовые точки. Привязаны к складу, с которого идут продажи." description="Кассовые точки. Привязаны к складу, с которого идут продажи."
actions={ actions={
@ -65,29 +65,30 @@ export function RetailPointsPage() {
</Button> </Button>
</> </>
} }
/> footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
<DataTable )}
rows={data?.items ?? []} >
isLoading={isLoading} <DataTable
rowKey={(r) => r.id} rows={data?.items ?? []}
onRowClick={(r) => setForm({ isLoading={isLoading}
id: r.id, name: r.name, code: r.code ?? '', storeId: r.storeId, rowKey={(r) => r.id}
address: r.address ?? '', phone: r.phone ?? '', onRowClick={(r) => setForm({
fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '', id: r.id, name: r.name, code: r.code ?? '', storeId: r.storeId,
isActive: r.isActive, address: r.address ?? '', phone: r.phone ?? '',
})} fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '',
columns={[ isActive: r.isActive,
{ header: 'Название', cell: (r) => r.name }, })}
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> }, columns={[
{ header: 'Склад', cell: (r) => r.storeName ?? '—' }, { header: 'Название', cell: (r) => r.name },
{ header: 'Адрес', cell: (r) => r.address ?? '—' }, { header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'ККМ', width: '140px', cell: (r) => r.fiscalSerial ?? '—' }, { header: 'Склад', cell: (r) => r.storeName ?? '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'Адрес', cell: (r) => r.address ?? '—' },
]} { header: 'ККМ', width: '140px', cell: (r) => r.fiscalSerial ?? '—' },
/> { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />} />
</ListPageShell>
<Modal <Modal
open={!!form} open={!!form}
@ -145,6 +146,6 @@ export function RetailPointsPage() {
</div> </div>
)} )}
</Modal> </Modal>
</div> </>
) )
} }

View file

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -43,8 +43,8 @@ export function StoresPage() {
} }
return ( return (
<div className="p-6"> <>
<PageHeader <ListPageShell
title="Склады" title="Склады"
description="Физические места хранения товара." description="Физические места хранения товара."
actions={ actions={
@ -53,28 +53,29 @@ export function StoresPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button> <Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</> </>
} }
/> footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
<DataTable )}
rows={data?.items ?? []} >
isLoading={isLoading} <DataTable
rowKey={(r) => r.id} rows={data?.items ?? []}
onRowClick={(r) => setForm({ isLoading={isLoading}
id: r.id, name: r.name, code: r.code ?? '', kind: r.kind, rowKey={(r) => r.id}
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '', onRowClick={(r) => setForm({
isMain: r.isMain, isActive: r.isActive, id: r.id, name: r.name, code: r.code ?? '', kind: r.kind,
})} address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
columns={[ isMain: r.isMain, isActive: r.isActive,
{ header: 'Название', cell: (r) => r.name }, })}
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> }, columns={[
{ header: 'Тип', width: '150px', cell: (r) => r.kind === StoreKind.Warehouse ? 'Склад' : 'Торговый зал' }, { header: 'Название', cell: (r) => r.name },
{ header: 'Адрес', cell: (r) => r.address ?? '—' }, { header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' }, { header: 'Тип', width: '150px', cell: (r) => r.kind === StoreKind.Warehouse ? 'Склад' : 'Торговый зал' },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'Адрес', cell: (r) => r.address ?? '—' },
]} { header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
/> { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />} />
</ListPageShell>
<Modal <Modal
open={!!form} open={!!form}
@ -129,6 +130,6 @@ export function StoresPage() {
</div> </div>
)} )}
</Modal> </Modal>
</div> </>
) )
} }

View file

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -38,8 +38,8 @@ export function UnitsOfMeasurePage() {
} }
return ( return (
<div className="p-6"> <>
<PageHeader <ListPageShell
title="Единицы измерения" title="Единицы измерения"
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)." description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
actions={ actions={
@ -48,24 +48,25 @@ export function UnitsOfMeasurePage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button> <Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</> </>
} }
/> footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
<DataTable )}
rows={data?.items ?? []} >
isLoading={isLoading} <DataTable
rowKey={(r) => r.id} rows={data?.items ?? []}
onRowClick={(r) => setForm({ ...r })} isLoading={isLoading}
columns={[ rowKey={(r) => r.id}
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> }, onRowClick={(r) => setForm({ ...r })}
{ header: 'Символ', width: '100px', cell: (r) => r.symbol }, columns={[
{ header: 'Название', cell: (r) => r.name }, { header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces }, { header: 'Символ', width: '100px', cell: (r) => r.symbol },
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' }, { header: 'Название', cell: (r) => r.name },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces },
]} { header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
/> { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />} />
</ListPageShell>
<Modal <Modal
open={!!form} open={!!form}
@ -113,6 +114,6 @@ export function UnitsOfMeasurePage() {
</div> </div>
)} )}
</Modal> </Modal>
</div> </>
) )
} }

View file

@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
@ -40,8 +40,8 @@ export function VatRatesPage() {
} }
return ( return (
<div className="p-6"> <>
<PageHeader <ListPageShell
title="Ставки НДС" title="Ставки НДС"
description="Настройки ставок налога на добавленную стоимость." description="Настройки ставок налога на добавленную стоимость."
actions={ actions={
@ -50,26 +50,27 @@ export function VatRatesPage() {
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button> <Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</> </>
} }
/> footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
<DataTable )}
rows={data?.items ?? []} >
isLoading={isLoading} <DataTable
rowKey={(r) => r.id} rows={data?.items ?? []}
onRowClick={(r) => setForm({ isLoading={isLoading}
id: r.id, name: r.name, percent: r.percent, rowKey={(r) => r.id}
isIncludedInPrice: r.isIncludedInPrice, isDefault: r.isDefault, isActive: r.isActive, onRowClick={(r) => setForm({
})} id: r.id, name: r.name, percent: r.percent,
columns={[ isIncludedInPrice: r.isIncludedInPrice, isDefault: r.isDefault, isActive: r.isActive,
{ header: 'Название', cell: (r) => r.name }, })}
{ header: '%', width: '90px', className: 'text-right font-mono', cell: (r) => r.percent.toFixed(2) }, columns={[
{ header: 'В цене', width: '100px', cell: (r) => r.isIncludedInPrice ? '✓' : '—' }, { header: 'Название', cell: (r) => r.name },
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' }, { header: '%', width: '90px', className: 'text-right font-mono', cell: (r) => r.percent.toFixed(2) },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'В цене', width: '100px', cell: (r) => r.isIncludedInPrice ? '✓' : '—' },
]} { header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
/> { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />} />
</ListPageShell>
<Modal <Modal
open={!!form} open={!!form}
@ -110,6 +111,6 @@ export function VatRatesPage() {
</div> </div>
)} )}
</Modal> </Modal>
</div> </>
) )
} }