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:
parent
e74bec3964
commit
a5f0fb83d8
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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 при смене маршрута.
|
||||||
|
|
|
||||||
86
src/food-market.web/src/pages/GroupMarkupsPage.tsx
Normal file
86
src/food-market.web/src/pages/GroupMarkupsPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue