diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 031938d..6250830 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 64a32d0..d457e5a 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -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 при смене маршрута. diff --git a/src/food-market.web/src/pages/GroupMarkupsPage.tsx b/src/food-market.web/src/pages/GroupMarkupsPage.tsx new file mode 100644 index 0000000..e3ce706 --- /dev/null +++ b/src/food-market.web/src/pages/GroupMarkupsPage.tsx @@ -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>('/api/catalog/product-groups?pageSize=500')).data.items, + }) + + const [rows, setRows] = useState([]) + 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 ( + save.mutate()} disabled={dirty.length === 0 || save.isPending}> + {save.isPending ? 'Сохраняю…' : `Сохранить (${dirty.length})`} + + } + > + r.id} + columns={[ + { header: 'Группа', cell: (r) => {r.path} }, + { header: 'Наценка %', width: '180px', className: 'text-right', cell: (r) => ( + { + 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" + /> + )}, + ]} + /> + + ) +} diff --git a/src/food-market.web/src/pages/ProductGroupsPage.tsx b/src/food-market.web/src/pages/ProductGroupsPage.tsx index 10ad7b7..981bd4a 100644 --- a/src/food-market.web/src/pages/ProductGroupsPage.tsx +++ b/src/food-market.web/src/pages/ProductGroupsPage.tsx @@ -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(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) => {r.path} }, + { 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) })} /> + + setForm({ ...form, markupPercent: e.target.value === '' ? null : Number(e.target.value) })} + /> + + При проведении приёмки розничная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉. + Пусто — автонаценка отключена. + + setForm({ ...form, isActive: v })} /> )}
+ При проведении приёмки розничная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉. + Пусто — автонаценка отключена. +