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:
parent
6b86106937
commit
b6eefd3437
|
|
@ -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>
|
||||||
|
|
|
||||||
102
src/food-market.web/src/components/AppLayout.tsx
Normal file
102
src/food-market.web/src/components/AppLayout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
src/food-market.web/src/components/Button.tsx
Normal file
39
src/food-market.web/src/components/Button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
72
src/food-market.web/src/components/DataTable.tsx
Normal file
72
src/food-market.web/src/components/DataTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
src/food-market.web/src/components/Field.tsx
Normal file
58
src/food-market.web/src/components/Field.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
src/food-market.web/src/components/Modal.tsx
Normal file
44
src/food-market.web/src/components/Modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/food-market.web/src/components/PageHeader.tsx
Normal file
21
src/food-market.web/src/components/PageHeader.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/food-market.web/src/components/Pagination.tsx
Normal file
36
src/food-market.web/src/components/Pagination.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/food-market.web/src/components/SearchBar.tsx
Normal file
22
src/food-market.web/src/components/SearchBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
src/food-market.web/src/lib/types.ts
Normal file
56
src/food-market.web/src/lib/types.ts
Normal 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[]
|
||||||
|
}
|
||||||
45
src/food-market.web/src/lib/useCatalog.ts
Normal file
45
src/food-market.web/src/lib/useCatalog.ts
Normal 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 }
|
||||||
|
}
|
||||||
198
src/food-market.web/src/pages/CounterpartiesPage.tsx
Normal file
198
src/food-market.web/src/pages/CounterpartiesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/food-market.web/src/pages/CountriesPage.tsx
Normal file
31
src/food-market.web/src/pages/CountriesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
src/food-market.web/src/pages/CurrenciesPage.tsx
Normal file
33
src/food-market.web/src/pages/CurrenciesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
src/food-market.web/src/pages/PriceTypesPage.tsx
Normal file
100
src/food-market.web/src/pages/PriceTypesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
src/food-market.web/src/pages/ProductGroupsPage.tsx
Normal file
108
src/food-market.web/src/pages/ProductGroupsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/food-market.web/src/pages/ProductsPage.tsx
Normal file
66
src/food-market.web/src/pages/ProductsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
150
src/food-market.web/src/pages/RetailPointsPage.tsx
Normal file
150
src/food-market.web/src/pages/RetailPointsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
134
src/food-market.web/src/pages/StoresPage.tsx
Normal file
134
src/food-market.web/src/pages/StoresPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
src/food-market.web/src/pages/UnitsOfMeasurePage.tsx
Normal file
118
src/food-market.web/src/pages/UnitsOfMeasurePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
src/food-market.web/src/pages/VatRatesPage.tsx
Normal file
115
src/food-market.web/src/pages/VatRatesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue