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:
parent
50e3676d71
commit
61f2c21016
293
src/food-market.api/Controllers/Purchases/SuppliesController.cs
Normal file
293
src/food-market.api/Controllers/Purchases/SuppliesController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/food-market.domain/Purchases/Supply.cs
Normal file
55
src/food-market.domain/Purchases/Supply.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using foodmarket.Domain.Catalog;
|
using foodmarket.Domain.Catalog;
|
||||||
using foodmarket.Domain.Common;
|
using foodmarket.Domain.Common;
|
||||||
using foodmarket.Domain.Inventory;
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
using foodmarket.Infrastructure.Identity;
|
using foodmarket.Infrastructure.Identity;
|
||||||
using foodmarket.Domain.Organizations;
|
using foodmarket.Domain.Organizations;
|
||||||
using foodmarket.Infrastructure.Persistence.Configurations;
|
using foodmarket.Infrastructure.Persistence.Configurations;
|
||||||
|
|
@ -39,6 +40,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
||||||
public DbSet<Stock> Stocks => Set<Stock>();
|
public DbSet<Stock> Stocks => Set<Stock>();
|
||||||
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
|
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
|
||||||
|
|
||||||
|
public DbSet<Supply> Supplies => Set<Supply>();
|
||||||
|
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|
@ -67,6 +71,7 @@ protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
|
||||||
builder.ConfigureCatalog();
|
builder.ConfigureCatalog();
|
||||||
builder.ConfigureInventory();
|
builder.ConfigureInventory();
|
||||||
|
builder.ConfigurePurchases();
|
||||||
|
|
||||||
// Apply multi-tenant query filter to every entity that implements ITenantEntity
|
// Apply multi-tenant query filter to every entity that implements ITenantEntity
|
||||||
foreach (var entityType in builder.Model.GetEntityTypes())
|
foreach (var entityType in builder.Model.GetEntityTypes())
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1713
src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.Designer.cs
generated
Normal file
1713
src/food-market.infrastructure/Persistence/Migrations/20260421195644_Phase2b_Supply.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1150,6 +1150,129 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.ToTable("organizations", "public");
|
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 =>
|
modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|
@ -1505,6 +1628,52 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Navigation("Store");
|
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 =>
|
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Authorizations");
|
b.Navigation("Authorizations");
|
||||||
|
|
@ -1530,6 +1699,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
b.Navigation("Children");
|
b.Navigation("Children");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("foodmarket.Domain.Purchases.Supply", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import { ProductEditPage } from '@/pages/ProductEditPage'
|
||||||
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
|
import { MoySkladImportPage } from '@/pages/MoySkladImportPage'
|
||||||
import { StockPage } from '@/pages/StockPage'
|
import { StockPage } from '@/pages/StockPage'
|
||||||
import { StockMovementsPage } from '@/pages/StockMovementsPage'
|
import { StockMovementsPage } from '@/pages/StockMovementsPage'
|
||||||
|
import { SuppliesPage } from '@/pages/SuppliesPage'
|
||||||
|
import { SupplyEditPage } from '@/pages/SupplyEditPage'
|
||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||||
|
|
||||||
|
|
@ -51,6 +53,9 @@ export default function App() {
|
||||||
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
|
<Route path="/catalog/currencies" element={<CurrenciesPage />} />
|
||||||
<Route path="/inventory/stock" element={<StockPage />} />
|
<Route path="/inventory/stock" element={<StockPage />} />
|
||||||
<Route path="/inventory/movements" element={<StockMovementsPage />} />
|
<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 path="/admin/import/moysklad" element={<MoySkladImportPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
|
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
|
||||||
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
|
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
|
||||||
Boxes, History,
|
Boxes, History, TruckIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
|
|
||||||
|
|
@ -40,6 +40,9 @@ const nav = [
|
||||||
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' },
|
{ to: '/inventory/stock', icon: Boxes, label: 'Остатки' },
|
||||||
{ to: '/inventory/movements', icon: History, label: 'Движения' },
|
{ to: '/inventory/movements', icon: History, label: 'Движения' },
|
||||||
]},
|
]},
|
||||||
|
{ group: 'Закупки', items: [
|
||||||
|
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
|
||||||
|
]},
|
||||||
{ group: 'Справочники', items: [
|
{ group: 'Справочники', items: [
|
||||||
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
|
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
|
||||||
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
|
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
|
||||||
|
|
|
||||||
88
src/food-market.web/src/components/ProductPicker.tsx
Normal file
88
src/food-market.web/src/components/ProductPicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -69,3 +69,31 @@ export interface MovementRow {
|
||||||
type: string; documentType: string; documentId: string | null; documentNumber: string | null;
|
type: string; documentType: string; documentId: string | null; documentNumber: string | null;
|
||||||
notes: 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[];
|
||||||
|
}
|
||||||
|
|
|
||||||
55
src/food-market.web/src/pages/SuppliesPage.tsx
Normal file
55
src/food-market.web/src/pages/SuppliesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
370
src/food-market.web/src/pages/SupplyEditPage.tsx
Normal file
370
src/food-market.web/src/pages/SupplyEditPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue