diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 08647d9..78a41f3 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -52,8 +52,8 @@ export function AppLayout() { }) return ( -
- -
+
diff --git a/src/food-market.web/src/components/DataTable.tsx b/src/food-market.web/src/components/DataTable.tsx index 23e974c..a84ad1e 100644 --- a/src/food-market.web/src/components/DataTable.tsx +++ b/src/food-market.web/src/components/DataTable.tsx @@ -15,58 +15,83 @@ interface DataTableProps { onRowClick?: (row: T) => void empty?: ReactNode 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({ rows, columns, rowKey, onRowClick, empty, isLoading }: DataTableProps) { - return ( -
- - +export function DataTable({ + rows, columns, rowKey, onRowClick, empty, isLoading, scrollable = true, +}: DataTableProps) { + const table = ( +
+ + + {columns.map((c, i) => ( + + ))} + + + + {isLoading ? ( - {columns.map((c, i) => ( - - ))} + - - - {isLoading ? ( - - + ) : rows.length === 0 ? ( + + + + ) : ( + rows.map((row) => ( + onRowClick?.(row)} + className={cn( + onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30', + )} + > + {columns.map((c, i) => ( + + ))} - ) : rows.length === 0 ? ( - - - - ) : ( - rows.map((row) => ( - onRowClick?.(row)} - className={cn( - 'border-t border-slate-100 dark:border-slate-800', - onRowClick && 'cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/30' - )} - > - {columns.map((c, i) => ( - - ))} - - )) - )} - -
+ {c.header} +
- {c.header} - + Загрузка… +
- Загрузка… -
+ {empty ?? 'Нет данных'} +
+ {c.cell(row)} +
- {empty ?? 'Нет данных'} -
- {c.cell(row)} -
+ )) + )} + + + ) + + if (!scrollable) { + return ( +
+ {table} +
+ ) + } + + return ( +
+ {table}
) } diff --git a/src/food-market.web/src/components/ListPageShell.tsx b/src/food-market.web/src/components/ListPageShell.tsx new file mode 100644 index 0000000..bc9686f --- /dev/null +++ b/src/food-market.web/src/components/ListPageShell.tsx @@ -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 ( +
+ +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+ ) +} diff --git a/src/food-market.web/src/components/PageHeader.tsx b/src/food-market.web/src/components/PageHeader.tsx index 0e8d345..a60738c 100644 --- a/src/food-market.web/src/components/PageHeader.tsx +++ b/src/food-market.web/src/components/PageHeader.tsx @@ -4,16 +4,30 @@ interface PageHeaderProps { title: string description?: string 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 ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ) + } + return (

{title}

- {description && ( -

{description}

- )} + {description &&

{description}

}
{actions &&
{actions}
}
diff --git a/src/food-market.web/src/pages/CounterpartiesPage.tsx b/src/food-market.web/src/pages/CounterpartiesPage.tsx index 5126c2a..e9abbb5 100644 --- a/src/food-market.web/src/pages/CounterpartiesPage.tsx +++ b/src/food-market.web/src/pages/CounterpartiesPage.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { Plus, Trash2 } from 'lucide-react' import { api } from '@/lib/api' -import { PageHeader } from '@/components/PageHeader' +import { ListPageShell } from '@/components/ListPageShell' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { SearchBar } from '@/components/SearchBar' @@ -70,8 +70,8 @@ export function CounterpartiesPage() { } return ( -
- + setForm(blankForm)}> Добавить } - /> - - r.id} - onRowClick={(r) => setForm({ - id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type, - bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '', - countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '', - bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '', - contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', isActive: r.isActive, - })} - columns={[ - { header: 'Название', cell: (r) => r.name }, - { header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] }, - { header: 'БИН/ИИН', width: '140px', cell: (r) => {r.bin ?? r.iin ?? '—'} }, - { header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' }, - { header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' }, - { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, - ]} - /> - - {data && } + footer={data && data.total > 0 && ( + + )} + > + r.id} + onRowClick={(r) => setForm({ + id: r.id, name: r.name, legalName: r.legalName ?? '', kind: r.kind, type: r.type, + bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '', + countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '', + bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '', + contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', isActive: r.isActive, + })} + columns={[ + { header: 'Название', cell: (r) => r.name }, + { header: 'Тип', width: '120px', cell: (r) => kindLabel[r.kind] }, + { header: 'БИН/ИИН', width: '140px', cell: (r) => {r.bin ?? r.iin ?? '—'} }, + { header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' }, + { header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' }, + { header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + ]} + /> + )} -
+ ) } diff --git a/src/food-market.web/src/pages/CountriesPage.tsx b/src/food-market.web/src/pages/CountriesPage.tsx index 5e44801..d9ec650 100644 --- a/src/food-market.web/src/pages/CountriesPage.tsx +++ b/src/food-market.web/src/pages/CountriesPage.tsx @@ -1,4 +1,4 @@ -import { PageHeader } from '@/components/PageHeader' +import { ListPageShell } from '@/components/ListPageShell' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { SearchBar } from '@/components/SearchBar' @@ -9,11 +9,14 @@ export function CountriesPage() { const { data, isLoading, page, setPage, search, setSearch } = useCatalogList('/api/catalog/countries') return ( -
- - } /> - + } + footer={data && data.total > 0 && ( + + )} + > r.sortOrder }, ]} /> - - {data && } -
+ ) } diff --git a/src/food-market.web/src/pages/CurrenciesPage.tsx b/src/food-market.web/src/pages/CurrenciesPage.tsx index a0b4795..510ebf7 100644 --- a/src/food-market.web/src/pages/CurrenciesPage.tsx +++ b/src/food-market.web/src/pages/CurrenciesPage.tsx @@ -1,4 +1,4 @@ -import { PageHeader } from '@/components/PageHeader' +import { ListPageShell } from '@/components/ListPageShell' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { SearchBar } from '@/components/SearchBar' @@ -9,11 +9,14 @@ export function CurrenciesPage() { const { data, isLoading, page, setPage, search, setSearch } = useCatalogList('/api/catalog/currencies') return ( -
- - } /> - + } + footer={data && data.total > 0 && ( + + )} + > r.isActive ? '✓' : '—' }, ]} /> - - {data && } -
+ ) } diff --git a/src/food-market.web/src/pages/PriceTypesPage.tsx b/src/food-market.web/src/pages/PriceTypesPage.tsx index 324e32e..5fe1254 100644 --- a/src/food-market.web/src/pages/PriceTypesPage.tsx +++ b/src/food-market.web/src/pages/PriceTypesPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { Plus, Trash2 } from 'lucide-react' -import { PageHeader } from '@/components/PageHeader' +import { ListPageShell } from '@/components/ListPageShell' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { SearchBar } from '@/components/SearchBar' @@ -29,8 +29,8 @@ export function PriceTypesPage() { } return ( -
- + setForm(blankForm)}> Добавить } - /> - - r.id} - onRowClick={(r) => setForm({ ...r })} - columns={[ - { header: 'Название', cell: (r) => r.name }, - { header: 'Розничная', width: '120px', cell: (r) => r.isRetail ? '✓' : '—' }, - { 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 && } + footer={data && data.total > 0 && ( + + )} + > + r.id} + onRowClick={(r) => setForm({ ...r })} + columns={[ + { header: 'Название', cell: (r) => r.name }, + { header: 'Розничная', width: '120px', cell: (r) => r.isRetail ? '✓' : '—' }, + { header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' }, + { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, + { header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' }, + ]} + /> + )} -
+ ) } diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 041db69..b810004 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -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 { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react' @@ -93,7 +93,6 @@ export function ProductEditPage() { }, [isNew, existing.data]) useEffect(() => { - // Pre-fill defaults for new product if (isNew && form.vatRateId === '' && vats.data?.length) { 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) => 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 ( -
-
-
- + + {/* Sticky top bar */} +
+
+ -
-

+
+

{isNew ? 'Новый товар' : form.name || 'Товар'}

-

Справочник товаров и услуг

+

+ {isNew ? 'Создание новой позиции каталога' : 'Редактирование'} +

-
+
{!isNew && ( )} -
- {error && ( -
{error}
- )} - -
-
-
- - setForm({ ...form, name: e.target.value })} /> - - - setForm({ ...form, article: e.target.value })} /> - -
- -