feat(product-card+list): drop supplier field, reorder sections, add cost column
Карточка товара: - убрано поле «Основной поставщик» из секции «Классификация» (домен/DTO оставлены без изменений; в payload отправляется null); - порядок секций: Основное → Цены → Классификация → Изображения → Штрихкоды (раньше Цены шли после Классификации). Цены — самое важное, должны быть ближе к названию товара. Список товаров: - добавлена колонка «Себестоимость» перед колонкой системной розничной цены. Источник — Product.Cost (скользящее среднее, обновляется при проведении приёмки). Cost = 0 (приёмок не было) показывается как «—», чтобы визуально отличать «не накопилось» от реальной себестоимости 0. - API: добавлен сортировочный case sort=cost,asc/desc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
94d3c4687b
commit
95bf2188c6
|
|
@ -172,6 +172,8 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
|
||||
("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name),
|
||||
("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name),
|
||||
("cost", false) => q.OrderBy(p => p.Cost).ThenBy(p => p.Name),
|
||||
("cost", true) => q.OrderByDescending(p => p.Cost).ThenBy(p => p.Name),
|
||||
("systemPrice", false) => q.OrderBy(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
|
||||
("systemPrice", true) => q.OrderByDescending(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
|
||||
("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { api } from '@/lib/api'
|
|||
import { Button } from '@/components/Button'
|
||||
import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
||||
import {
|
||||
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
|
||||
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes,
|
||||
} from '@/lib/useLookups'
|
||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
||||
|
|
@ -24,7 +24,6 @@ interface Form {
|
|||
vat: number
|
||||
vatEnabled: boolean
|
||||
productGroupId: string
|
||||
defaultSupplierId: string
|
||||
countryOfOriginId: string
|
||||
isService: boolean
|
||||
packaging: Packaging
|
||||
|
|
@ -42,7 +41,7 @@ interface Form {
|
|||
const emptyForm: Form = {
|
||||
name: '', article: '', description: '',
|
||||
unitOfMeasureId: '', vat: 16, vatEnabled: true,
|
||||
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
|
||||
productGroupId: '', countryOfOriginId: '',
|
||||
isService: false, packaging: Packaging.Piece, isMarked: false,
|
||||
minStock: '', maxStock: '',
|
||||
referencePrice: '', purchaseCurrencyId: '',
|
||||
|
|
@ -63,7 +62,6 @@ export function ProductEditPage() {
|
|||
const currencies = useCurrencies()
|
||||
const priceTypes = usePriceTypes()
|
||||
const org = useOrgSettings()
|
||||
const suppliers = useSuppliers()
|
||||
|
||||
const existing = useQuery({
|
||||
queryKey: ['/api/catalog/products', id],
|
||||
|
|
@ -80,7 +78,7 @@ export function ProductEditPage() {
|
|||
setForm({
|
||||
name: p.name, article: p.article ?? '', description: p.description ?? '',
|
||||
unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled,
|
||||
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
|
||||
productGroupId: p.productGroupId ?? '',
|
||||
countryOfOriginId: p.countryOfOriginId ?? '',
|
||||
isService: p.isService, packaging: p.packaging, isMarked: p.isMarked,
|
||||
|
||||
|
|
@ -145,7 +143,7 @@ export function ProductEditPage() {
|
|||
vat: org.data?.showVatEnabledOnProduct ? form.vat : null,
|
||||
vatEnabled: form.vatEnabled,
|
||||
productGroupId: form.productGroupId || null,
|
||||
defaultSupplierId: form.defaultSupplierId || null,
|
||||
defaultSupplierId: null,
|
||||
countryOfOriginId: form.countryOfOriginId || null,
|
||||
isService: form.isService,
|
||||
packaging: form.packaging,
|
||||
|
|
@ -332,65 +330,6 @@ export function ProductEditPage() {
|
|||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Классификация">
|
||||
<Grid cols={3}>
|
||||
<Field label="Группа *">
|
||||
<Select required value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
|
||||
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Единица измерения *">
|
||||
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
||||
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Фасовка">
|
||||
<Select value={form.packaging} onChange={(e) => setForm({ ...form, packaging: Number(e.target.value) as Packaging })}>
|
||||
<option value={Packaging.Piece}>Штучный</option>
|
||||
<option value={Packaging.Weight}>Весовой</option>
|
||||
<option value={Packaging.Liquid}>Разливной</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</Grid>
|
||||
<Grid cols={2}>
|
||||
<Field label="Основной поставщик">
|
||||
<Select value={form.defaultSupplierId} onChange={(e) => setForm({ ...form, defaultSupplierId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
{org.data?.showCountryOfOriginOnProduct && (
|
||||
<Field label="Страна происхождения">
|
||||
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</Grid>
|
||||
{org.data?.showVatEnabledOnProduct && form.vatEnabled && (
|
||||
<Grid cols={3}>
|
||||
<Field label="Ставка НДС, %">
|
||||
<NumberInput
|
||||
value={form.vat}
|
||||
onChange={(n) => setForm({ ...form, vat: n ?? 0 })}
|
||||
/>
|
||||
</Field>
|
||||
</Grid>
|
||||
)}
|
||||
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
|
||||
{org.data?.showVatEnabledOnProduct && (
|
||||
<Checkbox label="В том числе НДС" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
|
||||
)}
|
||||
{org.data?.showServiceOnProduct && (
|
||||
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
|
||||
)}
|
||||
{org.data?.showMarkedOnProduct && (
|
||||
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Цены"
|
||||
action={
|
||||
|
|
@ -506,6 +445,59 @@ export function ProductEditPage() {
|
|||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Классификация">
|
||||
<Grid cols={3}>
|
||||
<Field label="Группа *">
|
||||
<Select required value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
|
||||
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Единица измерения *">
|
||||
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
||||
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Фасовка">
|
||||
<Select value={form.packaging} onChange={(e) => setForm({ ...form, packaging: Number(e.target.value) as Packaging })}>
|
||||
<option value={Packaging.Piece}>Штучный</option>
|
||||
<option value={Packaging.Weight}>Весовой</option>
|
||||
<option value={Packaging.Liquid}>Разливной</option>
|
||||
</Select>
|
||||
</Field>
|
||||
</Grid>
|
||||
{org.data?.showCountryOfOriginOnProduct && (
|
||||
<Grid cols={2}>
|
||||
<Field label="Страна происхождения">
|
||||
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
</Grid>
|
||||
)}
|
||||
{org.data?.showVatEnabledOnProduct && form.vatEnabled && (
|
||||
<Grid cols={3}>
|
||||
<Field label="Ставка НДС, %">
|
||||
<NumberInput
|
||||
value={form.vat}
|
||||
onChange={(n) => setForm({ ...form, vat: n ?? 0 })}
|
||||
/>
|
||||
</Field>
|
||||
</Grid>
|
||||
)}
|
||||
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
|
||||
{org.data?.showVatEnabledOnProduct && (
|
||||
<Checkbox label="В том числе НДС" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
|
||||
)}
|
||||
{org.data?.showServiceOnProduct && (
|
||||
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
|
||||
)}
|
||||
{org.data?.showMarkedOnProduct && (
|
||||
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{org.data?.showMinMaxStock && (
|
||||
<AdvancedSection>
|
||||
<Grid cols={4}>
|
||||
|
|
|
|||
|
|
@ -126,6 +126,24 @@ export function ProductsPage() {
|
|||
{ header: 'Штрихкод', width: '160px', cell: (r) => (
|
||||
<span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span>
|
||||
)},
|
||||
// Себестоимость — расчётное скользящее среднее. До первой проведённой
|
||||
// приёмки = 0; в этом случае показываем «—», чтобы визуально отличать
|
||||
// «не накопилась» от «реально стоит 0».
|
||||
{
|
||||
header: 'Себестоимость',
|
||||
width: '160px',
|
||||
className: 'text-right font-mono',
|
||||
sortKey: 'cost',
|
||||
cell: (r) => {
|
||||
if (r.cost == null || r.cost === 0) return '—'
|
||||
const fractional = org.data?.allowFractionalPrices ?? false
|
||||
const num = r.cost.toLocaleString('ru',
|
||||
fractional
|
||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||
: { maximumFractionDigits: 0 })
|
||||
return `${num} ${org.data?.defaultCurrencySymbol ?? org.data?.defaultCurrencyCode ?? ''}`.trim()
|
||||
},
|
||||
},
|
||||
// Колонка системной розничной цены: заголовок = PriceType.Name той записи
|
||||
// что помечена IsSystem (если пользователь её переименовал — заголовок меняется).
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue