feat(web): price types CRUD visibility + group markup table
Some checks are pending
Some checks are pending
- 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
a3cf68eb11
commit
095ac04d31
|
|
@ -6,6 +6,7 @@ import { CountriesPage } from '@/pages/CountriesPage'
|
|||
import { CurrenciesPage } from '@/pages/CurrenciesPage'
|
||||
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
||||
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
||||
import { GroupMarkupsPage } from '@/pages/GroupMarkupsPage'
|
||||
import { StoresPage } from '@/pages/StoresPage'
|
||||
import { RetailPointsPage } from '@/pages/RetailPointsPage'
|
||||
import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
|
||||
|
|
@ -47,6 +48,7 @@ export default function App() {
|
|||
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
|
||||
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
|
||||
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
|
||||
<Route path="/settings/group-markups" element={<GroupMarkupsPage />} />
|
||||
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
|
||||
<Route path="/catalog/stores" element={<StoresPage />} />
|
||||
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { Percent } from 'lucide-react'
|
||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
|
||||
interface MeResponse {
|
||||
sub: string
|
||||
|
|
@ -19,16 +21,21 @@ interface MeResponse {
|
|||
orgId: string
|
||||
}
|
||||
|
||||
const nav = [
|
||||
{ group: 'Главное', items: [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
]},
|
||||
{ group: 'Каталог', items: [
|
||||
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
|
||||
type NavSection = { group: string; items: NavItem[] }
|
||||
|
||||
function buildNav(showPriceTypes: boolean): NavSection[] {
|
||||
const catalog: NavItem[] = [
|
||||
{ to: '/catalog/products', icon: Package, label: 'Товары' },
|
||||
{ to: '/catalog/product-groups', icon: FolderTree, 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: [
|
||||
{ to: '/catalog/counterparties', icon: Users, label: 'Контрагенты' },
|
||||
]},
|
||||
|
|
@ -55,8 +62,10 @@ const nav = [
|
|||
]},
|
||||
{ group: 'Настройки', items: [
|
||||
{ to: '/settings/organization', icon: Settings, label: 'Организация' },
|
||||
{ to: '/settings/group-markups', icon: Percent, label: 'Наценки по группам' },
|
||||
]},
|
||||
] as const
|
||||
]
|
||||
}
|
||||
|
||||
export function AppLayout() {
|
||||
const { data: me } = useQuery({
|
||||
|
|
@ -65,6 +74,9 @@ export function AppLayout() {
|
|||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const org = useOrgSettings()
|
||||
const nav = buildNav(org.data?.multiplePriceTypesEnabled ?? false)
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
// Закрывать 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'
|
||||
|
||||
interface Form { id?: string; name: string; parentId: string | null; sortOrder: number; isActive: boolean }
|
||||
const blankForm: Form = { name: '', parentId: null, sortOrder: 0, isActive: true }
|
||||
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, markupPercent: null }
|
||||
|
||||
export function ProductGroupsPage() {
|
||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL)
|
||||
|
|
@ -50,10 +50,11 @@ export function ProductGroupsPage() {
|
|||
sortKey={sortKey}
|
||||
sortOrder={sortOrder}
|
||||
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={[
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||
{ 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', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
|
||||
]}
|
||||
|
|
@ -103,6 +104,18 @@ export function ProductGroupsPage() {
|
|||
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
|
||||
/>
|
||||
</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 })} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue