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:
parent
c47826e015
commit
d3aa13dcbf
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
src/food-market.web/src/components/ListPageShell.tsx
Normal file
25
src/food-market.web/src/components/ListPageShell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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, 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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue