phase2b: Supply document (приёмка) — posts to stock atomically

Domain (foodmarket.Domain.Purchases):
- Supply: Number (auto "П-{yyyy}-{000001}" per tenant), Date, Status
  (Draft/Posted), Supplier (Counterparty), Store, Currency, invoice refs,
  Notes, Total, PostedAt/PostedByUserId, Lines.
- SupplyLine: ProductId, Quantity, UnitPrice, LineTotal, SortOrder.

EF: supplies + supply_lines tables, unique index (tenant,Number), indexes
by date/status/supplier/product. Migration Phase2b_Supply applied.

API (/api/purchases/supplies, roles Admin/Manager/Storekeeper for mutations):
- GET list with filters (status, storeId, supplierId, search by number/name),
  projected columns.
- GET {id} with full line list joined to products + units.
- POST create draft (lines optional at creation, grand total computed).
- PUT update — replaces all lines; rejected if already Posted.
- DELETE — drafts only.
- POST {id}/post — creates +qty StockMovements via IStockService.ApplyMovementAsync
  for each line, flips to Posted, stamps PostedAt. Atomic (one SaveChanges).
- POST {id}/unpost — reverses with -qty movements tagged "supply-reversal",
  returns to Draft so edits can resume.
- Auto-numbering scans existing numbers matching prefix per year+tenant.

Web:
- types: SupplyStatus, SupplyListRow, SupplyLineDto, SupplyDto.
- /purchases/supplies list (number, date, status badge, supplier, store,
  line count, total in currency).
- /purchases/supplies/new + /:id edit page (sticky top bar with
  Back / Save / Post / Unpost / Delete; reqisites grid; lines table with
  inline qty/price and running total + grand total in bottom row).
- ProductPicker modal: full-text search over products (name/article/barcode),
  shows purchase price for quick reference, click to add line.
- Sidebar new group "Закупки" → "Приёмки" (TruckIcon).

Flow: create draft → add lines via picker → edit qty/price → Save → Post.
Posting writes StockMovement rows (visible on Движения) and updates Stock
aggregate (visible on Остатки). Unpost reverses in place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-22 01:06:08 +05:00
parent 50e3676d71
commit 61f2c21016
13 changed files with 3003 additions and 1 deletions

View file

@ -0,0 +1,293 @@
using foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Purchases;
[ApiController]
[Authorize]
[Route("api/purchases/supplies")]
public class SuppliesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
public SuppliesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
public record SupplyListRow(
Guid Id, string Number, DateTime Date, SupplyStatus Status,
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
decimal Total, int LineCount,
DateTime? PostedAt);
public record SupplyLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
string? UnitSymbol,
decimal Quantity, decimal UnitPrice, decimal LineTotal, int SortOrder);
public record SupplyDto(
Guid Id, string Number, DateTime Date, SupplyStatus Status,
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);
public record SupplyLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice);
public record SupplyInput(
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
string? Notes,
IReadOnlyList<SupplyLineInput> Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<SupplyListRow>>> List(
[FromQuery] PagedRequest req,
[FromQuery] SupplyStatus? status,
[FromQuery] Guid? storeId,
[FromQuery] Guid? supplierId,
CancellationToken ct)
{
var q = from s in _db.Supplies.AsNoTracking()
join cp in _db.Counterparties on s.SupplierId equals cp.Id
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
select new { s, cp, st, cu };
if (status is not null) q = q.Where(x => x.s.Status == status);
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (supplierId is not null) q = q.Where(x => x.s.SupplierId == supplierId);
if (!string.IsNullOrWhiteSpace(req.Search))
{
var s = req.Search.Trim().ToLower();
q = q.Where(x => x.s.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number)
.Skip(req.Skip).Take(req.Take)
.Select(x => new SupplyListRow(
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
x.cp.Id, x.cp.Name,
x.st.Id, x.st.Name,
x.cu.Id, x.cu.Code,
x.s.Total,
x.s.Lines.Count,
x.s.PostedAt))
.ToListAsync(ct);
return new PagedResult<SupplyListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
{
var number = await GenerateNumberAsync(input.Date, ct);
var supply = new Supply
{
Number = number,
Date = input.Date,
Status = SupplyStatus.Draft,
SupplierId = input.SupplierId,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
SupplierInvoiceNumber = input.SupplierInvoiceNumber,
SupplierInvoiceDate = input.SupplierInvoiceDate,
Notes = input.Notes,
};
var order = 0;
foreach (var l in input.Lines)
{
supply.Lines.Add(new SupplyLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
SortOrder = order++,
});
}
supply.Total = supply.Lines.Sum(x => x.LineTotal);
_db.Supplies.Add(supply);
await _db.SaveChangesAsync(ct);
var dto = await GetInternal(supply.Id, ct);
return CreatedAtAction(nameof(Get), new { id = supply.Id }, dto);
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
{
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." });
supply.Date = input.Date;
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).
_db.SupplyLines.RemoveRange(supply.Lines);
supply.Lines.Clear();
var order = 0;
foreach (var l in input.Lines)
{
supply.Lines.Add(new SupplyLine
{
SupplyId = supply.Id,
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
LineTotal = l.Quantity * l.UnitPrice,
SortOrder = order++,
});
}
supply.Total = supply.Lines.Sum(x => x.LineTotal);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var supply = await _db.Supplies.FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Draft)
return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." });
_db.Supplies.Remove(supply);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/post"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
{
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status == SupplyStatus.Posted) return Conflict(new { error = "Документ уже проведён." });
if (supply.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
foreach (var line in supply.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: supply.StoreId,
Quantity: line.Quantity,
Type: MovementType.Supply,
DocumentType: "supply",
DocumentId: supply.Id,
DocumentNumber: supply.Number,
UnitCost: line.UnitPrice,
OccurredAt: supply.Date), ct);
}
supply.Status = SupplyStatus.Posted;
supply.PostedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Posted) return Conflict(new { error = "Документ не проведён." });
// Reverse: negative movements with same document reference
foreach (var line in supply.Lines)
{
await _stock.ApplyMovementAsync(new StockMovementDraft(
ProductId: line.ProductId,
StoreId: supply.StoreId,
Quantity: -line.Quantity,
Type: MovementType.Supply,
DocumentType: "supply-reversal",
DocumentId: supply.Id,
DocumentNumber: supply.Number,
UnitCost: line.UnitPrice,
OccurredAt: DateTime.UtcNow,
Notes: $"Отмена проведения документа {supply.Number}"), ct);
}
supply.Status = SupplyStatus.Draft;
supply.PostedAt = null;
await _db.SaveChangesAsync(ct);
return NoContent();
}
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
{
var year = date.Year;
var prefix = $"П-{year}-";
var lastNumber = await _db.Supplies
.Where(s => s.Number.StartsWith(prefix))
.OrderByDescending(s => s.Number)
.Select(s => s.Number)
.FirstOrDefaultAsync(ct);
var seq = 1;
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
seq = last + 1;
return $"{prefix}{seq:D6}";
}
private async Task<SupplyDto?> GetInternal(Guid id, CancellationToken ct)
{
var row = await (from s in _db.Supplies.AsNoTracking()
join cp in _db.Counterparties on s.SupplierId equals cp.Id
join st in _db.Stores on s.StoreId equals st.Id
join cu in _db.Currencies on s.CurrencyId equals cu.Id
where s.Id == id
select new { s, cp, st, cu }).FirstOrDefaultAsync(ct);
if (row is null) return null;
var lines = await (from l in _db.SupplyLines.AsNoTracking()
join p in _db.Products on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
where l.SupplyId == id
orderby l.SortOrder
select new SupplyLineDto(
l.Id, l.ProductId, p.Name, p.Article, u.Symbol,
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder))
.ToListAsync(ct);
return new SupplyDto(
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
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);
}
}

View file

@ -0,0 +1,55 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Purchases;
public enum SupplyStatus
{
Draft = 0,
Posted = 1,
}
public class Supply : TenantEntity
{
/// <summary>Human-readable document number, unique per organization (e.g. "П-2026-000001").</summary>
public string Number { get; set; } = "";
public DateTime Date { get; set; } = DateTime.UtcNow;
public SupplyStatus Status { get; set; } = SupplyStatus.Draft;
public Guid SupplierId { get; set; }
public Counterparty Supplier { get; set; } = null!;
public Guid StoreId { get; set; }
public Store Store { get; set; } = null!;
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>
public decimal Total { get; set; }
public DateTime? PostedAt { get; set; }
public Guid? PostedByUserId { get; set; }
public ICollection<SupplyLine> Lines { get; set; } = new List<SupplyLine>();
}
public class SupplyLine : TenantEntity
{
public Guid SupplyId { get; set; }
public Supply Supply { get; set; } = null!;
public Guid ProductId { get; set; }
public Product Product { get; set; } = null!;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal LineTotal { get; set; }
public int SortOrder { get; set; }
}

View file

@ -2,6 +2,7 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases;
using foodmarket.Infrastructure.Identity;
using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence.Configurations;
@ -39,6 +40,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Stock> Stocks => Set<Stock>();
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
public DbSet<Supply> Supplies => Set<Supply>();
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
@ -67,6 +71,7 @@ protected override void OnModelCreating(ModelBuilder builder)
builder.ConfigureCatalog();
builder.ConfigureInventory();
builder.ConfigurePurchases();
// Apply multi-tenant query filter to every entity that implements ITenantEntity
foreach (var entityType in builder.Model.GetEntityTypes())

View file

@ -0,0 +1,42 @@
using foodmarket.Domain.Purchases;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Infrastructure.Persistence.Configurations;
public static class PurchasesConfigurations
{
public static void ConfigurePurchases(this ModelBuilder b)
{
b.Entity<Supply>(e =>
{
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);
e.HasOne(x => x.Supplier).WithMany().HasForeignKey(x => x.SupplierId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
e.HasMany(x => x.Lines).WithOne(l => l.Supply).HasForeignKey(l => l.SupplyId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
e.HasIndex(x => new { x.OrganizationId, x.Date });
e.HasIndex(x => new { x.OrganizationId, x.Status });
e.HasIndex(x => new { x.OrganizationId, x.SupplierId });
});
b.Entity<SupplyLine>(e =>
{
e.ToTable("supply_lines");
e.Property(x => x.Quantity).HasPrecision(18, 4);
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
e.Property(x => x.LineTotal).HasPrecision(18, 4);
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
});
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,171 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase2b_Supply : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "supplies",
schema: "public",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Number = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
SupplierId = table.Column<Guid>(type: "uuid", nullable: false),
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
CurrencyId = table.Column<Guid>(type: "uuid", nullable: false),
SupplierInvoiceNumber = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
SupplierInvoiceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
Total = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
PostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
PostedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_supplies", x => x.Id);
table.ForeignKey(
name: "FK_supplies_counterparties_SupplierId",
column: x => x.SupplierId,
principalSchema: "public",
principalTable: "counterparties",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_supplies_currencies_CurrencyId",
column: x => x.CurrencyId,
principalSchema: "public",
principalTable: "currencies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_supplies_stores_StoreId",
column: x => x.StoreId,
principalSchema: "public",
principalTable: "stores",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "supply_lines",
schema: "public",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SupplyId = table.Column<Guid>(type: "uuid", nullable: false),
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_supply_lines", x => x.Id);
table.ForeignKey(
name: "FK_supply_lines_products_ProductId",
column: x => x.ProductId,
principalSchema: "public",
principalTable: "products",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_supply_lines_supplies_SupplyId",
column: x => x.SupplyId,
principalSchema: "public",
principalTable: "supplies",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_supplies_CurrencyId",
schema: "public",
table: "supplies",
column: "CurrencyId");
migrationBuilder.CreateIndex(
name: "IX_supplies_OrganizationId_Date",
schema: "public",
table: "supplies",
columns: new[] { "OrganizationId", "Date" });
migrationBuilder.CreateIndex(
name: "IX_supplies_OrganizationId_Number",
schema: "public",
table: "supplies",
columns: new[] { "OrganizationId", "Number" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_supplies_OrganizationId_Status",
schema: "public",
table: "supplies",
columns: new[] { "OrganizationId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_supplies_OrganizationId_SupplierId",
schema: "public",
table: "supplies",
columns: new[] { "OrganizationId", "SupplierId" });
migrationBuilder.CreateIndex(
name: "IX_supplies_StoreId",
schema: "public",
table: "supplies",
column: "StoreId");
migrationBuilder.CreateIndex(
name: "IX_supplies_SupplierId",
schema: "public",
table: "supplies",
column: "SupplierId");
migrationBuilder.CreateIndex(
name: "IX_supply_lines_OrganizationId_ProductId",
schema: "public",
table: "supply_lines",
columns: new[] { "OrganizationId", "ProductId" });
migrationBuilder.CreateIndex(
name: "IX_supply_lines_ProductId",
schema: "public",
table: "supply_lines",
column: "ProductId");
migrationBuilder.CreateIndex(
name: "IX_supply_lines_SupplyId",
schema: "public",
table: "supply_lines",
column: "SupplyId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "supply_lines",
schema: "public");
migrationBuilder.DropTable(
name: "supplies",
schema: "public");
}
}
}

View file

@ -1150,6 +1150,129 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("organizations", "public");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CurrencyId")
.HasColumnType("uuid");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Number")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTime?>("PostedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PostedByUserId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("StoreId")
.HasColumnType("uuid");
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)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CurrencyId");
b.HasIndex("StoreId");
b.HasIndex("SupplierId");
b.HasIndex("OrganizationId", "Date");
b.HasIndex("OrganizationId", "Number")
.IsUnique();
b.HasIndex("OrganizationId", "Status");
b.HasIndex("OrganizationId", "SupplierId");
b.ToTable("supplies", "public");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("LineTotal")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("ProductId")
.HasColumnType("uuid");
b.Property<decimal>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<Guid>("SupplyId")
.HasColumnType("uuid");
b.Property<decimal>("UnitPrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProductId");
b.HasIndex("SupplyId");
b.HasIndex("OrganizationId", "ProductId");
b.ToTable("supply_lines", "public");
});
modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b =>
{
b.Property<Guid>("Id")
@ -1505,6 +1628,52 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Store");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
{
b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency")
.WithMany()
.HasForeignKey("CurrencyId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Supplier")
.WithMany()
.HasForeignKey("SupplierId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Currency");
b.Navigation("Store");
b.Navigation("Supplier");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.SupplyLine", b =>
{
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("foodmarket.Domain.Purchases.Supply", "Supply")
.WithMany("Lines")
.HasForeignKey("SupplyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Product");
b.Navigation("Supply");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Navigation("Authorizations");
@ -1530,6 +1699,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
b.Navigation("Children");
});
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
{
b.Navigation("Lines");
});
#pragma warning restore 612, 618
}
}

View file

@ -16,6 +16,8 @@ import { ProductEditPage } from '@/pages/ProductEditPage'
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
import { StockPage } from '@/pages/StockPage'
import { StockMovementsPage } from '@/pages/StockMovementsPage'
import { SuppliesPage } from '@/pages/SuppliesPage'
import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { AppLayout } from '@/components/AppLayout'
import { ProtectedRoute } from '@/components/ProtectedRoute'
@ -51,6 +53,9 @@ export default function App() {
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
<Route path="/inventory/stock" element={<StockPage />} />
<Route path="/inventory/movements" element={<StockMovementsPage />} />
<Route path="/purchases/supplies" element={<SuppliesPage />} />
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
</Route>
</Route>

View file

@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
import {
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
Boxes, History,
Boxes, History, TruckIcon,
} from 'lucide-react'
import { Logo } from './Logo'
@ -40,6 +40,9 @@ const nav = [
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' },
{ to: '/inventory/movements', icon: History, label: 'Движения' },
]},
{ group: 'Закупки', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
]},
{ group: 'Справочники', items: [
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },

View file

@ -0,0 +1,88 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Search, X } from 'lucide-react'
import { api } from '@/lib/api'
import type { PagedResult, Product } from '@/lib/types'
interface Props {
open: boolean
onClose: () => void
onPick: (product: Product) => void
title?: string
}
export function ProductPicker({ open, onClose, onPick, title = 'Выбор товара' }: Props) {
const [search, setSearch] = useState('')
useEffect(() => { if (!open) setSearch('') }, [open])
const results = useQuery({
queryKey: ['product-picker', search],
queryFn: async () => {
const params = new URLSearchParams({ pageSize: '30' })
if (search) params.set('search', search)
return (await api.get<PagedResult<Product>>(`/api/catalog/products?${params}`)).data.items
},
enabled: open,
})
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div className="bg-white dark:bg-slate-900 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-5 py-3 border-b border-slate-200 dark:border-slate-800">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
autoFocus
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="По названию, артикулу или штрихкоду…"
className="w-full pl-9 pr-3 py-2 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)]"
/>
</div>
</div>
<div className="flex-1 overflow-auto">
{results.isLoading && <div className="p-6 text-center text-slate-400 text-sm">Загрузка</div>}
{results.data && results.data.length === 0 && (
<div className="p-6 text-center text-slate-400 text-sm">
{search ? 'Ничего не найдено' : 'Начни вводить название или штрихкод'}
</div>
)}
{results.data && results.data.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { onPick(p); onClose() }}
className="w-full text-left px-5 py-2.5 border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center justify-between gap-3"
>
<div className="min-w-0">
<div className="font-medium text-slate-900 dark:text-slate-100 truncate">{p.name}</div>
<div className="text-xs text-slate-400 flex gap-2 font-mono">
{p.article && <span>{p.article}</span>}
{p.barcodes[0] && <span>· {p.barcodes[0].code}</span>}
<span>· {p.unitSymbol}</span>
</div>
</div>
{p.purchasePrice !== null && (
<div className="text-xs text-slate-500 font-mono flex-shrink-0">
закуп: {p.purchasePrice.toLocaleString('ru')} {p.purchaseCurrencyCode ?? ''}
</div>
)}
</button>
))}
</div>
</div>
</div>
)
}

