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,18 +15,25 @@ 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> <tr>
{columns.map((c, i) => ( {columns.map((c, i) => (
<th <th
key={i} key={i}
className={cn('px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500', c.className)} 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} style={c.width ? { width: c.width } : undefined}
> >
{c.header} {c.header}
@ -53,12 +60,17 @@ export function DataTable<T>({ rows, columns, rowKey, onRowClick, empty, isLoadi
key={rowKey(row)} key={rowKey(row)}
onClick={() => onRowClick?.(row)} onClick={() => onRowClick?.(row)}
className={cn( className={cn(
'border-t border-slate-100 dark:border-slate-800', onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30',
onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30'
)} )}
> >
{columns.map((c, i) => ( {columns.map((c, i) => (
<td key={i} className={cn('px-4 py-2.5 text-slate-700 dark:text-slate-200', c.className)}> <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)} {c.cell(row)}
</td> </td>
))} ))}
@ -67,6 +79,19 @@ export function DataTable<T>({ rows, columns, rowKey, onRowClick, empty, isLoadi
)} )}
</tbody> </tbody>
</table> </table>
)
if (!scrollable) {
return (
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
{table}
</div>
)
}
return (
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 h-full overflow-auto">
{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,8 +80,10 @@ 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 <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -102,8 +104,7 @@ export function CounterpartiesPage() {
{ 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} />}
<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,8 +39,10 @@ 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 <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -54,8 +56,7 @@ export function PriceTypesPage() {
{ 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} />}
<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,33 +202,35 @@ 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>
{/* Scrollable body */}
<div className="flex-1 overflow-auto">
<div className="max-w-5xl mx-auto p-6 space-y-5">
{error && ( {error && (
<div className="mb-4 p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div> <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)} )}
<div className="space-y-5">
<Section title="Основное"> <Section title="Основное">
<div className="grid grid-cols-3 gap-3"> <Grid cols={3}>
<Field label="Название *" className="col-span-2"> <Field label="Название *" className="col-span-2">
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field> </Field>
<Field label="Артикул"> <Field label="Артикул">
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} /> <TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field> </Field>
</div> <Field label="Описание" className="col-span-3">
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /> <TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field> </Field>
</Grid>
</Section> </Section>
<Section title="Классификация"> <Section title="Классификация">
<div className="grid grid-cols-3 gap-3"> <Grid cols={3}>
<Field label="Единица измерения *"> <Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}> <Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value=""></option> <option value=""></option>
@ -254,8 +264,8 @@ export function ProductEditPage() {
<Field label="URL изображения"> <Field label="URL изображения">
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} /> <TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</Field> </Field>
</div> </Grid>
<div className="grid grid-cols-5 gap-3 pt-1"> <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.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: 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.isAlcohol} onChange={(v) => setForm({ ...form, isAlcohol: v })} />
@ -265,7 +275,7 @@ export function ProductEditPage() {
</Section> </Section>
<Section title="Остатки и закупка"> <Section title="Остатки и закупка">
<div className="grid grid-cols-4 gap-3"> <Grid cols={4}>
<Field label="Мин. остаток"> <Field label="Мин. остаток">
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} /> <TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} />
</Field> </Field>
@ -281,32 +291,38 @@ export function ProductEditPage() {
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)} {currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select> </Select>
</Field> </Field>
</div> </Grid>
</Section> </Section>
<Section title="Цены продажи" <Section
title="Цены продажи"
action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>} action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
> >
{form.prices.length === 0 ? ( {form.prices.length === 0 ? (
<div className="text-sm text-slate-400">Цен ещё нет. Добавь хотя бы розничную.</div> <div className="text-sm text-slate-400 py-2">Цен ещё нет. Добавь хотя бы розничную.</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{form.prices.map((p, i) => ( {form.prices.map((p, i) => (
<div key={i} className="grid grid-cols-[2fr_1fr_1fr_40px] gap-2 items-end"> <div key={i} className="grid grid-cols-12 gap-2 items-center">
<Field label={i === 0 ? 'Тип цены' : ''}> <div className="col-span-6">
<Select value={p.priceTypeId} onChange={(e) => updatePrice(i, { priceTypeId: e.target.value })}> <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>)} {priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
</Select> </Select>
</Field> </div>
<Field label={i === 0 ? 'Сумма' : ''}> <div className="col-span-3">
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} /> <TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
</Field> </div>
<Field label={i === 0 ? 'Валюта' : ''}> <div className="col-span-2">
<Select value={p.currencyId} onChange={(e) => updatePrice(i, { currencyId: e.target.value })}> <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>)} {currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select> </Select>
</Field> </div>
<button type="button" onClick={() => removePrice(i)} className="text-slate-400 hover:text-red-600 pb-2"> <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" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
@ -315,19 +331,20 @@ export function ProductEditPage() {
)} )}
</Section> </Section>
<Section title="Штрихкоды" <Section
title="Штрихкоды"
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>} action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
> >
{form.barcodes.length === 0 ? ( {form.barcodes.length === 0 ? (
<div className="text-sm text-slate-400">Штрихкодов нет.</div> <div className="text-sm text-slate-400 py-2">Штрихкодов нет.</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{form.barcodes.map((b, i) => ( {form.barcodes.map((b, i) => (
<div key={i} className="grid grid-cols-[2fr_1fr_auto_40px] gap-2 items-end"> <div key={i} className="grid grid-cols-12 gap-2 items-center">
<Field label={i === 0 ? 'Код' : ''}> <div className="col-span-6">
<TextInput value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} /> <TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</Field> </div>
<Field label={i === 0 ? 'Тип' : ''}> <div className="col-span-3">
<Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}> <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.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option> <option value={BarcodeType.Ean8}>EAN-8</option>
@ -337,14 +354,20 @@ export function ProductEditPage() {
<option value={BarcodeType.Upce}>UPC-E</option> <option value={BarcodeType.Upce}>UPC-E</option>
<option value={BarcodeType.Other}>Прочий</option> <option value={BarcodeType.Other}>Прочий</option>
</Select> </Select>
</Field>
<div className="pb-2">
<Checkbox label="Основной" checked={b.isPrimary} onChange={(v) => {
// Enforce single primary
setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })
}} />
</div> </div>
<button type="button" onClick={() => removeBarcode(i)} className="text-slate-400 hover:text-red-600 pb-2"> <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" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
@ -353,18 +376,24 @@ export function ProductEditPage() {
)} )}
</Section> </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,8 +39,10 @@ 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 <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -53,8 +55,7 @@ export function ProductGroupsPage() {
{ 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} />}
<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,10 +15,9 @@ 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="Каталог товаров и услуг." description={data ? `${data.total.toLocaleString('ru')} записей в каталоге` : 'Каталог товаров и услуг'}
actions={ actions={
<> <>
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" /> <SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
@ -29,8 +28,10 @@ export function ProductsPage() {
</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,8 +65,10 @@ export function RetailPointsPage() {
</Button> </Button>
</> </>
} }
/> 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}
@ -86,8 +88,7 @@ export function RetailPointsPage() {
{ 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} />}
<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,8 +53,10 @@ 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 <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -73,8 +75,7 @@ export function StoresPage() {
{ 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} />}
<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,8 +48,10 @@ 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 <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -64,8 +66,7 @@ export function UnitsOfMeasurePage() {
{ 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} />}
<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,8 +50,10 @@ 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 <DataTable
rows={data?.items ?? []} rows={data?.items ?? []}
isLoading={isLoading} isLoading={isLoading}
@ -68,8 +70,7 @@ export function VatRatesPage() {
{ 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} />}
<Modal <Modal
open={!!form} open={!!form}
@ -110,6 +111,6 @@ export function VatRatesPage() {
</div> </div>
)} )}
</Modal> </Modal>
</div> </>
) )
} }