phase3b: product card cleanup + supply form simplification

Product card:
- Barcodes moved inside «Основное», before description.
- Description hidden behind new ShowDescriptionOnProduct setting (default false).
- «Закупка» и «Цены продажи» объединены в один блок «Цены».

Supply (приёмка):
- Удалены поля «Дата накладной» и «№ накладной поставщика»
  (избыточны: дата документа уже есть, номер можно положить в Notes).
- Поле «Склад *» скрывается если в системе всего один склад
  — для большинства мелких магазинов лишний клик не нужен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 01:00:06 +05:00
parent eaf5b7399b
commit 306153d128
15 changed files with 2114 additions and 180 deletions

View file

@ -36,7 +36,8 @@ public record OrgSettingsDto(
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct, bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct); bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput( public record OrgSettingsInput(
@ -49,7 +50,8 @@ public record OrgSettingsInput(
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct, bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct); bool ShowCountryOfOriginOnProduct,
bool ShowDescriptionOnProduct);
[HttpGet("settings")] [HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct) public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
@ -87,6 +89,7 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.AllowFractionalPrices = input.AllowFractionalPrices; o.AllowFractionalPrices = input.AllowFractionalPrices;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct; o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct; o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
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);
@ -116,5 +119,6 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.ShowMinMaxStock, o.ShowMinMaxStock,
o.AllowFractionalPrices, o.AllowFractionalPrices,
o.ShowReferencePriceOnProduct, o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct); o.ShowCountryOfOriginOnProduct,
o.ShowDescriptionOnProduct);
} }

View file

@ -44,7 +44,6 @@ public record SupplyDto(
Guid SupplierId, string SupplierName, Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName, Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode, Guid CurrencyId, string CurrencyCode,
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
string? Notes, string? Notes,
decimal Total, DateTime? PostedAt, decimal Total, DateTime? PostedAt,
IReadOnlyList<SupplyLineDto> Lines); IReadOnlyList<SupplyLineDto> Lines);
@ -57,7 +56,6 @@ public record SupplyLineInput(
[Range(0, 1e10)] decimal? RetailPriceOverride = null); [Range(0, 1e10)] decimal? RetailPriceOverride = null);
public record SupplyInput( public record SupplyInput(
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId, DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
string? Notes, string? Notes,
IReadOnlyList<SupplyLineInput> Lines); IReadOnlyList<SupplyLineInput> Lines);
@ -135,8 +133,6 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
SupplierId = input.SupplierId, SupplierId = input.SupplierId,
StoreId = input.StoreId, StoreId = input.StoreId,
CurrencyId = input.CurrencyId, CurrencyId = input.CurrencyId,
SupplierInvoiceNumber = input.SupplierInvoiceNumber,
SupplierInvoiceDate = input.SupplierInvoiceDate,
Notes = input.Notes, Notes = input.Notes,
}; };
@ -177,8 +173,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
supply.SupplierId = input.SupplierId; supply.SupplierId = input.SupplierId;
supply.StoreId = input.StoreId; supply.StoreId = input.StoreId;
supply.CurrencyId = input.CurrencyId; supply.CurrencyId = input.CurrencyId;
supply.SupplierInvoiceNumber = input.SupplierInvoiceNumber;
supply.SupplierInvoiceDate = input.SupplierInvoiceDate;
supply.Notes = input.Notes; supply.Notes = input.Notes;
// Replace lines wholesale (simple, idempotent). // Replace lines wholesale (simple, idempotent).
@ -403,7 +397,6 @@ orderby l.SortOrder
row.cp.Id, row.cp.Name, row.cp.Id, row.cp.Name,
row.st.Id, row.st.Name, row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code, row.cu.Id, row.cu.Code,
row.s.SupplierInvoiceNumber, row.s.SupplierInvoiceDate,
row.s.Notes, row.s.Notes,
row.s.Total, row.s.PostedAt, row.s.Total, row.s.PostedAt,
lines); lines);

View file

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

View file

