Compare commits

..

4 commits

Author SHA1 Message Date
nns bac527d3a8 fix(retail-sale): блок пустого Draft на UI + бэк уже отказывает
Some checks failed
CI / Backend (.NET 8) (push) Successful in 1m18s
CI / Web (React + Vite) (push) Successful in 46s
Docker API / Build + push API (push) Successful in 1m30s
Docker Web / Build + push Web (push) Successful in 44s
Docker API / Deploy API on stage (push) Successful in 19s
Docker Web / Deploy Web on stage (push) Successful in 12s
CI / POS (WPF, Windows) (push) Has been cancelled
Серверная защита от пустого RetailSale пришла вместе с FK-валидацией
(commit 5716829: «Чек должен содержать хотя бы одну позицию» → 400).
Этот коммит — UI-сторона.

В RetailSaleEditPage кнопка «Сохранить» теперь disabled когда
form.lines.length === 0, с tooltip «Добавьте хотя бы одну позицию».
Раньше пользователь мог нажать Сохранить, получить 400 «empty lines»,
не понять что делать.
2026-05-08 12:09:37 +05:00
nns 319a91ff10 feat(bootstrap): системная ProductGroup «Все товары» при создании org
Гэп из e2e-отчёта: новая орга стартует с пустым каталогом групп, и
ProductsController.Create падает с 400 «ProductGroupId required» пока
юзер вручную не заведёт группу. Это плохой UX — особенно для quick-
create товара из чека или приёмки.

Что сделано:
- ProductGroup получил поле IsSystem (default false) + миграция Phase5e.
- DevDataSeeder.SeedTenantReferencesAsync теперь создаёт идемпотентно
  системную группу «Все товары» (IsSystem=true) при bootstrap'е новой
  org. Та же логика срабатывает в SuperAdminOrganizationsController.Create
  и AuthSignupController, потому что оба зовут SeedTenantReferencesAsync.
- ProductGroupsController.Delete: системная группа защищена от удаления
  (400 «Системную группу удалить нельзя.»). Иначе продукты могли бы
  осиротеть после ON DELETE RESTRICT.
- ProductEditPage / ProductQuickCreateModal: при создании нового товара
  автоматически выбирают «Все товары» (или единственную группу), чтобы
  пользователь мог сохранить продукт без лишнего клика.
2026-05-08 12:08:28 +05:00
nns 57168299ac fix(validation): обязательные FK-Guid проверяются на 400 + DbUpdateException → 400
Было: SupplyInput.SupplierId/StoreId/CurrencyId — non-nullable Guid. Если
JSON приходит без поля или с null, оно десериализуется в Guid.Empty, и
ошибка проявляется только на SaveChanges как PostgresException 23503
(FK violation) с HTTP 500. UI получает generic 500 и не понимает какое
поле виновато.

Что изменено:
- Добавлен helper RequiredGuid.FirstMissing(...) — возвращает имя первого
  Guid.Empty поля или null.
- SuppliesController.Create/Update, RetailSalesController.Create/Update,
  ProductsController.Create/Update — теперь начинают с проверки FK-Guid'ов
  и возвращают 400 {error, field} если какое-то пусто.
- В тех же контроллерах SaveChanges обёрнут в SaveOrFkErrorAsync, который
  ловит PostgresException SqlState=23503 (foreign_key_violation), парсит
  ConstraintName и возвращает 400 вместо 500. Защита для случая когда
  Guid не пуст, но указывает на удалённую/чужую запись.

TaskUpdate: closes step08-bug «Supply без supplierId → 500».
2026-05-08 12:05:01 +05:00
nns 9eb1a6c69a fix(retail-sale): блок overselling в Post — 409 если qty>остатка
Сценарий из e2e: остаток 10, продаём 99999 → /post возвращал 204
[Posted] и стоки уходили в минус. Это критично — кассир мог провести
чек на товар, которого нет, и в БД появлялся отрицательный остаток.

Что изменено:
- В Post перед SaveChanges собираем сумму запрошенного qty по каждому
  productId (учитываем дубль одного товара в нескольких строках чека).
- Читаем stocks.Quantity для всех затронутых productId на конкретном
  StoreId одним запросом.
- Если хоть по одной строке available < requested — возвращаем 409
  с body {error, lines:[{productId, productName, qty, available}]},
  не делаем SaveChanges.
- Всё под BeginTransactionAsync(Serializable): защищает от race condition
  между двумя одновременными post'ами на один товар (без блокировки оба
  бы прочли «5», списали по 3, получили бы −1).
2026-05-08 12:01:20 +05:00
12 changed files with 248 additions and 12 deletions

View file

@ -94,8 +94,13 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var owner = await _db.ProductGroups.Where(x => x.Id == id).Select(x => x.OrganizationId).FirstOrDefaultAsync(ct);
if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid();
var meta = await _db.ProductGroups
.Where(x => x.Id == id)
.Select(x => new { x.OrganizationId, x.IsSystem })
.FirstOrDefaultAsync(ct);
if (meta is null) return NotFound();
if (meta.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
if (meta.IsSystem) return BadRequest(new { error = "Системную группу удалить нельзя." });
var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct);
if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" });
var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct);

View file

@ -198,6 +198,10 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId),
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk)
return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk });
if (input.Barcodes is null || input.Barcodes.Count == 0)
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
@ -235,6 +239,10 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.UnitOfMeasureId), input.UnitOfMeasureId),
(nameof(input.ProductGroupId), input.ProductGroupId)) is { } missingFk)
return BadRequest(new { error = $"Поле {missingFk} обязательно.", field = missingFk });
if (input.Barcodes is null || input.Barcodes.Count == 0)
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)

View file

@ -124,6 +124,11 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.SupplierId), input.SupplierId),
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
@ -159,14 +164,43 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
supply.Total = supply.Lines.Sum(x => x.LineTotal);
_db.Supplies.Add(supply);
await _db.SaveChangesAsync(ct);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(supply.Id, ct);
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
}
/// <summary>SaveChanges + перехват PostgresException 23503 (FK violation).
/// Возвращает 400 с указанием поля если FK не сошёлся (например, SupplierId
/// указывает на несуществующего контрагента) — это лучше чем 500.</summary>
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
// pg.ConstraintName выглядит как FK_supplies_counterparties_SupplierId
// — вытащим из него имя FK-поля для UI.
var name = pg.ConstraintName ?? "";
string field = name.Contains("Supplier") ? "supplierId"
: name.Contains("Store") ? "storeId"
: name.Contains("Currency") ? "currencyId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.SupplierId), input.SupplierId),
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
@ -204,7 +238,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
}
supply.Total = supply.Lines.Sum(x => x.LineTotal);
await _db.SaveChangesAsync(ct);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}

View file

@ -197,6 +197,12 @@ public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct
[HttpPost, Authorize(Roles = "Admin,Cashier")]
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Чек должен содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
@ -216,14 +222,42 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
};
ApplyLines(sale, input.Lines, allowFractional);
_db.RetailSales.Add(sale);
await _db.SaveChangesAsync(ct);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
var dto = await GetInternal(sale.Id, ct);
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
}
/// <summary>SaveChanges + перехват PostgresException 23503 (FK violation).
/// Возвращает 400 с указанием поля если FK не сошёлся (например, StoreId
/// или RetailPointId указывают на несуществующую запись) — это лучше
/// чем 500.</summary>
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
{
try
{
await _db.SaveChangesAsync(ct);
return null;
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
{
var name = pg.ConstraintName ?? "";
string field = name.Contains("Store") ? "storeId"
: name.Contains("RetailPoint") ? "retailPointId"
: name.Contains("Customer") ? "customerId"
: name.Contains("Currency") ? "currencyId"
: name.Contains("Product") ? "productId"
: "(unknown)";
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
}
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Cashier")]
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
{
if (RequiredGuid.FirstMissing(
(nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Draft)
@ -245,7 +279,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
sale.Lines.Clear();
ApplyLines(sale, input.Lines, allowFractional);
await _db.SaveChangesAsync(ct);
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
@ -269,6 +303,56 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
// Транзакция Serializable: чтение остатков + запись stock_movements +
// апдейт stocks под одной блокировкой. Это защищает от race condition
// когда два кассира одновременно постят чеки на один и тот же товар:
// без транзакции оба бы прочли «на складе 5», списали по 3, и в итоге
// получили бы 1. Serializable заставляет вторую транзакцию подождать
// или откатиться при конфликте.
await using var tx = await _db.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, ct);
// Если в одном чеке один и тот же продукт встречается несколько раз,
// нужно сравнить с остатком СУММУ всех строк, а не каждую отдельно.
var requested = sale.Lines
.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
.ToList();
var productIds = requested.Select(r => r.ProductId).ToList();
var stocksByProduct = await _db.Stocks
.Where(s => s.StoreId == sale.StoreId && productIds.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var insufficient = new List<object>();
foreach (var r in requested)
{
stocksByProduct.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products
.Where(p => p.Id == r.ProductId)
.Select(p => p.Name)
.FirstOrDefaultAsync(ct);
insufficient.Add(new
{
productId = r.ProductId,
productName = name,
qty = r.Quantity,
available,
});
}
}
if (insufficient.Count > 0)
{
return Conflict(new
{
error = "Недостаточно остатка для проведения чека.",
lines = insufficient,
});
}
foreach (var line in sale.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
@ -286,6 +370,7 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
sale.Status = RetailSaleStatus.Posted;
sale.PostedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return NoContent();
}

View file

@ -174,6 +174,23 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
// организации все active globals через junction org_units_of_measure.
await EnableAllActiveUnitsForOrgAsync(db, orgId, ct);
// Phase5e: системная группа товаров «Все товары». Любой новый продукт,
// если юзер не указал группу явно, должен иметь хоть какую-то — без
// этой группы ProductsController.Create падал с 400 на новой орге.
var hasSystemGroup = await db.ProductGroups.IgnoreQueryFilters()
.AnyAsync(g => g.OrganizationId == orgId && g.IsSystem, ct);
if (!hasSystemGroup)
{
db.ProductGroups.Add(new ProductGroup
{
OrganizationId = orgId,
Name = "Все товары",
Path = "Все товары",
SortOrder = 0,
IsSystem = true,
});
}
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
// Если есть — никогда не создаём «системную копию», корректность IsSystem
// обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту:

View file

@ -0,0 +1,20 @@
namespace foodmarket.Application.Common;
/// <summary>Helper для контроллеров: проверка обязательных Guid-полей в DTO.
/// Без него отсутствующее в JSON поле десериализуется в Guid.Empty
/// (000…0), и ошибка проявляется только на SaveChanges как FK violation
/// 500 — что неудобно для UI. Этот helper возвращает payload для 400.</summary>
public static class RequiredGuid
{
/// <summary>Возвращает первое пустое поле из набора (fieldName, value),
/// или null если все заполнены. Контроллер сам решает что делать с null —
/// обычно return BadRequest(new { error, field }).</summary>
public static string? FirstMissing(params (string Field, Guid Value)[] fields)
{
foreach (var (name, value) in fields)
{
if (value == Guid.Empty) return name;
}
return null;
}
}

View file

@ -18,4 +18,10 @@ public class ProductGroup : Entity, IOptionalTenantEntity
/// <summary>Процент наценки на себестоимость для автоматического расчёта
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
public decimal? MarkupPercent { get; set; }
/// <summary>Системная группа создаётся при bootstrap'е новой org как
/// дефолт, чтобы любой новый продукт мог иметь хоть какую-то группу без
/// предварительной настройки. Удалить или переименовать нельзя — иначе
/// продукты осиротеют.</summary>
public bool IsSystem { get; set; }
}

View file

@ -108,6 +108,7 @@ private static void ConfigureProductGroup(EntityTypeBuilder<ProductGroup> b)
b.ToTable("product_groups");
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
b.Property(x => x.Path).HasMaxLength(1000);
b.Property(x => x.IsSystem).HasDefaultValue(false);
b.HasOne(x => x.Parent)
.WithMany(x => x.Children)
.HasForeignKey(x => x.ParentId)

View file

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase5e — добавляем product_groups.IsSystem (default false).
/// Используется для бутстрап-группы «Все товары», создаваемой при
/// запуске новой org: продукт без явной группы попадает в неё, удалить
/// её нельзя, иначе продукты осиротеют.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260508200000_Phase5e_ProductGroupIsSystem")]
public partial class Phase5e_ProductGroupIsSystem : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<bool>(
name: "IsSystem",
schema: "public",
table: "product_groups",
type: "boolean",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder b)
{
b.DropColumn(
name: "IsSystem",
schema: "public",
table: "product_groups");
}
}
}

View file

@ -4,7 +4,7 @@ import { api } from '@/lib/api'
import { Modal } from '@/components/Modal'
import { Button } from '@/components/Button'
import { Field, TextInput, Select, AsyncSelect } from '@/components/Field'
import { useUnits } from '@/lib/useLookups'
import { useUnits, useProductGroups } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, type Product } from '@/lib/types'
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
@ -26,6 +26,7 @@ interface Props {
* pre-fill'ятся из набранного запроса в зависимости от типа. */
export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) {
const units = useUnits()
const groups = useProductGroups()
const org = useOrgSettings()
const [name, setName] = useState('')
@ -66,7 +67,13 @@ export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: P
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
setUnitId(sht.id)
}
}, [open, units.data, unitId])
// Дефолт ProductGroup — системная «Все товары» (Phase5e), либо
// единственная имеющаяся.
if (!groupId && groups.data?.length) {
const sys = groups.data.find((g) => g.name === 'Все товары') ?? (groups.data.length === 1 ? groups.data[0] : undefined)
if (sys) setGroupId(sys.id)
}
}, [open, units.data, groups.data, unitId, groupId])
const create = useMutation({
mutationFn: async () => {

View file

@ -6,7 +6,7 @@ import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import {
useUnits, useCountries, useCurrencies, usePriceTypes,
useUnits, useCountries, useCurrencies, usePriceTypes, useProductGroups,
} from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, Packaging, type Product } from '@/lib/types'
@ -57,6 +57,7 @@ export function ProductEditPage() {
const qc = useQueryClient()
const units = useUnits()
const groups = useProductGroups()
const countries = useCountries()
const currencies = useCurrencies()
const priceTypes = usePriceTypes()
@ -99,6 +100,14 @@ export function ProductEditPage() {
setForm((f) => ({ ...f, unitOfMeasureId: sht.id }))
}
// Дефолт ProductGroup — системная «Все товары» (Phase5e). Если у орги
// ровно одна группа (тот самый системный bootstrap) — авто-выбираем её,
// чтобы пользователь мог сохранить продукт без дополнительного клика.
if (isNew && form.productGroupId === '' && groups.data?.length) {
const sys = groups.data.find((g) => g.name === 'Все товары') ?? (groups.data.length === 1 ? groups.data[0] : undefined)
if (sys) setForm((f) => ({ ...f, productGroupId: sys.id }))
}
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
const def = org.data?.defaultCurrencyId
? currencies.data?.find(c => c.id === org.data!.defaultCurrencyId)
@ -124,7 +133,7 @@ export function ProductEditPage() {
barcodes: [{ code: generateEan13InternalPrefix2(), type: BarcodeType.Ean13, isPrimary: true }],
}))
}
}, [isNew, units.data, currencies.data, org.data, form.unitOfMeasureId, form.purchaseCurrencyId, form.vat, form.article, 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({
mutationFn: async () => {

View file

@ -196,7 +196,10 @@ export function RetailSaleEditPage() {
const removeLine = (i: number) =>
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
const canSave = !!form.storeId && !!form.currencyId && isDraft
// Сохранять Draft без позиций — бессмысленно (его потом всё равно
// нельзя провести). Сервер тоже блокирует это с 400, но UX лучше когда
// кнопка disabled с подсказкой.
const canSave = !!form.storeId && !!form.currencyId && isDraft && form.lines.length > 0
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
@ -228,7 +231,12 @@ export function RetailSaleEditPage() {
</Button>
)}
{isDraft && (
<Button type="submit" variant="secondary" disabled={!canSave || save.isPending}>
<Button
type="submit"
variant="secondary"
disabled={!canSave || save.isPending}
title={form.lines.length === 0 ? 'Добавьте хотя бы одну позицию' : undefined}
>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
)}