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