@ -26,8 +26,6 @@ public class Supply : TenantEntity
public Guid CurrencyId { get; set; } public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!; public Currency Currency { get; set; } = null!;
public string? SupplierInvoiceNumber { get; set; }
public DateTime? SupplierInvoiceDate { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
/// <summary>Sum of line totals. Computed on save.</summary> /// <summary>Sum of line totals. Computed on save.</summary>

View file

@ -11,7 +11,6 @@ public static void ConfigurePurchases(this ModelBuilder b)
{ {
e.ToTable("supplies"); e.ToTable("supplies");
e.Property(x => x.Number).HasMaxLength(50).IsRequired(); e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.SupplierInvoiceNumber).HasMaxLength(100);
e.Property(x => x.Notes).HasMaxLength(1000); e.Property(x => x.Notes).HasMaxLength(1000);
e.Property(x => x.Total).HasPrecision(18, 4); e.Property(x => x.Total).HasPrecision(18, 4);

View file

@ -1099,6 +1099,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.Property<bool>("ShowCountryOfOriginOnProduct") b.Property<bool>("ShowCountryOfOriginOnProduct")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("ShowDescriptionOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowMarkedOnProduct") b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean"); .HasColumnType("boolean");

View file

@ -16,10 +16,14 @@ protected override void Up(MigrationBuilder b)
b.AddColumn<bool>( b.AddColumn<bool>(
name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations", name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false); type: "boolean", nullable: false, defaultValue: false);
b.AddColumn<bool>(
name: "ShowDescriptionOnProduct", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false);
} }
protected override void Down(MigrationBuilder b) protected override void Down(MigrationBuilder b)
{ {
b.DropColumn(name: "ShowDescriptionOnProduct", schema: "public", table: "organizations");
b.DropColumn(name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations"); b.DropColumn(name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations");
b.AddColumn<int>( b.AddColumn<int>(
name: "ShelfLifeDays", schema: "public", table: "products", name: "ShelfLifeDays", schema: "public", table: "products",

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>supplies.SupplierInvoiceNumber и SupplierInvoiceDate удалены.
/// Решено что эти поля избыточны: дата документа уже есть в supplies.Date,
/// а номер накладной поставщика всегда можно записать в Notes (примечание).</summary>
public partial class Phase3b_DropSupplyInvoiceFields : Migration
{
protected override void Up(MigrationBuilder b)
{
b.DropColumn(name: "SupplierInvoiceDate", schema: "public", table: "supplies");
b.DropColumn(name: "SupplierInvoiceNumber", schema: "public", table: "supplies");
}
protected override void Down(MigrationBuilder b)
{
b.AddColumn<System.DateTime>(
name: "SupplierInvoiceDate", schema: "public", table: "supplies",
type: "timestamp with time zone", nullable: true);
b.AddColumn<string>(
name: "SupplierInvoiceNumber", schema: "public", table: "supplies",
type: "character varying(100)", maxLength: 100, nullable: true);
}
}
}

View file

@ -1096,6 +1096,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("ShowCountryOfOriginOnProduct") b.Property<bool>("ShowCountryOfOriginOnProduct")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("ShowDescriptionOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowMarkedOnProduct") b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean"); .HasColumnType("boolean");
@ -1173,13 +1176,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<Guid>("SupplierId") b.Property<Guid>("SupplierId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTime?>("SupplierInvoiceDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("SupplierInvoiceNumber")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal>("Total") b.Property<decimal>("Total")
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("numeric(18,4)"); .HasColumnType("numeric(18,4)");

View file

@ -103,7 +103,6 @@ export interface SupplyDto {
supplierId: string; supplierName: string; supplierId: string; supplierName: string;
storeId: string; storeName: string; storeId: string; storeName: string;
currencyId: string; currencyCode: string; currencyId: string; currencyCode: string;
supplierInvoiceNumber: string | null; supplierInvoiceDate: string | null;
notes: string | null; notes: string | null;
total: number; postedAt: string | null; total: number; postedAt: string | null;
lines: SupplyLineDto[]; lines: SupplyLineDto[];

View file

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

View file

@ -47,6 +47,7 @@ export function OrganizationSettingsPage() {
allowFractionalPrices: form.allowFractionalPrices, allowFractionalPrices: form.allowFractionalPrices,
showReferencePriceOnProduct: form.showReferencePriceOnProduct, showReferencePriceOnProduct: form.showReferencePriceOnProduct,
showCountryOfOriginOnProduct: form.showCountryOfOriginOnProduct, showCountryOfOriginOnProduct: form.showCountryOfOriginOnProduct,
showDescriptionOnProduct: form.showDescriptionOnProduct,
} }
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
}, },
@ -172,6 +173,16 @@ export function OrganizationSettingsPage() {
По умолчанию выключено. Включай если торгуешь импортом или ведёшь По умолчанию выключено. Включай если торгуешь импортом или ведёшь
учёт по странам тогда в карточке товара будет селект «Страна происхождения». учёт по странам тогда в карточке товара будет селект «Страна происхождения».
</p> </p>
<Checkbox
label='Показывать «Описание» на товаре'
checked={form.showDescriptionOnProduct}
onChange={(v) => setForm({ ...form, showDescriptionOnProduct: 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

@ -266,10 +266,70 @@ export function ProductEditPage() {
<Field label="Артикул *"> <Field label="Артикул *">
<TextInput required 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>
<Field label="Описание" className="col-span-3">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
</Grid> </Grid>
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Штрихкоды</h3>
<Button type="button" variant="secondary" size="sm" onClick={addBarcode}>
<Plus className="w-3.5 h-3.5" /> Добавить
</Button>
</div>
{form.barcodes.length === 0 ? (
<div className="text-sm text-red-600 py-2">У товара должен быть хотя бы один штрихкод.</div>
) : (
<div className="space-y-2">
{form.barcodes.map((b, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</div>
<div className="col-span-3">
<Select value={b.type} onChange={(e) => {
const newType = Number(e.target.value) as BarcodeType
updateBarcode(i, { type: newType, code: generateBarcode(newType) })
}}>
<option value={BarcodeType.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option>
<option value={BarcodeType.Code128}>CODE 128</option>
<option value={BarcodeType.Code39}>CODE 39</option>
<option value={BarcodeType.Upca}>UPC-A</option>
<option value={BarcodeType.Upce}>UPC-E</option>
<option value={BarcodeType.Other}>Прочий</option>
</Select>
</div>
<div className="col-span-2">
<Checkbox
label="Основной"
checked={b.isPrimary}
onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })}
/>
</div>
{form.barcodes.length > 1 ? (
<button
type="button"
onClick={() => removeBarcode(i)}
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
) : (
<div className="col-span-1" />
)}
</div>
))}
</div>
)}
</div>
{org.data?.showDescriptionOnProduct && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
</div>
)}
</Section> </Section>
<Section title="Классификация"> <Section title="Классификация">
@ -331,7 +391,39 @@ export function ProductEditPage() {
</div> </div>
</Section> </Section>
<Section title="Закупка"> <Section
title="Цены"
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`)
const def = priceTypes.data?.find((pt) => pt.isSystem) ?? 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>
)}
</div>
}
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-3">Закупка</h3>
<Grid cols={4}> <Grid cols={4}>
{org.data?.showReferencePriceOnProduct && ( {org.data?.showReferencePriceOnProduct && (
<Field label="Эталонная цена"> <Field label="Эталонная цена">
@ -363,6 +455,56 @@ export function ProductEditPage() {
</Field> </Field>
)} )}
</Grid> </Grid>
<div className="mt-5 pt-5 border-t border-slate-100 dark:border-slate-800">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-3">Цены продажи</h3>
{/* Список цен рендерится по справочнику PriceType: одно поле на каждый
* тип, без выпадашки выбора. Значение хранится в form.prices,
* key = priceTypeId. Для отсутствующих записей при наборе создаётся
* новая, при стирании null Amount (UI пустое). Обязательные (IsRequired)
* помечаются звёздочкой. */}
<div className="space-y-3">
{priceTypes.data?.slice().sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((pt) => {
const idx = form.prices.findIndex(p => p.priceTypeId === pt.id)
const row = idx >= 0 ? form.prices[idx] : null
const required = pt.isRequired
return (
<div key={pt.id} className="grid grid-cols-1 sm:grid-cols-3 gap-2 items-start">
<label className="text-sm text-slate-700 dark:text-slate-200 sm:pt-2">
{pt.name}{required && <span className="text-red-500"> *</span>}
</label>
<div className="sm:col-span-2">
<MoneyInput
value={row?.amount ?? null}
onChange={(n) => {
if (n == null) {
setForm({ ...form, prices: form.prices.filter(x => x.priceTypeId !== pt.id) })
return
}
if (idx >= 0) {
updatePrice(idx, { amount: n })
} else {
const cur = currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? ''
setForm({ ...form, prices: [...form.prices, { priceTypeId: pt.id, amount: n, currencyId: cur }] })
}
}}
currencyCode={currencies.data?.find((c) => c.id === row?.currencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
currencySymbol={currencies.data?.find((c) => c.id === row?.currencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
/>
</div>
</div>
)
})}
{priceTypes.data?.length === 0 && (
<div className="text-sm text-slate-400 py-2">Нет ни одного типа цен. Создай в «Настройки Типы цен».</div>
)}
{missingRequiredPrices.length > 0 && (
<div className="text-sm text-red-600 mt-2">
Заполни обязательные цены: {missingRequiredPrices.map((pt) => `«${pt.name}»`).join(', ')} (значение должно быть больше 0).
</div>
)}
</div>
</div>
</Section> </Section>
{org.data?.showMinMaxStock && ( {org.data?.showMinMaxStock && (
@ -386,144 +528,11 @@ export function ProductEditPage() {
</AdvancedSection> </AdvancedSection>
)} )}
<Section
title="Цены продажи"
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`)
const def = priceTypes.data?.find((pt) => pt.isSystem) ?? 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>
)}
</div>
}
>
{/* Список цен рендерится по справочнику PriceType: одно поле на каждый
* тип, без выпадашки выбора. Значение хранится в form.prices,
* key = priceTypeId. Для отсутствующих записей при наборе создаётся
* новая, при стирании null Amount (UI пустое). Системная запись
* (IsSystem) и обязательные (IsRequired) помечаются звёздочкой. */}
<div className="space-y-3">
{priceTypes.data?.slice().sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((pt) => {
const idx = form.prices.findIndex(p => p.priceTypeId === pt.id)
const row = idx >= 0 ? form.prices[idx] : null
const required = pt.isRequired
return (
<div key={pt.id} className="grid grid-cols-1 sm:grid-cols-3 gap-2 items-start">
<label className="text-sm text-slate-700 dark:text-slate-200 sm:pt-2">
{pt.name}{required && <span className="text-red-500"> *</span>}
</label>
<div className="sm:col-span-2">
<MoneyInput
value={row?.amount ?? null}
onChange={(n) => {
if (n == null) {
// Стерли значение — удаляем строку, чтобы не слать 0 как required.
setForm({ ...form, prices: form.prices.filter(x => x.priceTypeId !== pt.id) })
return
}
if (idx >= 0) {
updatePrice(idx, { amount: n })
} else {
const cur = currencies.data?.find(c => c.code === 'KZT')?.id ?? currencies.data?.[0]?.id ?? ''
setForm({ ...form, prices: [...form.prices, { priceTypeId: pt.id, amount: n, currencyId: cur }] })
}
}}
currencyCode={currencies.data?.find((c) => c.id === row?.currencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
currencySymbol={currencies.data?.find((c) => c.id === row?.currencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
/>
</div>
</div>
)
})}
{priceTypes.data?.length === 0 && (
<div className="text-sm text-slate-400 py-2">Нет ни одного типа цен. Создай в «Настройки Типы цен».</div>
)}
{missingRequiredPrices.length > 0 && (
<div className="text-sm text-red-600 mt-2">
Заполни обязательные цены: {missingRequiredPrices.map((pt) => `«${pt.name}»`).join(', ')} (значение должно быть больше 0).
</div>
)}
</div>
</Section>
{!isNew && id && ( {!isNew && id && (
<Section title="Изображения"> <Section title="Изображения">
<ProductImageGallery productId={id} /> <ProductImageGallery productId={id} />
</Section> </Section>
)} )}
<Section
title="Штрихкоды"
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.barcodes.length === 0 ? (
<div className="text-sm text-red-600 py-2">У товара должен быть хотя бы один штрихкод.</div>
) : (
<div className="space-y-2">
{form.barcodes.map((b, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</div>
<div className="col-span-3">
<Select value={b.type} onChange={(e) => {
const newType = Number(e.target.value) as BarcodeType
updateBarcode(i, { type: newType, code: generateBarcode(newType) })
}}>
<option value={BarcodeType.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option>
<option value={BarcodeType.Code128}>CODE 128</option>
<option value={BarcodeType.Code39}>CODE 39</option>
<option value={BarcodeType.Upca}>UPC-A</option>
<option value={BarcodeType.Upce}>UPC-E</option>
<option value={BarcodeType.Other}>Прочий</option>
</Select>
</div>
<div className="col-span-2">
<Checkbox
label="Основной"
checked={b.isPrimary}
onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })}
/>
</div>
{form.barcodes.length > 1 ? (
<button
type="button"
onClick={() => removeBarcode(i)}
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
) : (
<div className="col-span-1" />
)}
</div>
))}
</div>
)}
</Section>
</div> </div>
</div> </div>
</form> </form>

View file

@ -30,8 +30,6 @@ interface Form {
supplierId: string supplierId: string
storeId: string storeId: string
currencyId: string currencyId: string
supplierInvoiceNumber: string
supplierInvoiceDate: string
notes: string notes: string
lines: LineRow[] lines: LineRow[]
} }
@ -41,7 +39,6 @@ const todayIso = () => new Date().toISOString().slice(0, 10)
const emptyForm: Form = { const emptyForm: Form = {
date: todayIso(), date: todayIso(),
supplierId: '', storeId: '', currencyId: '', supplierId: '', storeId: '', currencyId: '',
supplierInvoiceNumber: '', supplierInvoiceDate: '',
notes: '', notes: '',
lines: [], lines: [],
} }
@ -75,8 +72,6 @@ export function SupplyEditPage() {
supplierId: s.supplierId, supplierId: s.supplierId,
storeId: s.storeId, storeId: s.storeId,
currencyId: s.currencyId, currencyId: s.currencyId,
supplierInvoiceNumber: s.supplierInvoiceNumber ?? '',
supplierInvoiceDate: s.supplierInvoiceDate ? s.supplierInvoiceDate.slice(0, 10) : '',
notes: s.notes ?? '', notes: s.notes ?? '',
lines: s.lines.map((l) => ({ lines: s.lines.map((l) => ({
id: l.id ?? undefined, id: l.id ?? undefined,
@ -126,8 +121,6 @@ export function SupplyEditPage() {
supplierId: form.supplierId, supplierId: form.supplierId,
storeId: form.storeId, storeId: form.storeId,
currencyId: form.currencyId, currencyId: form.currencyId,
supplierInvoiceNumber: form.supplierInvoiceNumber || null,
supplierInvoiceDate: form.supplierInvoiceDate ? new Date(form.supplierInvoiceDate).toISOString() : null,
notes: form.notes || null, notes: form.notes || null,
lines: form.lines.map((l) => ({ lines: form.lines.map((l) => ({
productId: l.productId, productId: l.productId,
@ -276,13 +269,15 @@ export function SupplyEditPage() {
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} {suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select> </Select>
</Field> </Field>
<Field label="Склад *"> {(stores.data?.length ?? 0) > 1 && (
<Select value={form.storeId} disabled={isPosted} <Field label="Склад *">
onChange={(e) => setForm({ ...form, storeId: e.target.value })}> <Select value={form.storeId} disabled={isPosted}
<option value=""></option> onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)} <option value=""></option>
</Select> {stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Field> </Select>
</Field>
)}
{org.data?.multiCurrencyEnabled && ( {org.data?.multiCurrencyEnabled && (
<Field label="Валюта *"> <Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted} <Select value={form.currencyId} disabled={isPosted}
@ -292,14 +287,6 @@ export function SupplyEditPage() {
</Select> </Select>
</Field> </Field>
)} )}
<Field label="№ накладной поставщика">
<TextInput value={form.supplierInvoiceNumber} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierInvoiceNumber: e.target.value })} />
</Field>
<Field label="Дата накладной">
<TextInput type="date" value={form.supplierInvoiceDate} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierInvoiceDate: e.target.value })} />
</Field>
<Field label="Примечание" className="md:col-span-3"> <Field label="Примечание" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted} <TextArea rows={2} value={form.notes} disabled={isPosted}
onChange={(e) => setForm({ ...form, notes: e.target.value })} /> onChange={(e) => setForm({ ...form, notes: e.target.value })} />