phase1c: web UI — sidebar layout + list/form pages for catalog

Shared components:
- AppLayout with grouped sidebar nav (Главное / Каталог / Контрагенты / Склады / Справочники)
- DataTable, Pagination, SearchBar, Button, Modal, Field (TextInput/TextArea/Select/Checkbox)
- PageHeader, useCatalogList + useCatalogMutations hooks (TanStack Query)
- types.ts with enums as const objects (erasableSyntaxOnly-compatible)

Dashboard: 4 stat cards (products/counterparties/stores/retail-points counts) + roadmap

List + edit-in-modal for 9 entities:
- Countries, Currencies (read-only)
- VatRates, UnitsOfMeasure, PriceTypes, Stores, RetailPoints, ProductGroups, Counterparties (full CRUD)
- Products: list + filters; full form deferred to next commit

All pages: search, pagination, row-click → modal edit, create button, delete with confirm.
Counterparty form uses Country lookup; RetailPoint form uses Store lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-21 19:28:26 +05:00
parent 6b86106937
commit b6eefd3437
22 changed files with 1628 additions and 74 deletions

View file

@ -3,6 +3,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage' import { DashboardPage } from '@/pages/DashboardPage'
import { CountriesPage } from '@/pages/CountriesPage'
import { CurrenciesPage } from '@/pages/CurrenciesPage'
import { VatRatesPage } from '@/pages/VatRatesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage'
import { StoresPage } from '@/pages/StoresPage'
import { RetailPointsPage } from '@/pages/RetailPointsPage'
import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
import { CounterpartiesPage } from '@/pages/CounterpartiesPage'
import { ProductsPage } from '@/pages/ProductsPage'
import { AppLayout } from '@/components/AppLayout'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@ -21,7 +32,19 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/catalog/products" element={<ProductsPage />} />
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/vat-rates" element={<VatRatesPage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
<Route path="/catalog/stores" element={<StoresPage />} />
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
<Route path="/catalog/countries" element={<CountriesPage />} />
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
</Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View file

@ -0,0 +1,102 @@
import { NavLink, Outlet } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils'
import {
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut,
} from 'lucide-react'
interface MeResponse {
sub: string
name: string
email: string
roles: string[]
orgId: string
}
const nav = [
{ group: 'Главное', items: [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
]},
{ group: 'Каталог', items: [
{ to: '/catalog/products', icon: Package, label: 'Товары' },
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
{ to: '/catalog/vat-rates', icon: Percent, label: 'Ставки НДС' },
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' },
]},
{ group: 'Контрагенты', items: [
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
]},
{ group: 'Склады', items: [
{ to: '/catalog/stores', icon: Warehouse, label: 'Склады' },
{ to: '/catalog/retail-points', icon: StoreIcon, label: 'Точки продаж' },
]},
{ group: 'Справочники', items: [
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
]},
] as const
export function AppLayout() {
const { data: me } = useQuery({
queryKey: ['me'],
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
staleTime: 5 * 60 * 1000,
})
return (
<div className="min-h-screen flex bg-slate-50 dark:bg-slate-950">
<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">
<div className="h-14 flex items-center px-5 border-b border-slate-200 dark:border-slate-800">
<span className="font-semibold text-slate-900 dark:text-slate-100">food-market</span>
</div>
<nav className="flex-1 overflow-y-auto py-3">
{nav.map((section) => (
<div key={section.group} className="mb-4">
<div className="px-5 text-xs uppercase tracking-wide text-slate-400 mb-1">{section.group}</div>
{section.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={'end' in item ? item.end : undefined}
className={({ isActive }) => cn(
'flex items-center gap-2.5 px-5 py-1.5 text-sm transition-colors',
isActive
? 'bg-violet-50 dark:bg-violet-950/30 text-violet-700 dark:text-violet-300 font-medium border-r-2 border-violet-600'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50'
)}
>
<item.icon className="w-4 h-4" />
{item.label}
</NavLink>
))}
</div>
))}
</nav>
<div className="border-t border-slate-200 dark:border-slate-800 p-3">
{me && (
<div className="px-2 pb-2 text-xs text-slate-500">
<div className="truncate font-medium text-slate-700 dark:text-slate-200">{me.name}</div>
<div className="truncate">{me.roles.join(', ')}</div>
</div>
)}
<button
onClick={logout}
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50 rounded"
>
<LogOut className="w-4 h-4" /> Выход
</button>
</div>
</aside>
<main className="flex-1 overflow-x-hidden">
<Outlet />
</main>
</div>
)
}

View file

@ -0,0 +1,39 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { cn } from '@/lib/utils'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
type Size = 'sm' | 'md'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
size?: Size
children: ReactNode
}
const variants: Record<Variant, string> = {
primary: 'bg-violet-600 hover:bg-violet-700 text-white',
secondary: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-700',
ghost: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800',
danger: 'bg-red-600 hover:bg-red-700 text-white',
}
const sizes: Record<Size, string> = {
sm: 'px-2.5 py-1 text-xs',
md: 'px-3.5 py-1.5 text-sm',
}
export function Button({ variant = 'primary', size = 'md', className, children, ...rest }: ButtonProps) {
return (
<button
{...rest}
className={cn(
'inline-flex items-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
variants[variant],
sizes[size],
className,
)}
>
{children}
</button>
)
}

View file

@ -0,0 +1,72 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export interface Column<T> {
header: string
cell: (row: T) => ReactNode
className?: string
width?: string
}
interface DataTableProps<T> {
rows: T[]
columns: Column<T>[]
rowKey: (row: T) => string
onRowClick?: (row: T) => void
empty?: ReactNode
isLoading?: boolean
}
export function DataTable<T>({ rows, columns, rowKey, onRowClick, empty, isLoading }: DataTableProps<T>) {
return (
<div className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800/50 text-left">
<tr>
{columns.map((c, i) => (
<th
key={i}
className={cn('px-4 py-2.5 font-medium text-xs uppercase tracking-wide text-slate-500', c.className)}
style={c.width ? { width: c.width } : undefined}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400">
Загрузка
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-400">
{empty ?? 'Нет данных'}
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={rowKey(row)}
onClick={() => 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) => (
<td key={i} className={cn('px-4 py-2.5 text-slate-700 dark:text-slate-200', c.className)}>
{c.cell(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}

View file

@ -0,0 +1,58 @@
import type { InputHTMLAttributes, SelectHTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
interface FieldProps {
label: string
error?: string
children: ReactNode
className?: string
}
export function Field({ label, error, children, className }: FieldProps) {
return (
<label className={cn('block space-y-1.5', className)}>
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{label}</span>
{children}
{error && <span className="text-xs text-red-600">{error}</span>}
</label>
)
}
const inputClass = 'w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-violet-500 disabled:opacity-60'
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} className={cn(inputClass, props.className)} />
}
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea {...props} className={cn(inputClass, 'font-[inherit]', props.className)} />
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select {...props} className={cn(inputClass, props.className)} />
}
export function Checkbox({
label,
checked,
onChange,
disabled,
}: {
label: string
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
}) {
return (
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-200 cursor-pointer">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
/>
{label}
</label>
)
}

View file

@ -0,0 +1,44 @@
import { useEffect, type ReactNode } from 'react'
import { X } from 'lucide-react'
interface ModalProps {
open: boolean
onClose: () => void
title: string
children: ReactNode
footer?: ReactNode
width?: string
}
export function Modal({ open, onClose, title, children, footer, width = 'max-w-lg' }: ModalProps) {
useEffect(() => {
if (!open) return
const onEsc = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
document.addEventListener('keydown', onEsc)
return () => document.removeEventListener('keydown', onEsc)
}, [open, onClose])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 overflow-y-auto bg-slate-900/50 backdrop-blur-sm" onClick={onClose}>
<div
className={`w-full ${width} mt-16 bg-white dark:bg-slate-900 rounded-xl shadow-xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-200 dark:border-slate-800">
<h2 className="font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-4">{children}</div>
{footer && (
<div className="px-5 py-3 border-t border-slate-200 dark:border-slate-800 flex items-center justify-end gap-2">
{footer}
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<h1 className="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
{description && (
<p className="text-sm text-slate-500 mt-0.5">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)
}

View file

@ -0,0 +1,36 @@
interface PaginationProps {
page: number
pageSize: number
total: number
onPageChange: (page: number) => void
}
export function Pagination({ page, pageSize, total, onPageChange }: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const from = (page - 1) * pageSize + 1
const to = Math.min(page * pageSize, total)
if (total === 0) return null
return (
<div className="flex items-center justify-between mt-3 text-sm text-slate-500">
<span>{from}{to} из {total}</span>
<div className="flex items-center gap-1.5">
<button
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
className="px-2.5 py-1 rounded border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:hover:bg-transparent"
>
</button>
<span>{page} / {totalPages}</span>
<button
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
className="px-2.5 py-1 rounded border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-40 disabled:hover:bg-transparent"
>
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,22 @@
import { Search } from 'lucide-react'
interface SearchBarProps {
value: string
onChange: (v: string) => void
placeholder?: string
}
export function SearchBar({ value, onChange, placeholder = 'Поиск…' }: SearchBarProps) {
return (
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="pl-8 pr-3 py-1.5 w-64 rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm focus:outline-none focus:ring-2 focus:ring-violet-500"
/>
</div>
)
}

View file

@ -0,0 +1,56 @@
export interface PagedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export const CounterpartyKind = { Supplier: 1, Customer: 2, Both: 3 } as const
export type CounterpartyKind = (typeof CounterpartyKind)[keyof typeof CounterpartyKind]
export const CounterpartyType = { LegalEntity: 1, Individual: 2 } as const
export type CounterpartyType = (typeof CounterpartyType)[keyof typeof CounterpartyType]
export const StoreKind = { Warehouse: 1, RetailFloor: 2 } as const
export type StoreKind = (typeof StoreKind)[keyof typeof StoreKind]
export const BarcodeType = { Ean13: 1, Ean8: 2, Code128: 3, Code39: 4, Upca: 5, Upce: 6, Other: 99 } as const
export type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
export interface Country { id: string; code: string; name: string; sortOrder: number }
export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
export interface VatRate { id: string; name: string; percent: number; isIncludedInPrice: boolean; isDefault: boolean; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; symbol: string; name: string; decimalPlaces: number; isBase: boolean; isActive: boolean }
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
export interface Store {
id: string; name: string; code: string | null; kind: StoreKind; address: string | null; phone: string | null;
managerName: string | null; isMain: boolean; isActive: boolean
}
export interface RetailPoint {
id: string; name: string; code: string | null; storeId: string; storeName: string | null;
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
}
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
export interface Counterparty {
id: string; name: string; legalName: string | null; kind: CounterpartyKind; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
address: string | null; phone: string | null; email: string | null;
bankName: string | null; bankAccount: string | null; bik: string | null;
contactPerson: string | null; notes: string | null; isActive: boolean
}
export interface ProductBarcode { id: string; code: string; type: BarcodeType; isPrimary: boolean }
export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
export interface Product {
id: string; name: string; article: string | null; description: string | null;
unitOfMeasureId: string; unitSymbol: string;
vatRateId: string; vatPercent: number;
productGroupId: string | null; productGroupName: string | null;
defaultSupplierId: string | null; defaultSupplierName: string | null;
countryOfOriginId: string | null; countryOfOriginName: string | null;
isService: boolean; isWeighed: boolean; isAlcohol: boolean; isMarked: boolean;
minStock: number | null; maxStock: number | null;
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
imageUrl: string | null; isActive: boolean;
prices: ProductPrice[]; barcodes: ProductBarcode[]
}

View file

@ -0,0 +1,45 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type { PagedResult } from '@/lib/types'
export function useCatalogList<T>(url: string, extraParams: Record<string, string | number | boolean | undefined> = {}) {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const query = useQuery({
queryKey: [url, page, search, extraParams],
queryFn: async () => {
const params = new URLSearchParams({
page: String(page),
pageSize: '50',
...(search ? { search } : {}),
...Object.fromEntries(
Object.entries(extraParams).filter(([, v]) => v !== undefined && v !== '').map(([k, v]) => [k, String(v)])
),
})
const res = await api.get<PagedResult<T>>(`${url}?${params}`)
return res.data
},
placeholderData: (prev) => prev,
})
return { page, setPage, search, setSearch, ...query }
}
export function useCatalogMutations(url: string, listUrl: string) {
const qc = useQueryClient()
const create = useMutation({
mutationFn: async (input: unknown) => (await api.post(url, input)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }),
})
const update = useMutation({
mutationFn: async ({ id, input }: { id: string; input: unknown }) => (await api.put(`${url}/${id}`, input)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }),
})
const remove = useMutation({
mutationFn: async (id: string) => (await api.delete(`${url}/${id}`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: [listUrl] }),
})
return { create, update, remove }
}

View file

@ -0,0 +1,198 @@
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 { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Counterparty, type Country, type PagedResult, CounterpartyKind, CounterpartyType } from '@/lib/types'
const URL = '/api/catalog/counterparties'
interface Form {
id?: string
name: string
legalName: string
kind: CounterpartyKind
type: CounterpartyType
bin: string
iin: string
taxNumber: string
countryId: string
address: string
phone: string
email: string
bankName: string
bankAccount: string
bik: string
contactPerson: string
notes: string
isActive: boolean
}
const blankForm: Form = {
name: '', legalName: '', kind: CounterpartyKind.Supplier, type: CounterpartyType.LegalEntity,
bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '',
contactPerson: '', notes: '', isActive: true,
}
const kindLabel: Record<CounterpartyKind, string> = {
[CounterpartyKind.Supplier]: 'Поставщик',
[CounterpartyKind.Customer]: 'Покупатель',
[CounterpartyKind.Both]: 'Оба',
}
export function CounterpartiesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Counterparty>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const countries = useQuery({
queryKey: ['countries-lookup'],
queryFn: async () => (await api.get<PagedResult<Country>>('/api/catalog/countries?pageSize=500')).data.items,
staleTime: 10 * 60 * 1000,
})
const save = async () => {
if (!form) return
const { id, countryId, ...rest } = form
const payload = { ...rest, countryId: countryId || null }
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<div className="p-6">
<PageHeader
title="Контрагенты"
description="Поставщики и покупатели."
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по имени, БИН, ИИН, телефону…" />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => 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) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
{ header: 'Телефон', width: '160px', cell: (r) => r.phone ?? '—' },
{ header: 'Страна', width: '120px', cell: (r) => r.countryName ?? '—' },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать контрагента' : 'Новый контрагент'}
width="max-w-3xl"
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить контрагента?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="grid grid-cols-2 gap-3">
<Field label="Название" className="col-span-2">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Юридическое название" className="col-span-2">
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
</Field>
<Field label="Роль">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as CounterpartyKind })}>
<option value={CounterpartyKind.Supplier}>Поставщик</option>
<option value={CounterpartyKind.Customer}>Покупатель</option>
<option value={CounterpartyKind.Both}>Оба</option>
</Select>
</Field>
<Field label="Тип лица">
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>
<option value={CounterpartyType.Individual}>Физлицо</option>
</Select>
</Field>
<Field label="БИН (юрлицо РК)">
<TextInput value={form.bin} onChange={(e) => setForm({ ...form, bin: e.target.value })} />
</Field>
<Field label="ИИН (физлицо РК)">
<TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} />
</Field>
<Field label="ИНН / другой">
<TextInput value={form.taxNumber} onChange={(e) => setForm({ ...form, taxNumber: e.target.value })} />
</Field>
<Field label="Страна">
<Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}>
<option value=""></option>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Адрес" className="col-span-2">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field>
<Field label="Телефон">
<TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</Field>
<Field label="Email">
<TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</Field>
<Field label="Банк" className="col-span-2">
<TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} />
</Field>
<Field label="Расчётный счёт">
<TextInput value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} />
</Field>
<Field label="БИК">
<TextInput value={form.bik} onChange={(e) => setForm({ ...form, bik: e.target.value })} />
</Field>
<Field label="Контактное лицо" className="col-span-2">
<TextInput value={form.contactPerson} onChange={(e) => setForm({ ...form, contactPerson: e.target.value })} />
</Field>
<Field label="Заметки" className="col-span-2">
<TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
<div className="col-span-2">
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
</div>
)}
</Modal>
</div>
)
}

View file

@ -0,0 +1,31 @@
import { PageHeader } from '@/components/PageHeader'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { useCatalogList } from '@/lib/useCatalog'
import type { Country } from '@/lib/types'
export function CountriesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Country>('/api/catalog/countries')
return (
<div className="p-6">
<PageHeader title="Страны" description="Глобальный справочник. По умолчанию Казахстан." actions={
<SearchBar value={search} onChange={setSearch} />
} />
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Название', cell: (r) => r.name },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
</div>
)
}

View file

@ -0,0 +1,33 @@
import { PageHeader } from '@/components/PageHeader'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { useCatalogList } from '@/lib/useCatalog'
import type { Currency } from '@/lib/types'
export function CurrenciesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Currency>('/api/catalog/currencies')
return (
<div className="p-6">
<PageHeader title="Валюты" description="Доступные валюты для операций. Основная — тенге (KZT)." actions={
<SearchBar value={search} onChange={setSearch} />
} />
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Название', cell: (r) => r.name },
{ header: 'Символ', width: '100px', cell: (r) => <span className="text-lg">{r.symbol}</span> },
{ header: 'Знаки', width: '100px', className: 'text-right', cell: (r) => r.minorUnit },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
</div>
)
}

View file

@ -1,83 +1,66 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Package, Users, Warehouse, Store } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { logout } from '@/lib/auth' import type { PagedResult } from '@/lib/types'
interface MeResponse { function useCount(url: string) {
sub: string return useQuery({
name: string queryKey: [url, 'count'],
email: string queryFn: async () => (await api.get<PagedResult<unknown>>(`${url}?pageSize=1`)).data.total,
roles: string[] })
orgId: string
} }
export function DashboardPage() { interface StatCardProps {
const { data, isLoading, error } = useQuery({ icon: React.ComponentType<{ className?: string }>
queryKey: ['me'], label: string
queryFn: async () => (await api.get<MeResponse>('/api/me')).data, value: number | string | undefined
}) isLoading: boolean
}
function StatCard({ icon: Icon, label, value, isLoading }: StatCardProps) {
return ( return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900"> <div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<header className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700"> <div className="flex items-center justify-between">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between"> <span className="text-sm text-slate-500">{label}</span>
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">food-market</h1> <Icon className="w-4 h-4 text-slate-400" />
<div className="flex items-center gap-4 text-sm">
{data && (
<span className="text-slate-600 dark:text-slate-300">
{data.name} · <span className="text-slate-400">{data.roles.join(', ')}</span>
</span>
)}
<button
onClick={logout}
className="text-violet-600 hover:text-violet-700 font-medium"
>
Выход
</button>
</div> </div>
<div className="text-2xl font-semibold mt-2 text-slate-900 dark:text-slate-100">
{isLoading ? '…' : value ?? '—'}
</div> </div>
</header> </div>
)
<main className="max-w-7xl mx-auto px-6 py-10 space-y-6"> }
<section className="bg-white dark:bg-slate-800 rounded-xl shadow-sm p-8">
<h2 className="text-2xl font-semibold text-slate-900 dark:text-slate-100 mb-2"> export function DashboardPage() {
Dashboard const products = useCount('/api/catalog/products')
</h2> const counterparties = useCount('/api/catalog/counterparties')
<p className="text-sm text-slate-500"> const stores = useCount('/api/catalog/stores')
Phase 0 каркас работает. Справочники и документы появятся в Phase 1. const retailPoints = useCount('/api/catalog/retail-points')
</p>
return (
<div className="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="p-6">
{['Точки продаж', 'Склады', 'Сегодня продажи'].map((label) => ( <PageHeader
<div title="Dashboard"
key={label} description="Общие показатели системы. Детальные отчёты появятся после Phase 2."
className="rounded-lg border border-slate-200 dark:border-slate-700 p-4" />
>
<div className="text-xs uppercase tracking-wide text-slate-400">{label}</div> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="mt-1 text-2xl font-semibold text-slate-900 dark:text-slate-100"> <StatCard icon={Package} label="Товаров" value={products.data} isLoading={products.isLoading} />
<StatCard icon={Users} label="Контрагентов" value={counterparties.data} isLoading={counterparties.isLoading} />
</div> <StatCard icon={Warehouse} label="Складов" value={stores.data} isLoading={stores.isLoading} />
</div> <StatCard icon={Store} label="Точек продаж" value={retailPoints.data} isLoading={retailPoints.isLoading} />
))} </div>
</div>
</section> <section className="mt-8 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-6">
<h2 className="text-base font-semibold text-slate-900 dark:text-slate-100 mb-2">Что дальше</h2>
<section className="bg-white dark:bg-slate-800 rounded-xl shadow-sm p-8"> <ul className="text-sm text-slate-600 dark:text-slate-300 space-y-1.5 list-disc list-inside">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100 mb-3"> <li>Phase 2: приёмка товара, розничные продажи, складские остатки</li>
Профиль пользователя (/api/me) <li>Phase 3: инвентаризация, списание, оприходование, возвраты поставщикам</li>
</h3> <li>Phase 4: ценники, отчёты P&L, бонусная система, аудит</li>
{isLoading && <div className="text-sm text-slate-400">Загрузка</div>} <li>Phase 5: Windows-касса + синхронизация + весы</li>
{error && ( </ul>
<div className="text-sm text-red-600"> </section>
Ошибка: {error instanceof Error ? error.message : 'unknown'}
</div>
)}
{data && (
<pre className="text-xs bg-slate-50 dark:bg-slate-900 rounded-md p-4 overflow-auto border border-slate-200 dark:border-slate-700">
{JSON.stringify(data, null, 2)}
</pre>
)}
</section>
</main>
</div> </div>
) )
} }

View file

@ -0,0 +1,100 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { PriceType } from '@/lib/types'
const URL = '/api/catalog/price-types'
interface Form { id?: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
const blankForm: Form = { name: '', isDefault: false, isRetail: false, sortOrder: 0, isActive: true }
export function PriceTypesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<PriceType>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const { id, ...payload } = form
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<div className="p-6">
<PageHeader
title="Типы цен"
description="Розничная, оптовая и другие ценовые группы."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => 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 && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать тип цены' : 'Новый тип цены'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить тип цены?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Порядок сортировки">
<TextInput type="number"
value={form.sortOrder}
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
/>
</Field>
<Checkbox label="Розничная (используется на кассе)" checked={form.isRetail} onChange={(v) => setForm({ ...form, isRetail: v })} />
<Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
</div>
)
}

View file

@ -0,0 +1,108 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { ProductGroup } from '@/lib/types'
const URL = '/api/catalog/product-groups'
interface Form { id?: string; name: string; parentId: string | null; sortOrder: number; isActive: boolean }
const blankForm: Form = { name: '', parentId: null, sortOrder: 0, isActive: true }
export function ProductGroupsPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<ProductGroup>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const { id, ...payload } = form
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<div className="p-6">
<PageHeader
title="Группы товаров"
description="Иерархический справочник категорий."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive })}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Путь', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать группу' : 'Новая группа товаров'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить группу? (должна быть пустой)')) {
try { await remove.mutateAsync(form.id!); setForm(null) }
catch (e) { alert((e as Error).message) }
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Родительская группа">
<Select
value={form.parentId ?? ''}
onChange={(e) => setForm({ ...form, parentId: e.target.value || null })}
>
<option value="">(верхний уровень)</option>
{data?.items.filter((g) => g.id !== form.id).map((g) => (
<option key={g.id} value={g.id}>{g.path}</option>
))}
</Select>
</Field>
<Field label="Порядок сортировки">
<TextInput type="number"
value={form.sortOrder}
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
/>
</Field>
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
</div>
)
}

View file

@ -0,0 +1,66 @@
import { PageHeader } from '@/components/PageHeader'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Plus } from 'lucide-react'
import { useCatalogList } from '@/lib/useCatalog'
import type { Product } from '@/lib/types'
const URL = '/api/catalog/products'
export function ProductsPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Product>(URL)
return (
<div className="p-6">
<PageHeader
title="Товары"
description="Каталог товаров и услуг."
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по названию, артикулу, штрихкоду…" />
<Button disabled title="Форма редактирования — в следующем коммите">
<Plus className="w-4 h-4" /> Добавить
</Button>
</>
}
/>
<div className="mb-3 text-xs text-slate-400">
💡 Форма создания/редактирования товара (с ценами и штрихкодами) будет в следующем коммите.
Сейчас доступен просмотр и поиск создать товар можно через API (POST /api/catalog/products).
</div>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
{r.article && <div className="text-xs text-slate-400 font-mono">{r.article}</div>}
</div>
)},
{ header: 'Группа', width: '180px', cell: (r) => r.productGroupName ?? '—' },
{ header: 'Ед.', width: '70px', cell: (r) => r.unitSymbol },
{ header: 'НДС', width: '80px', className: 'text-right', cell: (r) => `${r.vatPercent}%` },
{ header: 'Тип', width: '120px', cell: (r) => (
<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.isWeighed && <span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700">Весовой</span>}
{r.isAlcohol && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-700">Алкоголь</span>}
{r.isMarked && <span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">Маркир.</span>}
</div>
)},
{ header: 'Штрихкодов', width: '120px', className: 'text-right', cell: (r) => r.barcodes.length },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
empty="Товаров ещё нет. Они появятся после приёмки или через API."
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
</div>
)
}

View file

@ -0,0 +1,150 @@
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 { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { PagedResult, RetailPoint, Store } from '@/lib/types'
const URL = '/api/catalog/retail-points'
interface Form {
id?: string
name: string
code: string
storeId: string
address: string
phone: string
fiscalSerial: string
fiscalRegNumber: string
isActive: boolean
}
const blankForm = (storeId: string): Form => ({
name: '', code: '', storeId, address: '', phone: '',
fiscalSerial: '', fiscalRegNumber: '', isActive: true,
})
export function RetailPointsPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<RetailPoint>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const stores = useQuery({
queryKey: ['stores-lookup'],
queryFn: async () => (await api.get<PagedResult<Store>>('/api/catalog/stores?pageSize=500')).data.items,
staleTime: 60_000,
})
const save = async () => {
if (!form) return
const { id, ...payload } = form
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
const firstStore = stores.data?.[0]?.id ?? ''
return (
<div className="p-6">
<PageHeader
title="Точки продаж"
description="Кассовые точки. Привязаны к складу, с которого идут продажи."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm(firstStore))} disabled={!firstStore}>
<Plus className="w-4 h-4" /> Добавить
</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, code: r.code ?? '', storeId: r.storeId,
address: r.address ?? '', phone: r.phone ?? '',
fiscalSerial: r.fiscalSerial ?? '', fiscalRegNumber: r.fiscalRegNumber ?? '',
isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'Склад', cell: (r) => r.storeName ?? '—' },
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
{ header: 'ККМ', width: '140px', cell: (r) => r.fiscalSerial ?? '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать точку продаж' : 'Новая точка продаж'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить точку продаж?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name || !form.storeId}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Код">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
</div>
<Field label="Склад">
<Select value={form.storeId} onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
{stores.data?.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</Select>
</Field>
<Field label="Адрес">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field>
<Field label="Телефон">
<TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Серийный № ККМ">
<TextInput value={form.fiscalSerial} onChange={(e) => setForm({ ...form, fiscalSerial: e.target.value })} />
</Field>
<Field label="Рег. № в ОФД">
<TextInput value={form.fiscalRegNumber} onChange={(e) => setForm({ ...form, fiscalRegNumber: e.target.value })} />
</Field>
</div>
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
</div>
)
}

View file

@ -0,0 +1,134 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Store, StoreKind } from '@/lib/types'
const URL = '/api/catalog/stores'
interface Form {
id?: string
name: string
code: string
kind: StoreKind
address: string
phone: string
managerName: string
isMain: boolean
isActive: boolean
}
const blankForm: Form = {
name: '', code: '', kind: StoreKind.Warehouse, address: '', phone: '',
managerName: '', isMain: false, isActive: true,
}
export function StoresPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<Store>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const { id, ...payload } = form
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<div className="p-6">
<PageHeader
title="Склады"
description="Физические места хранения товара."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, code: r.code ?? '', kind: r.kind,
address: r.address ?? '', phone: r.phone ?? '', managerName: r.managerName ?? '',
isMain: r.isMain, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: 'Код', width: '120px', cell: (r) => <span className="font-mono">{r.code ?? '—'}</span> },
{ header: 'Тип', width: '150px', cell: (r) => r.kind === StoreKind.Warehouse ? 'Склад' : 'Торговый зал' },
{ header: 'Адрес', cell: (r) => r.address ?? '—' },
{ header: 'Основной', width: '110px', cell: (r) => r.isMain ? '✓' : '—' },
{ header: 'Активен', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать склад' : 'Новый склад'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить склад?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Код">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
</div>
<Field label="Тип">
<Select value={form.kind} onChange={(e) => setForm({ ...form, kind: Number(e.target.value) as StoreKind })}>
<option value={StoreKind.Warehouse}>Склад</option>
<option value={StoreKind.RetailFloor}>Торговый зал</option>
</Select>
</Field>
<Field label="Адрес">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Телефон">
<TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</Field>
<Field label="Заведующий">
<TextInput value={form.managerName} onChange={(e) => setForm({ ...form, managerName: e.target.value })} />
</Field>
</div>
<Checkbox label="Основной склад организации" checked={form.isMain} onChange={(v) => setForm({ ...form, isMain: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
</div>
)
}

View file

@ -0,0 +1,118 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { UnitOfMeasure } from '@/lib/types'
const URL = '/api/catalog/units-of-measure'
interface Form {
id?: string
code: string
symbol: string
name: string
decimalPlaces: number
isBase: boolean
isActive: boolean
}
const blankForm: Form = { code: '', symbol: '', name: '', decimalPlaces: 0, isBase: false, isActive: true }
export function UnitsOfMeasurePage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<UnitOfMeasure>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const { id, ...payload } = form
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<div className="p-6">
<PageHeader
title="Единицы измерения"
description="Код по ОКЕИ (шт=796, кг=166, л=112, м=006)."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ ...r })}
columns={[
{ header: 'Код', width: '90px', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Символ', width: '100px', cell: (r) => r.symbol },
{ header: 'Название', cell: (r) => r.name },
{ header: 'Дробность', width: '120px', className: 'text-right', cell: (r) => r.decimalPlaces },
{ header: 'Базовая', width: '100px', cell: (r) => r.isBase ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать единицу' : 'Новая единица измерения'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить единицу измерения?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Код ОКЕИ">
<TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</Field>
<Field label="Символ">
<TextInput value={form.symbol} onChange={(e) => setForm({ ...form, symbol: e.target.value })} />
</Field>
</div>
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Количество знаков после запятой">
<TextInput
type="number" min="0" max="6"
value={form.decimalPlaces}
onChange={(e) => setForm({ ...form, decimalPlaces: Number(e.target.value) })}
/>
</Field>
<Checkbox label="Базовая единица организации" checked={form.isBase} onChange={(v) => setForm({ ...form, isBase: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
</div>
)
}

View file

@ -0,0 +1,115 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { VatRate } from '@/lib/types'
const URL = '/api/catalog/vat-rates'
interface Form {
id?: string
name: string
percent: number
isIncludedInPrice: boolean
isDefault: boolean
isActive: boolean
}
const blankForm: Form = { name: '', percent: 0, isIncludedInPrice: true, isDefault: false, isActive: true }
export function VatRatesPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<VatRate>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form) return
const payload = {
name: form.name, percent: form.percent,
isIncludedInPrice: form.isIncludedInPrice, isDefault: form.isDefault, isActive: form.isActive,
}
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
}
return (
<div className="p-6">
<PageHeader
title="Ставки НДС"
description="Настройки ставок налога на добавленную стоимость."
actions={
<>
<SearchBar value={search} onChange={setSearch} />
<Button onClick={() => setForm(blankForm)}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
/>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({
id: r.id, name: r.name, percent: r.percent,
isIncludedInPrice: r.isIncludedInPrice, isDefault: r.isDefault, isActive: r.isActive,
})}
columns={[
{ header: 'Название', cell: (r) => r.name },
{ header: '%', width: '90px', className: 'text-right font-mono', cell: (r) => r.percent.toFixed(2) },
{ header: 'В цене', width: '100px', cell: (r) => r.isIncludedInPrice ? '✓' : '—' },
{ header: 'По умолчанию', width: '140px', cell: (r) => r.isDefault ? '✓' : '—' },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
{data && <Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />}
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Редактировать ставку НДС' : 'Новая ставка НДС'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить ставку?')) {
await remove.mutateAsync(form.id!)
setForm(null)
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
<Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Процент">
<TextInput
type="number" step="0.01"
value={form.percent}
onChange={(e) => setForm({ ...form, percent: Number(e.target.value) })}
/>
</Field>
<Checkbox label="НДС включён в цену" checked={form.isIncludedInPrice} onChange={(v) => setForm({ ...form, isIncludedInPrice: v })} />
<Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
</div>
)
}