feat(web): product card pricing UI + settings toggles

- OrganizationSettingsPage: 2 новые галки —
  «Несколько типов цен (Опт, VIP и т.п.)» (multiplePriceTypesEnabled)
  и «Показывать «Эталонную цену» на товаре» (showReferencePriceOnProduct).
- ProductEditPage:
  • «Эталонная цена» с подписью «не обязательное поле»; рендерится только
    при showReferencePriceOnProduct=true.
  • «Себестоимость» — readonly MoneyInput, всегда виден; подпись
    «расчётная (скользящее среднее)».
  • Заголовок секции цен меняется: «Цены продажи» при multipleEnabled,
    иначе «Розничная цена».
  • Кнопка «Привести к себестоимости» (только для existing товара) —
    POST /api/catalog/products/{id}/recalc-retail. После 200 — обновляем
    дефолтный PriceType в form.prices, инвалидируем кэш. После 400 —
    показываем сообщение в общий error.
- useOrgSettings.ts: добавлены поля multiplePriceTypesEnabled и
  showReferencePriceOnProduct.

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

View file

@ -15,6 +15,8 @@ export interface OrgSettings {
showMarkedOnProduct: boolean
showMinMaxStock: boolean
allowFractionalPrices: boolean
multiplePriceTypesEnabled: boolean
showReferencePriceOnProduct: boolean
}
export function useOrgSettings() {

View file

@ -45,6 +45,8 @@ export function OrganizationSettingsPage() {
showMarkedOnProduct: form.showMarkedOnProduct,
showMinMaxStock: form.showMinMaxStock,
allowFractionalPrices: form.allowFractionalPrices,
multiplePriceTypesEnabled: form.multiplePriceTypesEnabled,
showReferencePriceOnProduct: form.showReferencePriceOnProduct,
}
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
},
@ -150,6 +152,26 @@ export function OrganizationSettingsPage() {
Включи если хочешь использовать цены с двумя знаками после запятой (1500.50 ).
По умолчанию целые тенге, без копеек.
</p>
<Checkbox
label='Несколько типов цен (Опт, VIP и т.п.)'
checked={form.multiplePriceTypesEnabled}
onChange={(v) => setForm({ ...form, multiplePriceTypesEnabled: v })}
/>
<p className="text-xs text-slate-500 -mt-2">
Если включено в меню появляется «Типы цен», на карточке товара
список цен по всем типам. По умолчанию одна розничная цена.
</p>
<Checkbox
label='Показывать «Эталонную цену» на товаре'
checked={form.showReferencePriceOnProduct}
onChange={(v) => setForm({ ...form, showReferencePriceOnProduct: v })}
/>
<p className="text-xs text-slate-500 -mt-2">
Справочная цена закупа необязательное поле. Авто-заполняется первой
проведённой приёмкой и через 30 дней без приёмок переписывается на текущую себестоимость.
</p>
</section>
<div className="mt-4 flex gap-3 items-center">

View file

@ -317,14 +317,26 @@ export function ProductEditPage() {
<Section title="Закупка">
<Grid cols={4}>
<Field label="Эталонная цена">
{org.data?.showReferencePriceOnProduct && (
<Field label="Эталонная цена">
<MoneyInput
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
onChange={(n) => setForm({ ...form, referencePrice: n == null ? '' : String(n) })}
currencyCode={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
currencySymbol={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
/>
<p className="text-xs text-slate-400 mt-1">не обязательное поле</p>
</Field>
)}
<Field label="Себестоимость">
<MoneyInput
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
onChange={(n) => setForm({ ...form, referencePrice: n == null ? '' : String(n) })}
currencyCode={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
currencySymbol={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
value={existing.data?.cost ?? 0}
onChange={() => {}}
disabled
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
/>
<p className="text-xs text-slate-400 mt-1">расчётная (скользящее среднее)</p>
</Field>
{org.data?.multiCurrencyEnabled && (
<Field label="Валюта закупки">
@ -359,8 +371,40 @@ export function ProductEditPage() {
)}
<Section
title="Цены продажи"
action={<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
title={org.data?.multiplePriceTypesEnabled ? 'Цены продажи' : 'Розничная цена'}
action={
<div className="flex items-center gap-2">
{!isNew && id && (
<Button type="button" variant="secondary" size="sm" onClick={async () => {
try {
const res = await api.post<{ retail: number }>(`/api/catalog/products/${id}/recalc-retail`)
// Обновим UI значение в form.prices для дефолтного PriceType.
const def = priceTypes.data?.find((pt) => pt.isDefault) ?? priceTypes.data?.find((pt) => pt.isRetail) ?? priceTypes.data?.[0]
if (def) {
setForm((f) => {
const has = f.prices.some(p => p.priceTypeId === def.id)
return {
...f,
prices: has
? f.prices.map(p => p.priceTypeId === def.id ? { ...p, amount: res.data.retail } : p)
: [...f.prices, { priceTypeId: def.id, amount: res.data.retail, currencyId: currencies.data?.[0]?.id ?? '' }],
}
})
}
await qc.invalidateQueries({ queryKey: ['/api/catalog/products', id] })
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? 'Ошибка пересчёта'
setError(msg)
}
}}>
Привести к себестоимости
</Button>
)}
{org.data?.multiplePriceTypesEnabled && (
<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>
)}
</div>
}
>
{form.prices.length === 0 ? (
<div className="text-sm text-slate-400 py-2">Цен ещё нет. Добавь хотя бы розничную.</div>