View file

@ -69,3 +69,31 @@ export interface MovementRow {
type: string; documentType: string; documentId: string | null; documentNumber: string | null;
notes: string | null;
}
export const SupplyStatus = { Draft: 0, Posted: 1 } as const
export type SupplyStatus = (typeof SupplyStatus)[keyof typeof SupplyStatus]
export interface SupplyListRow {
id: string; number: string; date: string; status: SupplyStatus;
supplierId: string; supplierName: string;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
total: number; lineCount: number; postedAt: string | null;
}
export interface SupplyLineDto {
id: string | null; productId: string;
productName: string | null; productArticle: string | null; unitSymbol: string | null;
quantity: number; unitPrice: number; lineTotal: number; sortOrder: number;
}
export interface SupplyDto {
id: string; number: string; date: string; status: SupplyStatus;
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[];
}

View file

@ -0,0 +1,55 @@
import { Link, useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { type SupplyListRow, SupplyStatus } from '@/lib/types'
const URL = '/api/purchases/supplies'
export function SuppliesPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<SupplyListRow>(URL)
return (
<ListPageShell
title="Приёмки от поставщиков"
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Документы поступления товара от поставщиков'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
<Link to="/purchases/supplies/new">
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
</Link>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => navigate(`/purchases/supplies/${r.id}`)}
columns={[
{ header: '№', width: '160px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '120px', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '130px', cell: (r) => (
r.status === SupplyStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Поставщик', cell: (r) => r.supplierName },
{ header: 'Склад', width: '180px', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` },
]}
empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,370 @@
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select } from '@/components/Field'
import { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
interface LineRow {
id?: string
productId: string
productName: string
productArticle: string | null
unitSymbol: string | null
quantity: number
unitPrice: number
}
interface Form {
date: string
supplierId: string
storeId: string
currencyId: string
supplierInvoiceNumber: string
supplierInvoiceDate: string
notes: string
lines: LineRow[]
}
const todayIso = () => new Date().toISOString().slice(0, 10)
const emptyForm: Form = {
date: todayIso(),
supplierId: '', storeId: '', currencyId: '',
supplierInvoiceNumber: '', supplierInvoiceDate: '',
notes: '',
lines: [],
}
export function SupplyEditPage() {
const { id } = useParams<{ id: string }>()
const isNew = !id || id === 'new'
const navigate = useNavigate()
const qc = useQueryClient()
const stores = useStores()
const currencies = useCurrencies()
const suppliers = useSuppliers()
const [form, setForm] = useState<Form>(emptyForm)
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
const existing = useQuery({
queryKey: ['/api/purchases/supplies', id],
queryFn: async () => (await api.get<SupplyDto>(`/api/purchases/supplies/${id}`)).data,
enabled: !isNew,
})
useEffect(() => {
if (!isNew && existing.data) {
const s = existing.data
setForm({
date: s.date.slice(0, 10),
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,
productId: l.productId,
productName: l.productName ?? '',
productArticle: l.productArticle,
unitSymbol: l.unitSymbol,
quantity: l.quantity,
unitPrice: l.unitPrice,
})),
})
}
}, [isNew, existing.data])
useEffect(() => {
// Prefill defaults for new document.
if (isNew) {
if (!form.storeId && stores.data?.length) {
const main = stores.data.find((s) => s.isMain) ?? stores.data[0]
setForm((f) => ({ ...f, storeId: main.id }))
}
if (!form.currencyId && currencies.data?.length) {
const kzt = currencies.data.find((c) => c.code === 'KZT') ?? currencies.data[0]
setForm((f) => ({ ...f, currencyId: kzt.id }))
}
if (!form.supplierId && suppliers.data?.length) {
setForm((f) => ({ ...f, supplierId: suppliers.data![0].id }))
}
}
}, [isNew, stores.data, currencies.data, suppliers.data, form.storeId, form.currencyId, form.supplierId])
const isDraft = isNew || existing.data?.status === SupplyStatus.Draft
const isPosted = existing.data?.status === SupplyStatus.Posted
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice
const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0)
const save = useMutation({
mutationFn: async () => {
const payload = {
date: new Date(form.date).toISOString(),
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, quantity: l.quantity, unitPrice: l.unitPrice })),
}
if (isNew) {
return (await api.post<SupplyDto>('/api/purchases/supplies', payload)).data
}
await api.put(`/api/purchases/supplies/${id}`, payload)
return null
},
onSuccess: (created) => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
navigate(created ? `/purchases/supplies/${created.id}` : `/purchases/supplies/${id}`)
},
onError: (e: Error) => setError(e.message),
})
const post = useMutation({
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/post`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/purchases/supplies/${id}/unpost`) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch()
},
onError: (e: Error) => setError(e.message),
})
const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/purchases/supplies/${id}`) },
onSuccess: () => navigate('/purchases/supplies'),
onError: (e: Error) => setError(e.message),
})
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
const addLineFromProduct = (p: Product) => {
setForm({
...form,
lines: [...form.lines, {
productId: p.id,
productName: p.name,
productArticle: p.article,
unitSymbol: p.unitSymbol,
quantity: 1,
unitPrice: p.purchasePrice ?? 0,
}],
})
}
const updateLine = (i: number, patch: Partial<LineRow>) =>
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
const removeLine = (i: number) =>
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
const canSave = !!form.supplierId && !!form.storeId && !!form.currencyId && isDraft
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
{/* Sticky top bar */}
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="flex items-center gap-3 min-w-0">
<Link to="/purchases/supplies" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая приёмка' : existing.data?.number ?? 'Приёмка'}
</h1>
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — товар не попадает на склад, пока не проведёшь'}
</p>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
{isPosted && (
<Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}>
<Undo2 className="w-4 h-4" /> Отменить проведение
</Button>
)}
{isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик приёмки?')) remove.mutate() }}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
{isDraft && (
<Button type="submit" variant="secondary" disabled={!canSave || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
</Button>
)}
{isDraft && !isNew && (
<Button type="button" onClick={() => post.mutate()} disabled={post.isPending || form.lines.length === 0}>
<CheckCircle className="w-4 h-4" /> {post.isPending ? 'Провожу…' : 'Провести'}
</Button>
)}
</div>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-6 space-y-5">
{error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
<Section title="Реквизиты документа">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата">
<TextInput type="date" value={form.date} disabled={isPosted}
onChange={(e) => setForm({ ...form, date: e.target.value })} />
</Field>
<Field label="Поставщик *">
<Select value={form.supplierId} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierId: e.target.value })}>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Склад *">
<Select value={form.storeId} disabled={isPosted}
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
<option value=""></option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted}
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</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 })} />
</Field>
</div>
</Section>
<Section
title="Позиции"
action={!isPosted && (
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
<Plus className="w-3.5 h-3.5" /> Добавить товар
</Button>
)}
>
{form.lines.length === 0 ? (
<div className="text-sm text-slate-400 py-4 text-center">Позиций нет. Нажми «Добавить товар».</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="py-2 pr-3 font-medium text-xs uppercase tracking-wide text-slate-500">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[90px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Количество</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Цена</th>
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[160px] text-right">Сумма</th>
<th className="py-2 pl-3 w-[40px]"></th>
</tr>
</thead>
<tbody>
{form.lines.map((l, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-2 pr-3">
<div className="font-medium">{l.productName}</div>
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono"
value={l.quantity}
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} />
</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted}
className="text-right font-mono"
value={l.unitPrice}
onChange={(e) => updateLine(i, { unitPrice: Number(e.target.value) })} />
</td>
<td className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
</td>
<td className="py-2 pl-3">
{!isPosted && (
<button type="button" onClick={() => removeLine(i)} className="text-slate-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={4} className="py-3 pr-3 text-right text-sm font-semibold text-slate-600 dark:text-slate-300">
Итого:
</td>
<td className="py-3 px-3 text-right font-mono text-lg font-bold">
{grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}
{' '}
<span className="text-sm text-slate-500">
{currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}
</span>
</td>
<td />
</tr>
</tfoot>
</table>
</div>
)}
</Section>
</div>
</div>
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
</form>
)
}
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
return (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{action}
</header>
<div className="p-5">{children}</div>
</section>
)
}