feat(product-card): drop ShelfLifeDays + recompose classification + auto-article + barcode trash hide
- Удаление поля «Срок годности (дней)»:
• 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:
parent
fea3498b8b
commit
eaf5b7399b
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; } = [];
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue