feat(web): price types CRUD visibility + group markup table

- ProductGroupsPage: колонка «Наценка %» в таблице, поле «Наценка %»
  в модалке редактирования с подсказкой про формулу
  ⌈Cost × (1 + наценка/100)⌉.
- НОВАЯ страница /settings/group-markups (GroupMarkupsPage) — массовая
  правка % наценки по группам. Inline TextInput, считается diff,
  кнопка «Сохранить (N)» делает PUT по каждой изменённой группе.
- AppLayout: меню «Типы цен» прячется когда multiplePriceTypesEnabled=false.
  Добавлен пункт «Настройки → Наценки по группам».
- App.tsx: новый маршрут /settings/group-markups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 21:09:10 +05:00
parent e74bec3964
commit a5f0fb83d8
4 changed files with 123 additions and 10 deletions

View file

@ -6,6 +6,7 @@ import { CountriesPage } from '@/pages/CountriesPage'
import { CurrenciesPage } from '@/pages/CurrenciesPage' import { CurrenciesPage } from '@/pages/CurrenciesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage' import { PriceTypesPage } from '@/pages/PriceTypesPage'
import { GroupMarkupsPage } from '@/pages/GroupMarkupsPage'
import { StoresPage } from '@/pages/StoresPage' import { StoresPage } from '@/pages/StoresPage'
import { RetailPointsPage } from '@/pages/RetailPointsPage' import { RetailPointsPage } from '@/pages/RetailPointsPage'
import { ProductGroupsPage } from '@/pages/ProductGroupsPage' import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
@ -47,6 +48,7 @@ export default function App() {
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} /> <Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} /> <Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} /> <Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/settings/group-markups" element={<GroupMarkupsPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} /> <Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
<Route path="/catalog/stores" element={<StoresPage />} /> <Route path="/catalog/stores" element={<StoresPage />} />
<Route path="/catalog/retail-points" element={<RetailPointsPage />} /> <Route path="/catalog/retail-points" element={<RetailPointsPage />} />

View file

@ -10,6 +10,8 @@ import {
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { Percent } from 'lucide-react'
import { useOrgSettings } from '@/lib/useOrgSettings'
interface MeResponse { interface MeResponse {
sub: string sub: string
@ -19,16 +21,21 @@ interface MeResponse {
orgId: string orgId: string
} }
const nav = [ type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
{ group: 'Главное', items: [ type NavSection = { group: string; items: NavItem[] }
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
]}, function buildNav(showPriceTypes: boolean): NavSection[] {
{ group: 'Каталог', items: [ const catalog: NavItem[] = [
{ to: '/catalog/products', icon: Package, label: 'Товары' }, { to: '/catalog/products', icon: Package, label: 'Товары' },
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' }, { to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' }, { to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' }, ]
if (showPriceTypes) catalog.push({ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' })
return [
{ group: 'Главное', items: [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
]}, ]},
{ group: 'Каталог', items: catalog },
{ group: 'Контрагенты', items: [ { group: 'Контрагенты', items: [
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' }, { to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
]}, ]},
@ -55,8 +62,10 @@ const nav = [
]}, ]},
{ group: 'Настройки', items: [ { group: 'Настройки', items: [
{ to: '/settings/organization', icon: Settings, label: 'Организация' }, { to: '/settings/organization', icon: Settings, label: 'Организация' },
{ to: '/settings/group-markups', icon: Percent, label: 'Наценки по группам' },
]}, ]},
] as const ]
}
export function AppLayout() { export function AppLayout() {
const { data: me } = useQuery({ const { data: me } = useQuery({
@ -65,6 +74,9 @@ export function AppLayout() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}) })
const org = useOrgSettings()
const nav = buildNav(org.data?.multiplePriceTypesEnabled ?? false)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const location = useLocation() const location = useLocation()
// Закрывать drawer при смене маршрута. // Закрывать drawer при смене маршрута.

View file

@ -0,0 +1,86 @@
import { useEffect, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { TextInput } from '@/components/Field'
import { Button } from '@/components/Button'
import type { ProductGroup, PagedResult } from '@/lib/types'
interface DraftRow { id: string; name: string; path: string; markupPercent: number | null; original: number | null }
/** Удобная массовая страница: список всех групп товаров с inline-вводом
* % наценки. Сохранение по группам только тех строк, которые изменились. */
export function GroupMarkupsPage() {
const qc = useQueryClient()
const groups = useQuery({
queryKey: ['/api/catalog/product-groups', 'all'],
queryFn: async () => (await api.get<PagedResult<ProductGroup>>('/api/catalog/product-groups?pageSize=500')).data.items,
})
const [rows, setRows] = useState<DraftRow[]>([])
useEffect(() => {
if (groups.data) setRows(groups.data.map((g) => ({
id: g.id, name: g.name, path: g.path,
markupPercent: g.markupPercent, original: g.markupPercent,
})))
}, [groups.data])
const dirty = rows.filter((r) => r.markupPercent !== r.original)
const save = useMutation({
mutationFn: async () => {
// Для PUT нужен полный ProductGroupInput. Подгружаем целиком из груп
// и патчим только markupPercent.
const byId = new Map(groups.data?.map((g) => [g.id, g]) ?? [])
for (const r of dirty) {
const orig = byId.get(r.id)
if (!orig) continue
await api.put(`/api/catalog/product-groups/${r.id}`, {
name: orig.name,
parentId: orig.parentId,
sortOrder: orig.sortOrder,
isActive: orig.isActive,
markupPercent: r.markupPercent,
})
}
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['/api/catalog/product-groups'] })
},
})
return (
<ListPageShell
title="Наценки по группам"
description="Массовая правка процента наценки для авто-расчёта розничной цены при проведении приёмки."
actions={
<Button onClick={() => save.mutate()} disabled={dirty.length === 0 || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : `Сохранить (${dirty.length})`}
</Button>
}
>
<DataTable
rows={rows}
isLoading={groups.isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Группа', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Наценка %', width: '180px', className: 'text-right', cell: (r) => (
<TextInput
type="number" step="0.01"
value={r.markupPercent ?? ''}
placeholder="—"
onChange={(e) => {
const v = e.target.value === '' ? null : Number(e.target.value)
setRows((rs) => rs.map((x) => x.id === r.id ? { ...x, markupPercent: v } : x))
}}
className="text-right"
/>
)},
]}
/>
</ListPageShell>
)
}

View file

@ -12,8 +12,8 @@ import type { ProductGroup } from '@/lib/types'
const URL = '/api/catalog/product-groups' const URL = '/api/catalog/product-groups'
interface Form { id?: string; name: string; parentId: string | null; sortOrder: number; isActive: boolean } interface Form { id?: string; name: string; parentId: string | null; sortOrder: number; isActive: boolean; markupPercent: number | null }
const blankForm: Form = { name: '', parentId: null, sortOrder: 0, isActive: true } const blankForm: Form = { name: '', parentId: null, sortOrder: 0, isActive: true, markupPercent: null }
export function ProductGroupsPage() { export function ProductGroupsPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL)
@ -50,10 +50,11 @@ export function ProductGroupsPage() {
sortKey={sortKey} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive })} onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive, markupPercent: r.markupPercent })}
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> }, { header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Наценка', width: '110px', className: 'text-right', cell: (r) => r.markupPercent != null ? `${r.markupPercent.toFixed(2)}%` : '—' },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
{ header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' }, { header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
]} ]}
@ -103,6 +104,18 @@ export function ProductGroupsPage() {
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
/> />
</Field> </Field>
<Field label="Наценка % (для авто-розничной по себестоимости)">
<TextInput
type="number" step="0.01"
value={form.markupPercent ?? ''}
placeholder="нет автонаценки"
onChange={(e) => setForm({ ...form, markupPercent: e.target.value === '' ? null : Number(e.target.value) })}
/>
<p className="text-xs text-slate-500 mt-1">
При проведении приёмки розничная цена товара = Себестоимость × (1 + наценка/100).
Пусто автонаценка отключена.
</p>
</Field>
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} /> <Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div> </div>
)} )}