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:
parent
eaf5b7399b
commit
306153d128
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)");
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 })} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue