phase3b: product card cleanup + supply form simplification
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 42s
CI / Web (React + Vite) (push) Successful in 35s
Docker API / Build + push API (push) Successful in 47s
Docker Web / Build + push Web (push) Has been cancelled
Docker API / Deploy API on stage (push) Failing after 37s
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 42s
CI / Web (React + Vite) (push) Successful in 35s
Docker API / Build + push API (push) Successful in 47s
Docker Web / Build + push Web (push) Has been cancelled
Docker API / Deploy API on stage (push) Failing after 37s
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
b69ba4950b
commit
86930bb71b
|
|
@ -36,7 +36,8 @@ public record OrgSettingsDto(
|
|||
bool ShowMinMaxStock,
|
||||
bool AllowFractionalPrices,
|
||||
bool ShowReferencePriceOnProduct,
|
||||
bool ShowCountryOfOriginOnProduct);
|
||||
bool ShowCountryOfOriginOnProduct,
|
||||
bool ShowDescriptionOnProduct);
|
||||
|
||||
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
||||
public record OrgSettingsInput(
|
||||
|
|
@ -49,7 +50,8 @@ public record OrgSettingsInput(
|
|||
bool ShowMinMaxStock,
|
||||
bool AllowFractionalPrices,
|
||||
bool ShowReferencePriceOnProduct,
|
||||
bool ShowCountryOfOriginOnProduct);
|
||||
bool ShowCountryOfOriginOnProduct,
|
||||
bool ShowDescriptionOnProduct);
|
||||
|
||||
[HttpGet("settings")]
|
||||
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.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
|
||||
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
|
||||
o.ShowDescriptionOnProduct = input.ShowDescriptionOnProduct;
|
||||
await _db.SaveChangesAsync(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.AllowFractionalPrices,
|
||||
o.ShowReferencePriceOnProduct,
|
||||
o.ShowCountryOfOriginOnProduct);
|
||||
o.ShowCountryOfOriginOnProduct,
|
||||
o.ShowDescriptionOnProduct);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ public record SupplyDto(
|
|||
Guid SupplierId, string SupplierName,
|
||||
Guid StoreId, string StoreName,
|
||||
Guid CurrencyId, string CurrencyCode,
|
||||
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
|
||||
string? Notes,
|
||||
decimal Total, DateTime? PostedAt,
|
||||
IReadOnlyList<SupplyLineDto> Lines);
|
||||
|
|
@ -57,7 +56,6 @@ public record SupplyLineInput(
|
|||
[Range(0, 1e10)] decimal? RetailPriceOverride = null);
|
||||
public record SupplyInput(
|
||||
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
|
||||
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
|
||||
string? Notes,
|
||||
IReadOnlyList<SupplyLineInput> Lines);
|
||||
|
||||
|
|
@ -135,8 +133,6 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
|
|||
SupplierId = input.SupplierId,
|
||||
StoreId = input.StoreId,
|
||||
CurrencyId = input.CurrencyId,
|
||||
SupplierInvoiceNumber = input.SupplierInvoiceNumber,
|
||||
SupplierInvoiceDate = input.SupplierInvoiceDate,
|
||||
Notes = input.Notes,
|
||||
};
|
||||
|
||||
|
|
@ -177,8 +173,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
|
|||
supply.SupplierId = input.SupplierId;
|
||||
supply.StoreId = input.StoreId;
|
||||
supply.CurrencyId = input.CurrencyId;
|
||||
supply.SupplierInvoiceNumber = input.SupplierInvoiceNumber;
|
||||
supply.SupplierInvoiceDate = input.SupplierInvoiceDate;
|
||||
supply.Notes = input.Notes;
|
||||
|
||||
// Replace lines wholesale (simple, idempotent).
|
||||
|
|
@ -403,7 +397,6 @@ orderby l.SortOrder
|
|||
row.cp.Id, row.cp.Name,
|
||||
row.st.Id, row.st.Name,
|
||||
row.cu.Id, row.cu.Code,
|
||||
row.s.SupplierInvoiceNumber, row.s.SupplierInvoiceDate,
|
||||
row.s.Notes,
|
||||
row.s.Total, row.s.PostedAt,
|
||||
lines);
|
||||
|
|
|
|||
|
|
@ -62,4 +62,9 @@ public class Organization : Entity
|
|||
/// Default: false. Большинству KZ-магазинов это поле не нужно;
|
||||
/// включать для торговцев импортом или маркируемой продукцией.</summary>
|
||||
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 Currency Currency { get; set; } = null!;
|
||||
|
||||
public string? SupplierInvoiceNumber { get; set; }
|
||||
public DateTime? SupplierInvoiceDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Sum of line totals. Computed on save.</summary>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ public static void ConfigurePurchases(this ModelBuilder b)
|
|||
{
|
||||
e.ToTable("supplies");
|
||||
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.Total).HasPrecision(18, 4);
|
||||
|
||||
|
|
|
|||
|
|
@ -1099,6 +1099,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|||
b.Property<bool>("ShowCountryOfOriginOnProduct")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("ShowDescriptionOnProduct")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("ShowMarkedOnProduct")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
|
|
|||
|
|
@ -16,10 +16,14 @@ protected override void Up(MigrationBuilder b)
|
|||
b.AddColumn<bool>(
|
||||
name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations",
|
||||
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)
|
||||
{
|
||||
b.DropColumn(name: "ShowDescriptionOnProduct", schema: "public", table: "organizations");
|
||||
b.DropColumn(name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations");
|
||||
b.AddColumn<int>(
|
||||
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")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("ShowDescriptionOnProduct")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("ShowMarkedOnProduct")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
|
@ -1173,13 +1176,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Property<Guid>("SupplierId")
|
||||
.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")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ export interface SupplyDto {
|
|||
supplierId: string; supplierName: string;
|
||||
storeId: string; storeName: string;
|
||||
currencyId: string; currencyCode: string;
|
||||
supplierInvoiceNumber: string | null; supplierInvoiceDate: string | null;
|
||||
notes: string | null;
|
||||
total: number; postedAt: string | null;
|
||||
lines: SupplyLineDto[];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface OrgSettings {
|
|||
allowFractionalPrices: boolean
|
||||
showReferencePriceOnProduct: boolean
|
||||
showCountryOfOriginOnProduct: boolean
|
||||
showDescriptionOnProduct: boolean
|
||||
}
|
||||
|
||||
export function useOrgSettings() {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function OrganizationSettingsPage() {
|
|||
allowFractionalPrices: form.allowFractionalPrices,
|
||||
showReferencePriceOnProduct: form.showReferencePriceOnProduct,
|
||||
showCountryOfOriginOnProduct: form.showCountryOfOriginOnProduct,
|
||||
showDescriptionOnProduct: form.showDescriptionOnProduct,
|
||||
}
|
||||
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
|
||||
},
|
||||
|
|
@ -172,6 +173,16 @@ export function OrganizationSettingsPage() {
|
|||
По умолчанию выключено. Включай если торгуешь импортом или ведёшь
|
||||
учёт по странам — тогда в карточке товара будет селект «Страна происхождения».
|
||||
</p>
|
||||
|
||||
<Checkbox
|
||||
label='Показывать «Описание» на товаре'
|
||||
checked={form.showDescriptionOnProduct}
|
||||
onChange={(v) => setForm({ ...form, showDescriptionOnProduct: v })}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 -mt-2">
|
||||
По умолчанию выключено — описания захламляют карточку. Включай если ведёшь
|
||||
подробные тексты на товарах.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 flex gap-3 items-center">
|
||||
|
|
|
|||
|
|
@ -266,10 +266,70 @@ export function ProductEditPage() {
|
|||
<Field label="Артикул *">
|
||||
<TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Описание" className="col-span-3">
|
||||
</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>
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Классификация">
|
||||
|
|
@ -331,7 +391,39 @@ export function ProductEditPage() {
|
|||
</div>
|
||||
</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}>
|
||||
{org.data?.showReferencePriceOnProduct && (
|
||||
<Field label="Эталонная цена">
|
||||
|
|
@ -363,66 +455,14 @@ export function ProductEditPage() {
|
|||
</Field>
|
||||
)}
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
{org.data?.showMinMaxStock && (
|
||||
<AdvancedSection>
|
||||
<Grid cols={4}>
|
||||
<Field label="Минимальный остаток (для уведомления)">
|
||||
<NumberInput
|
||||
value={form.minStock === '' ? null : Number(form.minStock)}
|
||||
onChange={(n) => setForm({ ...form, minStock: n == null ? '' : String(n) })}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Максимальный остаток (для автозаказа)">
|
||||
<NumberInput
|
||||
value={form.maxStock === '' ? null : Number(form.maxStock)}
|
||||
onChange={(n) => setForm({ ...form, maxStock: n == null ? '' : String(n) })}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
</Grid>
|
||||
</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>
|
||||
}
|
||||
>
|
||||
<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 пустое). Системная запись
|
||||
* (IsSystem) и обязательные (IsRequired) помечаются звёздочкой. */}
|
||||
* новая, при стирании — 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)
|
||||
|
|
@ -438,7 +478,6 @@ export function ProductEditPage() {
|
|||
value={row?.amount ?? null}
|
||||
onChange={(n) => {
|
||||
if (n == null) {
|
||||
// Стерли значение — удаляем строку, чтобы не слать 0 как required.
|
||||
setForm({ ...form, prices: form.prices.filter(x => x.priceTypeId !== pt.id) })
|
||||
return
|
||||
}
|
||||
|
|
@ -465,65 +504,35 @@ export function ProductEditPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{org.data?.showMinMaxStock && (
|
||||
<AdvancedSection>
|
||||
<Grid cols={4}>
|
||||
<Field label="Минимальный остаток (для уведомления)">
|
||||
<NumberInput
|
||||
value={form.minStock === '' ? null : Number(form.minStock)}
|
||||
onChange={(n) => setForm({ ...form, minStock: n == null ? '' : String(n) })}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Максимальный остаток (для автозаказа)">
|
||||
<NumberInput
|
||||
value={form.maxStock === '' ? null : Number(form.maxStock)}
|
||||
onChange={(n) => setForm({ ...form, maxStock: n == null ? '' : String(n) })}
|
||||
placeholder="—"
|
||||
/>
|
||||
</Field>
|
||||
</Grid>
|
||||
</AdvancedSection>
|
||||
)}
|
||||
|
||||
{!isNew && id && (
|
||||
<Section title="Изображения">
|
||||
<ProductImageGallery productId={id} />
|
||||
</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>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ interface Form {
|
|||
supplierId: string
|
||||
storeId: string
|
||||
currencyId: string
|
||||
supplierInvoiceNumber: string
|
||||
supplierInvoiceDate: string
|
||||
notes: string
|
||||
lines: LineRow[]
|
||||
}
|
||||
|
|
@ -41,7 +39,6 @@ const todayIso = () => new Date().toISOString().slice(0, 10)
|
|||
const emptyForm: Form = {
|
||||
date: todayIso(),
|
||||
supplierId: '', storeId: '', currencyId: '',
|
||||
supplierInvoiceNumber: '', supplierInvoiceDate: '',
|
||||
notes: '',
|
||||
lines: [],
|
||||
}
|
||||
|
|
@ -75,8 +72,6 @@ export function SupplyEditPage() {
|
|||
supplierId: s.supplierId,
|
||||
storeId: s.storeId,
|
||||
currencyId: s.currencyId,
|
||||
supplierInvoiceNumber: s.supplierInvoiceNumber ?? '',
|
||||
supplierInvoiceDate: s.supplierInvoiceDate ? s.supplierInvoiceDate.slice(0, 10) : '',
|
||||
notes: s.notes ?? '',
|
||||
lines: s.lines.map((l) => ({
|
||||
id: l.id ?? undefined,
|
||||
|
|
@ -126,8 +121,6 @@ export function SupplyEditPage() {
|
|||
supplierId: form.supplierId,
|
||||
storeId: form.storeId,
|
||||
currencyId: form.currencyId,
|
||||
supplierInvoiceNumber: form.supplierInvoiceNumber || null,
|
||||
supplierInvoiceDate: form.supplierInvoiceDate ? new Date(form.supplierInvoiceDate).toISOString() : null,
|
||||
notes: form.notes || null,
|
||||
lines: form.lines.map((l) => ({
|
||||
productId: l.productId,
|
||||
|
|
@ -276,6 +269,7 @@ export function SupplyEditPage() {
|
|||
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
{(stores.data?.length ?? 0) > 1 && (
|
||||
<Field label="Склад *">
|
||||
<Select value={form.storeId} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
|
||||
|
|
@ -283,6 +277,7 @@ export function SupplyEditPage() {
|
|||
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
{org.data?.multiCurrencyEnabled && (
|
||||
<Field label="Валюта *">
|
||||
<Select value={form.currencyId} disabled={isPosted}
|
||||
|
|
@ -292,14 +287,6 @@ export function SupplyEditPage() {
|
|||
</Select>
|
||||
</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">
|
||||
<TextArea rows={2} value={form.notes} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue