feat(product-card): drop ShelfLifeDays + recompose classification + auto-article + barcode trash hide
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 46s
CI / Web (React + Vite) (push) Successful in 35s
Docker API / Build + push API (push) Successful in 44s
Docker Web / Build + push Web (push) Successful in 25s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 11s

- Удаление поля «Срок годности (дней)»:
  • Domain.Product.ShelfLifeDays убран,
  • миграция Phase3b_DropProductShelfLifeDays — DROP COLUMN,
  • DTO/Input/UI/фильтр в списке товаров — выпилены.

- Перекомпоновка секции «Классификация» в карточке товара:
  • ряд 1 (3 col): Группа * | Единица измерения * | Фасовка,
  • ряд 2 (2 col): Основной поставщик | Страна происхождения,
  • Страна происхождения видна только если включена настройка
    Organization.ShowCountryOfOriginOnProduct (default false).
  • Та же миграция добавляет колонку, в OrganizationSettingsPage
    появляется галка «Показывать «Страну происхождения» на товаре»
    с подсказкой про импорт.

- Артикул теперь обязательное поле с авто-генерацией:
  • ProductEditPage: метка «Артикул *», required,
  • генератор generateArticle() (timestamp[-6] + 3 random) — у нового
    товара поле сразу заполнено,
  • canSave требует непустой article. Уникальность подтверждает
    сервер (он также имеет свой fallback-генератор max+1).

- Иконка корзины в секции «Штрихкоды» рендерится только при
  form.barcodes.length > 1 — для единственной строки удаления нет
  (минимум 1 штрихкод обязателен, удалять единственный нельзя).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 00:50:05 +05:00
parent defad7cbb4
commit b69ba4950b
14 changed files with 2014 additions and 83 deletions

View file

@ -115,8 +115,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
[FromQuery] decimal? purchasePriceTo, [FromQuery] decimal? purchasePriceTo,
[FromQuery] decimal? referencePriceFrom, [FromQuery] decimal? referencePriceFrom,
[FromQuery] decimal? referencePriceTo, [FromQuery] decimal? referencePriceTo,
[FromQuery] int? shelfLifeDaysFrom,
[FromQuery] int? shelfLifeDaysTo,
[FromQuery] decimal? systemPriceFrom, [FromQuery] decimal? systemPriceFrom,
[FromQuery] decimal? systemPriceTo, [FromQuery] decimal? systemPriceTo,
CancellationToken ct) CancellationToken ct)
@ -146,8 +144,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
var refTo = referencePriceTo ?? purchasePriceTo; var refTo = referencePriceTo ?? purchasePriceTo;
if (refFrom is not null) q = q.Where(p => p.ReferencePrice >= refFrom); if (refFrom is not null) q = q.Where(p => p.ReferencePrice >= refFrom);
if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo); if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo);
if (shelfLifeDaysFrom is not null) q = q.Where(p => p.ShelfLifeDays >= shelfLifeDaysFrom);
if (shelfLifeDaysTo is not null) q = q.Where(p => p.ShelfLifeDays <= shelfLifeDaysTo);
// Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true. // Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true.
if (systemPriceFrom is not null) if (systemPriceFrom is not null)
q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom)); q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom));
@ -421,7 +417,7 @@ public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
p.ReferencePrice, p.ReferencePriceUpdatedAt, p.ReferencePrice, p.ReferencePriceUpdatedAt,
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.Cost, p.LastSupplyAt, p.Cost, p.LastSupplyAt,
p.ImageUrl, p.ShelfLifeDays, p.ImageUrl,
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(), p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList()); p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
@ -451,6 +447,5 @@ private static void Apply(Product e, ProductInput i)
} }
e.PurchaseCurrencyId = i.PurchaseCurrencyId; e.PurchaseCurrencyId = i.PurchaseCurrencyId;
e.ImageUrl = i.ImageUrl; e.ImageUrl = i.ImageUrl;
e.ShelfLifeDays = i.ShelfLifeDays;
} }
} }

View file

@ -35,7 +35,8 @@ public record OrgSettingsDto(
bool ShowMarkedOnProduct, bool ShowMarkedOnProduct,
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct); bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput( public record OrgSettingsInput(
@ -47,7 +48,8 @@ public record OrgSettingsInput(
bool ShowMarkedOnProduct, bool ShowMarkedOnProduct,
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct); bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct);
[HttpGet("settings")] [HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct) public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
@ -84,6 +86,7 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.ShowMinMaxStock = input.ShowMinMaxStock; o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices; o.AllowFractionalPrices = input.AllowFractionalPrices;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct; o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct); await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
@ -112,5 +115,6 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.ShowMarkedOnProduct, o.ShowMarkedOnProduct,
o.ShowMinMaxStock, o.ShowMinMaxStock,
o.AllowFractionalPrices, o.AllowFractionalPrices,
o.ShowReferencePriceOnProduct); o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct);
} }

View file

@ -52,7 +52,7 @@ public record ProductDto(
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt, decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
decimal Cost, DateTime? LastSupplyAt, decimal Cost, DateTime? LastSupplyAt,
string? ImageUrl, int? ShelfLifeDays, string? ImageUrl,
IReadOnlyList<ProductPriceDto> Prices, IReadOnlyList<ProductPriceDto> Prices,
IReadOnlyList<ProductBarcodeDto> Barcodes); IReadOnlyList<ProductBarcodeDto> Barcodes);
@ -90,6 +90,6 @@ public record ProductInput(
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
[Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null, [Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null, [Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, [Range(0, 100000)] int? ShelfLifeDays = null, string? ImageUrl = null,
IReadOnlyList<ProductPriceInput>? Prices = null, IReadOnlyList<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = null); IReadOnlyList<ProductBarcodeInput>? Barcodes = null);

View file

@ -54,8 +54,6 @@ public class Product : TenantEntity
public DateTime? LastSupplyAt { get; set; } public DateTime? LastSupplyAt { get; set; }
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage) public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
/// <summary>Срок годности в днях (для отчётов и фильтрации). Не обязательное.</summary>
public int? ShelfLifeDays { get; set; }
public ICollection<ProductPrice> Prices { get; set; } = []; public ICollection<ProductPrice> Prices { get; set; } = [];
public ICollection<ProductBarcode> Barcodes { get; set; } = []; public ICollection<ProductBarcode> Barcodes { get; set; } = [];

View file

@ -57,4 +57,9 @@ public class Organization : Entity
/// <summary>Показывать ли в карточке товара поле «Эталонная цена». /// <summary>Показывать ли в карточке товара поле «Эталонная цена».
/// Default: true.</summary> /// Default: true.</summary>
public bool ShowReferencePriceOnProduct { get; set; } = true; public bool ShowReferencePriceOnProduct { get; set; } = true;
/// <summary>Показывать ли в карточке товара поле «Страна происхождения».
/// Default: false. Большинству KZ-магазинов это поле не нужно;
/// включать для торговцев импортом или маркируемой продукцией.</summary>
public bool ShowCountryOfOriginOnProduct { get; set; }
} }

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>products.ShelfLifeDays удалён — поле «Срок годности (дней)»
/// решили убрать целиком из системы. Также добавлена настройка
/// organizations.ShowCountryOfOriginOnProduct (default false) — поле
/// «Страна происхождения» в карточке теперь скрыто пока не включено в настройках.</summary>
public partial class Phase3b_DropProductShelfLifeDays : Migration
{
protected override void Up(MigrationBuilder b)
{
b.DropColumn(name: "ShelfLifeDays", schema: "public", table: "products");
b.AddColumn<bool>(
name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false);
}
protected override void Down(MigrationBuilder b)
{
b.DropColumn(name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations");
b.AddColumn<int>(
name: "ShelfLifeDays", schema: "public", table: "products",
type: "integer", nullable: true);
}
}
}

View file

@ -605,9 +605,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime?>("ReferencePriceUpdatedAt") b.Property<DateTime?>("ReferencePriceUpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<int?>("ShelfLifeDays")
.HasColumnType("integer");
b.Property<Guid>("UnitOfMeasureId") b.Property<Guid>("UnitOfMeasureId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@ -1096,6 +1093,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("MultiCurrencyEnabled") b.Property<bool>("MultiCurrencyEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("ShowCountryOfOriginOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowMarkedOnProduct") b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean"); .HasColumnType("boolean");

View file

@ -58,6 +58,16 @@ export function generateEan13InternalPrefix2(): string {
return ean13() return ean13()
} }
/** Сгенерировать числовой артикул для товара. На бэкенде есть надёжный
* max(article)+1 генератор, но для UX удобно сразу подставить значение в поле:
* берём последние 6 цифр времени + 3 случайных. Уникальность подтверждает
* сервер; конфликт по unique index возвращается как 400 «Артикул уже занят». */
export function generateArticle(): string {
const ts = Date.now().toString().slice(-6)
const rnd = Math.floor(100 + Math.random() * 900).toString()
return ts + rnd
}
/** Сгенерировать штрихкод под указанный формат. */ /** Сгенерировать штрихкод под указанный формат. */
export function generateBarcode(type: BarcodeType): string { export function generateBarcode(type: BarcodeType): string {
switch (type) { switch (type) {

View file

@ -60,7 +60,7 @@ export interface Product {
referencePrice: number | null; referencePriceUpdatedAt: string | null; referencePrice: number | null; referencePriceUpdatedAt: string | null;
purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
cost: number; lastSupplyAt: string | null; cost: number; lastSupplyAt: string | null;
imageUrl: string | null; shelfLifeDays: number | null; imageUrl: string | null;
prices: ProductPrice[]; barcodes: ProductBarcode[] prices: ProductPrice[]; barcodes: ProductBarcode[]
} }

View file

@ -16,6 +16,7 @@ export interface OrgSettings {
showMinMaxStock: boolean showMinMaxStock: boolean
allowFractionalPrices: boolean allowFractionalPrices: boolean
showReferencePriceOnProduct: boolean showReferencePriceOnProduct: boolean
showCountryOfOriginOnProduct: boolean
} }
export function useOrgSettings() { export function useOrgSettings() {

View file

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

View file

@ -11,7 +11,7 @@ import {
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, Packaging, type Product } from '@/lib/types' import { BarcodeType, Packaging, type Product } from '@/lib/types'
import { ProductImageGallery } from '@/components/ProductImageGallery' import { ProductImageGallery } from '@/components/ProductImageGallery'
import { generateEan13InternalPrefix2, generateBarcode } from '@/lib/barcode' import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode'
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string } interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean } interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean }
@ -34,7 +34,6 @@ interface Form {
referencePrice: string referencePrice: string
purchaseCurrencyId: string purchaseCurrencyId: string
imageUrl: string imageUrl: string
shelfLifeDays: string
prices: PriceRow[] prices: PriceRow[]
barcodes: BarcodeRow[] barcodes: BarcodeRow[]
} }
@ -48,7 +47,6 @@ const emptyForm: Form = {
minStock: '', maxStock: '', minStock: '', maxStock: '',
referencePrice: '', purchaseCurrencyId: '', referencePrice: '', purchaseCurrencyId: '',
imageUrl: '', imageUrl: '',
shelfLifeDays: '',
prices: [], prices: [],
barcodes: [], barcodes: [],
} }
@ -91,7 +89,6 @@ export function ProductEditPage() {
referencePrice: p.referencePrice?.toString() ?? '', referencePrice: p.referencePrice?.toString() ?? '',
purchaseCurrencyId: p.purchaseCurrencyId ?? '', purchaseCurrencyId: p.purchaseCurrencyId ?? '',
imageUrl: p.imageUrl ?? '', imageUrl: p.imageUrl ?? '',
shelfLifeDays: p.shelfLifeDays?.toString() ?? '',
prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })), prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })),
barcodes: p.barcodes.map((x) => ({ id: x.id, code: x.code, type: x.type, isPrimary: x.isPrimary })), barcodes: p.barcodes.map((x) => ({ id: x.id, code: x.code, type: x.type, isPrimary: x.isPrimary })),
}) })
@ -119,6 +116,13 @@ export function ProductEditPage() {
if (isNew && org.data && form.vat === 16 && org.data.vatRate !== 16) { if (isNew && org.data && form.vat === 16 && org.data.vatRate !== 16) {
setForm((f) => ({ ...f, vat: org.data!.vatRate })) setForm((f) => ({ ...f, vat: org.data!.vatRate }))
} }
// У нового товара сразу автогенерируется артикул — уникальность
// окончательно подтверждает сервер. Поле остаётся редактируемым,
// пользователь может заменить вручную.
if (isNew && form.article === '') {
const a = generateArticle()
setForm((f) => ({ ...f, article: a }))
}
// У нового товара сразу один сгенерированный штрихкод, чтобы кнопку Сохранить // У нового товара сразу один сгенерированный штрихкод, чтобы кнопку Сохранить
// можно было нажать без лишних кликов. // можно было нажать без лишних кликов.
if (isNew && form.barcodes.length === 0) { if (isNew && form.barcodes.length === 0) {
@ -127,7 +131,7 @@ export function ProductEditPage() {
barcodes: [{ code: generateEan13InternalPrefix2(), type: BarcodeType.Ean13, isPrimary: true }], barcodes: [{ code: generateEan13InternalPrefix2(), type: BarcodeType.Ean13, isPrimary: true }],
})) }))
} }
}, [isNew, units.data, groups.data, currencies.data, org.data, form.unitOfMeasureId, form.productGroupId, form.purchaseCurrencyId, form.vat, form.barcodes.length]) }, [isNew, units.data, groups.data, currencies.data, org.data, form.unitOfMeasureId, form.productGroupId, form.purchaseCurrencyId, form.vat, form.article, form.barcodes.length])
const save = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: async () => {
@ -152,7 +156,6 @@ export function ProductEditPage() {
referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice), referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice),
purchaseCurrencyId: form.purchaseCurrencyId || null, purchaseCurrencyId: form.purchaseCurrencyId || null,
imageUrl: form.imageUrl || null, imageUrl: form.imageUrl || null,
shelfLifeDays: form.shelfLifeDays === '' ? null : Number(form.shelfLifeDays),
prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })), prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })),
barcodes: form.barcodes.map((b) => ({ code: b.code, type: b.type, isPrimary: b.isPrimary })), barcodes: form.barcodes.map((b) => ({ code: b.code, type: b.type, isPrimary: b.isPrimary })),
} }
@ -204,6 +207,7 @@ export function ProductEditPage() {
}) })
const canSave = form.name.trim().length > 0 const canSave = form.name.trim().length > 0
&& form.article.trim().length > 0
&& !!form.unitOfMeasureId && !!form.unitOfMeasureId
&& !!form.productGroupId && !!form.productGroupId
&& form.barcodes.length > 0 && form.barcodes.length > 0
@ -259,16 +263,8 @@ export function ProductEditPage() {
<Field label="Название *" className="col-span-2"> <Field label="Название *" className="col-span-2">
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field> </Field>
<Field label="Артикул"> <Field label="Артикул *">
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} /> <TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field>
<Field label="Срок годности (дней)">
<NumberInput
value={form.shelfLifeDays === '' ? null : Number(form.shelfLifeDays)}
onChange={(n) => setForm({ ...form, shelfLifeDays: n == null ? '' : String(Math.max(0, Math.round(n))) })}
placeholder="—"
/>
<p className="text-xs text-slate-400 mt-1">не обязательное поле</p>
</Field> </Field>
<Field label="Описание" className="col-span-3"> <Field label="Описание" className="col-span-3">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /> <TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
@ -278,30 +274,16 @@ export function ProductEditPage() {
<Section title="Классификация"> <Section title="Классификация">
<Grid cols={3}> <Grid cols={3}>
<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="Группа *"> <Field label="Группа *">
<Select required value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}> <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>)} {groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
</Select> </Select>
</Field> </Field>
<Field label="Страна происхождения"> <Field label="Единица измерения *">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}> <Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value=""></option> {units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select> </Select>
</Field> </Field>
<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>
</Grid>
<Grid cols={3}>
<Field label="Фасовка"> <Field label="Фасовка">
<Select value={form.packaging} onChange={(e) => setForm({ ...form, packaging: Number(e.target.value) as Packaging })}> <Select value={form.packaging} onChange={(e) => setForm({ ...form, packaging: Number(e.target.value) as Packaging })}>
<option value={Packaging.Piece}>Штучный</option> <option value={Packaging.Piece}>Штучный</option>
@ -310,6 +292,22 @@ export function ProductEditPage() {
</Select> </Select>
</Field> </Field>
</Grid> </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 && ( {org.data?.showVatEnabledOnProduct && form.vatEnabled && (
<Grid cols={3}> <Grid cols={3}>
<Field label="Ставка НДС, %"> <Field label="Ставка НДС, %">
@ -509,14 +507,18 @@ export function ProductEditPage() {
onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })} onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })}
/> />
</div> </div>
<button {form.barcodes.length > 1 ? (
type="button" <button
onClick={() => removeBarcode(i)} type="button"
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center" onClick={() => removeBarcode(i)}
title="Удалить строку" className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
> title="Удалить строку"
<Trash2 className="w-4 h-4" /> >
</button> <Trash2 className="w-4 h-4" />
</button>
) : (
<div className="col-span-1" />
)}
</div> </div>
))} ))}
</div> </div>

View file

@ -23,8 +23,6 @@ interface Filters {
isMarked: TriFilter isMarked: TriFilter
systemPriceFrom: number | null systemPriceFrom: number | null
systemPriceTo: number | null systemPriceTo: number | null
shelfLifeDaysFrom: number | null
shelfLifeDaysTo: number | null
} }
const defaultFilters: Filters = { const defaultFilters: Filters = {
@ -34,8 +32,6 @@ const defaultFilters: Filters = {
isMarked: 'all', isMarked: 'all',
systemPriceFrom: null, systemPriceFrom: null,
systemPriceTo: null, systemPriceTo: null,
shelfLifeDaysFrom: null,
shelfLifeDaysTo: null,
} }
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => { const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
@ -46,8 +42,6 @@ const toExtra = (f: Filters): Record<string, string | number | boolean | undefin
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes' if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
if (f.systemPriceFrom != null) e.systemPriceFrom = f.systemPriceFrom if (f.systemPriceFrom != null) e.systemPriceFrom = f.systemPriceFrom
if (f.systemPriceTo != null) e.systemPriceTo = f.systemPriceTo if (f.systemPriceTo != null) e.systemPriceTo = f.systemPriceTo
if (f.shelfLifeDaysFrom != null) e.shelfLifeDaysFrom = f.shelfLifeDaysFrom
if (f.shelfLifeDaysTo != null) e.shelfLifeDaysTo = f.shelfLifeDaysTo
return e return e
} }
@ -59,8 +53,6 @@ const activeFilterCount = (f: Filters) => {
if (f.isMarked !== 'all') n++ if (f.isMarked !== 'all') n++
if (f.systemPriceFrom != null) n++ if (f.systemPriceFrom != null) n++
if (f.systemPriceTo != null) n++ if (f.systemPriceTo != null) n++
if (f.shelfLifeDaysFrom != null) n++
if (f.shelfLifeDaysTo != null) n++
return n return n
} }
@ -258,23 +250,6 @@ export function ProductsPage() {
/> />
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-500">Срок годности (дней)</span>
<input
type="number" min="0"
value={filters.shelfLifeDaysFrom ?? ''}
placeholder="от"
onChange={(e) => { const v = e.target.value === '' ? null : Number(e.target.value); setFilters({ ...filters, shelfLifeDaysFrom: v }); setPage(1) }}
className="w-20 px-2 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
/>
<input
type="number" min="0"
value={filters.shelfLifeDaysTo ?? ''}
placeholder="до"
onChange={(e) => { const v = e.target.value === '' ? null : Number(e.target.value); setFilters({ ...filters, shelfLifeDaysTo: v }); setPage(1) }}
className="w-20 px-2 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
/>
</div>
{activeCount > 0 && ( {activeCount > 0 && (
<button <button
type="button" type="button"